Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
13 KiB
Интеграция из 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://grabber-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.
Клиентская постобработка (одинакова во всех языках):
- токенизировать дорожку (max_len 128, truncation);
- прогнать ONNX, взять
argmax; - спан: первый токен с меткой B, затем цепочка I; текст спана — по offsets
токенизатора (или
decodeтокенов спана); is_18plus = argmax(age_logits) == 1;- канонизация автора по
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/grabber-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), поэтому
обучаемый разбор можно делать после накопления данных сборщиком.