From b983126e6ed81cf1948768007115522975b68c1b Mon Sep 17 00:00:00 2001 From: King-of-the-all-Cookies Date: Fri, 1 May 2026 15:13:02 +0300 Subject: [PATCH] First commit --- .env.example | 13 + .gitignore | 168 +-- API.mhtml | 3411 ++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 142 ++ Dockerfile | 23 + EXAMPLES.md | 343 +++++ IMAGES.md | 177 +++ INSTALLATION.md | 343 +++++ README.md | 256 +++- advanced_features.py | 325 ++++ bot.py | 699 +++++++++ config.py | 53 + detailed_handlers.py | 293 ++++ docker-compose.yml | 20 + requirements.txt | 6 + test_bot.py | 168 +++ utils.py | 353 +++++ vndb_client.py | 496 ++++++ 18 files changed, 7142 insertions(+), 147 deletions(-) create mode 100644 .env.example create mode 100644 API.mhtml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 EXAMPLES.md create mode 100644 IMAGES.md create mode 100644 INSTALLATION.md create mode 100644 advanced_features.py create mode 100644 bot.py create mode 100644 config.py create mode 100644 detailed_handlers.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 test_bot.py create mode 100644 utils.py create mode 100644 vndb_client.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4cfd037 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Telegram Bot Token (обязательно) +# Получите у @BotFather в Telegram +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here + +# VNDB API Token (опционально) +# Получите на https://vndb.org/u/tokens +VNDB_TOKEN=your_vndb_api_token_here + +# Опции логирования +LOG_LEVEL=INFO + +# Использовать sandbox API (для тестирования) +USE_SANDBOX=false diff --git a/.gitignore b/.gitignore index 36b13f1..be7c6a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,13 @@ -# ---> Python -# Byte-compiled / optimized / DLL files +# Environment variables +.env +.env.local +.env.*.local + +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging .Python build/ develop-eggs/ @@ -21,156 +21,32 @@ parts/ sdist/ var/ wheels/ +pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ +# Virtual environments venv/ +env/ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc +# Logs +*.log +logs/ +# Bot-specific +.cache/ +tmp/ diff --git a/API.mhtml b/API.mhtml new file mode 100644 index 0000000..4ea9a25 --- /dev/null +++ b/API.mhtml @@ -0,0 +1,3411 @@ +From: +Snapshot-Content-Location: https://api.vndb.org/kana +Subject: VNDB.org API v2 (Kana) +Date: Fri, 1 May 2026 14:37:04 +0300 +MIME-Version: 1.0 +Content-Type: multipart/related; + type="text/html"; + boundary="----MultipartBoundary--iFOSnX79r9F8KpuRTSwwVgShnV5hBx9uvFtLF8zrAZ----" + + +------MultipartBoundary--iFOSnX79r9F8KpuRTSwwVgShnV5hBx9uvFtLF8zrAZ---- +Content-Type: text/html +Content-ID: +Content-Transfer-Encoding: quoted-printable +Content-Location: https://api.vndb.org/kana + + + =20 + + + VNDB.org API v2 (Kana) + =20 + =20 + + +
+

VNDB.org API v2 (Kana)

+
+ +

Introduction

+

This document describes the HTTPS API to query information from the +VNDB database and manage user lists.

+

This version of the API replaces the old TCP-based API.

+

API endpoint: https://api.vndb.org/kana +

A sandbox endpoint is available for testing and development at https://beta.vndb.org/api/kana, +for more information see th= +e sandbox.

+

Usage Terms

+

This service is free for non-commercial use. The API is provided on a +best-effort basis, no guarantees are made about the stability or +applicability of this service.

+

The data obtained through this API is subject to our Data License.

+

API access is rate-limited in order to keep server resources in +check. The server will allow up to 200 requests per 5 minutes and up to +1 second of execution time per minute. Requests taking longer than 3 +seconds will be aborted. These limits should be more than enough for +most applications, but if this is still too limiting for you, don=E2=80=99t +hesitate to get in touch.

+

This API intentionally does not expose all functionality +provided by VNDB. Some site features, such as forums, database editing +or account creation will not be exposed through the API, other features +may be missing simply because nobody has asked for it yet. If you need +anything not yet provided by the API or if you have any other questions, +feel free to post on the forums, the issue tracker +or mail contact@vndb.org.

+

Common Data Types

+
+
vndbid
+
+A =E2=80=98vndbid=E2=80=99 is an identifier for an entry in the database, t= +ypically +formatted as a number with a one or two character prefix, e.g. =E2=80= +=9Cv17=E2=80=9D +refers to this visual novel and +=E2=80=9Csf190=E2=80=9D refers to th= +is +screenshot. +
+
+The API will return vndbids as a JSON string, but the filters also +accept bare integers if the prefix is unambiguous from the context. +
+
release date
+
+Release dates are represented as JSON strings as either +"YYYY-MM-DD", "YYYY-MM" or "YYYY" +formats, depending on whether the day and month are known. Unspecified +future dates are returned as "TBA". The values +"unknown" and "today" are also supported in +filters. +
+
+Partial dates are ordered after complete dates for the same +year/month, i.e. "2022" is ordered after +"2022-12", which in turn is ordered after +"2022-12-31". This can be unintuitive when writing filters: +["released", "<", "2022-01"] also matches all complete +dates in Jan 2022. Likewise, ["released", "=3D", "2022"] only +matches items for which the release date is exactly "2022", +not any other date in that year. +
+
enumeration types
+
+Several fields in the database are represented as an integer or string +with a limited number of possible values. These values are either +documented for the particular field or listed separately in the schema JSON. +
+
+

User Authentication

+

The majority of the API endpoints below are usable without any form +of authentication, but some user-related actions - in particular, list +management - require the calls to be authenticated with the respective +VNDB user account.

+

The API understands cookies originating from the main +vndb.org domain, so user scripts running from the site only +have to ensure that XMLHttpRequest.withCredentials +or the +Fetch API =E2=80=9Ccredentials=E2=80=9D parameter is set.

+

In all other cases, token authentication should to be used. Users can +obtain a token by opening their =E2=80=9CMy Profile=E2=80=9D form and going= + to the +=E2=80=9CApplications=E2=80=9D tab. The URL https://vndb.org/u/tokens= + can +also be used to redirect users to this form. Tokens look like +xxxx-xxxxx-xxxxx-xxxx-xxxxx-xxxxx-xxxx, with each +x representing a lowercase z-base-32 character. The dashes +in between are optional.

+

Tokens may be included in API requests using the +Authorization header with the Token type, for +example:

+
Authorization: Token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk
+

A HTTP 401 error is returned if the token is invalid. The GET /authinfo endpoint can be used= + validate and +extract information from tokens.

+

Simple Requests

+

GET /schema

+

Returns a JSON object w= +ith metadata +about several API objects, including enumeration values, which fields +are available for querying and a list of supported external links. The +JSON structure is hopefully self-explanatory.

+

This information does not change very often and can safely be used +for code generation or dynamic API introspection.

+

The url_format attribute of external links is purely +informational and should not be used to construct URLs. The API has +custom URL formatting rules for various sites that may not match this +url_format.

+

GET /stats

+

Returns a few overall database statistics.

+

curl https://api.vndb.org/kana/stats

+
{<=
+/span>
+  "chars": 112347,
+  "producers": 14789,
+  "releases": 91490,
+  "staff": 27929,
+  "tags": 2783,<=
+/span>
+  "traits": 3115,
+  "vn": 36880
+}
+

GET /user

+

Lookup users by id or username. Accepts two query parameters:

+
+
q
+
+User ID or username to look up, can be given multiple times to look up +multiple users. +
+
fields
+
+List of fields to select. The =E2=80=98id=E2=80=99 and =E2=80=98username=E2= +=80=99 fields are always +selected and should not be specified here. +
+
+

The response object contains one key for each given q +parameter, its value is either null if no such user was +found or otherwise an object with the following fields:

+
+
id
+
+String in "u123" format. +
+
username
+
+String. +
+
lengthvotes
+
+Integer, number of play time votes this user has submitted. +
+
lengthvotes_sum
+
+Integer, sum of the user=E2=80=99s play time votes, in minutes. +
+
+

Strings that look like user IDs are not valid usernames, so the +lookup is unambiguous. Usernames matching is case-insensitive.

+

curl 'https://api.vndb.org/kana/user?q=3DNoUserWithThisNameExists&= +amp;q=3DAYO&q=3Du3'

+
{<=
+/span>
+  "AYO": {
+    "id": "u3",<=
+/span>
+    "username": "ayo"
+  },
+  "NoUserWithThisNameExist=
+s": null,
+  "u3": {
+    "id": "u3",<=
+/span>
+    "username": "ayo"
+  }
+}
+

curl 'https://api.vndb.org/kana/user?q=3Dyorhel&fields=3Dlengt= +hvotes,lengthvotes_sum'

+
{<=
+/span>
+  "yorhel": {
+    "id": "u2",<=
+/span>
+    "lengthvotes": 9,
+    "lengthvotes_sum": 9685,
+    "username": "Yorhel"
+  }
+}
+

GET /authinfo

+

Validates and returns information about the given API token. The JSON object has the +following members:

+
+
id
+
+String, user ID. +
+
username
+
+String, username. +
+
permissions
+
+Array of strings, permissions granted to this token. +
+
+

The following permissions are currently implemented:

+
+
listread
+
+Allows read access to private labels and entries in the user=E2=80=99s visu= +al +novel list. +
+
listwrite
+
+Allows write access to the user=E2=80=99s visual novel list. +
+
+
curl=
+ https://api.vndb.org/kana/authinfo\
+    --header 'Authorization: token cdhy-bqy1q-6zobu-8w9k-xobxh-wzz4o-84fn'<=
+/span>
+
{<=
+/span>
+  "id": "u3",<=
+/span>
+  "username": "ayo",
+  "permissions": [
+    "listread"
+  ]
+}
+

Database Querying

+

API Structure

+

Searching for and fetching database entries is done through a custom +query format1. Queries are sent as +POST requests, but I expect to also support the +QUERY HTTP method once that gains more software +support.

+

Query format

+

A query is a JSON object that looks like this:

+
{<=
+/span>
+  "filters": [],=
+
+  "fields": "",<=
+/span>
+  "sort": "id",<=
+/span>
+  "reverse": false,
+  "results": 10,=
+
+  "page": 1,
+  "user": null,<=
+/span>
+  "count": false,
+  "compact_filters": false,
+  "normalized_filters": false
+}
+

All members are optional, defaults are shown above.

+
+
filters
+
+Filters are used to determine which database items to fetch, see the +section on Filters below. +
+
fields
+
+String. Comma-separated list of fields to fetch for each database item. +Dot notation can be used to select nested JSON objects, +e.g. "image.url" will select the url field +inside the image object. Multiple nested fields can be +selected with brackets, e.g. "image{id,url,dims}" is +equivalent to "image.id, image.url, image.dims". +
+
+Every field of interest must be explicitely mentioned, there is no +support for wildcard matching. The same applies to nested objects, it is +an error to list image without sub-fields in the example +above. +
+
+The top-level id field is always selected by default and +does not have to be mentioned in this list. +
+
sort
+
+Field to sort on. Supported values depend on the type of data being +queried and are documented separately. +
+
reverse
+
+Set to true to sort in descending order. +
+
results
+
+Number of results per page, max 100. Can also be set to 0 +if you=E2=80=99re not interested in the results at all, but just want to ve= +rify +your query or get the count, compact_filters +or normalized_filters. +
+
page
+
+Page number to request, starting from 1. See also the note on pagination below. +
+
user
+
+User ID. This field is mainly used for POST /ulist, but it +also sets the default user ID to use for the visual novel =E2=80=9Clabel=E2= +=80=9D +filter. Defaults to the currently authenticated user. +
+
count
+
+Whether the response should include the count field (see +below). This option should be avoided when the count is not needed since +it has a considerable performance impact. +
+
compact_filters
+
+Whether the response should include the compact_filters +field (see below). +
+
normalized_filters
+
+Whether the response should include the normalized_filters +field (see below). +
+
+

Response format

+
{<=
+/span>
+  "results": [],=
+
+  "more": false,=
+
+  "count": 1,
+  "compact_filters"=
+: ""=
+,
+  "normalized_filters": [],
+}
+
+
results
+
+Array of objects representing the query results. +
+
more
+
+When true, repeating the query with an incremented +page number will yield more results. This is a cheaper form +of pagination than using the count field. +
+
count
+
+Only present if the query contained "count":true. Indicates +the total number of entries that matched the given filters. +
+
compact_filters
+
+Only present if the query contained "compact_filters":true. +This is a compact string representation of the filters given in the +query. +
+
normalized_filters
+
+Only present if the query contained +"normalized_filters":true. This is a normalized JSON +representation of the filters given in the query. +
+
+

Filters

+

Simple predicates are represented as a three-element JSON array +containing a filter name, operator and value, +e.g. [ "id", "=3D", "v17" ]. All filters accept the +(in)equality operators =3D and !=3D. Filters that +support ordering also accept >=3D, >, +<=3D and <. The full list of accepted +filter names and values is documented below for each type of database +item.

+

Simple predicates can be combined into larger queries with and/or +predicates. These are represented as JSON arrays where the first element +is either "and" or "or", followed by two or +more other predicates.

+

Full example of a more complex visual novel filter (which, as of +writing, doesn=E2=80=99t actually match anything in the database):

+
[<=
+/span> "and"
+, [ "or"
+  , [ "lang", "=3D", "en" ]
+  , [ "lang", "=3D", "de" ]
+  , [ "lang", "=3D", "fr" ]
+  ]
+, [ "olang", "!=3D", "ja" ]
+, [ "release", <=
+span class=3D"st">"=3D", [ "and"
+    , [ "released", ">=3D", "2020-01-01" ]
+    , [ "producer", "=3D", [ "id",=
+ "=3D", "p30" ] ]
+    ]
+  ]
+]
+

Besides the above JSON format, filters can also be represented as a +more compact string. This representation is used in the URLs for the +advanced search web interface2 and = +is also accepted as +value to the "filters" field. Since actually working with +the compact string representation is kind of annoying, this API can +convert between the two representations, so you can freely copy filters +from the website to the API and the other way around.3

+

The compact representation of the above example is +"03132gen2gde2gfr3hjaN180272_0c2vQN6830u" and can be seen +in action in the +web UI. The following command will convert that string back into the +above JSON:

+
c=
+url https://api.vndb.org/kana/vn --header =
+'Content-Type: application/json' --data '{
+    "filters": "03132gen2=
+gde2gfr3hjaN180272_0c2vQN6830u",
+    "normalized_filters":=
+ true
+}'
+

Note that the advanced search editing UI on the site does not support +all filter types, for unsupported filters you will see an =E2=80=9CUnrecogn= +ized +filter=E2=80=9D block. These are pretty harmless, the filter still works. +

Filter flags

+

These flags are used in the documentation below to describe a few +common filter properties.

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
FlagDescription
oOrdering operators (such as +> and <) can be used with this +filter.
nThis filter accepts null as +value.
mA single entry can match multiple values. +For example, a visual novel available in both English and Japanese +matches both ["lang","=3D","en"] and +["lang","=3D","ja"].
iInverting or negating this filter (e.g.&nbs= +p;by +changing the operator from =E2=80=98=3D=E2=80=99 to =E2=80=98!=3D=E2=80=99 = +or from =E2=80=98>=E2=80=99 to =E2=80=98<=3D=E2=80=99) is not +always equivalent to inverting the selection of matching entries. This +often means that the filter implies another requirement (e.g. that the +information must be known in the first place), but the exact details +depend on the filter.
+

Be careful with applying boolean algebra to filters with the =E2=80=98m= +=E2=80=99 or +=E2=80=98i=E2=80=99 flags, the results may be unintuitive. For example, sea= +rching for +releases matching ["or",["minage","=3D",0],["minage","!=3D",0]] +will not find all releases in the database, but only +those for which the minage field is known. Exact semantics +regarding unknown or missing information often depends on how the filter +is implemented and may be subject to change.

+

POST /vn

+

Query visual novel entries.

+
c=
+url https://api.vndb.org/kana/vn --header =
+'Content-Type: application/json' --data '{
+    "filters": ["id", "=
+=3D", "v17"],
+    "fields": "title, ima=
+ge.url"
+}'
+

Accepted values for "sort": id, +title, released, rating, +votecount, searchrank.

+

Filters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameFDescription
idovndbid
searchmString search, matches on the VN titles, +aliases and release titles. The search algorithm is the same as used on +the site.
langmLanguage availability.
olangOriginal language.
platformmPlatform availability.
lengthoPlay time estimate, integer between 1 +(Very short) and 5 (Very long). This filter uses the length votes +average when available but falls back to the entries=E2=80=99 +length field when there are no votes.
releasedo,nRelease date.
ratingo,iBayesian rating, integer between 10 and +100.
votecountoInteger, number of votes.
has_descriptionOnly accepts a single value, integer +1. Can of course still be negated with the !=3D +operator.
has_animeSee has_description.
has_screenshotSee has_description.
has_reviewSee has_description.
devstatusDevelopment status, integer. See +devstatus field.
tagmTags applied to this VN, also matches +parent tags. See below for more details.
dtagmTags applied directly to this VN, does not +match parent tags. See below for details.
anime_idInteger, AniDB anime identifier.
labelmUser labels applied to this VN. Accepts a +two-element array containing a user ID and label ID. When authenticated +or if the "user" request parameter has been set, then it +also accepts just a label ID.
releasemMatch visual novels that have at least one +release matching the given release +filters.
charactermMatch visual novels that have at least one +character matching the given character +filters.
staffmMatch visual novels that have at least one +staff member matching the given staff +filters.
developermMatch visual novels developed by the given +producer filters= +.
+

The tag and dtag filters accept either a +plain tag ID or a three-element array containing the tag ID, maximum +spoiler level (0, 1 or 2) and minimum tag level (number between 0 and 3, +inclusive), for example ["tag","=3D",["g505",2,1.2]] matches +all visual novels that have a Donkan +Protagonist with a vote of at least 1.2 at any spoiler level. If +only an ID is given, 0 is assumed for both the spoiler and +tag levels. For example, ["tag","=3D","g505"] is equivalent +to ["tag","=3D",["g505",0,0]].

+

Fields

+
+
id
+
+vndbid. +
+
title
+
+String, main title as displayed on the site, typically romanized from +the original script. +
+
alttitle
+
+String, can be null. Alternative title, typically the same as +title but in the original script. +
+
titles
+
+Array of objects, full list of titles associated with the VN, always +contains at least one title. +
+
titles.lang
+
+String, language. Each language appears at most once in the titles list. +
+
titles.title
+
+String, title in the original script. +
+
titles.latin
+
+String, can be null, romanized version of title. +
+
titles.official
+
+Boolean. +
+
titles.main
+
+Boolean, whether this is the =E2=80=9Cmain=E2=80=9D title for the visual no= +vel entry. +Exactly one title has this flag set in the titles array and +it=E2=80=99s always the title whose lang matches the VN=E2=80= +=99s +olang field. This field is included for convenience, you +can of course also use the olang field to grab the main +title. +
+
aliases
+
+Array of strings, list of aliases. +
+
olang
+
+String, language the VN has originally been written in. +
+
devstatus
+
+Integer, development status. 0 meaning =E2=80=98Finished=E2=80=99, 1 is =E2= +=80=98In development=E2=80=99 +and 2 for =E2=80=98Cancelled=E2=80=99. +
+
released
+
+Release date, possibly null. +
+
languages
+
+Array of strings, list of languages this VN is available in. Does not +include machine translations. +
+
platforms
+
+Array of strings, list of platforms for which this VN is available. +
+
image
+
+Object, can be null. +
+
image.id
+
+String, image identifier. +
+
image.url
+
+String. +
+
image.dims
+
+Pixel dimensions of the image, array with two integer elements +indicating the width and height. +
+
image.sexual
+
+Number between 0 and 2 (inclusive), average image flagging vote for +sexual content. +
+
image.violence
+
+Number between 0 and 2 (inclusive), average image flagging vote for +violence. +
+
image.votecount
+
+Integer, number of image flagging votes. +
+
image.thumbnail
+
+String, URL to the thumbnail. +
+
image.thumbnail_dims
+
+Pixel dimensions of the thumbnail, array with two integer elements. +
+
length
+
+Integer, possibly null, rough length estimate of the VN between 1 (very +short) and 5 (very long). This field is only used as a fallback for when +there are no length votes, so you=E2=80=99ll probably want to fetch +length_minutes too. +
+
length_minutes
+
+Integer, possibly null, average of user-submitted play times in minutes. +
+
length_votes
+
+Integer, number of submitted play times. +
+
description
+
+String, possibly null, may contain format= +ting codes. +
+
average
+
+Raw vote average, between 10 and 100, null if nobody voted (cached, may +be out of date by an hour). +
+
rating
+
+Bayesian rating, between 10 and 100, null if nobody voted (cached). +
+
votecount
+
+Integer, number of votes (cached). +
+
screenshots
+
+Array of objects, possibly empty. +
+
screenshots.*
+
+The above image.* fields are also available for +screenshots. +
+
screenshots.release.*
+
+Release object. All re= +lease fields can be +selected. It is very common for all screenshots of a VN to be assigned +to the same release, so the fields you select here are likely to get +duplicated several times in the response. If you want to fetch more than +just a few fields, it is more efficient to only select +release.id here and then grab detailed release info with a +separate request. +
+
relations
+
+Array of objects, list of VNs directly related to this entry. +
+
relations.relation
+
+String, relation type. +
+
relations.relation_official
+
+Boolean, whether this VN relation is official. +
+
relations.*
+
+All visual novel fields= + can be selected here. +
+
tags
+
+Array of objects, possibly empty. Only directly applied tags are +returned, parent tags are not included. +
+
tags.rating
+
+Number, tag rating between 0 (exclusive) and 3 (inclusive). +
+
tags.spoiler
+
+Integer, 0, 1 or 2, spoiler level. +
+
tags.lie
+
+Boolean. +
+
tags.*
+
+All tag fields can be = +used here. If you=E2=80=99re +fetching tags for more than a single visual novel, it=E2=80=99s usually mor= +e +efficient to only select tags.id here and then fetch (and +cache) further tag information as a separate request. Otherwise the same +tag info may get duplicated many times in the response. +
+
developers
+
+Array of objects. The developers of a VN are all producers with a +=E2=80=9Cdeveloper=E2=80=9D role on a release linked to the VN. You can get= + this same +information by fetching all relevant release entries, but if all you +need is the list of developers then querying this field is faster. +
+
developers.*
+
+All producer fields can be used here. +
+
editions
+
+Array of objects, possibly empty. +
+
editions.eid
+
+Integer, edition identifier. This identifier is local to the visual +novel and not stable across edits of the VN entry, it=E2=80=99s only used f= +or +organizing the staff listing (see below) and has no meaning beyond that. +But this is subject to change in the future. +
+
editions.lang
+
+String, possibly null, language. +
+
editions.name
+
+String, English name / label identifying this edition. +
+
editions.official
+
+Boolean. +
+
staff
+
+Array of objects, possibly empty. +
+
staff.eid
+
+Integer, edition identifier or null when the staff has worked +on the =E2=80=9Coriginal=E2=80=9D version of the visual novel. +
+
staff.role
+
+String, see enums.staff_role in the schema JSON for possible values. +
+
staff.note
+
+String, possibly null. +
+
staff.*
+
+All staff fields can= + be used here. +
+
va
+
+Array of objects, possibly empty. Each object represents a voice actor +relation. The same voice actor may be listed multiple times for +different characters and the same character may be listed multiple times +if it has been voiced by several people. +
+
va.note
+
+String, possibly null. +
+
va.staff.*
+
+Person who voiced the character, all staff +fields can be used here. +
+
va.character.*
+
+VN character being voiced, all character +fields can be used here. +
+
extlinks
+
+Array, links to external websites. Works the same as the =E2=80=98extlinks= +=E2=80=99 release fiel= +d. +
+
+

POST /release

+

Accepted values for "sort": id, +title, released, searchrank.

+

Filters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameFDescription
idovndbid
searchmString search.
langmMatch on available languages.
platformmMatch on available platforms.
releasedoRelease date.
resolutiono,iMatch on the image resolution, in pixels. +Value must be a two-element integer array to which the width and height, +respectively, are compared. For example, +["resolution","<=3D",[640,480]] matches releases with a +resolution smaller than or equal to 640x480.
resolution_aspecto,iSame as the resolution +filter, but additionally requires that the aspect ratio matches that of +the given resolution.
minageo,n,iInteger (0-18), age rating.
mediumm,nString.
voicednInteger, see voiced +field.
enginenString.
rtypemString, see vns.rtype field. +If this filter is used when nested inside a visual novel filter, then +this matches the rtype of the particular visual novel. +Otherwise, this matches the rtype of any linked visual +novel.
extlinkmMatch on external links, see below for +details.
drmmString, match on DRM implementation.
imagem,nString, see images.type +field.
patchInteger, only accepts the value +1.
freewareSee patch.
uncensorediSee patch.
officialSee patch.
has_eroSee patch.
vnmMatch releases that are linked to at least +one visual novel matching the given visual novel +filters.
producermMatch releases that have at least one +producer matching the given producer +filters.
+

The extlink filter can be used with three types of +values:

+
    +
  • Just a site name, e.g. ["extlink","=3D","steam"] matc= +hes +all releases that have a steam ID.
  • +
  • A two-element array indicating the site name and the remote +identifier, e.g. ["extlink","=3D",["steam",702050]] to match +the Saya no Uta release on Steam. The second element can be either an +int or a string, depending on the site, but integer identifiers are also +accepted when formatted as a string.
  • +
  • A URL, +e.g. ["extlink","=3D","https://store.steampowered.com/app/702050= +/"] +is equivalent to the above example.
  • +
+

In all of the above forms, an error is returned if the site is not +known in the database or if the URL format is not recognized. The list +of supported sites and URL formats tends to change over time, see GET /schema for the current l= +ist of supported +sites.

+

Undocumented: animation

+

Fields

+
+
id
+
+vndbid. +
+
title
+
+String, main title as displayed on the site, typically romanized from +the original script. +
+
alttitle
+
+String, can be null. Alternative title, typically the same as +title but in the original script. +
+
languages
+
+Array of objects, languages this release is available in. There is +always exactly one language that is considered the =E2=80=9Cmain=E2=80=9D l= +anguage of +this release, which is only used to select the titles for the +title and alttitle fields. +
+
languages.lang
+
+String, language. Each language appears at most once. +
+
languages.title
+
+String, title in the original script. Can be null, in which case the +title for this language is the same as the =E2=80=9Cmain=E2=80=9D language. +
+
languages.latin
+
+String, can be null, romanized version of title. +
+
languages.mtl
+
+Boolean, whether this is a machine translation. +
+
languages.main
+
+Boolean, whether this language is used to determine the =E2=80=9Cmain=E2=80= +=9D title for +the release entry. +
+
platforms
+
+Array of strings. +
+
media
+
+Array of objects. +
+
media.medium
+
+String. +
+
media.qty
+
+Integer, quantity. This is 0 for media where the quantity +is unknown or where it does not make sense, like =E2=80=9Cinternet download= +=E2=80=9D. +
+
vns
+
+Array of objects, the list of visual novels this release is linked to. +
+
vns.rtype
+
+The release type for this visual novel, can be "trial", +"partial" or "complete". +
+
vns.*
+
+All visual novel fields= + are available. +
+
producers
+
+Array of objects. +
+
producers.developer
+
+Boolean. +
+
producers.publisher
+
+Boolean. +
+
producers.*
+
+All producer fields are available. +
+
images
+
+Array of objects, possibly empty. +
+
images.*
+
+All visual novel = +image.* fields +are available here as well. +
+
images.type
+
+Image type, valid values are "pkgfront", +"pkgback", "pkgcontent", +"pkgside", "pkgmed" and "dig". +
+
images.vn
+
+Visual novel ID to which this image applies, usually null. This field is +only useful for bundle releases that are linked to multiple VNs. +
+
images.languages
+
+Array of languages for which this image is valid, or null if the image +is valid for all languages assigned to this release. +
+
images.photo
+
+Boolean. +
+
released
+
+Release date. +
+
minage
+
+Integer, possibly null, age rating. +
+
patch
+
+Boolean. +
+
freeware
+
+Boolean. +
+
uncensored
+
+Boolean, can be null. +
+
official
+
+Boolean. +
+
has_ero
+
+Boolean. +
+
resolution
+
+Can either be null, the string "non-standard" or an array +of two integers indicating the width and height. +
+
engine
+
+String, possibly null. +
+
voiced
+
+Int, possibly null, 1 =3D not voiced, 2 =3D only ero scenes voiced, 3 =3D +partially voiced, 4 =3D fully voiced. +
+
notes
+
+String, possibly null, may contain format= +ting codes. +
+
gtin
+
+JAN/EAN/UPC code, formatted as a string, possibly null. +
+
catalog
+
+String, possibly null, catalog number. +
+
extlinks
+
+Array, links to external websites. This list is equivalent to the links +displayed on the release pages on the site, so it may include redundant +entries (e.g. if a Steam ID is known, links to both Steam and SteamDB +are included) and links that are automatically fetched from external +resources. These extra sites are not listed in the extlinks +list of the schema. +
+
extlinks.url
+
+String, URL. +
+
extlinks.label
+
+String, English human-readable label for this link. +
+
extlinks.name
+
+Internal identifier of the site, intended for applications that want to +localize the label or to parse/format/extract remote identifiers. Keep +in mind that the list of supported sites, their internal names and their +ID types are subject to change, but I=E2=80=99ll try to keep things stable. +
+
extlinks.id
+
+Remote identifier for this link. Not all sites have a sensible +identifier as part of their URL format, in such cases this field is +simply equivalent to the URL. +
+
+

Missing: animation.

+

POST /producer

+

Accepted values for "sort": id, +name, searchrank.

+

Filters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameFDescription
idovndbid
searchmString search.
langLanguage.
typeProducer type, see the type +field below.
extlinkmMatch on external links, works similar to +the extlink filter for releases.
+

Fields

+
+
id
+
+vndbid. +
+
name
+
+String. +
+
original
+
+String, possibly null, name in the original script. +
+
aliases
+
+Array of strings. +
+
lang
+
+String, primary language. +
+
type
+
+String, producer type, "co" for company, "in" +for individual and "ng" for amateur group. +
+
description
+
+String, possibly null, may contain format= +ting codes. +
+
extlinks
+
+Array, links to external websites. Works the same as the =E2=80=98extlinks= +=E2=80=99 release fiel= +d. +
+
+

Missing: relations.

+

POST /character

+

Accepted values for "sort": id, +name, searchrank.

+

Filters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameFDescription
idovndbid
searchmString search.
rolemString, see vns.role field. +If this filter is used when nested inside a visual novel filter, then +this matches the role of the particular visual novel. +Otherwise, this matches the role of any linked visual +novel.
blood_typeString.
sexString.
sex_spoilString.
genderString.
gender_spoilString.
heighto,n,iInteger, cm.
weighto,n,iInteger, kg.
busto,n,iInteger, cm.
waisto,n,iInteger, cm.
hipso,n,iInteger, cm.
cupo,n,iString, cup size.
ageo,n,iInteger.
traitmTraits applied to this character, also +matches parent traits. See below for more details.
dtraitmTraits applied directly to this character, +does not match parent traits. See below for details.
birthdaynArray of two integers, month and day. Day +may be 0 to find characters whose birthday is in a given +month.
seiyuumMatch characters that are voiced by the +matching staff filters<= +/a>. Voice actor +information is actually specific to visual novels, but this filter does +not (currently) correlate against the parent entry when nested inside a +visual novel filter.
vnmMatch characters linked to visual novels +described by visual novel = +filters.
+

The trait and dtrait filters accept either +a plain trait ID or a two-element array containing the trait ID and +maximum spoiler level. These work similar to the tag filters for visual novels, except that trait= +s don=E2=80=99t have a +rating.

+

Fields

+
+
id
+
+vndbid. +
+
name
+
+String. +
+
original
+
+String, possibly null, name in the original script. +
+
aliases
+
+Array of strings. +
+
description
+
+String, possibly null, may contain format= +ting codes. +
+
image.*
+
+Object, possibly null, same sub-fields as the image visual novel field. (Except for +thumbnail and thumbnail_dims because character +images are currently always limited to 256x300px, but that is subject to +change in the future). +
+
blood_type
+
+String, possibly null, "a", "b", +"ab" or "o". +
+
height
+
+Integer, possibly null, cm. +
+
weight
+
+Integer, possibly null, kg. +
+
bust
+
+Integer, possibly null, cm. +
+
waist
+
+Integer, possibly null, cm. +
+
hips
+
+Integer, possibly null, cm. +
+
cup
+
+String, possibly null, "AAA", "AA", or any +single letter in the alphabet. +
+
age
+
+Integer, possibly null, years. +
+
birthday
+
+Possibly null, otherwise an array of two integers: month and day, +respectively. +
+
sex
+
+Possibly null, otherwise an array of two strings: the character=E2=80=99s +apparent (non-spoiler) sex and the character=E2=80=99s real (spoiler) sex. +Possible values are null, "m", +"f", "b" (meaning =E2=80=9Cboth=E2=80=9D) or "n" +(sexless). +
+
gender
+
+Possibly null, otherwise an array of two strings indicating the +character=E2=80=99s non-spoiler gender and the character=E2=80=99s actual (= +spoiler) +gender. Possible values are null, "m", +"f", "o" (non-binary) or "a" +(ambiguous). +
+
vns
+
+Array of objects, visual novels this character appears in. The same +visual novel may be listed multiple times with a different release; the +spoiler level and role can be different per release. +
+
vns.spoiler
+
+Integer. +
+
vns.role
+
+String, "main" for protagonist, "primary" for +main characters, "side" or "appears". +
+
vns.*
+
+All visual novel fields= + are available here. +
+
vns.release.*
+
+Object, usually null, specific release that this character appears in. +All release fields= + are available here. +
+
traits
+
+Array of objects, possibly empty. +
+
traits.spoiler
+
+Integer, 0, 1 or 2, spoiler level. +
+
traits.lie
+
+Boolean. +
+
traits.*
+
+All trait fields are= + available here. +
+
+

Missing: gender, instances, voice actor

+

POST /staff

+

Unlike other database entries, staff have more than one unique +identifier. There is the main =E2=80=98staff ID=E2=80=99, which uniquely id= +entifies a +person and is what a staff page on the site represents.

+

Additionally, every staff alias also has its own unique identifier, +which is referenced from other database entries to identify which alias +was used. This identifier is generally hidden on the site and aliases do +not have their own page, but the IDs are exposed in this API in order to +facilitate linking VNs/characters to staff names.

+

This particular API queries staff names, not just staff +entries, which means that a staff entry with multiple names can +be included multiple times in the API results, once for each name they +are known as. When searching or listing staff entries, this is usually +what you want. When fetching more detailed information about specific +staff entries, this is very much not what you want. The +ismain filter can be used to remove this duplication and +ensure you get at most one result per staff entry, for example:

+
c=
+url https://api.vndb.org/kana/staff --header 'Content-Type: application/json' --data '{
+    "filters": ["and", ["=
+ismain", "=3D", 1], ["id", "=3D", "s81"] ],
+    "fields": "lang,alias=
+es{name,latin,ismain},description,extlinks{url,label}"
+}'
+

Accepted values for "sort": id, +name, searchrank.

+

Filters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameFDescription
idovndbid
aidinteger, alias identifier
searchmString search.
langLanguage.
genderGender.
rolemString, can either be +"seiyuu" or one of the values from +enums.staff_role in the schema +JSON. If this filter is used when nested inside a visual novel +filter, then this matches the role of the particular visual +novel. Otherwise, this matches the role of any linked +visual novel.
extlinkmMatch on external links, works similar to +the extlink filter for releases.
ismainOnly accepts a single value, integer +1.
+

Fields

+
+
id
+
+vndbid. +
+
aid
+
+Integer, alias id. +
+
ismain
+
+Boolean, whether the =E2=80=98name=E2=80=99 and =E2=80=98original=E2=80=99 = +fields represent the main +name for this staff entry. +
+
name
+
+String, possibly romanized name. +
+
original
+
+String, possibly null, name in original script. +
+
lang
+
+String, staff=E2=80=99s primary language. +
+
gender
+
+String, possibly null, "m" or "f". +
+
description
+
+String, possibly null, may contain format= +ting codes. +
+
extlinks
+
+Array, links to external websites. Works the same as the =E2=80=98extlinks= +=E2=80=99 release fiel= +d. +
+
aliases
+
+Array, list of names used by this person. +
+
aliases.aid
+
+Integer, alias id. +
+
aliases.name
+
+String, name in original script. +
+
aliases.latin
+
+String, possibly null, romanized version of =E2=80=98name=E2=80=99. +
+
aliases.ismain
+
+Boolean, whether this alias is used as =E2=80=9Cmain=E2=80=9D name for the = +staff entry. +
+
+

POST /tag

+

Accepted values for "sort": id, +name, vn_count, searchrank.

+

Filters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
NameFDescription
idovndbid
searchmString search.
categoryString, see category +field.
+

Fields

+
+
id
+
+vndbid. +
+
name
+
+String. +
+
aliases
+
+Array of strings. +
+
description
+
+String, may contain formatting +codes. +
+
category
+
+String, "cont" for content, "ero" for sexual +content and "tech" for technical tags. +
+
searchable
+
+Bool. +
+
applicable
+
+Bool. +
+
vn_count
+
+Integer, number of VNs this tag has been applied to, including any child +tags. +
+
+

Missing: some way to fetch parent/child tags. Not obvious how to +do this efficiently because tags form a DAG rather than a tree.

+

POST /trait

+

Accepted values for "sort": id, +name, char_count, searchrank.

+

Filters

+ +++++ + + + + + + + + + + + + + + + + + + + +
NameFDescription
idovndbid
searchmString search.
+

Fields

+
+
id
+
+vndbid +
+
name
+
+String. Trait names are not necessarily self-describing, so they should +always be displayed together with their =E2=80=9Cgroup=E2=80=9D (see below)= +, which is +the top-level parent that the trait belongs to. +
+
aliases
+
+Array of strings. +
+
description
+
+String, may contain formatting +codes. +
+
searchable
+
+Bool. +
+
applicable
+
+Bool. +
+
sexual
+
+Bool. +
+
group_id
+
+vndbid +
+
group_name
+
+String +
+
char_count
+
+Integer, number of characters this trait has been applied to, including +child traits. +
+
+

POST /quote

+

Query visual novel quotes.

+

Accepted values for "sort": id, +score.

+

To fetch a random quote, using the same algorithm as on the website +footer:

+
c=
+url https://api.vndb.org/kana/quote --header 'Content-Type: application/json' --data '{
+    "fields": "vn{id,titl=
+e},character{id,name},quote",
+    "filters": [ "random"=
+, "=3D", 1 ]
+}'
+

To fetch all quotes from a visual novel, ordered by score:

+
c=
+url https://api.vndb.org/kana/quote --header 'Content-Type: application/json' --data '{
+    "fields": "character{=
+id,name},quote,score",
+    "filters": [ "vn", "=
+=3D", [ "id", "=3D", "v5" ] ],
+    "sort": "score",
+    "reverse": true
+}'
+

Filters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameFDescription
idovndbid
vnMatch quotes from the visual novel(s) +described by visual novel = +filters.
characterMatch quotes from the characters(s) +described by charac= +ter filters.
randomOnly accepts a single value, integer +1. Matches exactly one random quote from the list of +all quotes with a positive score.
+

The random filter does not really combine with any other +filters; adding other filters to the query means you may randomly get +zero results instead. You could select more than one random +quote by putting multiple random filters inside an +or clause, but then there=E2=80=99s still the possibility that= + you +get fewer quotes than requested, when the algorithm happens to select +the same quote multiple times. See random entry +for alternative strategies.

+

Fields

+
+
id
+
+vndbid. +
+
quote
+
+String. +
+
score
+
+Integer. +
+
vn.*
+
+Visual novel info, all visu= +al novel fields can +be selected here. +
+
character.*
+
+Character info, all = +character fields can +be selected here. +
+
+

List Management

+

POST /ulist

+

Fetch a user=E2=80=99s list. This API is very much like +POST /vn, except it requires the "user" +parameter to be set and it has a different response structure. All visual novel filters can be u= +sed here.

+

If the user has visual novel entires on their list that have been +deleted from the database, these will not be returned through the API +even though they do show up on the website.

+

Accepted values for "sort": id, +title, released, rating, +votecount, voted, vote, +added, lastmod, started, +finished, searchrank.

+

Very important example on how to fetch Yorhel=E2=80=99s top 10 voted vis= +ual +novels:

+
c=
+url https://api.vndb.org/kana/ulist --header 'Content-Type: application/json' --data '{
+    "user": "u2",<=
+/span>
+    "fields": "id, vote, =
+vn.title",
+    "filters": [ "label",=
+ "=3D", 7 ],
+    "sort": "vote",
+    "reverse": true,
+    "results": 10<=
+/span>
+}'
+

Fields

+
+
id
+
+Visual novel ID. +
+
added
+
+Integer, unix timestamp. +
+
voted
+
+Integer, can be null, unix timestamp of when the user voted on this VN. +
+
lastmod
+
+Integer, unix timestamp when the user last modified their list for this +VN. +
+
vote
+
+Integer, can be null, 10 - 100. +
+
started
+
+String, start date, can be null, =E2=80=9CYYYY-MM-DD=E2=80=9D format. +
+
finished
+
+String, finish date, can be null. +
+
notes
+
+String, can be null. +
+
labels
+
+Array of objects, user labels assigned to this VN. Private labels are +only listed when the user is authenticated. +
+
labels.id
+
+Integer. +
+
labels.label
+
+String. +
+
vn.*
+
+Visual novel info, all visu= +al novel fields can +be selected here. +
+
releases
+
+Array of objects, releases of this VN that the user has added to their +list. +
+
releases.list_status
+
+Integer, 0 for =E2=80=9CUnknown=E2=80=9D, 1 for =E2=80=9CPending=E2=80=9D, = +2 for =E2=80=9CObtained=E2=80=9D, 3 for =E2=80=9COn +loan=E2=80=9D, 4 for =E2=80=9CDeleted=E2=80=9D. +
+
releases.*
+
+All release fields= + can be selected here. +
+
+

GET /ulist_labels

+

Fetch the list labels for a certain user. Accepts two query +parameters:

+
+
user
+
+The user ID to fetch the labels for. If the parameter is missing, the +labels for the currently authenticated user are fetched instead. +
+
fields
+
+List of fields to select. Currently only count may be +specified, the other fields are always selected. +
+
+

Returns a JSON object with a single key, "labels", which +is an array of objects with the following members:

+
+
id
+
+Integer identifier of the label. +
+
private
+
+Boolean, whether this label is private. Private labels are only included +when authenticated with the listread permission. The +=E2=80=98Voted=E2=80=99 label (id=3D7) is always included even when private= +. +
+
label
+
+String. +
+
count
+
+Integer. The =E2=80=98Voted=E2=80=99 label may have different counts depend= +ing on +whether the user has authenticated. +
+
+

Labels with an id below 10 are the pre-defined labels and are the +same for everyone, though even pre-defined labels are excluded if they +are marked private.

+

Example: Multi has only the default +labels.

+
c=
+url 'https://api.vndb.org/kana/ulist_labels?user=
+=3Du1'
+

PATCH /ulist/<id>

+

Add or update a visual novel in the user=E2=80=99s list. Requires the +listwrite permission. The JSON body accepts the following +members:

+
+
vote
+
+Integer between 10 and 100. +
+
notes
+
+String. +
+
started
+
+Date. +
+
finished
+
+Date. +
+
labels
+
+Array of integers, label ids. Setting this will overwrite any existing +labels assigned to the VN with the given array. +
+
labels_set
+
+Array of label ids to add to the VN, any already existing labels will be +unaffected. +
+
labels_unset
+
+Array of label ids to remove from the VN. +
+
+

All members are be optional, missing members are not modified. A +null value can be used to unset a field (except for +labels).

+

The virtual labels with id 0 (=E2=80=9CNo label=E2=80=9D) and 7 (=E2=80= +=9CVoted=E2=80=9D) can not be +set. The =E2=80=9Cvoted=E2=80=9D label is automatically added/removed based= + on the +vote field.

+

Wonky behavior alert: this API does not verify label ids and lets you +add non-existent labels. These are not displayed on the website and not +returned by POST /ulist, but they=E2=80=99re still +stored in the database and may magically show up if a label with that id +is created in the future. Don=E2=80=99t rely on this behavior, it=E2=80=99s= + a bug.

+

More wonky behavior: the website automatically unsets the other +Playing/Finished/Stalled/Dropped labels when you select one of those, +but this is not enforced server-side and the API lets you set all labels +at the same time. This is totally not a bug.

+

Example to remove the =E2=80=9CPlaying=E2=80=9D label, add the =E2=80=9C= +Finished=E2=80=9D label and +vote a 6:

+
c=
+url -XPATCH https://api.vndb.org/kana/ulis=
+t/v17 \
+    --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'=
+ \
+    --header 'Content-Type: application/json' \<=
+/span>
+    --data '{"labels_unset":[1],"labels_set":[2],"vote":60}'=
+
+

Or to remove an existing vote without affecting any of the other +fields:

+
c=
+url -XPATCH https://api.vndb.org/kana/ulis=
+t/v17 \
+    --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'=
+ \
+    --header 'Content-Type: application/json' \<=
+/span>
+    --data '{"vote":null}'
+

Slightly unintuitive behavior alert: this API always adds +the visual novel to the user=E2=80=99s list if it=E2=80=99s not already pre= +sent, and +that also applies to the above =E2=80=9Cremoving a vote=E2=80=9D example. U= +se DELETE if you w= +ant to remove a VN from the +list.

+

PATCH /rlist/<id>

+

Add or update a release in the user=E2=80=99s list. Requires the +listwrite permission. All visual novels linked to the +release are also added to the user=E2=80=99s visual novel list, if they are= +n=E2=80=99t +in the list yet. The JSON body accepts the following members:

+
+
status
+
+Release status, integer. See releases.list_status in the POST /ulist fields for th= +e list of possible +values. Defaults to 0. +
+
+

Example, to mark r12 as obtained:

+
c=
+url -XPATCH https://api.vndb.org/kana/rlis=
+t/r12 \
+    --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'=
+ \
+    --header 'Content-Type: application/json' \<=
+/span>
+    --data '{"status":2}'
+

DELETE /ulist/<id>

+

Remove a visual novel from the user=E2=80=99s list. Returns success even= + if +the VN is not on the user=E2=80=99s list. Removing a VN also removes any +associated releases from the user=E2=80=99s list.

+
c=
+url -XDELETE https://api.vndb.org/kana/uli=
+st/v17 \
+    --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'=
+
+

DELETE /rlist/<id>

+

Remove a release from the user=E2=80=99s list. Returns success even if t= +he +release is not on the user=E2=80=99s list. Removing a release does not remo= +ve +the associated visual novels from the user=E2=80=99s visual novel list, tha= +t +requires separate calls to DELETE +/ulist/<id>.

+
c=
+url -XDELETE https://api.vndb.org/kana/rli=
+st/r12 \
+    --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'=
+
+

HTTP Response Codes

+

Successful responses always return either 200 OK with a +JSON body or 204 No Content in the case of DELETE/PATCH +requests, but errors may happen. Error response codes are typically +followed with a text/plain or text/html body. +The following is a non-exhaustive list of error codes you can expect to +see:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeReason
400Invalid request body or query, the +included error message hopefully points at the problem.
401Invalid authentication token.
404Invalid API path or HTTP method
429Throttled
500Server error, usually points to a bug if +this persists
502Server is down, should be temporary
+

Tips & Troubleshooting

+

=E2=80=9CToo much data selected=E2=80=9D<= +/h2> +

The server calculates a rough estimate of the number of JSON keys it +would generate in response to your query and throws an error if that +estimation exceeds a certain threshold, i.e. if the response is expect= +ed +to be rather large. This estimation is entirely based on the +"fields" and "results" parameters, so you can +work around this error by either selecting fewer fields or fewer +results.

+

List of identifiers

+

If you have a (potentially large) list of database identifiers you=E2=80= +=99d +like to fetch, it is faster and more efficient to fetch 100 entries in a +single API call than it is to make 100 separate API calls. Simply create +a filter containing the identifiers, like in the following example:

+
c=
+url https://api.vndb.org/kana/vn --header =
+'Content-Type: application/json' --data '{
+  "fields": "title",
+  "filters": ["or"=
+
+     , ["id","=3D","v1"]<=
+/span>
+     , ["id","=3D","v2"]<=
+/span>
+     , ["id","=3D","v3"]<=
+/span>
+     , ["id","=3D","v4"]<=
+/span>
+     , ["id","=3D","v5"] =
+],
+  "results": 100
+}'=
+
+

Do not add more than 100 identifiers in a single query. You=E2=80=99ll +especially want to avoid sending the same list of identifiers multiple +times but with higher "page" numbers, see also the next +point.

+

Pagination

+

While the API supports pagination through the "page" +parameter, this is often not the most efficient way to retrieve a large +list of entries. Results are sorted on "id" by default so +you can also implement pagination by filtering on this field. For +example, if the last item you=E2=80=99ve received had id "v123", +you can fetch the next page by filtering on +["id",">","v123"].

+

This approach tends to not work as well when sorting on other fields, +so "page"-based pagination is often still the better +solution in those cases.

+

Random entry

+

Fetching a random entry from a database is, in general, pretty +challenging to do in a performant way. Here=E2=80=99s one approach that can= + be +used with the API: first grab the highest database identifier, then +select a random number between 1 and the highest identifier +(both inclusive) and then fetch the entry with that or the nearest +increasing id, e.g.:

+
c=
+url https://api.vndb.org/kana/vn --header =
+'Content-Type: application/json' --data '{
+    "sort": "id",<=
+/span>
+    "reverse": true,
+    "results": 1
+}'
+

Then, assuming you=E2=80=99ve randomly chosen id v4567:

+
c=
+url https://api.vndb.org/kana/vn --header =
+'Content-Type: application/json' --data '{
+    "filters": [ "id", "&=
+gt;=3D", "v4567" ],
+    "fields": "title",
+    "results": 1
+}'
+

The result of the first query can be cached. Additional filters can +be added to both queries if you want to narrow down the selection. This +method has a slight bias in its selection due to the presence of id +gaps, but you most likely don=E2=80=99t need perfect uniform random selecti= +on +anyway.

+

Change Log

+

2026-01-10

+ +

2025-06-02

+ +

2025-05-02

+
    +
  • Limit maximum number of filter predicates in a single request to +1000.
  • +
+

2025-04-05

+ +

2025-01-11

+ +

2025-01-09

+ +

2025-01-07

+ +

2024-09-09

+ +

2024-07-06

+ +

2024-06-05

+ +

2024-05-23

+ +

2024-05-18

+ +

2024-05-11

+
    +
  • Add image{thumbnail,thumbnail_dims} fields to POST /vn. Beware: VN images can now b= +e larger than +256x400px.
  • +
+

2024-03-13

+ +

2023-11-20

+ +

2023-08-02

+ +

2023-07-11

+
    +
  • Deprecated popularity sort options for POST /ulist and POST /vn, +it=E2=80=99s now equivalent to sorting on the reverse of +votecount.
  • +
  • Deprecated popularity filter and field for POST /vn.
  • +
+

2023-04-05

+
    +
  • Add searchrank sort option to all endpoints that have a +search filter.
  • +
+

2023-03-19

+ +

2023-01-17

+
+
+
+
    +
  1. Yes, sorry, I know every API having its own query system +sucks, but I couldn=E2=80=99t find an existing solution that works well for +VNDB.=E2=86=A9=EF=B8=8E

  2. +
  3. Fun fact: the web interface also accepts filters in JSON +form, but that tends to result in long and ugly URLs.=E2=86= +=A9=EF=B8=8E

  4. +
  5. There is also a third representation for filters, which +the API also accepts, but I won=E2=80=99t bother you with that. It=E2=80=99= +s only useful +as an intermediate representation when converting between the JSON and +string format, which you shouldn=E2=80=99t be doing manually.=E2=86=A9=EF=B8=8E

  6. +
+
+ + + +------MultipartBoundary--iFOSnX79r9F8KpuRTSwwVgShnV5hBx9uvFtLF8zrAZ---- +Content-Type: text/css +Content-Transfer-Encoding: quoted-printable +Content-Location: cid:css-d65ec0c8-f004-4010-8603-861f49b335f5@mhtml.blink + +@charset "utf-8"; + +body { max-width: 900px; } + +td { vertical-align: top; } + +header, header h1 { margin: 0px; } + +@media (min-width: 1100px) { + body { margin: 0px 0px 0px 270px; } + nav { box-sizing: border-box; position: fixed; padding: 50px 20px 10px 10= +px; top: 0px; left: 0px; height: 100%; overflow: scroll; } +} +------MultipartBoundary--iFOSnX79r9F8KpuRTSwwVgShnV5hBx9uvFtLF8zrAZ---- +Content-Type: text/css +Content-Transfer-Encoding: quoted-printable +Content-Location: cid:css-4278df0f-2856-4d8e-9d4c-63ab937f01cb@mhtml.blink + +@charset "utf-8"; + +html { color: rgb(26, 26, 26); background-color: rgb(253, 253, 253); } + +body { margin: 0px auto; max-width: 36em; padding: 50px; hyphens: auto; ove= +rflow-wrap: break-word; text-rendering: optimizelegibility; font-kerning: n= +ormal; } + +@media (max-width: 600px) { + body { font-size: 0.9em; padding: 12px; } + h1 { font-size: 1.8em; } +} + +@media print { + html { background-color: white; } + body { background-color: transparent; color: black; font-size: 12pt; } + p, h2, h3 { orphans: 3; widows: 3; } + h2, h3, h4 { break-after: avoid; } +} + +p { margin: 1em 0px; } + +a { color: rgb(26, 26, 26); } + +a:visited { color: rgb(26, 26, 26); } + +img { max-width: 100%; } + +svg { height: auto; max-width: 100%; } + +h1, h2, h3, h4, h5, h6 { margin-top: 1.4em; } + +h5, h6 { font-size: 1em; font-style: italic; } + +h6 { font-weight: normal; } + +ol, ul { padding-left: 1.7em; margin-top: 1em; } + +li > ol, li > ul { margin-top: 0px; } + +blockquote { margin: 1em 0px 1em 1.7em; padding-left: 1em; border-left: 2px= + solid rgb(230, 230, 230); color: rgb(96, 96, 96); } + +code { font-family: Menlo, Monaco, Consolas, "Lucida Console", monospace; f= +ont-size: 85%; margin: 0px; hyphens: manual; } + +pre { margin: 1em 0px; overflow: auto; } + +pre code { padding: 0px; overflow: visible; overflow-wrap: normal; } + +.sourceCode { background-color: transparent; overflow: visible; } + +hr { border-width: 1px medium medium; border-style: solid none none; border= +-color: rgb(26, 26, 26) currentcolor currentcolor; border-image: initial; h= +eight: 1px; margin: 1em 0px; } + +table { margin: 1em 0px; border-collapse: collapse; width: 100%; overflow-x= +: auto; display: block; font-variant-numeric: lining-nums tabular-nums; } + +table caption { margin-bottom: 0.75em; } + +tbody { margin-top: 0.5em; border-top: 1px solid rgb(26, 26, 26); border-bo= +ttom: 1px solid rgb(26, 26, 26); } + +th { border-top: 1px solid rgb(26, 26, 26); padding: 0.25em 0.5em; } + +td { padding: 0.125em 0.5em 0.25em; } + +header { margin-bottom: 4em; text-align: center; } + +#TOC li { list-style: none; } + +#TOC ul { padding-left: 1.3em; } + +#TOC > ul { padding-left: 0px; } + +#TOC a:not(:hover) { text-decoration: none; } + +code { white-space: pre-wrap; } + +span.smallcaps { font-variant: small-caps; } + +div.columns { display: flex; gap: min(4vw, 1.5em); } + +div.column { flex: 1 1 auto; overflow-x: auto; } + +div.hanging-indent { margin-left: 1.5em; text-indent: -1.5em; } + +ul.task-list[class] { list-style: none; } + +ul.task-list li input[type=3D"checkbox"] { font-size: inherit; width: 0.8em= +; margin: 0px 0.8em 0.2em -1.6em; vertical-align: middle; } + +.display.math { display: block; text-align: center; margin: 0.5rem auto; } + +html { text-size-adjust: 100%; } + +pre > code.sourceCode { white-space: pre; position: relative; } + +pre > code.sourceCode > span { display: inline-block; line-height: 1.25; } + +pre > code.sourceCode > span:empty { height: 1.2em; } + +.sourceCode { overflow: visible; } + +code.sourceCode > span { color: inherit; text-decoration: inherit; } + +div.sourceCode { margin: 1em 0px; } + +pre.sourceCode { margin: 0px; } + +@media screen { + div.sourceCode { overflow: auto; } +} + +@media print { + pre > code.sourceCode { white-space: pre-wrap; } + pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; } +} + +pre.numberSource code { counter-reset: source-line 0; } + +pre.numberSource code > span { position: relative; left: -4em; counter-incr= +ement: source-line 1; } + +pre.numberSource code > span > a:first-child::before { content: counter(sou= +rce-line); position: relative; left: -1em; text-align: right; vertical-alig= +n: baseline; border-width: medium; border-style: none; border-color: curren= +tcolor; border-image: initial; display: inline-block; user-select: none; pa= +dding: 0px 4px; width: 4em; color: rgb(170, 170, 170); } + +pre.numberSource { margin-left: 3em; border-left: 1px solid rgb(170, 170, 1= +70); padding-left: 4px; } + +div.sourceCode { } + +@media screen { + pre > code.sourceCode > span > a:first-child::before { text-decoration: u= +nderline; } +} + +code span.al { color: rgb(255, 0, 0); font-weight: bold; } + +code span.an { color: rgb(96, 160, 176); font-weight: bold; font-style: ita= +lic; } + +code span.at { color: rgb(125, 144, 41); } + +code span.bn { color: rgb(64, 160, 112); } + +code span.bu { color: rgb(0, 128, 0); } + +code span.cf { color: rgb(0, 112, 32); font-weight: bold; } + +code span.ch { color: rgb(64, 112, 160); } + +code span.cn { color: rgb(136, 0, 0); } + +code span.co { color: rgb(96, 160, 176); font-style: italic; } + +code span.cv { color: rgb(96, 160, 176); font-weight: bold; font-style: ita= +lic; } + +code span.do { color: rgb(186, 33, 33); font-style: italic; } + +code span.dt { color: rgb(144, 32, 0); } + +code span.dv { color: rgb(64, 160, 112); } + +code span.er { color: rgb(255, 0, 0); font-weight: bold; } + +code span.ex { } + +code span.fl { color: rgb(64, 160, 112); } + +code span.fu { color: rgb(6, 40, 126); } + +code span.im { color: rgb(0, 128, 0); font-weight: bold; } + +code span.in { color: rgb(96, 160, 176); font-weight: bold; font-style: ita= +lic; } + +code span.kw { color: rgb(0, 112, 32); font-weight: bold; } + +code span.op { color: rgb(102, 102, 102); } + +code span.ot { color: rgb(0, 112, 32); } + +code span.pp { color: rgb(188, 122, 0); } + +code span.sc { color: rgb(64, 112, 160); } + +code span.ss { color: rgb(187, 102, 136); } + +code span.st { color: rgb(64, 112, 160); } + +code span.va { color: rgb(25, 23, 124); } + +code span.vs { color: rgb(64, 112, 160); } + +code span.wa { color: rgb(96, 160, 176); font-weight: bold; font-style: ita= +lic; } +------MultipartBoundary--iFOSnX79r9F8KpuRTSwwVgShnV5hBx9uvFtLF8zrAZ------ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d4d6106 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,142 @@ +# CHANGELOG + +## [1.0.0] - 2026-05-01 + +### Added +- ✨ Полная поддержка VNDB API v2 (Kana) +- 🎮 Поиск визуальных новелл по названию, языку, платформе, тегам, рейтингу +- 👥 Поиск персонажей по имени, полу, черт характера +- 🎬 Поиск релизов по названию, платформе, типу +- 👨‍💼 Поиск сотрудников (сценаристы, художники, композиторы) +- 🏢 Поиск продюсеров и издателей +- 🏷️ Просмотр популярных тегов +- ✨ Просмотр черт характера персонажей +- 💬 Получение случайных цитат +- 📊 Статистика базы данных VNDB +- 📋 Информация о схеме API +- 🔐 Поддержка авторизации с помощью API токена + +### Features +- ⚡ Асинхронные запросы для быстрого отклика +- 🔄 Обработка ошибок с пользовательскими сообщениями +- 📝 Подробное логирование для отладки +- 🎨 Красивое форматирование ответов с поддержкой Markdown +- 📦 Полная поддержка Docker и Docker Compose +- 🧪 Unit тесты для основных функций +- ⚙️ Гибкая конфигурация через переменные окружения + +### Architecture +- `bot.py` - Основной модуль бота с обработчиками команд +- `vndb_client.py` - VNDB API клиент с поддержкой всех endpoints +- `config.py` - Управление конфигурацией +- `utils.py` - Утилиты для форматирования и обработки ошибок +- `advanced_features.py` - Продвинутые функции (кэширование, rate limiting, сессии) +- `test_bot.py` - Unit тесты +- `requirements.txt` - Зависимости Python +- `Dockerfile` и `docker-compose.yml` - Контейнеризация + +### Documentation +- 📚 README.md с полной документацией +- 📖 INSTALLATION.md с пошаговыми инструкциями установки +- 🔍 Inline документация в исходном коде +- 📝 Примеры использования для каждой команды + +### Commands +- `/start` - Начало работы с ботом +- `/help` - Справка по всем командам +- `/search <название>` - Поиск визуальных новелл +- `/char <имя>` - Поиск персонажей +- `/release <название>` - Поиск релизов +- `/staff <имя>` - Поиск сотрудников +- `/producer <название>` - Поиск продюсеров +- `/tag` - Список тегов +- `/trait` - Список черт характера +- `/quote [число]` - Случайные цитаты +- `/stats` - Статистика базы данных +- `/schema` - Информация о схеме API +- `/authinfo` - Информация об авторизации + +### Technical Details +- Python 3.8+ +- python-telegram-bot 21.0 +- httpx для асинхронных HTTP запросов +- Асинхронная обработка с asyncio +- Полная поддержка rate limiting API VNDB (200 запросов за 5 минут) + +### Known Limitations +- Максимум 100 результатов на странице (ограничение API) +- Функции управления списками требуют валидный API токен +- Время выполнения одного запроса не должно превышать 3 секунды (ограничение API) + +### Future Plans +- 🔄 Добавить пагинацию результатов с кнопками навигации +- 💾 Добавить кэширование популярных запросов +- 📊 Добавить статистику использования бота +- 🌍 Поддержка разных языков интерфейса +- ⚙️ Админ панель для управления ботом +- 🔔 Уведомления о новых релизах избранных ВН +- 📱 Поддержка inline режима для использования в других чатах + +--- + +## Version History + +### v1.0.0 (Current) +- Initial release with full VNDB API v2 support + +--- + +## Dependencies + +### Core +- python-telegram-bot==21.0 - Telegram Bot API +- python-dotenv==1.0.0 - Environment variables +- aiohttp==3.9.1 - Async HTTP client +- requests==2.31.0 - HTTP library + +### Testing +- pytest==7.4.0 - Testing framework +- pytest-asyncio==0.21.0 - Async support for pytest + +### Development (Optional) +- black - Code formatter +- pylint - Code linter +- mypy - Type checker + +--- + +## Contributing + +Если вы хотите улучшить бот: +1. Форкните репозиторий +2. Создайте ветку для вашей функции +3. Коммитьте изменения +4. Отправьте Pull Request + +--- + +## License + +Данные, полученные через VNDB API, подлежат [Data License VNDB](https://vndb.org/d17#4). + +--- + +## Support + +Если у вас есть вопросы или проблемы: +- Проверьте документацию (README.md, INSTALLATION.md) +- Проверьте файлы логов +- Убедитесь, что API доступен +- Проверьте правильность конфигурации + +--- + +## Acknowledgments + +- VNDB (Visual Novel Database) за отличную базу данных и API +- python-telegram-bot за удобную библиотеку +- Python асинхронное сообщество + +--- + +**Последнее обновление**: 1 мая 2026 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9efd028 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application files +COPY bot.py . +COPY vndb_client.py . +COPY config.py . +COPY utils.py . + +# Copy environment file (or use docker secrets/environment variables) +# COPY .env . + +# Create non-root user for security +RUN useradd -m -u 1000 botuser && chown -R botuser:botuser /app +USER botuser + +# Run the bot +CMD ["python", "bot.py"] diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 0000000..db8d3f2 --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,343 @@ +# Примеры использования VNDB Telegram Bot + +## Быстрый старт + +### 1. Начало работы + +Напишите боту: +``` +/start +``` + +Бот вернёт приветственное сообщение с основной информацией. + +### 2. Справка + +Для просмотра всех доступных команд: +``` +/help +``` + +## Примеры основных команд + +### Поиск визуальных новелл + +#### Простой поиск +``` +/search Steins Gate +``` + +**Результат:** +- Список из 10 найденных ВН с основной информацией +- Рейтинг и количество голосов +- Дата выпуска +- Автоматическая отправка обложек первых 3 результатов 📸 + +#### Различные запросы +``` +/search Clannad +/search Fate +/search Genshin +/search Tsukihime +``` + +### Поиск персонажей + +#### Простой поиск +``` +/char Okabe Rintaro +``` + +**Результат:** +- Список найденных персонажей +- Информация о поле персонажа +- В каких ВН появляется персонаж +- Аватары первых 3 персонажей 🖼️ + +#### Другие примеры +``` +/char Nagisa +/char Sakura +/char Saber +/char Rem +``` + +### Поиск релизов + +#### Поиск по платформе +``` +/release Windows +/release Switch +/release PlayStation +``` + +#### Поиск по названию +``` +/release Steins Gate Windows +/release Clannad PS2 +``` + +## Детальный просмотр с картинками + +### Просмотр ВН + +Сначала найдите ВН: +``` +/search Steins Gate +``` + +Видите в результатах: `v17` - это ID Steins;Gate + +Затем просмотрите детали: +``` +/vn_detail v17 +``` + +**Получите:** +- 🖼️ Высокое качество обложки +- 📋 Полная информация: + - Название: Steins;Gate + - Оригинальное название: シュタインズ・ゲート + - Дата выпуска: 2009-09-15 + - Рейтинг и голоса + - Длительность + - Разработчик + - Подробное описание + - Прямая ссылка на VNDB + +### Просмотр персонажа + +Найдите персонажа: +``` +/char Okabe +``` + +Видите: `c4 - Okabe Rintaro` + +Посмотрите детали: +``` +/char_detail c4 +``` + +**Получите:** +- 👤 Аватар персонажа высокого качества +- 📋 Информация: + - Имя и оригинальное имя + - Пол + - Группа крови + - Связанные ВН + - Биография + +### Просмотр релиза + +Найдите релиз: +``` +/release Steins Gate Windows +``` + +Видите ID релиза в результатах + +Посмотрите детали: +``` +/release_detail r12345 +``` + +## Информационные команды + +### Статистика базы данных +``` +/stats +``` + +**Результат:** +- Количество ВН +- Количество персонажей +- Количество релизов +- Количество продюсеров +- Количество сотрудников +- Количество тегов +- Количество черт характера + +### Популярные теги +``` +/tag +``` + +**Результат:** +- Список 15 самых популярных тегов +- Категории и описания + +### Черты характера персонажей +``` +/trait +``` + +**Результат:** +- Список 15 самых распространённых черт +- Количество персонажей с каждой чертой + +### Случайные цитаты +``` +/quote +``` + +Получить одну случайную цитату + +``` +/quote 3 +``` + +Получить 3 случайные цитаты (максимум 5) + +## Расширенные примеры + +### Изучение конкретной ВН + +```bash +# 1. Поиск ВН +/search Clannad + +# 2. Просмотр полной информации (видите обложку и описание) +/vn_detail v4 + +# 3. Если интересует персонаж из Clannad +/char Nagisa + +# 4. Просмотр информации о персонаже +/char_detail c82 + +# 5. Поиск выпусков для разных платформ +/release Clannad Windows + +# 6. Просмотр конкретного релиза +/release_detail r500 +``` + +### Исследование визуальных новелл от одного разработчика + +```bash +# 1. Поиск ВН от Key (фамилия разработчика) +/search Key + +# 2. Посмотрите детали найденных ВН +/vn_detail v17 # Steins;Gate +/vn_detail v4 # Clannad +/vn_detail v1 # Air + +# 3. Изучите персонажей из каждой ВН +/char Nagisa # из Clannad +/char Okabe # из Steins;Gate +``` + +### Поиск информации о персонаже + +```bash +# 1. Найти персонажа по имени +/char Saber + +# 2. Посмотреть его подробную информацию +/char_detail c1234 + +# 3. Найти его родственные ВН в списке +# (информация включена в /char_detail) +``` + +## Советы и трюки + +### 💡 Совет 1: Комбинирование команд + +Эффективный способ использования: +```bash +/search [название] # Найдите элемент +/[type]_detail [id] # Посмотрите детали +``` + +### 💡 Совет 2: Использование ID + +После поиска вы видите ID в скобках: +``` +1. **Steins;Gate** (v17) +``` + +Скопируйте ID и используйте: +``` +/vn_detail v17 +``` + +### 💡 Совет 3: Максимум информации за раз + +Команды подробного просмотра дают максимум информации: +- Картинку (если есть) +- Все основные данные +- Описание +- Связанные элементы +- Ссылку на оригинальный сайт + +### 💡 Совет 4: Работа с изображениями + +- **Быстрый поиск**: используйте `/search`, получите автоматические картинки +- **Качество**: используйте `/vn_detail` и т.д. для лучшего качества картинок +- **Нет картинки**: если элемента нет в VNDB, текстовая информация всё равно будет доступна + +## Обработка ошибок + +### Если ничего не найдено +``` +/search очень_редкое_название +# Результат: 😞 Ничего не найдено +``` + +Попробуйте: +- Изменить орфографию +- Использовать часть названия +- Попробовать русское или английское название + +### Если элемент не существует +``` +/vn_detail v999999 +# Результат: 😞 ВН с ID v999999 не найдена +``` + +Убедитесь, что: +- ID правильный +- ID на самом сайте VNDB имеет этот вид + +### Если картинка не загружается +Текстовая информация по-прежнему будет отправлена. Вы можете: +- Попробовать ещё раз +- Посетить https://vndb.org напрямую для просмотра картинок +- Сообщить об ошибке, если проблема повторяется + +## Полезные команды для разных целей + +### 📚 Исследование ВН +```bash +/stats # Сколько всего ВН +/search [имя] # Найти конкретную ВН +/vn_detail [id] # Полная информация о ВН +``` + +### 👥 Изучение персонажей +```bash +/char [имя] # Найти персонажа +/char_detail [id] # Полная информация о персонаже +/trait # Посмотреть черты характера +``` + +### 🎬 Поиск релизов +```bash +/release [название] # Найти релиз +/release_detail [id] # Информация о релизе +/search [ВН] # Найти ВН и её релизы +``` + +### 🏷️ Просмотр категорий +```bash +/tag # Теги и категории +/trait # Черты характера +/schema # Полная информация о полях API +``` + +--- + +**Нужна помощь?** Напишите `/help` для справки по командам diff --git a/IMAGES.md b/IMAGES.md new file mode 100644 index 0000000..701a952 --- /dev/null +++ b/IMAGES.md @@ -0,0 +1,177 @@ +# Работа с изображениями в VNDB Telegram Bot + +## 📸 Поддерживаемые изображения + +Бот может отправлять изображения для: +- 🎮 **Визуальные новеллы** - обложки и постеры +- 👤 **Персонажи** - аватары и официальные картинки +- 📦 **Релизы** - коробки и обложки физических изданий + +## 🚀 Использование изображений + +### 1. Автоматическая отправка при поиске + +При поиске бот автоматически отправляет изображения для первых 3 результатов: + +``` +/search Steins Gate +``` + +Бот вернёт: +- 📝 Текстовую информацию со списком найденных ВН +- 🖼️ Обложки первых 3 ВН + +### 2. Детальный просмотр с полной информацией + +Для получения полной информации с изображением используйте команды подробного просмотра: + +#### Просмотр визуальной новеллы + +``` +/vn_detail v17 +``` + +Отправит: +- 🎮 Обложку ВН (высокое качество) +- 📋 Полная информация: + - Название и оригинальное название + - Дата выпуска + - Рейтинг и количество голосов + - Длительность + - Разработчик + - Описание + - Ссылка на VNDB + +#### Просмотр персонажа + +``` +/char_detail c1 +``` + +Отправит: +- 👤 Аватар персонажа +- 📋 Информация: + - Имя и оригинальное имя + - Пол + - Группа крови + - Список ВН, в которых появляется персонаж + - Описание + +#### Просмотр релиза + +``` +/release_detail r1 +``` + +Отправит: +- 📦 Картинку релиза +- 📋 Информация: + - Название + - Дата выпуска + - Платформа + - Тип (оригинальный, фан-перевод и т.д.) + - Языки + - Издание + - ВН, к которой относится + - Описание + +## 🎨 Примеры + +### Поиск и просмотр ВН + +``` +# Найти ВН по названию +/search Clannad + +# Просмотреть детали найденной ВН +/vn_detail v4 +``` + +### Поиск и просмотр персонажа + +``` +# Найти персонажа +/char Nagisa + +# Просмотреть детали персонажа +/char_detail c82 +``` + +### Поиск и просмотр релиза + +``` +# Найти релиз +/release Clannad Windows + +# Просмотреть детали релиза +/release_detail r500 +``` + +## 🔗 Как найти ID элемента + +### ID визуальной новеллы +- На сайте VNDB URL выглядит как: `https://vndb.org/v17` +- ID: `v17` + +### ID персонажа +- На сайте VNDB URL: `https://vndb.org/c1` +- ID: `c1` + +### ID релиза +- На сайте VNDB URL: `https://vndb.org/r123` +- ID: `r123` + +### ID посредством поиска +При поиске бот показывает ID в формате: +``` +1. **Steins;Gate** + ID: v17 +``` + +Скопируйте ID и используйте в команде подробного просмотра: +``` +/vn_detail v17 +``` + +## ⚠️ Обработка ошибок при изображениях + +Если изображение недоступно или бот не может его отправить: +- Бот вернёт текстовую информацию без изображения +- Информация будет полной и точной +- Вы по-прежнему можете посетить VNDB напрямую для просмотра изображения + +## 🌐 Источник изображений + +Все изображения загружаются с официального CDN VNDB: +- **URL база**: `https://t.vndb.org` +- **Качество**: Оптимальное для отображения в Telegram + +## 💡 Советы + +1. **Для быстрого поиска**: используйте `/search`, `/char`, `/release` + - Автоматически получите первые изображения + - Быстрый просмотр информации + +2. **Для подробной информации**: используйте `/vn_detail`, `/char_detail`, `/release_detail` + - Полная информация о элементе + - Высокое качество изображения + - Ссылка на официальный VNDB + +3. **Сочетание команд**: сначала найдите элемент, потом просмотрите детали + ``` + /search Steins Gate + # Видите v17 в результатах + /vn_detail v17 + # Получаете полную информацию с обложкой + ``` + +## 📝 Примечания + +- Не все элементы имеют изображения в VNDB +- Если изображение отсутствует, бот отправит только текстовую информацию +- Качество изображений зависит от наличия и разрешения в базе данных VNDB +- Бот поддерживает максимум до 3 изображений при поиске (для экономии трафика и скорости) + +--- + +Для других вопросов см. `/help` или README.md diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..2099e18 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,343 @@ +# Руководство по установке VNDB Telegram Bot + +## Быстрый старт (5 минут) + +### Требования +- Python 3.8 или выше +- pip (идёт с Python) +- Интернет соединение + +### Шаг 1: Получение токенов + +#### Telegram Bot Token +1. Откройте Telegram и найдите @BotFather +2. Напишите `/newbot` +3. Ответьте на вопросы о названии и username вашего бота +4. Вы получите токен (выглядит как `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) + +#### VNDB API Token (опционально) +1. Создайте аккаунт на https://vndb.org (если ещё нет) +2. Перейдите на https://vndb.org/u/tokens +3. Нажмите "Create new token" +4. Скопируйте полученный токен + +### Шаг 2: Установка бота + +```bash +# Скачайте проект +git clone +cd vntgbot + +# Создайте виртуальное окружение (рекомендуется) +python -m venv venv + +# Активируйте виртуальное окружение +# На Windows: +venv\Scripts\activate +# На macOS/Linux: +source venv/bin/activate + +# Установите зависимости +pip install -r requirements.txt +``` + +### Шаг 3: Настройка переменных окружения + +Создайте файл `.env` в корневой папке проекта: + +``` +TELEGRAM_BOT_TOKEN=ваш_токен_тг +VNDB_TOKEN=ваш_токен_vndb +``` + +Заменив `ваш_токен_тг` и `ваш_токен_vndb` на реальные токены. + +### Шаг 4: Запуск бота + +```bash +python bot.py +``` + +Если все настроено правильно, вы должны увидеть сообщение о том, что бот запущен. + +--- + +## Подробная установка + +### Linux/macOS + +```bash +# 1. Скачайте исходный код +git clone +cd vntgbot + +# 2. Создайте виртуальное окружение +python3 -m venv venv +source venv/bin/activate + +# 3. Обновите pip +pip install --upgrade pip + +# 4. Установите зависимости +pip install -r requirements.txt + +# 5. Создайте .env файл +cp .env.example .env +# Отредактируйте .env и добавьте токены +nano .env + +# 6. Запустите бота +python bot.py +``` + +### Windows (PowerShell) + +```powershell +# 1. Скачайте исходный код +git clone +cd vntgbot + +# 2. Создайте виртуальное окружение +python -m venv venv +.\venv\Scripts\Activate.ps1 + +# 3. Обновите pip +python -m pip install --upgrade pip + +# 4. Установите зависимости +pip install -r requirements.txt + +# 5. Создайте .env файл +Copy-Item .env.example .env +# Отредактируйте .env с помощью notepad или вашего любимого редактора +notepad .env + +# 6. Запустите бота +python bot.py +``` + +### Windows (CMD) + +```cmd +# 1. Скачайте исходный код +git clone +cd vntgbot + +# 2. Создайте виртуальное окружение +python -m venv venv +venv\Scripts\activate.bat + +# 3. Обновите pip +python -m pip install --upgrade pip + +# 4. Установите зависимости +pip install -r requirements.txt + +# 5. Создайте .env файл +copy .env.example .env +REM Отредактируйте .env + +# 6. Запустите бота +python bot.py +``` + +--- + +## Развертывание с помощью Docker + +### Требования +- Docker +- Docker Compose (опционально) + +### С использованием Docker Compose (рекомендуется) + +```bash +# 1. Создайте .env файл в корневой папке +echo "TELEGRAM_BOT_TOKEN=ваш_токен" > .env +echo "VNDB_TOKEN=ваш_токен" >> .env + +# 2. Запустите бота +docker-compose up -d + +# 3. Проверьте логи +docker-compose logs -f vndb-bot + +# 4. Остановите бота +docker-compose down +``` + +### С использованием Docker напрямую + +```bash +# 1. Создайте образ +docker build -t vndb-bot . + +# 2. Запустите контейнер +docker run -d \ + --name vndb-bot \ + -e TELEGRAM_BOT_TOKEN=ваш_токен \ + -e VNDB_TOKEN=ваш_токен \ + vndb-bot + +# 3. Проверьте логи +docker logs vndb-bot + +# 4. Остановите контейнер +docker stop vndb-bot +docker rm vndb-bot +``` + +--- + +## Проверка установки + +После запуска бота: + +1. Откройте Telegram +2. Найдите вашего бота по username +3. Напишите `/start` +4. Вы должны увидеть приветственное сообщение + +Если всё работает, попробуйте команду: +``` +/stats +``` + +--- + +## Решение проблем + +### Ошибка: "TELEGRAM_BOT_TOKEN not set" +- Проверьте, что файл `.env` существует в корневой папке +- Убедитесь, что в `.env` правильно указан токен +- Убедитесь, что токен не содержит кавычки или лишних пробелов + +### Ошибка: "Connection refused" +- Проверьте интернет соединение +- Убедитесь, что сервер VNDB API доступен (https://api.vndb.org/kana) + +### Бот не отвечает на сообщения +- Проверьте, что бот имеет права администратора в чате (если используется в групповом чате) +- Убедитесь, что в настройках бота включены приватные сообщения +- Перезагрузите бота: остановите его и запустите снова + +### Медленные ответы +- Это может быть из-за ограничений API VNDB +- Попробуйте уменьшить количество запрашиваемых полей +- Убедитесь, что ваше интернет соединение стабильно + +### Ошибка импорта модулей +```bash +# Убедитесь, что виртуальное окружение активировано и зависимости установлены +pip install -r requirements.txt --upgrade +``` + +### SSL Certificate Error +```bash +# На некоторых системах может потребоваться: +pip install certifi +# Или использовать нестабильное соединение (не рекомендуется): +# Добавьте в bot.py перед запуском: import ssl; ssl._create_default_https_context = ssl._create_unverified_context +``` + +--- + +## Конфигурация + +### Переменные окружения + +| Переменная | Обязательна | По умолчанию | Описание | +|-----------|----------|----------|-----------| +| TELEGRAM_BOT_TOKEN | Да | - | Токен Telegram бота | +| VNDB_TOKEN | Нет | - | Токен VNDB API для авторизации | +| LOG_LEVEL | Нет | INFO | Уровень логирования (DEBUG, INFO, WARNING, ERROR) | +| USE_SANDBOX | Нет | false | Использовать sandbox API для тестирования | + +### Файл config.py + +Вы можете отредактировать `config.py` для изменения других параметров: + +```python +MAX_RESULTS_PER_PAGE = 100 # Максимум результатов на странице +DEFAULT_RESULTS_PER_PAGE = 10 # По умолчанию результатов на странице +MAX_QUOTES_AT_ONCE = 5 # Максимум цитат за раз +API_TIMEOUT = 10 # Timeout для API запросов (в секундах) +BOT_TIMEOUT = 30 # Timeout для бота (в секундах) +``` + +--- + +## Проверка зависимостей + +```bash +# Проверьте, что все зависимости установлены правильно +pip check + +# Обновите зависимости +pip install -r requirements.txt --upgrade +``` + +--- + +## Выполнение тестов + +```bash +# Установите зависимости для тестирования (они уже в requirements.txt) +# Запустите тесты +pytest test_bot.py -v + +# С покрытием +pytest test_bot.py --cov=. --cov-report=html +``` + +--- + +## Автозагрузка при запуске системы + +### Linux/macOS (systemd) + +Создайте файл `/etc/systemd/system/vndb-bot.service`: + +```ini +[Unit] +Description=VNDB Telegram Bot +After=network.target + +[Service] +Type=simple +User=your_username +WorkingDirectory=/path/to/vntgbot +Environment="PATH=/path/to/vntgbot/venv/bin" +ExecStart=/path/to/vntgbot/venv/bin/python bot.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Затем: +```bash +sudo systemctl daemon-reload +sudo systemctl enable vndb-bot +sudo systemctl start vndb-bot +``` + +### Windows (Планировщик задач) + +1. Откройте Планировщик задач +2. Создайте новую задачу +3. Установите триггер "При запуске" +4. Установите действие: запустить `python.exe` с аргументом `C:\path\to\bot.py` +5. Сохраните задачу + +--- + +## Нужна помощь? + +- 📖 Прочитайте [README.md](README.md) +- 🐛 Проверьте [логи](#логирование) +- 📝 Посмотрите примеры команд в `/help` +- 🌐 Посетите https://api.vndb.org/kana для справки по API + +Удачи! 🚀 diff --git a/README.md b/README.md index 61e7741..a3b1e5a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,256 @@ -# ayako +# VNDB Telegram Bot +Полнофункциональный Telegram бот для работы с базой данных [VNDB](https://vndb.org/) (Visual Novel Database). + +## Возможности + +Бот поддерживает **все методы** VNDB API v2: + +### Поиск и запросы +- 🎮 **Визуальные новеллы** - полный поиск по названию, языку, платформе, тегам, рейтингу и дате выпуска +- 👥 **Персонажи** - поиск по имени, полу, роли, чертам характера +- 🎬 **Релизы** - поиск по названию, платформе, типу, дате выпуска +- 👨‍💼 **Сотрудники** - поиск сценаристов, художников, композиторов и других +- 🏢 **Продюсеры** - поиск издателей и разработчиков +- 🏷️ **Теги** - просмотр популярных тегов и категорий +- ✨ **Черты характера** - список черт персонажей +- 💬 **Цитаты** - получение случайных цитат из ВН + +### Изображения 📸 +- **Обложки ВН** - автоматическая отправка обложек при поиске визуальных новелл +- **Аватары персонажей** - картинки персонажей при поиске +- **Картинки релизов** - изображения для каждого релиза +- **Подробный просмотр** - команды `/vn_detail`, `/char_detail`, `/release_detail` для полной информации с высоким качеством изображений + +### Управление списками (требует токена) +- Добавление ВН в личный список +- Обновление статуса просмотра +- Добавление заметок и оценок +- Управление меткамиме + +### Информация +- 📊 Статистика базы данных +- 📋 Информация о схеме API +- 🔐 Информация об авторизации + +## Установка + +### Требования +- Python 3.8+ +- pip + +### Шаг 1: Клонирование репозитория +```bash +git clone +cd vntgbot +``` + +### Шаг 2: Установка зависимостей +```bash +pip install -r requirements.txt +``` + +### Шаг 3: Настройка переменных окружения + +Создайте файл `.env` в корневой папке проекта: + +```env +# Обязательно +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here + +# Опционально (для функций авторизации) +VNDB_TOKEN=your_vndb_api_token_here +``` + +#### Получение токена Telegram Bot +1. Напишите боту [@BotFather](https://t.me/botfather) в Telegram +2. Используйте команду `/newbot` +3. Следуйте инструкциям и получите токен + +#### Получение VNDB API токена +1. Создайте аккаунт на [VNDB.org](https://vndb.org) +2. Перейдите на https://vndb.org/u/tokens +3. Создайте новый токен +4. Скопируйте его в переменную `VNDB_TOKEN` + +### Шаг 4: Запуск бота + +```bash +python bot.py +``` + +## Использование + +### Команды + +#### Поиск +- `/search <название>` - Поиск визуальных новелл +- `/char <имя>` - Поиск персонажей +- `/release <название>` - Поиск релизов +- `/staff <имя>` - Поиск сотрудников +- `/producer <название>` - Поиск продюсеров + +#### Информация +- `/tag` - Список популярных тегов +- `/trait` - Список черт характера +- `/quote [число]` - Случайные цитаты (макс. 5) +- `/stats` - Статистика базы данных +- `/schema` - Информация о схеме API +- `/authinfo` - Информация об авторизации (если токен установлен) + +#### Просмотр с картинками +- `/vn_detail ` - Полная информация о ВН с обложкой (_Пример: /vn_detail v17_) +- `/char_detail ` - Информация о персонаже с аватаром (_Пример: /char_detail c1_) +- `/release_detail ` - Информация о релизе с картинкой (_Пример: /release_detail r1_) + +#### Справка +- `/start` - Приветствие и основная информация +- `/help` - Подробная справка по всем командам + +### Примеры использования + +``` +/search Steins Gate +/char Okabe Rintaro +/release Windows +/staff Yoko Taro +/producer Key +/quote 3 +/vn_detail v17 +/char_detail c25 +/release_detail r1 +``` + +## Структура проекта + +``` +vntgbot/ +├── bot.py # Основной файл бота с обработчиками команд +├── vndb_client.py # VNDB API клиент +├── requirements.txt # Зависимости Python +├── .env # Переменные окружения (не отслеживается в git) +├── .gitignore # Файлы для игнорирования +└── README.md # Этот файл +``` + +## API Endpoints + +Бот поддерживает следующие endpoint'ы VNDB API: + +### Простые запросы +- `GET /schema` - Информация о схеме API +- `GET /stats` - Статистика базы данных +- `GET /user` - Информация о пользователе +- `GET /authinfo` - Информация об авторизации + +### Запросы к базе данных +- `POST /vn` - Запрос визуальных новелл +- `POST /release` - Запрос релизов +- `POST /character` - Запрос персонажей +- `POST /staff` - Запрос сотрудников +- `POST /producer` - Запрос продюсеров +- `POST /tag` - Запрос тегов +- `POST /trait` - Запрос черт характера +- `POST /quote` - Запрос цитат + +### Управление списками +- `POST /ulist` - Запрос списка ВН пользователя +- `POST /rlist` - Запрос списка релизов пользователя +- `PATCH /ulist/` - Обновление записи в списке ВН +- `PATCH /rlist/` - Обновление записи в списке релизов +- `DELETE /ulist/` - Удаление из списка ВН +- `DELETE /rlist/` - Удаление из списка релизов +- `GET /ulist_labels` - Получение меток списка + +## Фильтры и опции + +### Поддерживаемые параметры запросов: +- **filters** - Условия фильтрации +- **fields** - Выбираемые поля +- **sort** - Сортировка (id, title, released, rating, votecount) +- **reverse** - Обратный порядок сортировки +- **results** - Количество результатов (макс. 100) +- **page** - Номер страницы для пагинации +- **count** - Включить общее количество результатов +- **user** - ID пользователя для фильтров специфичных для пользователя + +## Ограничения API + +VNDB API имеет следующие ограничения: +- **200 запросов** за 5 минут +- **1 секунда** общего времени выполнения в минуту +- **3 секунды** максимального времени для одного запроса + +Бот учитывает эти ограничения и использует асинхронные запросы для оптимальной производительности. + +## Обработка ошибок + +Бот обрабатывает следующие типы ошибок: +- Сетевые ошибки (HTTP, timeout) +- Ошибки парсинга JSON +- Ошибки авторизации (401) +- Ошибки валидации данных + +Все ошибки логируются и сообщаются пользователю в удобном формате. + +## Расширение функционала + +Для добавления новых команд: + +1. Добавьте метод в класс `BotHandlers` +2. Зарегистрируйте обработчик в функции `main()` +3. (Опционально) Расширьте `VndbClient` новыми методами API + +Пример: + +```python +@staticmethod +async def my_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Description""" + try: + # Your code here + pass + except Exception as e: + logger.error(f"Error: {e}") + await update.message.reply_text(f"❌ Error: {str(e)}") + +# In main(): +application.add_handler(CommandHandler("mycommand", BotHandlers.my_command)) +``` + +## Логирование + +Бот использует встроенный модуль `logging` для отслеживания операций. Логи выводятся в консоль с уровнем INFO. + +Для изменения уровня логирования отредактируйте `bot.py`: + +```python +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.DEBUG # Измените на DEBUG для более подробных логов +) +``` + +## Документация VNDB API + +Полная документация VNDB API доступна по адресу: https://api.vndb.org/kana + +## Лицензирование + +Данные, полученные через VNDB API, подлежат [Data License VNDB](https://vndb.org/d17#4). + +## Благодарности + +- [VNDB](https://vndb.org/) за отличную базу данных и API +- [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) за удобную библиотеку + +## Поддержка + +Если у вас есть вопросы или проблемы: +1. Проверьте файл логов +2. Убедитесь, что токены установлены правильно +3. Проверьте интернет соединение и статус API VNDB + +## Автор + +Создано для удобного доступа к VNDB из Telegram. diff --git a/advanced_features.py b/advanced_features.py new file mode 100644 index 0000000..8392718 --- /dev/null +++ b/advanced_features.py @@ -0,0 +1,325 @@ +""" +Advanced features for VNDB Telegram Bot +Includes pagination, caching, and rate limiting +""" +import asyncio +import time +from typing import Dict, List, Any, Optional, Callable +from collections import defaultdict +from functools import wraps +import logging + +logger = logging.getLogger(__name__) + + +class RateLimiter: + """Rate limiter for API requests""" + + def __init__(self, max_requests: int = 200, window_seconds: int = 300): + """ + Initialize rate limiter + + Args: + max_requests: Maximum requests allowed in window + window_seconds: Time window in seconds (default 5 minutes = 300 seconds) + """ + self.max_requests = max_requests + self.window_seconds = window_seconds + self.requests = [] + + def is_allowed(self) -> bool: + """Check if a request is allowed""" + now = time.time() + + # Remove old requests outside the window + self.requests = [req_time for req_time in self.requests + if now - req_time < self.window_seconds] + + # Check if we can make another request + if len(self.requests) < self.max_requests: + self.requests.append(now) + return True + + return False + + def wait_if_needed(self) -> None: + """Wait if rate limit is reached""" + if not self.is_allowed(): + if self.requests: + oldest = self.requests[0] + wait_time = self.window_seconds - (time.time() - oldest) + if wait_time > 0: + logger.warning(f"Rate limit reached, waiting {wait_time:.1f}s") + time.sleep(wait_time) + self.is_allowed() + + +class SimpleCache: + """Simple in-memory cache for API responses""" + + def __init__(self, ttl_seconds: int = 300): + """ + Initialize cache + + Args: + ttl_seconds: Time to live for cached items + """ + self.ttl_seconds = ttl_seconds + self.cache: Dict[str, tuple] = {} + + def _make_key(self, endpoint: str, params: Dict[str, Any]) -> str: + """Create cache key from endpoint and parameters""" + params_str = str(sorted(params.items())) + return f"{endpoint}:{params_str}" + + def get(self, endpoint: str, params: Dict[str, Any]) -> Optional[Any]: + """Get item from cache""" + key = self._make_key(endpoint, params) + + if key not in self.cache: + return None + + value, timestamp = self.cache[key] + + # Check if expired + if time.time() - timestamp > self.ttl_seconds: + del self.cache[key] + return None + + logger.debug(f"Cache hit for {key}") + return value + + def set(self, endpoint: str, params: Dict[str, Any], value: Any) -> None: + """Set item in cache""" + key = self._make_key(endpoint, params) + self.cache[key] = (value, time.time()) + logger.debug(f"Cached {key}") + + def clear(self) -> None: + """Clear all cache""" + self.cache.clear() + + def stats(self) -> Dict[str, int]: + """Get cache statistics""" + now = time.time() + expired = sum( + 1 for _, (_, timestamp) in self.cache.items() + if now - timestamp > self.ttl_seconds + ) + return { + "total_items": len(self.cache), + "expired_items": expired, + } + + +class Paginator: + """Handle pagination for search results""" + + def __init__(self, items: List[Dict[str, Any]], items_per_page: int = 5): + """ + Initialize paginator + + Args: + items: List of items to paginate + items_per_page: Number of items per page + """ + self.items = items + self.items_per_page = items_per_page + self.current_page = 1 + + @property + def total_pages(self) -> int: + """Get total number of pages""" + return (len(self.items) + self.items_per_page - 1) // self.items_per_page + + @property + def current_items(self) -> List[Dict[str, Any]]: + """Get items for current page""" + start = (self.current_page - 1) * self.items_per_page + end = start + self.items_per_page + return self.items[start:end] + + def next_page(self) -> bool: + """Go to next page""" + if self.current_page < self.total_pages: + self.current_page += 1 + return True + return False + + def prev_page(self) -> bool: + """Go to previous page""" + if self.current_page > 1: + self.current_page -= 1 + return True + return False + + def goto_page(self, page: int) -> bool: + """Go to specific page""" + if 1 <= page <= self.total_pages: + self.current_page = page + return True + return False + + def page_info(self) -> str: + """Get page information string""" + return f"Страница {self.current_page}/{self.total_pages}" + + +class UserSession: + """Manage user session data""" + + def __init__(self, user_id: int): + """Initialize session""" + self.user_id = user_id + self.data: Dict[str, Any] = {} + self.created_at = time.time() + self.last_activity = time.time() + + def set(self, key: str, value: Any) -> None: + """Set session data""" + self.data[key] = value + self.last_activity = time.time() + + def get(self, key: str, default: Any = None) -> Any: + """Get session data""" + self.last_activity = time.time() + return self.data.get(key, default) + + def update_activity(self) -> None: + """Update last activity time""" + self.last_activity = time.time() + + def is_idle(self, timeout_seconds: int = 1800) -> bool: + """Check if session is idle""" + return time.time() - self.last_activity > timeout_seconds + + def clear(self) -> None: + """Clear session data""" + self.data.clear() + + +class SessionManager: + """Manage user sessions""" + + def __init__(self, idle_timeout_seconds: int = 1800): + """ + Initialize session manager + + Args: + idle_timeout_seconds: Timeout for idle sessions + """ + self.sessions: Dict[int, UserSession] = {} + self.idle_timeout = idle_timeout_seconds + + def get_session(self, user_id: int) -> UserSession: + """Get or create user session""" + if user_id not in self.sessions: + self.sessions[user_id] = UserSession(user_id) + else: + self.sessions[user_id].update_activity() + + return self.sessions[user_id] + + def cleanup_idle_sessions(self) -> int: + """Remove idle sessions""" + user_ids_to_remove = [ + user_id for user_id, session in self.sessions.items() + if session.is_idle(self.idle_timeout) + ] + + for user_id in user_ids_to_remove: + del self.sessions[user_id] + + logger.info(f"Cleaned up {len(user_ids_to_remove)} idle sessions") + return len(user_ids_to_remove) + + def stats(self) -> Dict[str, Any]: + """Get session statistics""" + return { + "active_sessions": len(self.sessions), + "total_users": len(self.sessions), + } + + +class RequestLogger: + """Log API requests for debugging""" + + def __init__(self): + """Initialize request logger""" + self.requests: List[Dict[str, Any]] = [] + self.max_history = 100 + + def log_request( + self, + endpoint: str, + method: str, + params: Optional[Dict[str, Any]] = None, + response_time: float = 0, + status_code: int = 0, + error: Optional[str] = None, + ) -> None: + """Log an API request""" + request_log = { + "timestamp": time.time(), + "endpoint": endpoint, + "method": method, + "params": params, + "response_time": response_time, + "status_code": status_code, + "error": error, + } + + self.requests.append(request_log) + + # Keep only recent requests + if len(self.requests) > self.max_history: + self.requests = self.requests[-self.max_history:] + + def get_stats(self) -> Dict[str, Any]: + """Get request statistics""" + if not self.requests: + return {"requests_logged": 0} + + total_time = sum(r["response_time"] for r in self.requests) + avg_time = total_time / len(self.requests) if self.requests else 0 + errors = sum(1 for r in self.requests if r["error"]) + + return { + "requests_logged": len(self.requests), + "total_time": total_time, + "average_time": avg_time, + "errors": errors, + "success_rate": (len(self.requests) - errors) / len(self.requests) * 100, + } + + +def rate_limit(limiter: RateLimiter): + """Decorator for rate limiting""" + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs): + limiter.wait_if_needed() + return await func(*args, **kwargs) + return wrapper + return decorator + + +def with_cache(cache: SimpleCache, ttl: int = 300): + """Decorator for caching""" + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(self, *args, endpoint: str = "", **kwargs): + # Try to get from cache + cached = cache.get(endpoint, kwargs) + if cached: + return cached + + # Call function + result = await func(self, *args, endpoint=endpoint, **kwargs) + + # Cache result + cache.set(endpoint, kwargs, result) + + return result + return wrapper + return decorator diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..580aaf9 --- /dev/null +++ b/bot.py @@ -0,0 +1,699 @@ +""" +VNDB Telegram Bot +Main bot implementation with command handlers +""" +import logging +from typing import Optional +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ( + Application, + CommandHandler, + MessageHandler, + CallbackQueryHandler, + ConversationHandler, + ContextTypes, + filters, +) +from vndb_client import VndbClient +from config import Config +from utils import Formatter, ErrorHandler, QueryBuilder +from detailed_handlers import get_detail_handlers + +# Setup logging +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=Config.LOG_LEVEL +) +logger = logging.getLogger(__name__) + +# States for conversation handlers +SEARCH_VN, SELECT_VN, VN_DETAILS = range(3) +SEARCH_CHARACTER, SELECT_CHARACTER = range(2) +SEARCH_RELEASE, SELECT_RELEASE = range(2) +SEARCH_STAFF, SELECT_STAFF = range(2) + +# Global VNDB client +vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX) + + +class BotHandlers: + """Telegram bot command and message handlers""" + + @staticmethod + async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Start command handler""" + welcome_text = """ +🎮 **Добро пожаловать в VNDB Telegram Бот!** + +Этот бот позволяет искать информацию о визуальных новеллах, персонажах, релизах и многом другом из базы данных VNDB. + +**Доступные команды:** +/search - Поиск визуальных новелл +/char - Поиск персонажей +/release - Поиск релизов +/staff - Поиск сотрудников +/producer - Поиск продюсеров +/tag - Поиск тегов +/trait - Поиск черт характера +/quote - Поиск цитат +/stats - Статистика базы данных +/schema - Информация о схеме API +/help - Справка по командам + +*Используйте /help для получения подробной информации* + """ + await update.message.reply_text(welcome_text, parse_mode="Markdown") + + @staticmethod + async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Help command handler""" + help_text = """ +**📚 Справка по командам VNDB Бота** + +**Поиск информации:** +/search <название> - Поиск визуальных новелл по названию +/char <название> - Поиск персонажей по имени +/release <название> - Поиск релизов +/staff <название> - Поиск сотрудников (сценаристы, художники и т.д.) +/producer <название> - Поиск продюсеров +/tag - Список популярных тегов +/trait - Список черт характера +/quote <количество> - Получить случайные цитаты + +**Подробный просмотр (с картинками):** +/vn_detail - Просмотр полной информации о ВН с обложкой + _Пример: /vn_detail v17_ +/char_detail - Просмотр информации о персонаже с аватаром + _Пример: /char_detail c1_ +/release_detail - Просмотр информации о релизе с картинкой + _Пример: /release_detail r1_ + +**Информация:** +/stats - Показать статистику базы данных VNDB +/schema - Получить информацию о доступных полях API +/authinfo - Информация об авторизации (если настроена) + +**Функции пользователя (требуют токена):** +Чтобы использовать функции списка, установите токен в переменной окружения VNDB_TOKEN + +**Примеры использования:** +/search Steins Gate +/char Okabe +/release Windows +/vn_detail v17 +/char_detail c25 +/stats + +**Важно:** +- Бот работает в асинхронном режиме +- Результаты ограничены 10 элементами по умолчанию +- При поиске автоматически отправляются картинки (первые 3 результата) +- Для просмотра полной информации с картинкой используйте /vn_detail, /char_detail и т.д. + """ + await update.message.reply_text(help_text, parse_mode="Markdown") + + @staticmethod + async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Get database statistics""" + try: + stats = await vndb_client.get_stats() + + stats_text = f""" +📊 **Статистика базы данных VNDB:** + +🎮 Визуальные новеллы: {stats.get('vn', 0):,} +👥 Персонажи: {stats.get('chars', 0):,} +🎬 Релизы: {stats.get('releases', 0):,} +🏢 Продюсеры: {stats.get('producers', 0):,} +👨‍💼 Сотрудники: {stats.get('staff', 0):,} +🏷️ Теги: {stats.get('tags', 0):,} +✨ Черты характера: {stats.get('traits', 0):,} + """ + await update.message.reply_text(stats_text, parse_mode="Markdown") + except Exception as e: + logger.error(f"Error getting stats: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + @staticmethod + async def schema(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Get API schema information""" + try: + await update.message.reply_text( + "⏳ Загружаю информацию о схеме... (это может занять некоторое время)", + parse_mode="Markdown" + ) + + schema = await vndb_client.get_schema() + + # Build schema info + schema_text = "📋 **Информация о схеме VNDB API:**\n\n" + + # Database types + if "db_types" in schema: + schema_text += "**Типы данных:**\n" + for db_type, info in list(schema["db_types"].items())[:5]: + schema_text += f"• {db_type}\n" + schema_text += "\n" + + # Search fields + if "fields" in schema: + schema_text += "**Доступные поля для запросов:**\n" + for field_type, fields in list(schema["fields"].items())[:3]: + schema_text += f"• {field_type}\n" + schema_text += "\n" + + schema_text += "Для полного списка полей и типов посетите: https://api.vndb.org/kana" + + await update.message.reply_text(schema_text, parse_mode="Markdown") + except Exception as e: + logger.error(f"Error getting schema: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + @staticmethod + async def search_vn(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Search for visual novels""" + try: + args = " ".join(context.args) if context.args else "" + + if not args: + await update.message.reply_text( + "❌ Пожалуйста, укажите название для поиска\n" + "Пример: /search Steins Gate" + ) + return ConversationHandler.END + + await update.message.reply_text(f"🔍 Поиск визуальных новелл: **{args}**\n⏳ Загрузка...", parse_mode="Markdown") + + # Search for VN + filters = ["search", "=", args] + results = await vndb_client.query_vn( + filters=[filters], + fields=["title", "original", "released", "rating", "votecount", "image{url}"], + results=10 + ) + + if not results.get("results"): + await update.message.reply_text("😞 Ничего не найдено") + return ConversationHandler.END + + # Format results + response_text = f"**Результаты поиска: {args}**\n\n" + + for i, vn in enumerate(results["results"], 1): + vn_id = vn.get("id", "Unknown") + title = vn.get("title", "Unknown") + original = vn.get("original", "") + released = vn.get("released", "Unknown") + rating = vn.get("rating", 0) + votecount = vn.get("votecount", 0) + + response_text += ( + f"{i}. **{title}**\n" + f" ID: {vn_id}\n" + ) + if original: + response_text += f" Оригинал: {original}\n" + response_text += ( + f" Релиз: {released}\n" + f" Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)\n\n" + ) + + response_text += f"\n📌 Всего найдено: {len(results['results'])} результатов" + if results.get("more"): + response_text += " (есть еще результаты)" + + await update.message.reply_text(response_text, parse_mode="Markdown") + + # Send images if available + for vn in results["results"][:3]: # Send images for first 3 results + image = vn.get("image") + if image and isinstance(image, dict): + image_url = image.get("url") + if image_url: + try: + title = vn.get("title", "VN") + await update.message.reply_photo( + photo=f"https://t.vndb.org{image_url}", + caption=f"🎮 {title}", + parse_mode="Markdown" + ) + except Exception as e: + logger.warning(f"Could not send image: {e}") + + # Store results for detail view + context.user_data["vn_results"] = results["results"] + + except Exception as e: + logger.error(f"Error searching VN: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + return ConversationHandler.END + + @staticmethod + async def search_character(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Search for characters""" + try: + args = " ".join(context.args) if context.args else "" + + if not args: + await update.message.reply_text( + "❌ Пожалуйста, укажите имя персонажа\n" + "Пример: /char Okabe" + ) + return ConversationHandler.END + + await update.message.reply_text(f"🔍 Поиск персонажей: **{args}**\n⏳ Загрузка...", parse_mode="Markdown") + + filters = ["search", "=", args] + results = await vndb_client.query_character( + filters=[filters], + fields=["name", "original", "gender", "vn", "image{url}"], + results=10 + ) + + if not results.get("results"): + await update.message.reply_text("😞 Ничего не найдено") + return ConversationHandler.END + + response_text = f"**Результаты поиска персонажей: {args}**\n\n" + + for i, char in enumerate(results["results"], 1): + char_id = char.get("id", "Unknown") + name = char.get("name", "Unknown") + original = char.get("original", "") + gender = char.get("gender", "Unknown") + vns = char.get("vn", []) + + response_text += ( + f"{i}. **{name}**\n" + f" ID: {char_id}\n" + ) + if original: + response_text += f" Оригинал: {original}\n" + response_text += f" Пол: {gender}\n" + if vns: + response_text += f" Появляется в {len(vns)} VN\n\n" + else: + response_text += "\n" + + await update.message.reply_text(response_text, parse_mode="Markdown") + + # Send character images if available + for char in results["results"][:3]: # Send images for first 3 results + image = char.get("image") + if image and isinstance(image, dict): + image_url = image.get("url") + if image_url: + try: + name = char.get("name", "Character") + await update.message.reply_photo( + photo=f"https://t.vndb.org{image_url}", + caption=f"👤 {name}", + parse_mode="Markdown" + ) + except Exception as e: + logger.warning(f"Could not send character image: {e}") + + context.user_data["char_results"] = results["results"] + + except Exception as e: + logger.error(f"Error searching characters: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + return ConversationHandler.END + + @staticmethod + async def search_release(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Search for releases""" + try: + args = " ".join(context.args) if context.args else "" + + if not args: + await update.message.reply_text( + "❌ Пожалуйста, укажите название для поиска\n" + "Пример: /release Windows" + ) + return ConversationHandler.END + + await update.message.reply_text(f"🔍 Поиск релизов: **{args}**\n⏳ Загрузка...", parse_mode="Markdown") + + filters = ["search", "=", args] + results = await vndb_client.query_release( + filters=[filters], + fields=["title", "original", "released", "platform", "type", "image{url}"], + results=10 + ) + + if not results.get("results"): + await update.message.reply_text("😞 Ничего не найдено") + return ConversationHandler.END + + response_text = f"**Результаты поиска релизов: {args}**\n\n" + + for i, release in enumerate(results["results"], 1): + release_id = release.get("id", "Unknown") + title = release.get("title", "Unknown") + original = release.get("original", "") + released = release.get("released", "Unknown") + platform = release.get("platform", "Unknown") + release_type = release.get("type", "Unknown") + + response_text += ( + f"{i}. **{title}**\n" + f" ID: {release_id}\n" + ) + if original: + response_text += f" Оригинал: {original}\n" + response_text += ( + f" Дата: {released}\n" + f" Платформа: {platform}\n" + f" Тип: {release_type}\n\n" + ) + + await update.message.reply_text(response_text, parse_mode="Markdown") + + # Send release images if available + for release in results["results"][:3]: # Send images for first 3 results + image = release.get("image") + if image and isinstance(image, dict): + image_url = image.get("url") + if image_url: + try: + title = release.get("title", "Release") + await update.message.reply_photo( + photo=f"https://t.vndb.org{image_url}", + caption=f"🎬 {title}", + parse_mode="Markdown" + ) + except Exception as e: + logger.warning(f"Could not send release image: {e}") + + context.user_data["release_results"] = results["results"] + + except Exception as e: + logger.error(f"Error searching releases: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + return ConversationHandler.END + + @staticmethod + async def search_staff(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Search for staff members""" + try: + args = " ".join(context.args) if context.args else "" + + if not args: + await update.message.reply_text( + "❌ Пожалуйста, укажите имя\n" + "Пример: /staff Yoko" + ) + return ConversationHandler.END + + await update.message.reply_text(f"🔍 Поиск сотрудников: **{args}**\n⏳ Загрузка...", parse_mode="Markdown") + + filters = ["search", "=", args] + results = await vndb_client.query_staff( + filters=[filters], + fields=["name", "original", "gender", "role"], + results=10 + ) + + if not results.get("results"): + await update.message.reply_text("😞 Ничего не найдено") + return ConversationHandler.END + + response_text = f"**Результаты поиска сотрудников: {args}**\n\n" + + for i, staff in enumerate(results["results"], 1): + staff_id = staff.get("id", "Unknown") + name = staff.get("name", "Unknown") + original = staff.get("original", "") + gender = staff.get("gender", "Unknown") + + response_text += ( + f"{i}. **{name}**\n" + f" ID: {staff_id}\n" + ) + if original: + response_text += f" Оригинал: {original}\n" + response_text += f" Пол: {gender}\n\n" + + await update.message.reply_text(response_text, parse_mode="Markdown") + context.user_data["staff_results"] = results["results"] + + except Exception as e: + logger.error(f"Error searching staff: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + return ConversationHandler.END + + @staticmethod + async def search_producer(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Search for producers""" + try: + args = " ".join(context.args) if context.args else "" + + if not args: + await update.message.reply_text( + "❌ Пожалуйста, укажите название\n" + "Пример: /producer Key" + ) + return ConversationHandler.END + + await update.message.reply_text(f"🔍 Поиск продюсеров: **{args}**\n⏳ Загрузка...", parse_mode="Markdown") + + filters = ["search", "=", args] + results = await vndb_client.query_producer( + filters=[filters], + fields=["name", "original", "type"], + results=10 + ) + + if not results.get("results"): + await update.message.reply_text("😞 Ничего не найдено") + return ConversationHandler.END + + response_text = f"**Результаты поиска продюсеров: {args}**\n\n" + + for i, producer in enumerate(results["results"], 1): + producer_id = producer.get("id", "Unknown") + name = producer.get("name", "Unknown") + original = producer.get("original", "") + producer_type = producer.get("type", "Unknown") + + response_text += ( + f"{i}. **{name}**\n" + f" ID: {producer_id}\n" + ) + if original: + response_text += f" Оригинал: {original}\n" + response_text += f" Тип: {producer_type}\n\n" + + await update.message.reply_text(response_text, parse_mode="Markdown") + + except Exception as e: + logger.error(f"Error searching producers: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + return ConversationHandler.END + + @staticmethod + async def list_tags(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """List popular tags""" + try: + await update.message.reply_text("⏳ Загружаю теги...", parse_mode="Markdown") + + results = await vndb_client.query_tag( + fields=["name", "description"], + sort="vns", + reverse=True, + results=15 + ) + + if not results.get("results"): + await update.message.reply_text("😞 Ничего не найдено") + return + + response_text = "**🏷️ Популярные теги VNDB:**\n\n" + + for i, tag in enumerate(results["results"], 1): + tag_id = tag.get("id", "Unknown") + name = tag.get("name", "Unknown") + description = tag.get("description", "") + + response_text += f"{i}. **{name}** (`{tag_id}`)\n" + if description and len(description) < 50: + response_text += f" {description}\n" + response_text += "\n" + + await update.message.reply_text(response_text, parse_mode="Markdown") + + except Exception as e: + logger.error(f"Error listing tags: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + @staticmethod + async def list_traits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """List character traits""" + try: + await update.message.reply_text("⏳ Загружаю черты характера...", parse_mode="Markdown") + + results = await vndb_client.query_trait( + fields=["name", "description"], + sort="chars", + reverse=True, + results=15 + ) + + if not results.get("results"): + await update.message.reply_text("😞 Ничего не найдено") + return + + response_text = "**✨ Популярные черты характера:**\n\n" + + for i, trait in enumerate(results["results"], 1): + trait_id = trait.get("id", "Unknown") + name = trait.get("name", "Unknown") + description = trait.get("description", "") + + response_text += f"{i}. **{name}** (`{trait_id}`)\n" + if description and len(description) < 50: + response_text += f" {description}\n" + response_text += "\n" + + await update.message.reply_text(response_text, parse_mode="Markdown") + + except Exception as e: + logger.error(f"Error listing traits: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + @staticmethod + async def get_quote(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Get random quotes""" + try: + count = 1 + if context.args and context.args[0].isdigit(): + count = min(int(context.args[0]), 5) # Max 5 quotes + + await update.message.reply_text(f"⏳ Загружаю {count} цитат...", parse_mode="Markdown") + + results = await vndb_client.query_quote( + fields=["character", "quote"], + sort="id", + results=count + ) + + if not results.get("results"): + await update.message.reply_text("😞 Ничего не найдено") + return + + response_text = "**💬 Случайные цитаты:**\n\n" + + for quote in results["results"]: + quote_text = quote.get("quote", "") + character = quote.get("character", "Unknown") + + if quote_text: + response_text += f"_{quote_text}_\n" + response_text += f"— **{character}**\n\n" + + await update.message.reply_text(response_text, parse_mode="Markdown") + + except Exception as e: + logger.error(f"Error getting quotes: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(f"❌ {error_msg}") + + @staticmethod + async def authinfo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Get authentication info""" + try: + token = Config.VNDB_TOKEN + if not token: + await update.message.reply_text( + "❌ Токен VNDB не установлен\n\n" + "Чтобы использовать функции авторизации:\n" + "1. Посетите https://vndb.org/u/tokens\n" + "2. Создайте новый токен\n" + "3. Установите переменную окружения VNDB_TOKEN" + ) + return + + client_with_token = VndbClient(token=token, use_sandbox=Config.USE_SANDBOX) + auth_info = await client_with_token.get_authinfo() + + response_text = f""" +**👤 Информация об авторизации:** + +ID: {auth_info.get('id', 'Unknown')} +Пользователь: {auth_info.get('username', 'Unknown')} + +**Разрешения:** +""" + + permissions = auth_info.get("permissions", []) + if "listread" in permissions: + response_text += "✅ Чтение списка (listread)\n" + if "listwrite" in permissions: + response_text += "✅ Запись в список (listwrite)\n" + + if not permissions: + response_text += "❌ Нет разрешений" + + await update.message.reply_text(response_text, parse_mode="Markdown") + + except Exception as e: + logger.error(f"Error getting authinfo: {e}") + error_msg = ErrorHandler.format_error(e) + await update.message.reply_text(error_msg) + + +def main() -> None: + """Start the bot""" + # Validate configuration + try: + Config.validate() + except ValueError as e: + logger.error(f"Configuration error: {e}") + raise + + logger.info(f"Starting bot with config: {Config.to_dict()}") + + # Create application + application = Application.builder().token(Config.TELEGRAM_BOT_TOKEN).build() + + # Add handlers + application.add_handler(CommandHandler("start", BotHandlers.start)) + application.add_handler(CommandHandler("help", BotHandlers.help_command)) + application.add_handler(CommandHandler("stats", BotHandlers.stats)) + application.add_handler(CommandHandler("schema", BotHandlers.schema)) + application.add_handler(CommandHandler("search", BotHandlers.search_vn)) + application.add_handler(CommandHandler("char", BotHandlers.search_character)) + application.add_handler(CommandHandler("release", BotHandlers.search_release)) + application.add_handler(CommandHandler("staff", BotHandlers.search_staff)) + application.add_handler(CommandHandler("producer", BotHandlers.search_producer)) + application.add_handler(CommandHandler("tag", BotHandlers.list_tags)) + application.add_handler(CommandHandler("trait", BotHandlers.list_traits)) + application.add_handler(CommandHandler("quote", BotHandlers.get_quote)) + application.add_handler(CommandHandler("authinfo", BotHandlers.authinfo)) + + # Add detailed handlers for viewing with images + for handler in get_detail_handlers(): + application.add_handler(handler) + + # Start the bot + logger.info("Starting bot...") + application.run_polling(allowed_updates=Update.ALL_TYPES) + + +if __name__ == "__main__": + main() diff --git a/config.py b/config.py new file mode 100644 index 0000000..70e6f2b --- /dev/null +++ b/config.py @@ -0,0 +1,53 @@ +""" +Configuration module for VNDB Telegram Bot +Handles environment variables and settings +""" +import os +from typing import Optional +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +class Config: + """Bot configuration class""" + + # Telegram configuration + TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "") + + # VNDB API configuration + VNDB_TOKEN: Optional[str] = os.getenv("VNDB_TOKEN") + USE_SANDBOX: bool = os.getenv("USE_SANDBOX", "false").lower() == "true" + + # Logging configuration + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + + # API limits + MAX_RESULTS_PER_PAGE: int = 100 + DEFAULT_RESULTS_PER_PAGE: int = 10 + MAX_QUOTES_AT_ONCE: int = 5 + + # Timeouts (in seconds) + API_TIMEOUT: int = 10 + BOT_TIMEOUT: int = 30 + + @classmethod + def validate(cls) -> bool: + """Validate that required configuration is present""" + if not cls.TELEGRAM_BOT_TOKEN: + raise ValueError("TELEGRAM_BOT_TOKEN environment variable is required") + return True + + @classmethod + def to_dict(cls) -> dict: + """Convert config to dictionary""" + return { + "TELEGRAM_BOT_TOKEN": "***" if cls.TELEGRAM_BOT_TOKEN else "NOT SET", + "VNDB_TOKEN": "SET" if cls.VNDB_TOKEN else "NOT SET", + "USE_SANDBOX": cls.USE_SANDBOX, + "LOG_LEVEL": cls.LOG_LEVEL, + "MAX_RESULTS_PER_PAGE": cls.MAX_RESULTS_PER_PAGE, + "DEFAULT_RESULTS_PER_PAGE": cls.DEFAULT_RESULTS_PER_PAGE, + "API_TIMEOUT": cls.API_TIMEOUT, + } diff --git a/detailed_handlers.py b/detailed_handlers.py new file mode 100644 index 0000000..185aff6 --- /dev/null +++ b/detailed_handlers.py @@ -0,0 +1,293 @@ +""" +Inline handlers for detailed item viewing with images +""" +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ContextTypes, CommandHandler, CallbackQueryHandler +from vndb_client import VndbClient +from utils import ImageHandler +from config import Config +import logging + +logger = logging.getLogger(__name__) +vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX) + + +class DetailedHandlers: + """Handlers for detailed item viewing""" + + @staticmethod + async def view_vn_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """View detailed VN information with image""" + try: + if not context.args: + await update.message.reply_text( + "❌ Пожалуйста, укажите ID визуальной новеллы\n" + "Пример: /vn_detail v17" + ) + return + + vn_id = context.args[0] + await update.message.reply_text(f"⏳ Загружаю информацию о {vn_id}...", parse_mode="Markdown") + + # Get detailed VN information + filters = ["id", "=", vn_id] + results = await vndb_client.query_vn( + filters=[filters], + fields=[ + "title", "original", "released", "rating", "votecount", + "description", "image{url,dims}", "length", "developer" + ] + ) + + if not results.get("results"): + await update.message.reply_text(f"😞 ВН с ID {vn_id} не найдена") + return + + vn = results["results"][0] + + # Build detailed text + title = vn.get("title", "Unknown") + original = vn.get("original", "") + released = vn.get("released", "Unknown") + rating = vn.get("rating", 0) + votecount = vn.get("votecount", 0) + description = vn.get("description", "") + length = vn.get("length", "") + developer = vn.get("developer", "") + + detail_text = f""" +**🎮 {title}** (`{vn_id}`) +""" + + if original: + detail_text += f"Оригинал: {original}\n" + + detail_text += f""" +Дата релиза: {released} +Рейтинг: {rating/10:.1f}/10 ({votecount} голосов) +""" + + if length: + detail_text += f"Длительность: {length}\n" + + if developer: + detail_text += f"Разработчик: {developer}\n" + + if description: + # Truncate long descriptions + desc_truncated = description[:300] + "..." if len(description) > 300 else description + detail_text += f"\nОписание:\n{desc_truncated}\n" + + detail_text += f"\n[Открыть на VNDB](https://vndb.org/{vn_id})" + + # Send with image if available + image_data = vn.get("image") + if image_data: + image_url = ImageHandler.get_image_url(image_data) + if image_url: + try: + await update.message.reply_photo( + photo=image_url, + caption=detail_text, + parse_mode="Markdown" + ) + except Exception as e: + logger.warning(f"Could not send VN image: {e}") + await update.message.reply_text(detail_text, parse_mode="Markdown") + else: + await update.message.reply_text(detail_text, parse_mode="Markdown") + else: + await update.message.reply_text(detail_text, parse_mode="Markdown") + + except Exception as e: + logger.error(f"Error viewing VN detail: {e}") + await update.message.reply_text(f"❌ Ошибка при загрузке информации: {str(e)}") + + @staticmethod + async def view_character_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """View detailed character information with image""" + try: + if not context.args: + await update.message.reply_text( + "❌ Пожалуйста, укажите ID персонажа\n" + "Пример: /char_detail c1" + ) + return + + char_id = context.args[0] + await update.message.reply_text(f"⏳ Загружаю информацию о {char_id}...", parse_mode="Markdown") + + # Get detailed character information + filters = ["id", "=", char_id] + results = await vndb_client.query_character( + filters=[filters], + fields=[ + "name", "original", "gender", "bloodtype", "height", "weight", + "bust", "waist", "hips", "description", "image{url,dims}", "vn" + ] + ) + + if not results.get("results"): + await update.message.reply_text(f"😞 Персонаж с ID {char_id} не найден") + return + + char = results["results"][0] + + # Build detailed text + name = char.get("name", "Unknown") + original = char.get("original", "") + gender = char.get("gender", "") + bloodtype = char.get("bloodtype", "") + description = char.get("description", "") + vns = char.get("vn", []) + + detail_text = f"**👤 {name}** (`{char_id}`)\n" + + if original: + detail_text += f"Оригинал: {original}\n" + + if gender: + detail_text += f"Пол: {gender}\n" + + if bloodtype: + detail_text += f"Группа крови: {bloodtype}\n" + + if vns: + detail_text += f"\nПоявляется в:\n" + for vn in vns[:5]: # Show first 5 VNs + vn_id = vn.get("id", "") + if vn_id: + detail_text += f"• [{vn_id}](https://vndb.org/{vn_id})\n" + + if description: + desc_truncated = description[:300] + "..." if len(description) > 300 else description + detail_text += f"\nОписание:\n{desc_truncated}\n" + + detail_text += f"\n[Открыть на VNDB](https://vndb.org/{char_id})" + + # Send with image if available + image_data = char.get("image") + if image_data: + image_url = ImageHandler.get_image_url(image_data) + if image_url: + try: + await update.message.reply_photo( + photo=image_url, + caption=detail_text, + parse_mode="Markdown" + ) + except Exception as e: + logger.warning(f"Could not send character image: {e}") + await update.message.reply_text(detail_text, parse_mode="Markdown") + else: + await update.message.reply_text(detail_text, parse_mode="Markdown") + else: + await update.message.reply_text(detail_text, parse_mode="Markdown") + + except Exception as e: + logger.error(f"Error viewing character detail: {e}") + await update.message.reply_text(f"❌ Ошибка при загрузке информации: {str(e)}") + + @staticmethod + async def view_release_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """View detailed release information with image""" + try: + if not context.args: + await update.message.reply_text( + "❌ Пожалуйста, укажите ID релиза\n" + "Пример: /release_detail r1" + ) + return + + release_id = context.args[0] + await update.message.reply_text(f"⏳ Загружаю информацию о {release_id}...", parse_mode="Markdown") + + # Get detailed release information + filters = ["id", "=", release_id] + results = await vndb_client.query_release( + filters=[filters], + fields=[ + "title", "original", "released", "platform", "type", "language", + "edition", "description", "image{url,dims}", "vn" + ] + ) + + if not results.get("results"): + await update.message.reply_text(f"😞 Релиз с ID {release_id} не найден") + return + + release = results["results"][0] + + # Build detailed text + title = release.get("title", "Unknown") + original = release.get("original", "") + released = release.get("released", "Unknown") + platform = release.get("platform", "") + rel_type = release.get("type", "") + language = release.get("language", []) + edition = release.get("edition", "") + description = release.get("description", "") + vns = release.get("vn", []) + + detail_text = f"**🎬 {title}** (`{release_id}`)\n" + + if original: + detail_text += f"Оригинал: {original}\n" + + detail_text += f""" +Дата выпуска: {released} +Платформа: {platform} +Тип: {rel_type} +""" + + if language: + lang_str = ", ".join(language) if isinstance(language, list) else str(language) + detail_text += f"Языки: {lang_str}\n" + + if edition: + detail_text += f"Издание: {edition}\n" + + if vns: + detail_text += f"\nЧасть из:\n" + for vn in vns[:3]: + vn_id = vn.get("id", "") + if vn_id: + detail_text += f"• [{vn_id}](https://vndb.org/{vn_id})\n" + + if description: + desc_truncated = description[:200] + "..." if len(description) > 200 else description + detail_text += f"\nОписание:\n{desc_truncated}\n" + + detail_text += f"\n[Открыть на VNDB](https://vndb.org/{release_id})" + + # Send with image if available + image_data = release.get("image") + if image_data: + image_url = ImageHandler.get_image_url(image_data) + if image_url: + try: + await update.message.reply_photo( + photo=image_url, + caption=detail_text, + parse_mode="Markdown" + ) + except Exception as e: + logger.warning(f"Could not send release image: {e}") + await update.message.reply_text(detail_text, parse_mode="Markdown") + else: + await update.message.reply_text(detail_text, parse_mode="Markdown") + else: + await update.message.reply_text(detail_text, parse_mode="Markdown") + + except Exception as e: + logger.error(f"Error viewing release detail: {e}") + await update.message.reply_text(f"❌ Ошибка при загрузке информации: {str(e)}") + + +def get_detail_handlers(): + """Get all detail view handlers""" + return [ + CommandHandler("vn_detail", DetailedHandlers.view_vn_detail), + CommandHandler("char_detail", DetailedHandlers.view_character_detail), + CommandHandler("release_detail", DetailedHandlers.view_release_detail), + ] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bbc0c73 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + vndb-bot: + build: . + container_name: vndb-telegram-bot + restart: unless-stopped + environment: + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - VNDB_TOKEN=${VNDB_TOKEN:-} + - LOG_LEVEL=INFO + - USE_SANDBOX=false + volumes: + - ./logs:/app/logs + networks: + - bot-network + +networks: + bot-network: + driver: bridge diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af7e425 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +python-telegram-bot==21.0 +python-dotenv==1.0.0 +aiohttp==3.9.1 +requests==2.31.0 +pytest==7.4.0 +pytest-asyncio==0.21.0 diff --git a/test_bot.py b/test_bot.py new file mode 100644 index 0000000..5440648 --- /dev/null +++ b/test_bot.py @@ -0,0 +1,168 @@ +""" +Tests for VNDB Telegram Bot +""" +import asyncio +import pytest +from vndb_client import VndbClient +from config import Config +from utils import Formatter, ErrorHandler, QueryBuilder + + +class TestVndbClient: + """Test VNDB client functionality""" + + @pytest.fixture + def client(self): + """Create VNDB client instance""" + return VndbClient() + + @pytest.mark.asyncio + async def test_get_stats(self, client): + """Test getting database statistics""" + stats = await client.get_stats() + assert "vn" in stats + assert "chars" in stats + assert isinstance(stats["vn"], int) + + @pytest.mark.asyncio + async def test_get_schema(self, client): + """Test getting API schema""" + schema = await client.get_schema() + assert "db_types" in schema + assert "fields" in schema + + @pytest.mark.asyncio + async def test_query_vn(self, client): + """Test querying visual novels""" + results = await client.query_vn( + filters=[["id", "=", "v17"]], + fields=["title", "original"] + ) + assert "results" in results + assert len(results["results"]) > 0 + + @pytest.mark.asyncio + async def test_query_vn_search(self, client): + """Test searching visual novels""" + results = await client.query_vn( + filters=[["search", "=", "Steins"]], + fields=["title", "rating"], + results=5 + ) + assert "results" in results + + @pytest.mark.asyncio + async def test_query_character(self, client): + """Test querying characters""" + results = await client.query_character( + fields=["name", "gender"], + results=5 + ) + assert "results" in results + assert len(results["results"]) > 0 + + @pytest.mark.asyncio + async def test_query_tag(self, client): + """Test querying tags""" + results = await client.query_tag( + fields=["name"], + results=5 + ) + assert "results" in results + assert len(results["results"]) > 0 + + @pytest.mark.asyncio + async def test_query_quote(self, client): + """Test querying quotes""" + results = await client.query_quote( + fields=["quote", "character"], + results=2 + ) + assert "results" in results + + +class TestFormatter: + """Test formatter utilities""" + + def test_truncate_text(self): + """Test text truncation""" + text = "A" * 300 + truncated = Formatter.truncate_text(text, 100) + assert len(truncated) <= 100 + assert truncated.endswith("...") + + def test_truncate_text_short(self): + """Test truncation of short text""" + text = "Short text" + truncated = Formatter.truncate_text(text, 100) + assert truncated == text + + def test_format_vn_item(self): + """Test VN item formatting""" + vn = { + "id": "v17", + "title": "Clannad", + "original": "クラナド", + "released": "2004-04-28", + "rating": 85, + "votecount": 1000 + } + formatted = Formatter.format_vn_item(vn) + assert "v17" in formatted + assert "Clannad" in formatted + assert "8.5" in formatted + + +class TestErrorHandler: + """Test error handling utilities""" + + def test_format_error_http_error(self): + """Test formatting HTTP error""" + error = Exception("HTTP Error") + formatted = ErrorHandler.format_error(error) + assert isinstance(formatted, str) + assert len(formatted) > 0 + + def test_format_error_value_error(self): + """Test formatting ValueError""" + error = ValueError("Invalid value") + formatted = ErrorHandler.format_error(error) + assert "Invalid value" in formatted + + +class TestQueryBuilder: + """Test query builder utilities""" + + def test_build_search_filter(self): + """Test building search filter""" + filter_result = QueryBuilder.build_search_filter("Clannad") + assert filter_result == ["search", "=", "Clannad"] + + def test_build_id_filter(self): + """Test building ID filter""" + filter_result = QueryBuilder.build_id_filter("v17") + assert filter_result == ["id", "=", "v17"] + + def test_build_complex_filter(self): + """Test building complex filter""" + filter1 = ["search", "=", "Clannad"] + filter2 = ["lang", "=", "en"] + complex_filter = QueryBuilder.build_complex_filter(filter1, filter2) + assert complex_filter[0] == "and" + assert len(complex_filter) == 3 + + +class TestConfig: + """Test configuration""" + + def test_config_dict(self): + """Test configuration as dictionary""" + config_dict = Config.to_dict() + assert "TELEGRAM_BOT_TOKEN" in config_dict + assert "VNDB_TOKEN" in config_dict + assert "LOG_LEVEL" in config_dict + + +if __name__ == "__main__": + # Run tests with pytest + pytest.main([__file__, "-v"]) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..7e087f3 --- /dev/null +++ b/utils.py @@ -0,0 +1,353 @@ +""" +Utility functions for VNDB Telegram Bot +""" +from typing import Dict, List, Any, Optional, Tuple +import json +from telegram import Update + + +class Formatter: + """Utility class for formatting API responses""" + + @staticmethod + def truncate_text(text: str, max_length: int = 200) -> str: + """Truncate text to max length""" + if len(text) > max_length: + return text[:max_length-3] + "..." + return text + + @staticmethod + def format_vn_item(vn: Dict[str, Any]) -> str: + """Format a VN item for display""" + vn_id = vn.get("id", "Unknown") + title = vn.get("title", "Unknown") + original = vn.get("original", "") + released = vn.get("released", "Unknown") + rating = vn.get("rating", 0) + votecount = vn.get("votecount", 0) + + text = f"**{title}** (`{vn_id}`)\n" + if original: + text += f"Оригинал: {original}\n" + text += f"Дата: {released}\n" + if rating > 0: + text += f"Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)\n" + + return text + + @staticmethod + def format_character_item(char: Dict[str, Any]) -> str: + """Format a character item for display""" + char_id = char.get("id", "Unknown") + name = char.get("name", "Unknown") + original = char.get("original", "") + gender = char.get("gender", "Unknown") + vns = char.get("vn", []) + + text = f"**{name}** (`{char_id}`)\n" + if original: + text += f"Оригинал: {original}\n" + text += f"Пол: {gender}\n" + if vns: + text += f"Появляется в: {len(vns)} VN\n" + + return text + + @staticmethod + def format_release_item(release: Dict[str, Any]) -> str: + """Format a release item for display""" + release_id = release.get("id", "Unknown") + title = release.get("title", "Unknown") + original = release.get("original", "") + released = release.get("released", "Unknown") + platform = release.get("platform", "Unknown") + release_type = release.get("type", "Unknown") + + text = f"**{title}** (`{release_id}`)\n" + if original: + text += f"Оригинал: {original}\n" + text += f"Дата: {released}\n" + text += f"Платформа: {platform} | Тип: {release_type}\n" + + return text + + @staticmethod + def format_staff_item(staff: Dict[str, Any]) -> str: + """Format a staff member item for display""" + staff_id = staff.get("id", "Unknown") + name = staff.get("name", "Unknown") + original = staff.get("original", "") + gender = staff.get("gender", "Unknown") + role = staff.get("role", "") + + text = f"**{name}** (`{staff_id}`)\n" + if original: + text += f"Оригинал: {original}\n" + text += f"Пол: {gender}\n" + if role: + text += f"Роль: {role}\n" + + return text + + @staticmethod + def format_producer_item(producer: Dict[str, Any]) -> str: + """Format a producer item for display""" + producer_id = producer.get("id", "Unknown") + name = producer.get("name", "Unknown") + original = producer.get("original", "") + producer_type = producer.get("type", "Unknown") + + text = f"**{name}** (`{producer_id}`)\n" + if original: + text += f"Оригинал: {original}\n" + if producer_type: + text += f"Тип: {producer_type}\n" + + return text + + @staticmethod + def format_tag_item(tag: Dict[str, Any]) -> str: + """Format a tag item for display""" + tag_id = tag.get("id", "Unknown") + name = tag.get("name", "Unknown") + description = tag.get("description", "") + vns = tag.get("vns", 0) + + text = f"**{name}** (`{tag_id}`)\n" + if description: + truncated = Formatter.truncate_text(description, 100) + text += f"_{truncated}_\n" + if vns: + text += f"ВН: {vns}\n" + + return text + + @staticmethod + def format_trait_item(trait: Dict[str, Any]) -> str: + """Format a trait item for display""" + trait_id = trait.get("id", "Unknown") + name = trait.get("name", "Unknown") + description = trait.get("description", "") + chars = trait.get("chars", 0) + + text = f"**{name}** (`{trait_id}`)\n" + if description: + truncated = Formatter.truncate_text(description, 100) + text += f"_{truncated}_\n" + if chars: + text += f"Персонажей: {chars}\n" + + return text + + +class ErrorHandler: + """Error handling utilities""" + + @staticmethod + def format_error(error: Exception) -> str: + """Format error message for user""" + error_type = type(error).__name__ + error_msg = str(error) + + # Map common errors to user-friendly messages + error_messages = { + "HTTPStatusError": "Ошибка сервера API. Попробуйте позже.", + "ConnectError": "Ошибка подключения. Проверьте интернет соединение.", + "Timeout": "Запрос истек. Сервер слишком долго отвечает.", + "ValueError": f"Ошибка данных: {error_msg}", + "JSONDecodeError": "Ошибка парсинга ответа сервера.", + } + + return error_messages.get(error_type, f"❌ {error_msg}") + + +class PaginationHelper: + """Helper for pagination""" + + @staticmethod + def get_page_info(page: int, results_count: int, total: Optional[int] = None) -> str: + """Get pagination info string""" + info = f"📄 Страница {page}" + + if total: + info += f" | Всего: {total}" + + if results_count > 0: + info += f" | Результатов: {results_count}" + + return info + + +class FieldValidator: + """Validate and clean fields""" + + @staticmethod + def validate_fields(fields: List[str], allowed_fields: List[str]) -> List[str]: + """Validate that requested fields are allowed""" + return [f for f in fields if f in allowed_fields] + + @staticmethod + def clean_field_list(fields_str: str) -> List[str]: + """Parse and clean field list from string""" + fields = [f.strip() for f in fields_str.split(",")] + return [f for f in fields if f] # Remove empty strings + + +class QueryBuilder: + """Helper for building API queries""" + + @staticmethod + def build_search_filter(search_term: str) -> List[str]: + """Build a search filter""" + return ["search", "=", search_term] + + @staticmethod + def build_id_filter(vn_id: str) -> List[str]: + """Build an ID filter""" + return ["id", "=", vn_id] + + @staticmethod + def build_tag_filter(tag_id: str, depth: int = 0) -> Dict[str, Any]: + """Build a tag filter with optional depth""" + filter_dict = { + "tag": tag_id, + } + if depth > 0: + filter_dict["depth"] = depth + return filter_dict + + @staticmethod + def build_complex_filter(*filters: List[str]) -> List[Any]: + """Build a complex AND filter from multiple simple filters""" + if len(filters) == 1: + return filters[0] + return ["and"] + list(filters) + + +class ImageHandler: + """Handle image processing and sending""" + + # VNDB Image CDN + VNDB_CDN = "https://t.vndb.org" + + @staticmethod + def get_image_url(image_data: Dict[str, Any]) -> Optional[str]: + """ + Extract image URL from image data + + Args: + image_data: Image data from API + + Returns: + Full image URL or None + """ + if not image_data or not isinstance(image_data, dict): + return None + + image_path = image_data.get("url") + if not image_path: + return None + + # Construct full URL + if image_path.startswith("http"): + return image_path + + return f"{ImageHandler.VNDB_CDN}{image_path}" + + @staticmethod + def format_item_with_image( + item_type: str, + item: Dict[str, Any], + ) -> tuple[str, Optional[str]]: + """ + Format item with image information + + Args: + item_type: Type of item (vn, character, release, etc.) + item: Item data + + Returns: + Tuple of (text, image_url) + """ + text = "" + image_url = None + + if item_type == "vn": + item_id = item.get("id", "Unknown") + title = item.get("title", "Unknown") + original = item.get("original", "") + released = item.get("released", "Unknown") + rating = item.get("rating", 0) + votecount = item.get("votecount", 0) + + text = f"**{title}** (`{item_id}`)\n" + if original: + text += f"Оригинал: {original}\n" + text += f"Дата релиза: {released}\n" + if rating > 0: + text += f"Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)\n" + + elif item_type == "character": + item_id = item.get("id", "Unknown") + name = item.get("name", "Unknown") + original = item.get("original", "") + gender = item.get("gender", "Unknown") + + text = f"**{name}** (`{item_id}`)\n" + if original: + text += f"Оригинал: {original}\n" + text += f"Пол: {gender}\n" + + elif item_type == "release": + item_id = item.get("id", "Unknown") + title = item.get("title", "Unknown") + original = item.get("original", "") + released = item.get("released", "Unknown") + platform = item.get("platform", "Unknown") + + text = f"**{title}** (`{item_id}`)\n" + if original: + text += f"Оригинал: {original}\n" + text += f"Дата: {released}\n" + text += f"Платформа: {platform}\n" + + # Try to get image + image_data = item.get("image") + if image_data: + image_url = ImageHandler.get_image_url(image_data) + + return text, image_url + + @staticmethod + async def send_item_with_photo( + update: Update, + item_type: str, + item: Dict[str, Any], + emoji: str = "📦", + ) -> None: + """ + Send item with photo if available + + Args: + update: Telegram update + item_type: Type of item + item: Item data + emoji: Emoji to use in caption + """ + text, image_url = ImageHandler.format_item_with_image(item_type, item) + + if image_url: + try: + title = item.get("title") or item.get("name", "Item") + caption = f"{emoji} {title}" + + await update.message.reply_photo( + photo=image_url, + caption=caption, + parse_mode="Markdown" + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Could not send photo: {e}") + await update.message.reply_text(text, parse_mode="Markdown") diff --git a/vndb_client.py b/vndb_client.py new file mode 100644 index 0000000..b7603b1 --- /dev/null +++ b/vndb_client.py @@ -0,0 +1,496 @@ +""" +VNDB API Client +Handles all interactions with the VNDB API +""" +import httpx +import json +from typing import Dict, List, Any, Optional +from enum import Enum + + +class VndbEndpoint(Enum): + """VNDB API Endpoints""" + SCHEMA = "/schema" + STATS = "/stats" + USER = "/user" + AUTHINFO = "/authinfo" + VN = "/vn" + RELEASE = "/release" + CHARACTER = "/character" + STAFF = "/staff" + PRODUCER = "/producer" + TAG = "/tag" + TRAIT = "/trait" + QUOTE = "/quote" + ULIST = "/ulist" + RLIST = "/rlist" + ULIST_LABELS = "/ulist_labels" + + +class VndbClient: + """Client for interacting with VNDB API""" + + BASE_URL = "https://api.vndb.org/kana" + SANDBOX_URL = "https://beta.vndb.org/api/kana" + + def __init__(self, token: Optional[str] = None, use_sandbox: bool = False): + """ + Initialize VNDB client + + Args: + token: Optional API token for authenticated requests + use_sandbox: Whether to use sandbox endpoint + """ + self.token = token + self.base_url = self.SANDBOX_URL if use_sandbox else self.BASE_URL + self.headers = { + "Content-Type": "application/json", + } + if token: + self.headers["Authorization"] = f"Token {token}" + + async def _request( + self, + endpoint: str, + method: str = "GET", + data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Make HTTP request to VNDB API + + Args: + endpoint: API endpoint + method: HTTP method (GET, POST, PATCH, DELETE) + data: Request body data + + Returns: + Response JSON + + Raises: + httpx.HTTPError: On HTTP errors + json.JSONDecodeError: On invalid JSON response + """ + url = f"{self.base_url}{endpoint}" + + async with httpx.AsyncClient(timeout=10) as client: + response = await client.request( + method, + url, + headers=self.headers, + content=json.dumps(data) if data else None, + ) + response.raise_for_status() + return response.json() + + # Simple Requests + + async def get_schema(self) -> Dict[str, Any]: + """Get API schema with metadata""" + return await self._request(VndbEndpoint.SCHEMA.value) + + async def get_stats(self) -> Dict[str, Any]: + """Get database statistics""" + return await self._request(VndbEndpoint.STATS.value) + + async def get_user(self, queries: List[str], fields: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Lookup users by ID or username + + Args: + queries: List of user IDs or usernames to lookup + fields: List of fields to retrieve (lengthvotes, lengthvotes_sum) + + Returns: + User information + """ + params = {"q": queries} + if fields: + params["fields"] = fields + + # Build query string + query_parts = [f"q={q}" for q in queries] + if fields: + query_parts.append(f"fields={','.join(fields)}") + query_string = "&".join(query_parts) + + url = f"{self.base_url}{VndbEndpoint.USER.value}?{query_string}" + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(url, headers=self.headers) + response.raise_for_status() + return response.json() + + async def get_authinfo(self) -> Dict[str, Any]: + """Get authenticated user information""" + if not self.token: + raise ValueError("Token required for authinfo") + return await self._request(VndbEndpoint.AUTHINFO.value) + + # Database Querying + + async def query_vn( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + count: bool = False, + user: Optional[str] = None, + compact_filters: bool = False, + normalized_filters: bool = False, + ) -> Dict[str, Any]: + """ + Query visual novels + + Args: + filters: Filter conditions + fields: Fields to retrieve + sort: Sort field (id, title, released, rating, votecount, searchrank) + reverse: Sort in descending order + results: Number of results per page (max 100) + page: Page number starting from 1 + count: Include total count + user: User ID for user-specific filters + compact_filters: Include compact filter representation + normalized_filters: Include normalized filter representation + + Returns: + Query results + """ + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + "compact_filters": compact_filters, + "normalized_filters": normalized_filters, + } + if user: + data["user"] = user + + return await self._request(VndbEndpoint.VN.value, "POST", data) + + async def query_release( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + count: bool = False, + user: Optional[str] = None, + compact_filters: bool = False, + normalized_filters: bool = False, + ) -> Dict[str, Any]: + """Query releases""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + "compact_filters": compact_filters, + "normalized_filters": normalized_filters, + } + if user: + data["user"] = user + + return await self._request(VndbEndpoint.RELEASE.value, "POST", data) + + async def query_character( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + count: bool = False, + compact_filters: bool = False, + normalized_filters: bool = False, + ) -> Dict[str, Any]: + """Query characters""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + "compact_filters": compact_filters, + "normalized_filters": normalized_filters, + } + + return await self._request(VndbEndpoint.CHARACTER.value, "POST", data) + + async def query_staff( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + count: bool = False, + compact_filters: bool = False, + normalized_filters: bool = False, + ) -> Dict[str, Any]: + """Query staff""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + "compact_filters": compact_filters, + "normalized_filters": normalized_filters, + } + + return await self._request(VndbEndpoint.STAFF.value, "POST", data) + + async def query_producer( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + count: bool = False, + compact_filters: bool = False, + normalized_filters: bool = False, + ) -> Dict[str, Any]: + """Query producers""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + "compact_filters": compact_filters, + "normalized_filters": normalized_filters, + } + + return await self._request(VndbEndpoint.PRODUCER.value, "POST", data) + + async def query_tag( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + count: bool = False, + compact_filters: bool = False, + normalized_filters: bool = False, + ) -> Dict[str, Any]: + """Query tags""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + "compact_filters": compact_filters, + "normalized_filters": normalized_filters, + } + + return await self._request(VndbEndpoint.TAG.value, "POST", data) + + async def query_trait( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + count: bool = False, + compact_filters: bool = False, + normalized_filters: bool = False, + ) -> Dict[str, Any]: + """Query traits""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + "compact_filters": compact_filters, + "normalized_filters": normalized_filters, + } + + return await self._request(VndbEndpoint.TRAIT.value, "POST", data) + + async def query_quote( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + count: bool = False, + compact_filters: bool = False, + normalized_filters: bool = False, + ) -> Dict[str, Any]: + """Query quotes""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + "compact_filters": compact_filters, + "normalized_filters": normalized_filters, + } + + return await self._request(VndbEndpoint.QUOTE.value, "POST", data) + + # List Management + + async def query_ulist( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + user: Optional[str] = None, + count: bool = False, + ) -> Dict[str, Any]: + """Query user visual novel list""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + } + if user: + data["user"] = user + + return await self._request(VndbEndpoint.ULIST.value, "POST", data) + + async def query_rlist( + self, + filters: Optional[List[Any]] = None, + fields: Optional[List[str]] = None, + sort: str = "id", + reverse: bool = False, + results: int = 10, + page: int = 1, + user: Optional[str] = None, + count: bool = False, + ) -> Dict[str, Any]: + """Query user release list""" + data = { + "filters": filters or [], + "fields": ",".join(fields) if fields else "", + "sort": sort, + "reverse": reverse, + "results": results, + "page": page, + "count": count, + } + if user: + data["user"] = user + + return await self._request(VndbEndpoint.RLIST.value, "POST", data) + + async def get_ulist_labels(self, user: Optional[str] = None) -> Dict[str, Any]: + """Get user list labels""" + url = f"{self.base_url}{VndbEndpoint.ULIST_LABELS.value}" + if user: + url += f"?user={user}" + + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(url, headers=self.headers) + response.raise_for_status() + return response.json() + + async def add_to_ulist( + self, + vn_id: str, + status: Optional[str] = None, + notes: Optional[str] = None, + labels: Optional[List[str]] = None, + voted: Optional[int] = None, + ) -> Dict[str, Any]: + """Add or update visual novel in user list""" + if not self.token: + raise ValueError("Token required for list operations") + + data = {"id": vn_id} + if status: + data["status"] = status + if notes: + data["notes"] = notes + if labels: + data["labels"] = labels + if voted is not None: + data["voted"] = voted + + return await self._request( + f"{VndbEndpoint.ULIST.value}/{vn_id}", + "PATCH", + data + ) + + async def add_to_rlist( + self, + release_id: str, + status: Optional[str] = None, + notes: Optional[str] = None, + ) -> Dict[str, Any]: + """Add or update release in user list""" + if not self.token: + raise ValueError("Token required for list operations") + + data = {"id": release_id} + if status: + data["status"] = status + if notes: + data["notes"] = notes + + return await self._request( + f"{VndbEndpoint.RLIST.value}/{release_id}", + "PATCH", + data + ) + + async def remove_from_ulist(self, vn_id: str) -> None: + """Remove visual novel from user list""" + if not self.token: + raise ValueError("Token required for list operations") + + await self._request(f"{VndbEndpoint.ULIST.value}/{vn_id}", "DELETE") + + async def remove_from_rlist(self, release_id: str) -> None: + """Remove release from user list""" + if not self.token: + raise ValueError("Token required for list operations") + + await self._request(f"{VndbEndpoint.RLIST.value}/{release_id}", "DELETE")