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")