Files
grabber-ai/tests/run_tests.py
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

1098 lines
73 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Юнит-тесты DUB-AI (запуск: .venv/Scripts/python tests/run_tests.py).
Проверяют ключевые части без обращения к GPU:
* поиск спана автора (точно / алиас / инициалы / кавычки / разделители);
* канонизация имён студий;
* снятие префикса строки дорожки;
* сегментацию страниц на дорожки;
* HTML -> текст;
* BIO-выравнивание спана по offsets токенизатора.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.html_extract import (RE_LINE_PREFIX, _split_entries, # noqa: E402
extract_segments, html_to_text)
from src.studio_norm import canonical, find_studio_span # noqa: E402
PASS = 0
FAIL = 0
def check(name: str, cond: bool, extra: str = "") -> None:
global PASS, FAIL
if cond:
PASS += 1
print(f" ok {name}")
else:
FAIL += 1
print(f"FAIL {name} {extra}")
# ---------------------------------------------------------------- спаны
print("== find_studio_span ==")
txt = "Профессиональный (многоголосый закадровый) | HDRezka Studio 18+"
s, e = find_studio_span(txt, "HDrezka")
check("HDrezka найден", s is not None and "HDRezka" in txt[s:e], f"span={(s, e)}")
s, e = find_studio_span("Авторский (одноголосый закадровый) - Ю. Сербин", "Юрий Сербин")
check("инициалы Ю. Сербин", "Сербин" in "Авторский (одноголосый закадровый) - Ю. Сербин"[s:e] if s is not None else False)
s, e = find_studio_span("Авторский (одноголосый закадровый) Гаврилов", "Андрей Гаврилов")
check("фамилия Гаврилов", s is not None and e is not None and s > 0)
s, e = find_studio_span('Профессиональный (многоголосый закадровый) Студия «Кравец», LostFilm',
"Студия Кравец, LostFilm")
check("кавычки в тексте", s is not None)
s, e = find_studio_span("Профессиональный (многоголосый закадровый) - SDI Media | TV 1000",
"SDI Media TV 1000")
check("разделитель |", s is not None)
check("нет автора -> None", find_studio_span("Профессиональный (дублированный)", "") is None)
check("нет вхождения -> None", find_studio_span("Профессиональный (дублированный)", "HDrezka") is None)
# ---------------------------------------------------------------- канонизация
print("== canonical ==")
check("HDRezka Studio -> HDrezka", canonical("HDRezka Studio") == "HDrezka")
check("hdrezka -> HDrezka", canonical("hdrezka") == "HDrezka")
check("Read Head Sound -> Red Head Sound", canonical("Read Head Sound") == "Red Head Sound")
check("Ultradox Studio -> Ultradox", canonical("Ultradox Studio") == "Ultradox")
check("Jaskier ненормативная лексика -> Jaskier",
canonical("Jaskier ненормативная лексика") == "Jaskier")
check("официальный -> ''", canonical("официальный") == "")
# ---------------------------------------------------------------- префиксы
print("== RE_LINE_PREFIX ==")
for line, body in [
("Перевод: Профессиональный (дублированный)", "Профессиональный (дублированный)"),
("Перевод 2: Любительский (одноголосый закадровый)", "Любительский (одноголосый закадровый)"),
("Перевод #1: Дублированный", "Дублированный"),
("Озвучивание: Дублированное | Official", "Дублированное | Official"),
("Аудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)",
"Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)"),
("Перевод:Профессиональный (дублированный)", "Профессиональный (дублированный)"),
]:
check(f"префикс «{line[:20]}…»", RE_LINE_PREFIX.sub("", line) == body)
# ---------------------------------------------------------------- сегментация
print("== extract_segments ==")
segs = extract_segments(
"Описание релиза\n"
"Перевод 1: Профессиональный (дублированный)\n"
"Перевод 2: Профессиональный (двухголосый закадровый) tycoon\n"
"Субтитры: русские, английские\n")
check("две дорожки", len(segs) == 2, f"got {segs}")
segs = extract_segments("Перевод 1: Профессиональный (дублированный) 2. Авторский (одноголосый закадровый) Гоблин")
check("нумерация 1./2.", len(segs) == 2, f"got {segs}")
segs = extract_segments("Перевод: Одноголосый, многоголосый закадровый")
check("запятая-перечисление", len(segs) == 2, f"got {segs}")
segs = extract_segments("Перевод: Профессиональный (многоголосый, закадровый)")
check("скобки-уточнение не режутся", len(segs) == 1, f"got {segs}")
segs = extract_segments("Перевод:\nПрофессиональный (дублированный) Невафильм")
check("значение на следующей строке",
segs == ["Профессиональный (дублированный) Невафильм"], f"got {segs}")
segs = extract_segments("Перевод: Профессиональный (двухголосый закадровый) + Любительский (одноголосый закадровый)")
check("разделитель +", len(segs) == 2, f"got {segs}")
segs = extract_segments("Аудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)\n"
"Аудио 2: Opus, 6 ch, 199 Kbps | Русский DVO (Кубик в Кубе)")
check("nnm аудио-дорожки", len(segs) == 2, f"got {segs}")
segs = extract_segments("Аудио: Русский (AC3, 6 ch, 640 Кбит/с), Английский (AC3)")
check("codec-строка без типа пропущена", len(segs) == 0, f"got {segs}")
check("_split_entries одиночная", _split_entries("Дублированный") == ["Дублированный"])
# ---------------------------------------------------------------- html
print("== html_to_text ==")
html = ("<html><body><div>меню форума</div>"
'<div class="post_body">Перевод: Профессиональный (дублированный) Невафильм</div>'
"</body></html>")
text = html_to_text(html)
check("селектор .post_body", "Невафильм" in text and "меню форума" not in text)
html2 = "<html><body><p>hello</p></body></html>"
check("fallback на весь текст", "hello" in html_to_text(html2))
print("== read_input / mojibake ==")
import tempfile
from src.html_extract import read_input
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "mojibake.html"
p.write_bytes("Перевод: Дублированный".encode("utf-8").decode("cp1251",
errors="ignore").encode("utf-8"))
check("mojibake восстановлен", "Дублированный" in read_input(p))
p2 = Path(td) / "cp1251.txt"
p2.write_bytes("Перевод: Дублированный".encode("cp1251"))
check("cp1251 прочитан", "Дублированный" in read_input(p2))
# ---------------------------------------------------------------- BIO
print("== BIO-выравнивание (требует токенизатор) ==")
try:
from transformers import AutoTokenizer
from src.train import make_collate
tok = AutoTokenizer.from_pretrained("models/best")
collate = make_collate(tok, {"проф. дубляж": 0})
text = "Авторский (одноголосый закадровый) - Сергей Визгунов"
s0 = text.index("Сергей")
enc, types, bio, ages, offsets = collate(
[{"text": text, "translation_type": "проф. дубляж",
"span": [s0, s0 + len("Сергей Визгунов")], "is_18plus": False}])
marked = {i for i, lab in enumerate(bio[0].tolist()) if lab >= 1}
span_toks = [tok.convert_ids_to_tokens(int(enc["input_ids"][0, i])) for i in sorted(marked)]
joined = "".join(t.lstrip("#") for t in span_toks).lower()
# у ruBert-base токенизатор strips accents: «й» может стать «и»
check("токены спана = ФИ", "визгунов" in joined and joined.startswith("сер"),
f"tokens={span_toks}")
check("первый токен B", bio[0, min(marked)].item() == 1 if marked else False)
except Exception as e: # noqa: BLE001
print(f" пропущено (нет модели models/best): {e}")
# ---------------------------------------------------------------- релиз / ссылки
print("== release / fetch ==")
from src.fetch import decode_bytes, detect_tracker # noqa: E402
from src.release import aggregate, title_segments # noqa: E402
check("tracker rutracker", detect_tracker("https://rutracker.org/forum/viewtopic.php?t=1") == "rutracker")
check("tracker kinozal", detect_tracker("https://kinozal.tv/details.php?id=1") == "kinozal")
check("tracker nnm", detect_tracker("https://nnmclub.to/forum/viewtopic.php?t=1") == "nnm")
for bad in ("http://localhost:8000/", "http://127.0.0.1/", "http://169.254.169.254/",
"https://rutracker.org.evil.com/", "file:///etc/passwd", "ftp://rutracker.org/"):
try:
detect_tracker(bad)
check(f"SSRF отклонён: {bad}", False)
except ValueError:
check(f"SSRF отклонён: {bad}", True)
check("decode cp1251", decode_bytes("Перевод".encode("cp1251")) == "Перевод")
check("decode charset", decode_bytes("Перевод".encode("cp1251"), "text/html; charset=windows-1251") == "Перевод")
check("title: ПМ (студия)",
title_segments("Гангстерленд / 2025 / ПМ (LostFilm) / WEB-DLRip")
== ["Профессиональный (многоголосый закадровый) | LostFilm"])
check("title: ДБ + список ПМ без студий",
title_segments("Х / 2026 / ДБ (Red Head Sound), 5 x ПМ, ПД, ЛМ, СТ / 4K")
== ["Профессиональный (дублированный) | Red Head Sound"])
check("title: пусто", title_segments("Просто фильм / 2020 / WEB-DL") == [])
def _t(typ, st, age=False):
return {"segment": f"{typ}|{st}", "translation_type": typ, "studio": st, "is_18plus": age}
agg = aggregate([_t("проф. дубляж", "MovieDalen"), _t("проф. дубляж", "moviedalen", True),
_t("проф. многоголосый", "LostFilm")])
check("aggregate: дедуп", len(agg["tracks"]) == 2, str(agg))
check("aggregate: 18+ = OR", agg["is_18plus"] is True)
check("aggregate: студии", [x["studio"] for x in agg["studios"]] == ["MovieDalen", "LostFilm"])
check("aggregate: пусто", aggregate([])["is_18plus"] is False)
from src.fetch import blocked_reason # noqa: E402
from src.release import merge_title_tracks, title_from_text # noqa: E402
check("заглушка Cloudflare", blocked_reason("<html><title>Один момент…</title>") is not None)
check("нормальная страница", blocked_reason("<html><title>X</title>Перевод: A") is None)
_page = [dict(_t("проф. двухголосый", None), studio_span=None, studio_confidence=None),
dict(_t("проф. дубляж", "MovieDalen"), studio_span="MovieDalen", studio_confidence=1)]
_title = [dict(_t("проф. двухголосый", "Red Head Sound"), studio_span="Red Head Sound",
studio_confidence=0.9),
dict(_t("проф. дубляж", "MovieDalen"), studio_span="x", studio_confidence=1),
dict(_t("проф. многоголосый", "LostFilm"), studio_span="LostFilm", studio_confidence=1)]
merge_title_tracks(_page, _title)
check("title: студия вписана в дорожку без студии",
_page[0]["studio"] == "Red Head Sound" and _page[0]["source"] == "page+title")
check("title: дубль студии пропущен, новая добавлена",
len(_page) == 3 and _page[2]["source"] == "title")
_dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n"
"Гангстерленд / 2026 / ДБ (X) / WEB-DL")
check("title_from_text: первая строка",
"LostFilm" in title_from_text(_dump) and "ДБ" not in title_from_text(_dump))
from src.predict import refine_span # noqa: E402
check("«Авторский, одноголосый - X» не режется",
_split_entries("Авторский, одноголосый - Ю. Сербин") == ["Авторский, одноголосый - Ю. Сербин"])
check("перечисление голосностей режется",
len(_split_entries("Одноголосый, многоголосый закадровый")) == 2)
_t4 = "Russian: 3/2 (L,C,R,l,r) + LFE ch |Дубляж"
check("span: раскладка каналов -> None", refine_span(_t4, (14, 23)) is None)
_t5 = "Русский MVO HDRezka Studio"
check("span: расширение до границ слова", _t5[slice(*refine_span(_t5, (12, 14)))] == "HDRezka")
check("span: один символ -> None", refine_span("М. Чадов", (0, 1)) is None)
import json as _json # noqa: E402
import threading # noqa: E402
import urllib.error # noqa: E402
from http.server import BaseHTTPRequestHandler, HTTPServer # noqa: E402
import src.fetch as _fetch # noqa: E402
class _FakeFlare(BaseHTTPRequestHandler):
def do_POST(self):
req = _json.loads(self.rfile.read(int(self.headers["Content-Length"])))
ok = req.get("cmd") == "request.get" and "rutracker.org" in req.get("url", "")
body = _json.dumps({"status": "ok", "solution": {"response": "<html>Перевод: Дублированный | MovieDalen</html>"}}
if ok else {"status": "error", "message": "bad"}).encode()
self.send_response(200)
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
_srv = HTTPServer(("127.0.0.1", 0), _FakeFlare)
threading.Thread(target=_srv.serve_forever, daemon=True).start()
_flare = f"http://127.0.0.1:{_srv.server_port}"
def _deny(url, timeout, cookie):
raise urllib.error.HTTPError(url, 403, "Forbidden", {}, None)
_orig = _fetch._fetch_direct
_fetch._fetch_direct = _deny
try:
h, tr = _fetch.fetch_html("https://rutracker.org/forum/viewtopic.php?t=1", flaresolverr=_flare)
check("FlareSolverr: fallback при 403", "MovieDalen" in h and tr == "rutracker")
try:
_fetch.fetch_html("https://rutracker.org/forum/viewtopic.php?t=1")
check("без FlareSolverr 403 пробрасывается", False)
except urllib.error.HTTPError:
check("без FlareSolverr 403 пробрасывается", True)
finally:
_fetch._fetch_direct = _orig
_srv.shutdown()
check("rutracker: форма логина в шапке не блокирует тему",
blocked_reason('<html><a href="login.php"></a><input name="login_username"><div class="post_body">x</div></html>') is None)
check("rutracker: голая страница логина блокируется",
blocked_reason('<html><a href="login.php"></a><input name="login_username"></html>') is not None)
_rt = ('<div class="post_body"><span class="post-b">Перевод 1</span>: Профессиональный '
'(дублированный) <a href="#">Продубляж</a><br><span>Перевод 2</span>: Профессиональный '
'(многоголосый закадровый) <a>AlexFilm</a><br>Субтитры: Русские</div>')
check("html: студия-ссылка внутри строки не рвёт дорожку",
extract_segments(html_to_text(_rt)) == ["Профессиональный (дублированный) Продубляж",
"Профессиональный (многоголосый закадровый) AlexFilm"])
check("«Дубляж, Продубляж» не режется",
len(_split_entries("Russian AC3 |Дубляж, Продубляж|")) == 1)
_agg2 = aggregate([_t("проф. дубляж", "Продубляж"), _t("проф. дубляж", None),
_t("проф. двухголосый", None)])
check("aggregate: дорожка без студии того же типа схлопывается",
[x["translation_type"] for x in _agg2["tracks"]] == ["проф. дубляж", "проф. двухголосый"])
print("== серии по студиям ==")
from src.episodes import format_ranges as _fr, parse_episodes as _pe # noqa: E402
from src.release import series_info as _si # noqa: E402
check("episodes: диапазоны, перечисления и «из N»",
[(_fr(_pe(s)["ranges"]), _pe(s)["total"]) for s in
("русский (MVO, Lostfilm) серии 1-2", "серии 1, 3-5 из 12", "с 1 по 4 серии", "1-3 серии",
"S01E05-E08", "AC3, 5.1 ch, 640 Kbps", "Перевод: Профессиональный (многоголосый)")]
== [("1-2", None), ("1,3-5", 12), ("1-4", None), ("1-3", None), ("5-8", None), ("", None), ("", None)])
check("episodes: соседние диапазоны склеиваются", _fr(_pe("серии 1-3, 4-6")["ranges"]) == "1-6")
def _tk(typ, studio, eps=None):
r = _pe(eps or "")["ranges"]
return {**_t(typ, studio), "episode_ranges": r, "episodes": _fr(r) or None}
_ag = aggregate([_tk("неизвестно", "LostFilm"), _tk("проф. многоголосый", "LostFilm", "серии 1-2"),
_tk("проф. многоголосый", "LostFilm", "серии 3-4"), _tk("проф. дубляж", "Red Head Sound", "серия 1")])
check("aggregate: «неизвестно» сливается с известным типом, серии объединяются",
[(x["translation_type"], x["studio"], x["episodes"]) for x in _ag["tracks"]]
== [("проф. многоголосый", "LostFilm", "1-4"), ("проф. дубляж", "Red Head Sound", "1")], str(_ag["tracks"]))
_s = _si("Тёмная материя / Dark Matter (2026) (сезон 2, серии 1-9 из 10)", _ag["tracks"])
check("series_info: сезон, серии, total и «студия -> серии»",
(_s["season"], _s["episodes"], _s["total"]) == ("2", "1-9", 10)
and _s["by_studio"] == {"LostFilm": "1-4", "Red Head Sound": "1"}, str(_s))
print("== сборщик датасета (tools/collect.py) ==")
import csv as _csv # noqa: E402
import json as _json # noqa: E402
import tempfile as _tmp # noqa: E402
from pathlib import Path as _P # noqa: E402
sys.path.insert(0, str(_P(__file__).resolve().parents[1] / "tools"))
import collect as _col # noqa: E402
_lab = {"rutracker": {"100", "200"}, "nnm": {"200", "300"}, "kinozal": set()}
check("page_stem: размеченный id другого источника не используется bare",
_col.page_stem("rutracker", "200", _lab) == "rt-200"
and _col.page_stem("nnm", "200", _lab) == "nnm-200")
check("page_stem: у размеченной раздачи прежний id, у новой — префикс",
_col.page_stem("rutracker", "100", _lab) == "100"
and _col.page_stem("kinozal", "5", _lab) == "kz-5")
check("login_state: выход > гость > неизвестно",
_col.login_state({"out": True, "guest": True}) == "in"
and _col.login_state({"guest": True}) == "guest"
and _col.login_state({}) == "unknown")
_hv = _col.hv
check("ошибка сервера: 503/502/Cloudflare определяются по заголовку и тексту, обычные страницы нет",
all(_hv.RE_ERROR_TITLE.search(t) for t in
("503 Service Temporarily Unavailable", "Error 502", "Attention Required! | Cloudflare",
"504 Gateway Time-out", "Ошибка 503"))
and all(_hv.RE_ERROR_HEAD.search(t) for t in
("error code: 1015 you are being rate limited", "web server is returning an unknown error",
"ошибка 503 сервис недоступен"))
and not any(_hv.RE_ERROR_TITLE.search(t) for t in
("Зарубежное кино (HD Video) [стр. 2] :: RuTracker.org", "Фильм 5030 (2026)", "Раздачи :: Кинозал.МЕ")))
def _retry_run(results, retries=3):
seq, hooks, slept = list(results), [], []
old = (_hv._goto_once, _hv.time.sleep, _hv.RETRY_HOOK)
try:
_hv._goto_once = lambda *a, **k: seq.pop(0)
_hv.time.sleep = lambda s: slept.append(s)
_hv.RETRY_HOOK = lambda url, a, n, why: hooks.append((a, n, why))
res = _hv.goto(None, "http://x", retries=retries)
finally:
_hv._goto_once, _hv.time.sleep, _hv.RETRY_HOOK = old
return res, hooks, slept, seq
_e = {"ok": False, "server_error": True, "reason": "503"}
_r, _h, _s, _left = _retry_run([_e, _e, {"ok": True}])
check("retry: 503 дважды, потом успех — 2 повтора, паузы 3 и 8 с, хук получил попытки",
_r["ok"] and _h == [(1, 3, "503"), (2, 3, "503")] and _s == [3.0, 8.0] and not _left)
_r, _h, _s, _left = _retry_run([_e, _e, _e, _e])
check("retry: упорная ошибка — ровно 3 повтора и отказ", not _r["ok"] and len(_h) == 3 and not _left
and _s == [3.0, 8.0, 20.0])
_r, _h, _s, _left = _retry_run([{"ok": False, "timeout": True, "reason": "таймаут"},
{"ok": False, "timeout": True, "reason": "таймаут"}])
check("retry: таймаут повторяется один раз", not _r["ok"] and len(_h) == 1 and not _left)
_r, _h, _s, _left = _retry_run([{"ok": False, "dead": True}, {"ok": True}])
check("retry: удалённая раздача не повторяется", _r.get("dead") and not _h and len(_left) == 1)
_sd = _P(_tmp.mkdtemp())
_old_raw = _col.RAW
try:
_col.RAW = _sd
check("settings: по умолчанию 3 вкладки; значение сохраняется и ограничивается 1–4",
_col.settings()["tabs"] == 3)
_col.save_settings({**_col.settings(), "tabs": 9, "limit": 50})
_s = _col.settings()
check("settings: сохранённые вкладки (9 -> 4), лимит и переменная DUB_TABS",
_s["tabs"] == 4 and _s["limit"] == 50 and _col.os.environ["DUB_TABS"] == "4"
and _col.clamp_tabs("x") == 3 and _col.clamp_tabs(0) == 1)
finally:
_col.RAW = _old_raw
_col.os.environ["DUB_TABS"] = "3"
check("is_dead: «нет раздачи» — удалена, обычная ошибка загрузки — нет",
_col.is_dead({"ok": False, "error": "не открылась: нет раздачи: Торрент трекер"})
and not _col.is_dead({"ok": False, "error": "не открылась: Just a moment"})
and not _col.is_dead({"ok": True}))
_dd = _P(_tmp.mkdtemp())
_old_dead = _col.DEAD
try:
_col.DEAD = _dd / "dead.json"
_col.mark_dead("kinozal", "18364907")
_col.mark_dead("kinozal", "5")
_col.mark_dead("nnm", "7")
check("mark_dead: id запоминаются по трекерам и без дублей",
_col.load_dead("kinozal") == {"18364907", "5"} and _col.load_dead("nnm") == {"7"}
and _col.load_dead("rutracker") == set())
finally:
_col.DEAD = _old_dead
check("has_body: пустой пост — не раздача",
not _col.has_body("nnm", {"post": " "}) and _col.has_body("kinozal", {"release_tab": "Аудио"}))
import discover as _dc # noqa: E402
def _picked(site, name):
return bool(_dc.INCLUDE[site].search(name)) and not _dc.EXCLUDE.search(name)
check("discover: rutracker берёт зарубежное кино, сериалы и годовые разделы",
all(_picked("rutracker", n) for n in
["Зарубежное кино (UHD Video)", "Зарубежные сериалы (HD Video)",
"Фильмы 2021-2025", "Новинки и сериалы в стадии показа (HD Video)",
"Классика мирового кинематографа"]))
check("discover: rutracker не берёт наше кино, русские сериалы, музыку и Apple",
not any(_picked("rutracker", n) for n in
["Наше кино (HD Video)", "Русские сериалы (HD Video)",
"Зарубежный Rock", "Фильмы HD для Apple TV", "Азиатские сериалы"]))
check("discover: аниме-музыка (OST, Lossless, AMV, саундтреки) исключена, само аниме берётся",
not any(_picked(site, n) for site in ("nnm", "rutracker") for n in
["Аниме OST (Lossless)", "Аниме OST", "Саундтреки к аниме", "Аниме AMV и клипы", "Anime Music (MP3)"])
and all(_picked("nnm", n) for n in ["Аниме (HD, FHD)", "Аниме (SD)"]))
check("discover: nnm берёт форматы и годы, но не коллекции и оригиналы",
all(_picked("nnm", n) for n in
["Зарубежные Фильмы (UHD)", "Зарубежные Новинки (SD, DVD)", "Зарубежная Классика (DVD)",
"Старые Зарубежные Фильмы (SD)", "Зарубежные Мультсериалы"])
and not any(_picked("nnm", n) for n in
["Зарубежное кино (коллекции / *логии)", "Фильмы в оригинале (SD, DVD)",
"Отечественные Фильмы (HD, FHD, UHD)", "Зарубежное видео для устройств Apple"]))
check("discover: мультфильмы, мультсериалы и аниме берутся, русские мультфильмы нет",
all(_picked("rutracker", n) for n in
["Мультфильмы (HD Video)", "Мультсериалы (SD Video)", "Аниме (HD Video)", "Онгоинги (HD Video)"])
and all(_picked("nnm", n) for n in
["Зарубежные Мультфильмы 21-го века (SD)", "Аниме с озвучкой (HD)", "Мультфильмы (3D)"])
and not any(_picked(s, n) for s, n in
[("nnm", "Отечественные Мультфильмы 20-го века (SD)"),
("rutracker", "Наши мультфильмы"), ("nnm", "Мультимедиа и Графика")]))
_kz = [x["ref"] for x in _dc.kinozal_grid()]
check("discover: kinozal — общие списки фильмы/сериалы/мульты/аниме и сетка без 4K до 2012",
all(any(f"c={c}&v=0&d=0" in u for u in _kz) for c in ("1002", "46", "21", "20"))
and not any("v=7" in u and int(u.split("d=")[1].split("&")[0]) < 2012 for u in _kz)
and len(_kz) == len(set(_kz)))
_calls = []
_orig = (_dc.candidate_forums, _dc.probe, _dc.time.sleep)
try:
_dc.candidate_forums = lambda cdp, site: [{"ref": "1", "name": "Зарубежное кино (HD Video)"},
{"ref": "2", "name": "Зарубежное кино (DVD Video)"},
{"ref": "3", "name": "Зарубежные сериалы (UHD Video)"},
{"ref": "4", "name": "Фильмы 2026"}]
_dc.probe = lambda cdp, site, ref: (_calls.append(ref), {"2": 50, "3": 2, "4": 0}.get(ref, 0))[1]
_dc.time.sleep = lambda s: None
_saved = {}
_res = _dc.discover_forums(None, "rutracker", probes={"1": 47}, on_probe=lambda r, n: _saved.update({r: n}))
finally:
_dc.candidate_forums, _dc.probe, _dc.time.sleep = _orig
_st = {e["ref"]: (e["status"], e["keep"]) for e in _res}
check("discover: раздел из кэша проверок повторно не открывается",
"1" not in _calls and _saved.get("1") is None and _st["1"] == ("ok", True), str(_calls))
check("discover: статусы ok / мало / не проверен (оставляется), прогресс-колбэки",
_st["2"] == ("ok", True) and _st["3"] == ("мало", False) and _st["4"] == ("?", True)
and _saved == {"2": 50, "3": 2, "4": 0}, str((_st, _saved)))
import ui as _ui # noqa: E402
check("ui: таблица найденных разделов строится", _ui.sections_table("rutracker", _res) is not None
and _ui.sections_table("kinozal", [{"ref": "https://kinozal.me/browse.php?s=&g=0&c=46&v=3&d=2020&w=0&t=0&f=0",
"name": "2020, HD", "topics": None, "keep": True, "status": "ok"}]) is not None)
import retrain as _rt # noqa: E402
_cmp = {"rules": {"nnm": (300, .84, .93), "rutracker": (250, .86, .94), "kinozal": (12, .5, .6)},
"old": {"nnm": (300, .64, .96), "rutracker": (250, .90, .95)},
"good": {"nnm": (300, .85, .95), "rutracker": (250, .91, .95)},
"badrec": {"nnm": (300, .90, .80), "rutracker": (250, .91, .95)}}
check("verdict: precision выше и recall держится -> лучше", _rt.verdict(_cmp, "old", "good")[0])
check("verdict: провал recall на трекере -> не лучше", not _rt.verdict(_cmp, "old", "badrec")[0])
check("compare: разбор строки e2e",
_rt.RE_CELL.findall("model: nnm n=301 P=0.637 R=0.961; kinozal n=12 P=0.5 R=0.75")
== [("nnm", "301", "0.637", "0.961"), ("kinozal", "12", "0.5", "0.75")])
import meta as _mt # noqa: E402
_nnm = _mt.extract("nnm", {"id": "1", "title": (
"Хрустальное озеро / Crystal Lake (2026) WEB-DL [H.265/2160p] [4K, HDR10, DV 8.1, 10-bit] "
"(сезон 1, серии 1-8 из 8) Novamedia, HDRezka"), "post": (
"Производство: США, A24, Peacock\nЖанр: ужасы, триллер, драма\n"
"Режиссер: Логан Джордж, Селин Хелд\nАктеры: Линда Карделлини, Уильям Кэтлетт\n"
"Возраст: 18+ (зрителям, достигшим 18 лет)\nРелиз от: kupec\n"
"Продолжительность: ~00:56:00 серия\nКачество видео: WEB-DL\n"
"Язык озвучки: Русский, Английский\nСубтитры: Русские (Forced), Английские\n"
"Видео: HEVC/H.265, 3840x2160, ~15.0 Mbps\n"
"Аудио 1: AC3, 6 ch, 384 Kbps - Русский (DUB, Novamedia) серии 1-8\n"
"Аудио 2: AC3, 2 ch, 384 Kbps - Русский (MVO, HDRezka Studio 18+) серии 1\n"
"Аудио 3: E-AC3 JOC, 6 ch, 640 Kbps - Английский\n")})
_t = _json.loads(_nnm["audio_tracks"])
check("meta nnm: сезон, серии, разрешение, кодек, HDR, возраст",
(_nnm["season"], _nnm["episodes"], _nnm["episodes_total"], _nnm["resolution"],
_nnm["video_codec"], _nnm["age_limit"], _nnm["is_series"], _nnm["kind"])
== ("1", "1-8", "8", "2160p", "HEVC", "18", True, "series") and "HDR10" in _nnm["hdr"]
and "DV" in _nnm["hdr"], str({k: _nnm[k] for k in ("season", "episodes", "hdr", "kind")}))
check("meta nnm: дорожки — язык, кодек, каналы, битрейт, тип, серии, 18+",
len(_t) == 3 and (_t[0]["lang"], _t[0]["codec"], _t[0]["channels"], _t[0]["bitrate_kbps"],
_t[0]["tag"], _t[0]["episodes"]) == ("RUS", "AC3", "6", 384, "DUB", "1-8")
and _t[1]["is_18plus"] and _t[2]["lang"] == "ENG" and _t[2]["codec"].startswith("E-AC3"), str(_t))
check("meta nnm: субтитры, forced, языки озвучки",
_nnm["sub_langs"] == "RUS+ENG" and _nnm["sub_forced"] and _nnm["audio_langs"] == "RUS+ENG")
_rt = _mt.extract("rutracker", {"id": "2", "title": (
"Отрочество / Boyhood (Ричард Линклейтер) [2014, США, драма, BDRip 720p] Dub (НТВ+) + Sub Rus"), "post": (
"Страна: США\nЖанр: Драма\nГод выпуска: 2014\nПродолжительность: 02:45:16\n"
"Оригинальная аудиодорожка: английский\nКонтейнер: MKV\n"
"Видео: [email protected], 1280x692, 10.7 Mbps, 23.976 fps.\n"
"Аудио 1: 48 kHz, AC3, 3/2 (L,C,R,l,r) + LFE ch, 384.00 kbps\n"
"Аудио 2: 48 kHz, DTS, 3/2 (L,C,R,l,r) + LFE ch, 1510 kbps avg | Одноголосый закадровый, А. Дасевич\n"
"Аудио 3: 48 kHz, DTS, 2/0 ch, 1510 kbps | Original\n")})
_tr = _json.loads(_rt["audio_tracks"])
check("meta rutracker: год, контейнер, 720p/AVC, длительность, оригинал",
(_rt["year"], _rt["container"], _rt["resolution"], _rt["video_codec"], _rt["duration_min"],
_rt["orig_audio"]) == ("2014", "MKV", "720p", "AVC", 165, "ENG"), str(_rt))
check("meta rutracker: схема каналов 3/2+LFE = 6, тип VO, Original",
_tr[0]["channels"] == "6" and _tr[1]["tag"] == "AVO" and _tr[2]["tag"] == "ORIG", str(_tr))
_kz = _mt.extract("kinozal", {"id": "3", "h1": "Гран-при / Grand Prix / 1966 / ДБ, ПМ / BDRip (1080p)",
"release_tab": ("Жанр: Драма\nКачество: BDRip (720p)\n"
"Видео: MPEG-4 AVC, 5200 Кбит/с, 1280x580, 23.976 кадр/с\n"
"Аудио: Русский (AC3, 2 ch, 256 Кбит/с), (DTS, 6 ch, 1509 Кбит/с), "
"английский (DTS, 6 ch, 1509 Кбит/с)\nРазмер: 10.66 ГБ\n"
"Субтитры: Русские, английские")})
_tk = _json.loads(_kz["audio_tracks"])
check("meta kinozal: однострочное «Аудио» разбирается на дорожки с языками",
[(x["lang"], x["codec"], x["channels"], x["bitrate_kbps"]) for x in _tk]
== [("RUS", "AC3", "2", 256), ("RUS", "DTS", "6", 1509), ("ENG", "DTS", "6", 1509)], str(_tk))
check("meta kinozal: размер, качество, год, тип по разделу",
_kz["size_gb"] == 10.66 and _kz["year"] == "1966" and _kz["resolution"] == "720p"
and _mt.kind_of("Аниме (HD Video)", "", "", False) == "anime"
and _mt.kind_of("Зарубежные Мультфильмы", "", "", False) == "cartoon")
_d = _P(_tmp.mkdtemp())
_old = (_col.RAW, _col.PAGES, _col.LABELS, _col.POSTONLY, _col.META)
try:
_col.RAW, _col.PAGES, _col.LABELS, _col.POSTONLY, _col.META = (
_d / "raw", _d / "pages", _d / "labels.csv", _d / "po.txt", _d / "meta.csv")
_col.LABELS.write_text("", encoding="utf-8")
(_col.RAW / "nnm").mkdir(parents=True)
_post = ("Год выпуска: 2020\nАудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)\n"
"Аудио 2: AC3, 6 ch, 448 Kbps | Русский MVO (LostFilm)\n")
(_col.RAW / "nnm" / "777.json").write_text(_json.dumps(
{"id": "777", "source": "nnm", "ok": True, "title": "Фильм (2020) BDRip",
"post": _post}, ensure_ascii=False), encoding="utf-8")
(_col.RAW / "nnm" / "777.html").write_text(
f"<html><body><h1>Фильм (2020) BDRip</h1><div class='postbody'>"
f"{_post.replace(chr(10), '<br>')}</div></body></html>", encoding="utf-8")
_st = _col.cmd_build()
check("build: страница раздачи nnm записана с префиксом",
(_col.PAGES / "nnm-777.txt").exists(), str(_st))
with _col.LABELS.open(encoding="utf-8", newline="") as _f:
_rows = list(_csv.reader(_f))
check("build: метки добавлены с release_id=nnm-777", len(_rows) >= 3
and _rows[0][1] == "release_id" and all(r[1] == "nnm-777" for r in _rows[1:]),
str(_rows[:2]))
_before = _col.LABELS.read_text(encoding="utf-8")
_calls_x = []
_orig_x = _col.metamod.extract
_col.metamod.extract = lambda *a, **k: (_calls_x.append(1), _orig_x(*a, **k))[1]
try:
_st2 = _col.cmd_build()
check("build идемпотентен: повторный запуск не дублирует метки",
_col.LABELS.read_text(encoding="utf-8") == _before)
check("build инкрементален: неизменённые страницы и meta не разбираются повторно",
_st2["unchanged"] == 1 and _st2["meta_reused"] == 1 and not _calls_x, str(_st2))
(_col.RAW / "nnm" / "777.json").touch() # файл изменился -> разбирается заново
_st3 = _col.cmd_build()
check("build: изменённый файл разбирается заново, остальное из кэша",
_st3["unchanged"] == 0 and len(_calls_x) == 1, str(_st3))
_col.cmd_build(rewrite=True)
check("build: rewrite=True игнорирует кэши", len(_calls_x) == 2)
finally:
_col.metamod.extract = _orig_x
check("build: meta.csv содержит релиз с префиксным id",
"nnm-777" in _col.META.read_text(encoding="utf-8-sig"))
_good = _col.LABELS.read_text(encoding="utf-8")
_col.LABELS.write_text(_good.replace("LostFilm", "СТАРАЯ_РАЗМЕТКА"), encoding="utf-8")
_st4 = _col.cmd_build(rewrite=True)
check("build без relabel не трогает размеченные релизы", "СТАРАЯ_РАЗМЕТКА" in _col.LABELS.read_text(encoding="utf-8"))
_st5 = _col.cmd_build(rewrite=True, relabel=True)
_now = _col.LABELS.read_text(encoding="utf-8-sig")
check("relabel: метки пересчитаны из сохранённого HTML, дублей нет",
"СТАРАЯ_РАЗМЕТКА" not in _now and _now.count("nnm-777") == _good.count("nnm-777"), str(_st5))
check("relabel: копия прежних меток сохранена", bool(list(_d.glob("labels.bak_*.csv"))))
finally:
_col.RAW, _col.PAGES, _col.LABELS, _col.POSTONLY, _col.META = _old
print("== 18+ из строк «Аудио» ==")
_h = open("data/samples/rt-topic-6894333.html", encoding="utf-8").read()
_pt = html_to_text(_h)
_res = {"tracks": [{"segment": "Профессиональный (многоголосый закадровый) HDRezka", "studio": "HDrezka",
"translation_type": "проф. многоголосый", "is_18plus": False, "source": "page"},
{"segment": "Профессиональный (многоголосый закадровый) LostFilm", "studio": "LostFilm",
"translation_type": "проф. многоголосый", "is_18plus": False, "source": "page"}]}
from src.audio18 import apply_audio_18 # noqa: E402
_res = apply_audio_18(_res, _pt)
_hd = [t for t in _res["tracks"] if t["studio"] == "HDrezka"]
check("HDrezka: две дорожки, обычная и 18+", sorted(t["is_18plus"] for t in _hd) == [False, True], str(_hd))
check("LostFilm без 18+", not any(t["is_18plus"] for t in _res["tracks"] if t["studio"] == "LostFilm"))
check("общий флаг 18+ у релиза", _res["is_18plus"] is True)
import sys as _s, os as _o # noqa: E402
_s.path.insert(0, _o.path.join("tools", "harvest"))
import harvest_core as _hc # noqa: E402
_rows, _ = _hc.rt_parse({"id": "6894333", "title": "Джек Ричер / Reacher / Сезон 4 [2026, WEB-DL 1080p]",
"post": "\n".join(l for l in _pt.splitlines() if l.startswith(("Перевод", "Аудио")))})
_hr = [r for r in _rows if r["studio"] == "HDrezka"]
check("метки rt_parse: HDrezka обычная + 18+", sorted(int(r["age_18"]) for r in _hr) == [0, 1], str(_hr))
check("метки rt_parse: кодек и битрейт из строки «Аудио»",
all(r["codec"] == "AC3" and r["kbps"] == "192" for r in _hr), str(_hr))
check("метки rt_parse: 5 студий + 1 доп. 18+", len(_rows) == 6, str(len(_rows)))
_sf = __import__("src.release", fromlist=["x"]).series_info("Сериал / Сезон 1 / Серии 1-8 из 8 [2026]", [
{"studio": "LostFilm", "episodes": "1-4", "episode_ranges": [[1, 4]]},
{"studio": "HDrezka", "episodes": None, "episode_ranges": []}])
check("серии не указаны -> full_release", _sf["full_release"] == ["HDrezka"], str(_sf))
import json as _js2 # noqa: E402
sys.path.insert(0, "tools")
import meta as _meta # noqa: E402
_mm = _meta.extract("rutracker", {"id": "6894333", "post": _pt[:6000],
"title": "Джек Ричер / Reacher / Сезон 4 / Серии 1-8 из 8 [2026, WEB-DL 1080p]"}, page_text=_pt)
_at = _js2.loads(_mm["audio_tracks"])
check("meta: 7 аудиодорожек rutracker-вида «Аудио RUS: …»", _mm["n_audio"] == 7, str(_mm["n_audio"]))
check("meta: HDrezka обычная и 18+, кодек AC3, тип MVO из «Перевод»",
sorted(a["is_18plus"] for a in _at if a["studio"] == "HDrezka") == [False, True]
and all(a["codec"] == "AC3" and a["tag"] == "MVO" for a in _at if a["studio"]))
print("== разнообразная выборка ==")
import random as _rnd # noqa: E402
import pathlib as _pl, tempfile as _tf # noqa: E402
import diversity as _dv # noqa: E402
check("год из скобок заголовка", _dv.year_of("Матрица / The Matrix (1999) BDRip 1080p") == 1999
and _dv.year_of("Сериал / Season 2 [2021, WEB-DL 2160p]") == 2021 and _dv.year_of("Без года") is None)
check("корзина: десятилетие до 2010, дальше по годам",
_dv.year_band(1994) == "1990-е" and _dv.year_band(2023) == "2023" and _dv.year_band(None) == "?")
check("качество и сериал в заголовке", _dv.bucket("Сериал (2021) Сезон 1 WEB-DL 2160p") == ("2021", "4K", "сериал")
and _dv.bucket("Фильм (2005) DVDRip")[1] == "SD")
_items = [{"id": str(i), "text": f"Фильм {i} / Film ({2024}) WEB-DL 1080p"} for i in range(8)] + [{"id": "old", "text": "Старое кино / Old (1975) DVDRip"}]
_cnt = _dv.counts_from_meta(_pl.Path("нет_такого_файла.csv"))
_cnt[_dv.bucket(_items[0]["text"])] = 500 # свежие 1080p уже в избытке
_got = _dv.pick(_items, 1, _cnt, _rnd.Random(1))
check("pick: берётся тема из недопредставленной корзины (старое кино)", _got[0]["id"] == "old", str(_got))
_vis: set[int] = set()
_rng = _rnd.Random(3)
_order = []
for _ in range(40):
_nx = _dv.next_pages(40, _vis, 1, _rng)
_order += _nx
_vis |= set(_nx)
check("next_pages: все 40 страниц ровно по разу, не подряд", sorted(_order) == list(range(40)) and _order[:5] != [0, 1, 2, 3, 4], str(_order[:8]))
check("next_pages: когда всё посещено — пусто", _dv.next_pages(5, set(range(5)), 1, _rng) == [])
_tmp_meta = _pl.Path(_tf.mkdtemp()) / "meta.csv"
_tmp_meta.write_text(chr(10).join(['source,title,year,quality,is_series', 'nnm,A,2020,WEB-DL (2160p),False', 'nnm,B,2020,WEB-DL (2160p),False', 'rutracker,C,1999,DVD,True']) + chr(10), encoding="utf-8")
_c2 = _dv.counts_from_meta(_tmp_meta, "nnm")
check("counts_from_meta: считает корзины нужного трекера", _c2[("2020", "4K", "фильм")] == 2 and sum(_c2.values()) == 2, str(_c2))
# раздел вразброс: страницы с годами, глубина 20, берутся разные страницы и недопредставленные годы
_calls = []
_pages = {p: [{"id": f"{p}{k}", "text": f"Фильм {p}{k} / F ({2025 - p}) WEB-DL 1080p"} for k in range(5)] for p in range(20)}
_o_li, _o_hi, _o_depth, _o_un = _col.list_items, _col.harvest_ids, _col.hv.list_depth, _col.update_state
_col.list_items = lambda cdp, site, ref, page: (True, _pages.get(page, []))
_col.hv.list_depth = lambda cdp: 20
_col.update_state = lambda *a, **k: None
_o_ls = _col.load_state
_col.load_state = lambda: {} # состояние реального сбора не должно влиять на тест
_col.harvest_ids = lambda cdps, site, ids, limit, delay, stop, section="": (_calls.append(list(ids)), len(ids))[1]
try:
_col.ui.get_stats("rutracker").begin(30)
_n = _col.collect_ref_balanced("rutracker", "313", None, type("T", (), {"cdps": [None]})(), set(),
_dv.Counter(), 12, 100, 0.0, __import__("threading").Event(), 3, _rnd.Random(5))
_years = {int(i[0] if len(i) == 2 else i[:2]) for c in _calls for i in c}
check("balanced: набран лимит раздела по 3 раздачи со страницы", _n == 12 and all(len(c) <= 3 for c in _calls), str(_calls))
check("balanced: взяты разные страницы, а не первые подряд", len({c[0][:-1] for c in _calls}) == len(_calls)
and {c[0][:-1] for c in _calls} != {"0", "1", "2", "3"}, str(_calls))
finally:
_col.list_items, _col.harvest_ids, _col.hv.list_depth, _col.update_state = _o_li, _o_hi, _o_depth, _o_un
_col.load_state = _o_ls
print("== читаемый HTML ==")
_old3 = _col.RAW
_rt3 = _pl.Path(_tf.mkdtemp())
try:
_col.RAW = _rt3
(_rt3 / "nnm").mkdir()
_hp = _rt3 / "nnm" / "1.html"
_hp.write_text("<html><head><title>Т</title></head><body>Привет</body></html>", encoding="utf-8")
_mt = _hp.stat().st_mtime_ns
_r1 = _col.cmd_repair_html(with_css=False)
_txt = _hp.read_text(encoding="utf-8")
check("repair-html: добавлены UTF-8 и base, содержимое цело",
'<meta charset="utf-8">' in _txt and "<base href=" in _txt and "Привет" in _txt and _r1["fixed"] == 1, _txt[:120])
check("repair-html: время файла не изменилось (кэши разбора остаются)", _hp.stat().st_mtime_ns == _mt)
check("repair-html: повторный запуск ничего не меняет", _col.cmd_repair_html(with_css=False)["fixed"] == 0)
check("store_css: таблица стилей пишется один раз по хешу",
_col.store_css("a{color:red}") == _col.store_css("a{color:red}") and len(list((_rt3 / "_css").glob("*.css"))) == 1)
check("snapshot.js читается и содержит перенос стилей и удаление скриптов",
"@@CSS" in _col.SNAPSHOT_JS and "script" in _col.SNAPSHOT_JS)
finally:
_col.RAW = _old3
print("== язык и строки «Аудио» ==")
from src import trackinfo as _ti # noqa: E402
_kz_txt = ("Аудио 1: AC3, 6 ch, 448 Кбит/с - Русский дубляж (Movie Dubbing) - официальный дубляж для СНГ\n"
"Аудио 3: E-AC3, 6 ch, 640 Кбит/с - Украинский дубляж (LeDoyen)\n"
"Аудио 4: AC3, 6 ch, 640 Кбит/с - Английский\n")
_ls = _ti.audio_lines_all(_kz_txt)
check("trackinfo: язык, кодек, студия, официальность строки kinozal",
[l["lang"] for l in _ls] == ["RUS", "UKR", "ENG"] and _ls[0]["names"] == ["Movie Dubbing"]
and _ls[0]["codec"] == "AC3" and _ls[0]["kbps"] == "448" and _ls[0]["official"], str(_ls[0]))
_rt_ls = _ti.audio_lines_all(_pt)
check("trackinfo: rutracker «Аудио RUS: АС3 … |HDrezka| 18+» — обе строки HDrezka",
len(_ti.match_audio("Профессиональный (многоголосый закадровый) HDRezka", _rt_ls)) == 2
and _ti.context(_rt_ls[1]) == "[RUS AC3 2ch 192kbps 18+]", _ti.context(_rt_ls[1]))
check("trackinfo: «Аудиокниги» и меню — не дорожки", _ti.audio_lines_all("Аудиокниги и аудио\nАудио и видео") == [])
check("trackinfo: роль дорожки", _ti.role_of(_ls[0]) == "перевод" and _ti.role_of(_rt_ls[-1]) == "оригинал")
check("model_text: без строки «Аудио» вход не меняется", _ti.model_text("Дублированный", None) == "Дублированный")
_kz_rows, _ = _hc.kz_parse({"id": "9", "page_title": "Фильм / Film / 2025 / ДБ :: Кинозал",
"release_tab": _kz_txt, "tech_tab": ""})
check("kinozal: формат «Аудио N … - Русский дубляж (Студия)» даёт метки со студией, типом и языком",
[(r["studio"], r["lang"], r["translation_type"]) for r in _kz_rows] ==
[("Movie Dubbing", "RUS", "проф. дубляж"), ("LeDoyen", "UKR", "проф. дубляж")], str(_kz_rows))
check("метки rt_parse: lang и audio_raw из строки «Аудио»",
all(r["lang"] == "RUS" and r["audio_raw"] for r in _hr), str(_hr[:1]))
import csv as _csv2 # noqa: E402
_old4 = (_col.LABELS,)
_tl = _pl.Path(_tf.mkdtemp()) / "labels.csv"
try:
_col.LABELS = _tl
_hdr = [f for f in _hc.FIELDS if f not in ("lang", "audio_raw")]
with _tl.open("w", encoding="utf-8-sig", newline="") as _f:
_w = _csv2.DictWriter(_f, fieldnames=_hdr)
_w.writeheader()
_w.writerow({**{k: "" for k in _hdr}, "source": "nnm", "release_id": "1", "studio": "X"})
check("labels.csv: старая шапка переписывается на новую", _col.migrate_labels() is True)
check("labels.csv: повторная миграция ничего не делает", _col.migrate_labels() is False)
_got2 = _col.read_labels()
check("labels.csv: данные целы, новые колонки пустые",
len(_got2) == 1 and _got2[0]["studio"] == "X" and _got2[0]["lang"] == "")
with _tl.open("a", encoding="utf-8", newline="") as _f: # дописывание строк как в cmd_build
_csv2.DictWriter(_f, fieldnames=_hc.FIELDS, extrasaction="ignore").writerow(
{**{k: "" for k in _hc.FIELDS}, "release_id": "2", "lang": "UKR", "raw": "r"})
check("labels.csv: дописанная строка не сдвигает колонки",
_col.read_labels()[1]["lang"] == "UKR" and _col.read_labels()[1]["release_id"] == "2")
finally:
(_col.LABELS,) = _old4
_mk = lambda **k: {"segment": "s", "translation_type": "проф. дубляж", "studio": "Студия", # noqa: E731
"is_18plus": False, **k}
_agg = aggregate([_mk(lang="RUS"), _mk(lang="UKR"), _mk(lang=None)])
check("aggregate: русский и украинский дубляж одной студии — две дорожки; без языка сливается",
sorted(t["lang"] for t in _agg["tracks"]) == ["RUS", "UKR"], str(_agg["tracks"]))
_agg2 = aggregate([_mk(_audio18=False), _mk(_audio18=True, is_18plus=True), _mk()])
check("aggregate: обычная и 18+ дорожки одной студии раздельны, из заголовка — к обычной",
len(_agg2["tracks"]) == 2 and sorted(t["is_18plus"] for t in _agg2["tracks"]) == [False, True], str(_agg2["tracks"]))
import torch as _torch # noqa: E402
from src.model import DubTagger as _DT, load_model as _lm, save_model as _sm # noqa: E402
try:
_bb = "cointegrated/rubert-tiny2"
_tiny = _DT(_bb, n_types=3, n_langs=4)
_ids = _torch.randint(5, 100, (2, 8))
_out = _tiny(_ids, _torch.ones_like(_ids), type_labels=_torch.tensor([0, 1]),
lang_labels=_torch.tensor([1, -100]), return_lang=True)
check("модель: языковая голова даёт логиты [batch, n_langs], лосс конечен",
_out[4].shape == (2, 4) and bool(_torch.isfinite(_out[0])))
_out0 = _tiny(_ids, _torch.ones_like(_ids), type_labels=_torch.tensor([0, 1]),
lang_labels=_torch.tensor([-100, -100]), return_lang=True)
check("модель: все языки неизвестны — лосс языка не входит (нет NaN)", bool(_torch.isfinite(_out0[0])))
_legacy = _DT(_bb, n_types=3)
check("модель без языковой головы: прежний интерфейс и lang=None",
len(_legacy(_ids, _torch.ones_like(_ids))) == 4 and _legacy(_ids, _torch.ones_like(_ids), return_lang=True)[4] is None)
_d = _pl.Path(_tf.mkdtemp())
_sm(_legacy, __import__("transformers").AutoTokenizer.from_pretrained(_bb), _d)
check("чекпоинт без языковой головы загружается (n_langs=0)", _lm(_d).n_langs == 0)
except Exception as _e: # noqa: BLE001
print(f" пропущено (нет {_bb}): {_e}")
import subprocess as _sp, json as _js3, sys as _sys3 # noqa: E402
_wd = _pl.Path(_tf.mkdtemp())
_rows3 = []
for _i in range(30):
_rows3.append({"source": "kinozal", "release_id": f"r{_i}", "studio": f"Студия{_i % 5}", "translation_type": "проф. дубляж",
"raw": f"Русский дубляж (Студия{_i % 5})", "age_18": 0, "lang": "RUS",
"audio_raw": "AC3, 6 ch, 448 Кбит/с - Русский дубляж (Студия%d)" % (_i % 5)})
_rows3.append({"source": "kinozal", "release_id": f"u{_i}", "studio": f"Студия{_i % 5}", "translation_type": "проф. дубляж",
"raw": f"Украинский дубляж (Студия{_i % 5})", "age_18": 0, "lang": "UKR",
"audio_raw": "AC3, 6 ch, 640 Кбит/с - Украинский дубляж (Студия%d)" % (_i % 5)})
_rows3.append({"source": "nnm", "release_id": "p1", "studio": "(не указана)", "translation_type": "проф. дубляж",
"raw": "Дублированный", "age_18": 0, "lang": "POL", "audio_raw": ""})
with (_wd / "l.csv").open("w", encoding="utf-8-sig", newline="") as _f:
_w = _csv2.DictWriter(_f, fieldnames=_hc.FIELDS, restval="")
_w.writeheader()
_w.writerows(_rows3)
_pr = _sp.run([_sys3.executable, "-c",
f"import sys; sys.path.insert(0, r'{_o.getcwd()}'); from src.prepare_data import main; main()",
"--csv", str(_wd / "l.csv"), "--out", str(_wd / "out")], cwd=_wd, capture_output=True, text=True,
encoding="utf-8", env={**_o.environ, "PYTHONUTF8": "1"})
check("prepare_data с языком выполняется", _pr.returncode == 0, (_pr.stderr or "")[-300:])
_ds = _js3.loads((_wd / "out" / "dataset.json").read_text(encoding="utf-8"))
_lm2 = _js3.loads((_wd / "out" / "label_maps.json").read_text(encoding="utf-8"))
check("prepare_data: классы языка (редкий POL отброшен), флаг audio_context",
_lm2["langs"] == ["RUS", "UKR"] and _lm2["audio_context"] is True, str(_lm2))
check("prepare_data: вход модели несёт строку «Аудио», спан автора по-прежнему в чистом тексте",
any(r["text"].endswith("[UKR AC3 6ch 640kbps]") for r in _ds)
and all(r["text"][r["span"][0]:r["span"][1]].startswith("Студия") for r in _ds if r["span"]))
check("prepare_data: у редкого языка метка пустая", [r["lang"] for r in _ds if r["release_id"] == "p1"] == [None])
print("== зависания сбора ==")
import json as _js5, threading as _th5, time as _tm5 # noqa: E402
class _FakeCDP:
def __init__(self, state):
self.state, self.calls, self.aborted, self.reconnected = state, 0, 0, 0
def send(self, *a, **k):
return {}
def eval(self, expr, await_promise=True):
self.calls += 1
return _js5.dumps(self.state) if "JSON.stringify" in expr else "короткая страница"
def abort(self):
self.aborted += 1
def reconnect(self):
self.reconnected += 1
_t0 = _tm5.time()
_res = _hc._goto_once(_FakeCDP({"t": "Тема перенесена", "r": "complete", "len": 300}), "http://x", min_len=900, timeout=45)
check("короткая загруженная страница принимается за ~5 с, а не ждёт таймаут 45 с",
_res.get("ok") and _res.get("short") and _tm5.time() - _t0 < 15, f"{_res} {_tm5.time() - _t0:.1f}")
_res2 = _hc._goto_once(_FakeCDP({"t": "Just a moment...", "r": "complete", "len": 300}), "http://x", min_len=900, timeout=7)
check("короткая страница проверки Cloudflare за готовую не считается", _res2.get("timeout"), str(_res2))
_old5 = (_col.fetch_release, _col.HARD_LIMIT_S, _col.SLOW_S, _col.mark_dead)
try:
_col.HARD_LIMIT_S, _col.SLOW_S = 0.6, 0.3
_col.fetch_release = lambda cdp, site, rid: (_tm5.sleep(1.2), {"ok": False, "error": "x"})[1]
_fc = _FakeCDP({})
_col.ui.get_stats("rutracker").begin(5)
_col.harvest_ids([_fc], "rutracker", ["111"], 5, 0.0, _th5.Event())
check("сторож: зависшая раздача — вызов оборван, вкладка переподключена",
_fc.aborted == 1 and _fc.reconnected == 1, f"abort={_fc.aborted} reconnect={_fc.reconnected}")
_col.HARD_LIMIT_S = 30
_fc2 = _FakeCDP({})
_col.harvest_ids([_fc2], "rutracker", ["112"], 5, 0.0, _th5.Event())
check("сторож не трогает нормальную (медленную, но завершившуюся) раздачу", _fc2.reconnected == 0)
finally:
_col.fetch_release, _col.HARD_LIMIT_S, _col.SLOW_S, _col.mark_dead = _old5
print("== параллельная переиндексация ==")
import subprocess as _sp6 # noqa: E402
_pc = _sp6.run([sys.executable, "tests/par_check.py"], capture_output=True, text=True, encoding="utf-8",
env={**__import__("os").environ, "PYTHONUTF8": "1"})
_line = next((l for l in _pc.stdout.splitlines() if l.startswith("RESULT ")), "")
_pr6 = _json.loads(_line[7:]) if _line else {}
check("параллельная сборка (процессы) даёт те же метки, страницы и meta, что и последовательная",
_pr6.get("same") is True and _pr6.get("pages") == 50 and _pr6.get("labeled") == [50, 50], (_pc.stderr or _pc.stdout)[-300:])
check("worker_count: значение по умолчанию в пределах 1–12", 1 <= _col.worker_count() <= 12)
print("== обучение по ссылкам ==")
check("ссылка rutracker", _col.parse_link("https://rutracker.org/forum/viewtopic.php?t=6894333") == ("rutracker", "6894333"))
check("ссылка nnm", _col.parse_link("https://nnmclub.to/forum/viewtopic.php?p=1&t=77") == ("nnm", "77"))
check("ссылка kinozal", _col.parse_link("https://kinozal.me/details.php?id=2155882") == ("kinozal", "2155882"))
check("не ссылка на раздачу", _col.parse_link("https://rutracker.org/forum/viewforum.php?f=313") is None)
import tempfile as _tf, pathlib as _pl # noqa: E402
_old2 = (_col.PAGES, _col.LABELS, _col.MANUAL, _col.RAW)
_tmp = _pl.Path(_tf.mkdtemp())
try:
_col.PAGES, _col.LABELS, _col.MANUAL, _col.RAW = _tmp / "pages", _tmp / "labels.csv", _tmp / "manual_ids.txt", _tmp / "raw"
_rows2 = [dict(_r, age_18=0) for _r in _rows[:2]]
_stem = _col.store_manual("rutracker", {"id": "6894333"}, _rows2, "текст страницы")
check("ручные метки: страница и id записаны",
(_col.PAGES / f"{_stem}.txt").exists() and _stem in _col.MANUAL.read_text(encoding="utf-8").split())
_lab = _col.read_labels()
check("ручные метки: строки помечены manual", len(_lab) == 2 and all(r["studio_source"] == "manual" for r in _lab))
_col.store_manual("rutracker", {"id": "6894333"}, _rows2[:1], "текст")
check("повторная запись заменяет строки релиза", len(_col.read_labels()) == 1)
from src.manual import manual_ids # noqa: E402
check("manual_ids читает файл", manual_ids(_col.MANUAL) == {_stem})
finally:
_col.PAGES, _col.LABELS, _col.MANUAL, _col.RAW = _old2
print("== реестр моделей и сравнение (src/runs.py) ==")
import json as _json # noqa: E402
import subprocess as _sp7 # noqa: E402
import tempfile as _tf7 # noqa: E402
from src import runs as _runs # noqa: E402
from src.train import DEFAULT_MODELS, MODEL_CONFIGS # noqa: E402
check("plain_text: снимает хвост из поля tail",
_runs.plain_text({"text": "Дубляж [RUS AC3 6ch]", "tail": "[RUS AC3 6ch]"}) == "Дубляж")
check("plain_text: пустой tail — текст как есть", _runs.plain_text({"text": "Дубляж [X]", "tail": ""}) == "Дубляж [X]")
check("plain_text: старый датасет без tail — по шаблону хвоста",
_runs.plain_text({"text": "Дубляж [RUS AC3 6ch 448kbps 18+]"}) == "Дубляж"
and _runs.plain_text({"text": "Дубляж [? AC3]"}) == "Дубляж"
and _runs.plain_text({"text": "Озвучка [Лостфильм]"}) == "Озвучка [Лостфильм]")
_ra = [{"text": "a", "translation_type": "x", "span": None, "is_18plus": False},
{"text": "b", "translation_type": "y", "span": [0, 1], "is_18plus": True, "lang": "RUS"}]
check("отпечаток выборки не зависит от порядка и меняется с метками",
_runs.fingerprint(_ra) == _runs.fingerprint(_ra[::-1])
and _runs.fingerprint(_ra) != _runs.fingerprint([_ra[0], {**_ra[1], "translation_type": "z"}]))
_lo, _hi = _runs.wilson(94, 100)
check("Уилсон: интервал вокруг доли", _lo < 0.94 < _hi and 0.85 < _lo and _hi < 0.98 and _runs.wilson(0, 0) == (0.0, 0.0))
check("знаковый критерий: 30/5 значимо, 12/10 — шум, 0/0 — 1",
_runs.sign_test(30, 5) < 0.001 and _runs.sign_test(12, 10) > 0.5 and _runs.sign_test(0, 0) == 1.0)
check("парное сравнение считает починенные и сломанные",
_runs.paired([1, 0, 1, 0, 1], [1, 1, 0, 0, 1])["fixed"] == 1
and _runs.paired([1, 0, 1, 0, 1], [1, 1, 0, 0, 1])["broken"] == 1)
import retrain as _rt7 # noqa: E402
check("профили меню совпадают с профилями обучения", set(MODEL_CONFIGS) == set(_rt7.PROFILES))
check("python -m src.train по умолчанию — tiny2 и base, крупные профили только по запросу",
DEFAULT_MODELS == ["tiny2", "base"] and {"large", "modern"} <= set(MODEL_CONFIGS))
with _tf7.TemporaryDirectory() as _td7:
_m = Path(_td7)
_old7 = (_runs.MODELS, _runs.RUNS)
_runs.MODELS, _runs.RUNS = _m, _m / "runs"
try:
def _mk(d: Path, backbone="x/bert", cfg=None, input_names=None):
d.mkdir(parents=True)
(d / "model.pt").write_bytes(b"w" * 10)
(d / "dub_config.json").write_text(_json.dumps({"backbone": backbone}), encoding="utf-8")
(d / "train_config.json").write_text(_json.dumps(cfg or {"name": d.name, "uid": d.name}), encoding="utf-8")
(d / "tokenizer_config.json").write_text(_json.dumps(
{"model_input_names": input_names or ["input_ids", "token_type_ids", "attention_mask"]}), encoding="utf-8")
_mk(_m / "best", cfg={"name": "base"})
_mk(_m / "base", cfg={"name": "base"}) # копия best — в списке не нужна
_mk(_m / "runs" / "20260101_000000_large", backbone="x/large")
_mk(_m / "runs" / "20260102_000000_modern", backbone="x/modern", input_names=["input_ids", "attention_mask"])
_names = [p.name for p in _runs.candidates()]
check("кандидаты: best первой, копия best не дублируется", _names[0] == "best" and "base" not in _names and len(_names) == 3, str(_names))
check("удалять можно запуск, но не best", _runs.deletable(_m / "runs" / "20260101_000000_large") and not _runs.deletable(_m / "best"))
_w = _runs.warnings_for(_m / "runs" / "20260102_000000_modern")
check("предупреждения: другая основа и нет token_type_ids", any("другая основа" in x for x in _w) and any("token_type_ids" in x for x in _w), str(_w))
_arch = _runs.promote(_m / "runs" / "20260101_000000_large")
check("promote: best заменена, прежняя в best_backup_*",
_arch and (_m / _arch / "dub_config.json").exists()
and _runs.describe(_m / "best")["backbone"] == "x/large" and not (_m / ".best_new").exists())
check("promote: исходный запуск остаётся (копирование, не перенос)", (_m / "runs" / "20260101_000000_large" / "model.pt").exists())
check("promote той же модели ничего не делает", _runs.promote(_m / "best") is None)
_runs.delete(_m / _arch)
check("delete убрал архивную копию", not (_m / _arch).exists())
try:
_runs.delete(_m / "best")
check("delete не трогает best", False)
except ValueError:
check("delete не трогает best", (_m / "best").exists())
finally:
_runs.MODELS, _runs.RUNS = _old7
_ds7 = Path("data/processed/dataset.json")
if _ds7.exists():
# короткий настоящий прогон: tiny2, 1 эпоха, подвыборка; запуск лежит в своём каталоге, best/reports не тронуты
import random as _rnd7 # noqa: E402
with _tf7.TemporaryDirectory() as _td7:
_t = Path(_td7)
_all = _json.loads(_ds7.read_text(encoding="utf-8"))
_tr = [r for r in _all if r["split"] == "train"]
_te = [r for r in _all if r["split"] == "test"]
_rnd7.Random(1).shuffle(_tr)
_rnd7.Random(1).shuffle(_te)
(_t / "dataset.json").write_text(_json.dumps(_tr[:300] + _te[:100], ensure_ascii=False), encoding="utf-8")
(_t / "label_maps.json").write_text(Path("data/processed/label_maps.json").read_text(encoding="utf-8"), encoding="utf-8")
_best_before = (Path("models/best/model.pt").stat().st_mtime if Path("models/best/model.pt").exists() else None)
_sum_before = Path("reports/summary.md").read_bytes() if Path("reports/summary.md").exists() else None
_p = _sp7.run([sys.executable, "-m", "src.train", "--runs", "--models", "tiny2", "--folds", "0",
"--epochs-override", "1", "--data", str(_t / "dataset.json"), "--runs-dir", str(_t / "runs"),
"--tag", "_t"], capture_output=True, text=True, encoding="utf-8", errors="replace")
_dirs = list((_t / "runs").glob("*_tiny2_t"))
check("обучение --runs: запуск создан со всеми файлами",
_p.returncode == 0 and len(_dirs) == 1
and all((_dirs[0] / f).exists() for f in ("model.pt", "run.json", "eval.json", "label_maps.json", "confusion_tiny2.png")),
(_p.stderr or "")[-300:])
check("обучение --runs не трогает models/best и reports/summary.md",
(_best_before is None or Path("models/best/model.pt").stat().st_mtime == _best_before)
and (_sum_before is None or Path("reports/summary.md").read_bytes() == _sum_before))
if _dirs:
_rows7 = [r for r in _json.loads((_t / "dataset.json").read_text(encoding="utf-8")) if r["split"] == "test"]
_fp7 = _runs.fingerprint(_rows7)
_stored = _runs.load_eval(_dirs[0], _fp7)
_fresh = _runs.eval_checkpoint(_dirs[0], _rows7)
check("оценка чекпоинта воспроизводит метрики обучения построчно",
_stored is not None and _stored["per_row"] == _fresh["per_row"] and len(_fresh["per_row"]) == len(_rows7))
_cmp = _runs.compare([_dirs[0]], _rows7)
check("compare: модель без baseline, доверительный интервал считается", _cmp[0]["vs"] is None and _cmp[0]["ci"][0] <= _cmp[0]["ci"][1])
else:
print(" пропущено (нет data/processed/dataset.json)")
print("== Docker: сервис без пакетов обучения, токен, настройки ==")
_code = chr(10).join(["import sys",
"for m in ('sklearn','matplotlib','pandas','onnx','onnxruntime'): sys.modules[m] = None",
"import src.service, src.predict, src.release, src.segmenter",
"print('IMPORT_OK')"])
_r8 = _sp7.run([sys.executable, "-c", _code], capture_output=True, text=True, encoding="utf-8", errors="replace")
check("сервис импортируется без sklearn/matplotlib/pandas/onnx", "IMPORT_OK" in _r8.stdout, (_r8.stderr or "")[-300:])
from src import spans as _spans # noqa: E402
from src import train as _train8 # noqa: E402
check("decode_span и MAX_LEN живут в src.spans, train их реэкспортирует",
_train8.decode_span is _spans.decode_span and _train8.MAX_LEN == _spans.MAX_LEN == 128)
from fastapi.testclient import TestClient # noqa: E402
from src.service import create_app # noqa: E402
class _Stub:
model_dir, device, types, segmenter = Path("models/best"), "cpu", ["x"], None
def predict_text(self, text):
return {"translation_type": "x", "text": text}
_c = TestClient(create_app(_Stub(), api_token="tok"))
check("токен: /health открыт, /predict без токена — 401",
_c.get("/health").status_code == 200 and _c.post("/predict", json={"text": "a"}).status_code == 401
and _c.post("/predict", json={"text": "a"}, headers={"Authorization": "Bearer bad"}).status_code == 401)
check("токен: Bearer и X-API-Key принимаются",
_c.post("/predict", json={"text": "a"}, headers={"Authorization": "Bearer tok"}).status_code == 200
and _c.post("/predict", json={"text": "a"}, headers={"X-API-Key": "tok"}).status_code == 200)
check("без DUB_API_TOKEN /predict открыт, /health показывает segmenter и auth",
TestClient(create_app(_Stub())).post("/predict", json={"text": "a"}).status_code == 200
and _c.get("/health").json()["auth"] is True and _c.get("/health").json()["segmenter"] == "rules")
import src.service as _svc8 # noqa: E402
_fetch_calls: list = []
_orig_fetch = _svc8.fetch_html
_svc8.fetch_html = lambda url, **kw: (_fetch_calls.append(kw), ("<html></html>", "rutracker"))[1]
try:
_svc8.predict_release = lambda *a, **k: {"tracks": []}
TestClient(create_app(_Stub(), fetch_timeout=77)).post("/predict", json={"url": "https://rutracker.org/forum/viewtopic.php?t=1"})
check("DUB_FETCH_TIMEOUT доходит до загрузки страницы", _fetch_calls and _fetch_calls[0].get("timeout") == 77, str(_fetch_calls))
finally:
_svc8.fetch_html = _orig_fetch
import importlib # noqa: E402
importlib.reload(_svc8)
check("env_float: мусор и пусто — значение по умолчанию", _svc8.env_float("DUB_NOPE_XYZ", 5.0) == 5.0)
for _f in ("Dockerfile", ".dockerignore", "docker-compose.yml", ".env.example", ".gitea/workflows/docker.yml",
"requirements-service.txt", "docs/docker.md", "docker-models/models/.gitkeep"):
check(f"есть {_f}", Path(_f).exists())
check("в requirements-service.txt нет пакетов обучения",
not any(x in Path("requirements-service.txt").read_text(encoding="utf-8") for x in ("scikit", "pandas", "matplotlib", "onnx")))
try:
import yaml as _yaml # noqa: E402
for _f in ("docker-compose.yml", ".gitea/workflows/docker.yml"):
_yaml.safe_load(Path(_f).read_text(encoding="utf-8"))
check("YAML compose и workflow разбираются", True)
except ImportError:
print(" пропущено: нет PyYAML")
print("== сегментатор (модель) ==")
try:
from src.segmenter import LineSegmenter, is_test_release # noqa: E402
_seg = LineSegmenter("models/segmenter", "cpu")
_fx = open("data/samples/rt-topic-6870900.html", encoding="utf-8").read()
_lines = _seg.select_lines(html_to_text(_fx))
check("сегментатор: найдены 4 строки «Перевод N» реальной страницы",
sum(l.startswith("Перевод") for l in _lines) == 4, str(_lines[:5]))
check("сегментатор: «Субтитры» и «Оригинальная аудиодорожка» не дорожки",
not any(l.startswith(("Субтитры", "Оригинальная")) for l in _lines))
check("разбиение страниц детерминировано", is_test_release("6870900") == is_test_release("6870900"))
check("сегментатор: строка из знаков препинания (>160 токенов) не роняет",
len(_seg.score(["|;," * 100, "Перевод: Профессиональный (дублированный)"])) == 2)
except Exception as e: # noqa: BLE001
print(f" пропущено (нет models/segmenter): {e}")
print(f"\nитог: {PASS} ok, {FAIL} failed")
sys.exit(1 if FAIL else 0)