Compare commits

...

2 Commits

111
bot.py
View File

@@ -3,12 +3,12 @@ import logging
import re import re
import httpx import httpx
import random import random
import random
from aiogram import Bot, Dispatcher, types from aiogram import Bot, Dispatcher, types
from aiogram.filters import Command, CommandObject from aiogram.filters import Command, CommandObject
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
# Конфиг
TOKEN = os.getenv("TELEGRAM_TOKEN") TOKEN = os.getenv("TELEGRAM_TOKEN")
API_URL = os.getenv("VNDB_API_URL", "https://api.vndb.org/kana") API_URL = os.getenv("VNDB_API_URL", "https://api.vndb.org/kana")
@@ -18,33 +18,35 @@ logger = logging.getLogger(__name__)
bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher() dp = Dispatcher()
# --- UTILS --- # --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---
def clean_text(text: str, limit: int = 900) -> str: def clean_text(text: str, limit: int = 800) -> str:
"""Очистка текста от VNDB тегов и лимит символов."""
if not text: return "No description available." if not text: return "No description available."
# Удаляем все теги [b], [i], [url] и т.д. text = re.sub(r'\[.*?\]', '', text) # Удаляем [b], [url] и т.д.
text = re.sub(r'\[.*?\]', '', text)
text = text.replace('"', "'") text = text.replace('"', "'")
if len(text) > limit: if len(text) > limit:
return text[:limit].rsplit(' ', 1)[0] + "..." return text[:limit].rsplit(' ', 1)[0] + "..."
return text return text
def safe_list_to_str(data) -> str: def safe_list_to_str(data) -> str:
"""Универсальный парсер списков для VNDB API."""
if not data: return "N/A" if not data: return "N/A"
if isinstance(data, list): if isinstance(data, list):
processed = [] processed = []
for x in data: for x in data:
if isinstance(x, str): processed.append(x.upper()) if isinstance(x, str): processed.append(x.upper())
elif isinstance(x, dict): elif isinstance(x, dict):
# Ищем любое человекочитаемое поле # Извлекаем lang для релизов или name для разработчиков
val = x.get('lang') or x.get('name') or x.get('title') or x.get('label') val = x.get('lang') or x.get('name') or x.get('title') or x.get('label')
if val: processed.append(str(val).upper()) if val: processed.append(str(val).upper())
return ", ".join(processed) if processed else "N/A" return ", ".join(processed) if processed else "N/A"
return str(data).upper() return str(data).upper()
async def fetch_vndb(endpoint: str, filters: list, fields: str, sort: str = "id", results: int = 10): async def fetch_vndb(endpoint: str, filters: list, fields: str, sort: str = "id", results: int = 10):
"""Базовый POST запрос к VNDB API."""
payload = {"filters": filters, "fields": fields, "sort": sort, "results": results} payload = {"filters": filters, "fields": fields, "sort": sort, "results": results}
async with httpx.AsyncClient(timeout=15.0) as client: async with httpx.AsyncClient(timeout=20.0) as client:
try: try:
response = await client.post(f"{API_URL}/{endpoint}", json=payload) response = await client.post(f"{API_URL}/{endpoint}", json=payload)
if response.status_code == 200: if response.status_code == 200:
@@ -52,47 +54,48 @@ async def fetch_vndb(endpoint: str, filters: list, fields: str, sort: str = "id"
logger.error(f"VNDB Error {response.status_code}: {response.text}") logger.error(f"VNDB Error {response.status_code}: {response.text}")
return None return None
except Exception as e: except Exception as e:
logger.error(f"Error: {e}") logger.error(f"Request failed: {e}")
return None return None
async def send_result(message: types.Message, text: str, image_url: str = None): async def send_result(message: types.Message, text: str, image_url: str = None):
"""Отправка сообщения: с фото (caption) или просто текст."""
try: try:
if image_url: if image_url:
await message.answer_photo(photo=image_url, caption=text[:1024]) await message.answer_photo(photo=image_url, caption=text[:1024])
else: else:
await message.answer(text) await message.answer(text)
except Exception as e: except Exception as e:
logger.warning(f"Photo send failed: {e}")
await message.answer(text) await message.answer(text)
def find_exact_match(results, query, attr): def find_exact_match(results, query, attr):
"""Ищет 100% совпадение по имени/названию в результатах.""" """Проверка на 100% совпадение названия в результатах поиска."""
if not results or not query: return None
for item in results: for item in results:
if item.get(attr, "").lower() == query.lower(): if item.get(attr, "").lower() == query.lower():
return item return item
return None return None
# --- HANDLERS --- # --- ОБРАБОТЧИКИ КОМАНД ---
@dp.message(Command("vn")) @dp.message(Command("vn"))
async def handle_vn(message: types.Message, command: CommandObject): async def handle_vn(message: types.Message, command: CommandObject):
if not command.args: return await message.answer("Usage: /vn <name/id>") if not command.args: return await message.answer("Example: /vn Steins Gate")
is_id = command.args.startswith('v') and command.args[1:].isdigit() is_id = command.args.startswith('v') and command.args[1:].isdigit()
filt = ["id", "=", command.args] if is_id else ["search", "=", command.args] filt = ["id", "=", command.args] if is_id else ["search", "=", command.args]
fields = "id, title, alttitle, released, rating, votecount, description, languages, platforms, developers{name}, image{url}, relations{title, id, relation}" fields = "id, title, alttitle, released, rating, votecount, description, languages, platforms, developers{name}, image{url}, relations{title, id, relation}"
res = await fetch_vndb("vn", filt, fields) res = await fetch_vndb("vn", filt, fields)
if not res: return await message.answer("Not found.") if not res: return await message.answer("VN not found.")
# Логика умного поиска
target = res[0] target = res[0]
if not is_id and len(res) > 1: if not is_id and len(res) > 1:
exact = find_exact_match(res, command.args, 'title') exact = find_exact_match(res, command.args, 'title')
if exact: if exact: target = exact
target = exact
else: else:
out = [f"{i['title']} — <code>{i['id']}</code>" for i in res[:10]] out = [f"{i['title']} — <code>{i['id']}</code>" for i in res[:10]]
return await message.answer("🔍 <b>Select VN:</b>\n" + "\n".join(out)) return await message.answer("🔍 <b>Select VN by ID:</b>\n" + "\n".join(out))
img = target.get('image', {}).get('url') if target.get('image') else None img = target.get('image', {}).get('url') if target.get('image') else None
rel_str = "\n".join([f"{r['title']} (<code>{r['id']}</code>) - {r['relation']}" for r in target.get('relations', [])[:3]]) rel_str = "\n".join([f"{r['title']} (<code>{r['id']}</code>) - {r['relation']}" for r in target.get('relations', [])[:3]])
@@ -100,54 +103,27 @@ async def handle_vn(message: types.Message, command: CommandObject):
text = ( text = (
f"<b>TITLE:</b> {target['title']}\n" f"<b>TITLE:</b> {target['title']}\n"
f"<b>ORIGINAL:</b> {target.get('alttitle') or 'N/A'}\n" f"<b>ORIGINAL:</b> {target.get('alttitle') or 'N/A'}\n"
f"<b>RELEASED:</b> {target.get('released', 'N/A')}\n"
f"<b>DEVELOPER:</b> {safe_list_to_str(target.get('developers'))}\n" f"<b>DEVELOPER:</b> {safe_list_to_str(target.get('developers'))}\n"
f"<b>RATING:</b> {target['rating']/10 if target.get('rating') else 'N/A'} ({target.get('votecount', 0)} votes)\n" f"<b>RATING:</b> {target['rating']/10 if target.get('rating') else 'N/A'} ({target.get('votecount', 0)} votes)\n"
f"<b>LANGS:</b> {safe_list_to_str(target.get('languages'))}\n" f"<b>LANGS:</b> {safe_list_to_str(target.get('languages'))}\n"
f"<b>PLATFORMS:</b> {safe_list_to_str(target.get('platforms'))}\n\n" f"<b>PLATFORMS:</b> {safe_list_to_str(target.get('platforms'))}\n\n"
f"<b>RELATIONS:</b>\n{rel_str or 'None'}\n\n" f"<b>RELATIONS:</b>\n{rel_str or 'None'}\n\n"
f"<b>DESC:</b>\n<i>{clean_text(target.get('description'), 450)}</i>\n\n" f"<b>DESC:</b>\n<i>{clean_text(target.get('description'), 400)}</i>\n\n"
f"<b>VNDB:</b> https://vndb.org/{target['id']}" f"<b>VNDB:</b> https://vndb.org/{target['id']}"
) )
await send_result(message, text, img) await send_result(message, text, img)
@dp.message(Command("random"))
async def handle_random(message: types.Message):
"""Выбирает случайную новеллу из 50 популярных с высоким рейтингом."""
# Фильтр: рейтинг > 75, популярность высокая
res = await fetch_vndb("vn", ["rating", ">=", 75], "id, title, image{url}, rating, description", sort="votecount", results=50)
if not res: return await message.answer("❌ API error")
v = random.choice(res)
text = (
f"🎲 <b>RANDOM VN PICK</b>\n\n"
f"<b>TITLE:</b> {v['title']}\n"
f"<b>RATING:</b> {v['rating']/10}\n\n"
f"<b>DESCRIPTION:</b>\n<i>{clean_text(v.get('description'), 300)}</i>\n\n"
f"<b>VNDB:</b> https://vndb.org/{v['id']}"
)
await send_result(message, text, v.get('image', {}).get('url'))
@dp.message(Command("top"))
async def handle_top(message: types.Message):
"""Выводит топ-10 новелл по рейтингу."""
res = await fetch_vndb("vn", ["votecount", ">", 1000], "id, title, rating", sort="rating", results=10)
if not res: return await message.answer("❌ API error")
out = ["🏆 <b>TOP RATED VISUAL NOVELS</b>\n"]
for i, v in enumerate(res, 1):
out.append(f"{i}. {v['title']} — <b>{v['rating']/10}</b> (<code>{v['id']}</code>)")
await message.answer("\n".join(out))
@dp.message(Command("char")) @dp.message(Command("char"))
async def handle_char(message: types.Message, command: CommandObject): async def handle_char(message: types.Message, command: CommandObject):
if not command.args: return await message.answer("Usage: /char <name/id>") if not command.args: return await message.answer("Example: /char Kurisu")
is_id = command.args.startswith('c') and command.args[1:].isdigit() is_id = command.args.startswith('c') and command.args[1:].isdigit()
filt = ["id", "=", command.args] if is_id else ["search", "=", command.args] filt = ["id", "=", command.args] if is_id else ["search", "=", command.args]
fields = "id, name, original, description, gender, age, blood_type, image{url}" fields = "id, name, original, description, gender, age, blood_type, image{url}"
res = await fetch_vndb("character", filt, fields) res = await fetch_vndb("character", filt, fields)
if not res: return await message.answer("Not found.") if not res: return await message.answer("Character not found.")
target = res[0] target = res[0]
if not is_id and len(res) > 1: if not is_id and len(res) > 1:
@@ -155,7 +131,7 @@ async def handle_char(message: types.Message, command: CommandObject):
if exact: target = exact if exact: target = exact
else: else:
out = [f"{i['name']} — <code>{i['id']}</code>" for i in res[:10]] out = [f"{i['name']} — <code>{i['id']}</code>" for i in res[:10]]
return await message.answer("👤 <b>Select Character:</b>\n" + "\n".join(out)) return await message.answer("👤 <b>Select Character by ID:</b>\n" + "\n".join(out))
img = target.get('image', {}).get('url') if target.get('image') else None img = target.get('image', {}).get('url') if target.get('image') else None
raw_gender = target.get('gender') raw_gender = target.get('gender')
@@ -173,14 +149,14 @@ async def handle_char(message: types.Message, command: CommandObject):
@dp.message(Command("release")) @dp.message(Command("release"))
async def handle_release(message: types.Message, command: CommandObject): async def handle_release(message: types.Message, command: CommandObject):
if not command.args: return await message.answer("Usage: /release <name/id>") if not command.args: return await message.answer("Example: /release Steins Gate")
is_id = command.args.startswith('r') and command.args[1:].isdigit() is_id = command.args.startswith('r') and command.args[1:].isdigit()
filt = ["id", "=", command.args] if is_id else ["search", "=", command.args] filt = ["id", "=", command.args] if is_id else ["search", "=", command.args]
fields = "id, title, alttitle, released, languages{lang}, platforms, extlinks{url, label}, images{url}" fields = "id, title, alttitle, released, languages{lang}, platforms, extlinks{url, label}, images{url}"
res = await fetch_vndb("release", filt, fields) res = await fetch_vndb("release", filt, fields)
if not res: return await message.answer("Not found.") if not res: return await message.answer("Release not found.")
target = res[0] target = res[0]
if not is_id and len(res) > 1: if not is_id and len(res) > 1:
@@ -188,7 +164,7 @@ async def handle_release(message: types.Message, command: CommandObject):
if exact: target = exact if exact: target = exact
else: else:
out = [f"{i['title']} — <code>{i['id']}</code>" for i in res[:10]] out = [f"{i['title']} — <code>{i['id']}</code>" for i in res[:10]]
return await message.answer("💿 <b>Select Release:</b>\n" + "\n".join(out)) return await message.answer("💿 <b>Select Release by ID:</b>\n" + "\n".join(out))
img_list = target.get('images', []) img_list = target.get('images', [])
img = img_list[0].get('url') if img_list else None img = img_list[0].get('url') if img_list else None
@@ -200,30 +176,51 @@ async def handle_release(message: types.Message, command: CommandObject):
f"<b>DATE:</b> {target.get('released', 'N/A')}\n" f"<b>DATE:</b> {target.get('released', 'N/A')}\n"
f"<b>LANGS:</b> {safe_list_to_str(target.get('languages'))}\n" f"<b>LANGS:</b> {safe_list_to_str(target.get('languages'))}\n"
f"<b>PLATFORMS:</b> {safe_list_to_str(target.get('platforms'))}\n\n" f"<b>PLATFORMS:</b> {safe_list_to_str(target.get('platforms'))}\n\n"
f"<b>LINKS / STORES:</b>\n{links or 'N/A'}\n\n" f"<b>STORES:</b>\n{links or 'N/A'}\n\n"
f"<b>VNDB:</b> https://vndb.org/{target['id']}" f"<b>VNDB:</b> https://vndb.org/{target['id']}"
) )
await send_result(message, text, img) await send_result(message, text, img)
@dp.message(Command("random")) @dp.message(Command("random"))
async def handle_random(message: types.Message): async def handle_random(message: types.Message):
"""Выдает случайную популярную VN с высоким рейтингом."""
# Получаем 100 популярных новелл с рейтингом > 8.0
res = await fetch_vndb("vn", ["rating", ">=", 80], "id, title, image{url}, rating, description", sort="votecount", results=100) res = await fetch_vndb("vn", ["rating", ">=", 80], "id, title, image{url}, rating, description", sort="votecount", results=100)
if not res: return await message.answer("❌ API error") if not res: return await message.answer("❌ API error")
v = random.choice(res) v = random.choice(res)
text = f"🎲 <b>RANDOM VN</b>\n\n<b>{v['title']}</b>\nRating: {v['rating']/10}\n\n<i>{clean_text(v.get('description'), 400)}</i>\n\nhttps://vndb.org/{v['id']}" text = (
f"🎲 <b>RANDOM VN PICK</b>\n\n"
f"<b>{v['title']}</b>\n"
f"Rating: {v['rating']/10}\n\n"
f"<i>{clean_text(v.get('description'), 400)}</i>\n\n"
f"https://vndb.org/{v['id']}"
)
await send_result(message, text, v.get('image', {}).get('url')) await send_result(message, text, v.get('image', {}).get('url'))
@dp.message(Command("top")) @dp.message(Command("top"))
async def handle_top(message: types.Message): async def handle_top(message: types.Message):
res = await fetch_vndb("vn", ["votecount", ">", 2000], "id, title, rating", sort="rating", results=10) """Выводит Топ-10 по рейтингу (среди популярных)."""
out = ["🏆 <b>TOP 10 VISUAL NOVELS</b>"] res = await fetch_vndb("vn", ["votecount", ">", 2500], "id, title, rating", sort="rating", results=10)
if not res: return await message.answer("❌ API error")
out = ["🏆 <b>TOP 10 VISUAL NOVELS</b>\n"]
for i, v in enumerate(res, 1): for i, v in enumerate(res, 1):
out.append(f"{i}. {v['title']} — <b>{v['rating']/10}</b>") out.append(f"{i}. {v['title']} — <b>{v['rating']/10}</b>")
await message.answer("\n".join(out)) await message.answer("\n".join(out))
@dp.message(Command("start", "help", "search")) @dp.message(Command("start", "help", "search"))
async def cmd_start(message: types.Message): async def cmd_start(message: types.Message):
await message.answer("🤖 <b>VNDB Professional</b>\n/vn, /char, /release, /random, /top") await message.answer(
"🤖 <b>VNDB Professional Bot</b>\n\n"
"<b>Commands:</b>\n"
"• /vn [name/id] - VN Details\n"
"• /char [name/id] - Characters\n"
"• /release [name/id] - Release Info\n"
"• /random - Random high-rated VN\n"
"• /top - Top 10 by Rating"
)
if __name__ == "__main__": if __name__ == "__main__":
dp.run_polling(bot) dp.run_polling(bot)