Files
grabber-ai/docs/integration.md
T
DenozordecandClaude Sonnet 5.5 efc9a4f56a
docker / build (push) Failing after 39s
Docker-сервис, выбор основы и сравнение моделей, сборщик датасета
- Docker: Dockerfile (CPU), compose с FlareSolverr, настройка через DUB_* переменные,
  токен доступа к /predict, tools/pack_models.py, workflow Gitea Actions, docs/docker.md
- Модель грузится офлайн (config.json рядом с весами); общий код вынесен в src/spans.py,
  сервису не нужны sklearn/matplotlib/pandas
- Обучение: профили tiny2/base/large/modern, запуски в models/runs, реестр и честное
  сравнение на одной тестовой выборке (src/runs.py), пункт меню «Модели»
- Датасет: язык и аудио-параметры дорожек, 18+ по строкам «Аудио», ручные метки по ссылкам,
  переиндексация сохранённого HTML, параллельная сборка, сбалансированная выборка
- Тексты страниц (data/pages) больше не хранятся в git

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 17:19:10 +07:00

13 KiB
Raw Blame History

Интеграция из Rust / TypeScript

Два пути. А (HTTP) — самый простой: Python-процесс один раз загружает модель, любой язык ходит по REST. Б (ONNX) — без Python в рантайме: модель запускается локально в вашем процессе.

Путь А: HTTP-сервис Путь Б: ONNX
Зависимости рантайма запущенный Python-процесс onnxruntime (+ токенизатор) в вашем языке
Сегментация HTML да, на стороне сервиса нет — в примерах обрабатывается одна дорожка; сегментацию нужно вызвать отдельно или портировать (html_extract.py ~60 строк)
Канонизация имён да, на стороне сервиса по aliases.json (простой словарь)
Скорость сеть + инференс (~мс) локальный инференс (~мс)
Кому подходит сервис/бэкенд CLI/десктоп/edge, где Python нельзя

Путь А: HTTP-сервис

Запуск (из корня проекта):

python -m src.service --model models/best --port 8000
# или открыто наружу: python -m src.service --host 0.0.0.0 --port 8000

В Docker (VPS, FlareSolverr через переменные окружения, CI/CD в Gitea) — см. docker.md.

API:

  • GET /health → {"status": "ok", "model": "...", "device": "...", "classes": [...]}
  • POST /predict — ровно одно из полей (url принимает опциональный cookie; ответ для html/url — агрегат релиза, см. docs/inference.md):
    • {"url": "https://rutracker.org/forum/viewtopic.php?t=…"} → страница загружается сервисом;
    • {"text": "Перевод: Дублированный | MovieDalen"} → одна дорожка;
    • {"html": "<html>…вся страница…"} → все дорожки релиза.

Ответ: {"tracks": [ …контракт из docs/inference.md… ]}.

Каждая дорожка помимо типа, студии и 18+ несёт (поля только добавляются, прежние не менялись):

Поле Что это
lang, lang_confidence, lang_source язык дорожки (RUS, UKR, ENG, …); источник: model (языковая голова), audio (строка «Аудио»), text (слова в строке дорожки) или null
role перевод / оригинал / комментарии (по строке «Аудио» и тексту, правилами)
official в строке «Аудио» сказано «официальный» / «лицензионный»
audio {codec, channels, bitrate_kbps} из строки «Аудио» (или null)

Русский и украинский дубляж одной студии — две отдельные дорожки; так же раздельны обычная дорожка студии и её дорожка 18+ (если на странице две строки «Аудио» с одной студией).

curl:

curl -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" \
     -d '{"text": "Перевод: Дублированный | MovieDalen"}'

Windows/Git Bash: кириллица в аргументе -d может искажаться кодировкой консоли — используйте --data-binary @req.json с файлом в UTF-8.

Rust (examples/rust/http_client)

cd examples/rust/http_client
cargo run -- --text "Перевод: Дублированный | MovieDalen"
cargo run -- --html page.html
DUB_URL=http://host:8000 cargo run -- --text "..."

Ядро клиента:

let resp: PredictResponse = client
    .post("http://127.0.0.1:8000/predict")
    .json(&serde_json::json!({ "html": page_html }))
    .send().await?
    .json().await?;
for t in &resp.tracks {
    println!("{} — {}{}", t.translation_type,
             t.studio.clone().unwrap_or_else(|| "—".into()),
             if t.is_18plus { " 18+" } else { "" });
}

TypeScript / Node.js (examples/typescript)

Без зависимостей (Node >= 18, глобальный fetch):

node examples/typescript/predict.mjs --text "Одноголосый закадровый — Дмитрий Есарев"
node examples/typescript/predict.mjs --html page.html
const res = await fetch("http://127.0.0.1:8000/predict", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: "Одноголосый закадровый — Дмитрий Есарев" }),
});
const { tracks } = await res.json();

Типизированная версия — examples/typescript/predict.ts (запуск: npx tsx predict.ts --text "...").

PowerShell (examples/powershell/predict.ps1)

Скрипт (работает и в Windows PowerShell 5.1, и в PowerShell 7):

powershell -NoProfile -ExecutionPolicy Bypass -File examples\powershell\predict.ps1 -Text "Перевод: Дублированный | MovieDalen"
pwsh -File examples\powershell\predict.ps1 -HtmlFile page.html -Url http://host:8000

Однострочник (POST с кириллицей; UTF-8 байтами — важно для PS 5.1, где строковый -Body уходит в ISO-8859-1):

$b = [Text.Encoding]::UTF8.GetBytes((@{ text = "Перевод: Дублированный | MovieDalen" } | ConvertTo-Json))
(Invoke-RestMethod -Uri http://127.0.0.1:8000/predict -Method Post -ContentType "application/json" -Body $b).tracks

Проверка живости: Invoke-RestMethod http://127.0.0.1:8000/health. Ответ сервис отдаёт с charset=utf-8, поэтому кириллица в ответе корректно декодируется и в PS 5.1.

Cloudflare / 403 на трекерах (FlareSolverr)

Как и Jackett/Prowlarr, сервис умеет ходить через ваш экземпляр FlareSolverr: при ответе трекера 403/429/503 или странице-заглушке («Один момент…») запрос повторяется через него.

docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
python -m src.service --flaresolverr http://127.0.0.1:8191     # или env DUB_FLARESOLVERR_URL
python -m src.predict --url <ссылка> --flaresolverr http://127.0.0.1:8191

Адрес FlareSolverr задаётся только настройкой сервиса, не полем запроса. Если grabber уже работает через Prowlarr/FlareSolverr, проще скачать страницу там и прислать её в поле html. Ограничения: FlareSolverr не гарантирует обход (rutracker дополнительно требует авторизацию / может блокировать по региону), cookie сессии передаются через cookie.

grabber (PHP)

kinozal требует авторизации, поэтому grabber скачивает страницу сам (KinozalClient) и отправляет сервису готовый HTML вместе с заголовком раздачи:

$ch = curl_init('http://dub-ai:8000/predict');
curl_setopt_array($ch, [
    CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode(['html' => $tab, 'title' => $releaseTitle], JSON_UNESCAPED_UNICODE),
]);
$release = json_decode(curl_exec($ch), true);
// $tab — ответ get_srv_details.php (заголовка в нём нет, поэтому передаём title)
// $release['studios'], $release['translation_types'], $release['is_18plus']

Путь Б: ONNX без Python

Экспорт (из корня проекта, после обучения):

python -m src.export_onnx            # -> models/best-onnx/ + reports/onnx_parity.md

Для встраивания — малая модель: python -m src.export_onnx --model models/tiny2 --quantize кладёт в models/tiny2-onnx/ ещё и model.int8.onnx (29 МБ вместо ~440 МБ у base; parity с PyTorch: тип 99.8%, спан 99.2%, 18+ 100% — reports/onnx_parity_tiny2_int8.md).

Состав models/best-onnx/: model.onnx (входы input_ids, attention_mask, token_type_ids int64 [B, seq]; выходы type_logits [B, 14], bio_logits [B, seq, 3], age_logits [B, 2]), tokenizer.json (HF fast-токенизатор), label_maps.json (классы), aliases.json (нормализация имён).

Эквивалентность PyTorch проверяется на всём held-out test (совпадение типа/спана/18+ ≥ 99%) — отчёт в reports/onnx_parity.md.

Клиентская постобработка (одинакова во всех языках):

  1. токенизировать дорожку (max_len 128, truncation);
  2. прогнать ONNX, взять argmax;
  3. спан: первый токен с меткой B, затем цепочка I; текст спана — по offsets токенизатора (или decode токенов спана);
  4. is_18plus = argmax(age_logits) == 1;
  5. канонизация автора по aliases.json.

Rust (examples/rust/onnx_local)

cd examples/rust/onnx_local && cargo build
ORT_DYLIB_PATH="$(find ../../.venv -name onnxruntime.dll | head -1)" \
  ./target/debug/dub-ai-onnx-local.exe ../../models/best-onnx \
  "Перевод: Дублированный | MovieDalen"

Крейты: ort 2.0.0-rc.15 + tokenizers (читает tokenizer.json как есть). Библиотека onnxruntime грузится динамически — DLL можно взять из pip-пакета onnxruntime (уже есть в .venv проекта) или из официального релиза; проверено: тип, спан автора и 18+ совпадают с Python-инференсом.

TypeScript (examples/typescript/onnx_local.mjs)

cd examples/typescript
npm init -y && npm i onnxruntime-node @huggingface/transformers
node onnx_local.mjs "../../models/best-onnx" "Перевод: Дублированный | MovieDalen"

@huggingface/transformers используется только как токенизатор; инференс — onnxruntime-node.

Замечание про сегментацию страниц в пути Б

Сервис (путь А) сам находит все дорожки в HTML. В пути Б примеры принимают одну дорожку; для полной обработки страницы нужно сначала получить сегменты (например, один вызов лёгкого HTTP-эндпоинта или порт html_extract.py — это регулярки без зависимостей, кроме BeautifulSoup для HTML).

Серии по студиям (для Sonarr)

В ответе релиза у каждой дорожки есть episodes («1-4», «1,3-5») и episode_ranges ([[1, 4]]); null — серии у дорожки не указаны, считается, что она покрывает весь релиз. Для сериалов добавлен блок series:

"series": {"season": "2", "episodes": "1-9", "total": 10,
           "by_studio": {"LostFilm": "1-4", "Red Head Sound": "1"},
           "full_release": ["Syncmer"]}

full_release — студии, у которых серии не указаны: для сериала это значит, что озвучены все серии, вышедшие в релизе.

episodes и total берутся из заголовка («сезон 2, серии 1-9 из 10»), by_studio — из строк дорожек. Диапазоны разбирает src/episodes.py правилами (числа и диапазоны точны); размеченных примеров пока мало (серии указаны примерно в 5% строк nnm), поэтому обучаемый разбор можно делать после накопления данных сборщиком.