diff --git a/docs/integration.md b/docs/integration.md index 5f7b4ca..05a32d1 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -110,16 +110,17 @@ $b = [Text.Encoding]::UTF8.GetBytes((@{ text = "Перевод: Дублиров ### grabber (PHP) kinozal требует авторизации, поэтому grabber скачивает страницу сам -(`KinozalClient`) и отправляет сервису готовый HTML: +(`KinozalClient`) и отправляет сервису готовый HTML вместе с заголовком раздачи: ```php $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' => $html], JSON_UNESCAPED_UNICODE), + 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'] ``` diff --git a/docs/plan.md b/docs/plan.md index 26d3ff6..bbf8bb6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -64,15 +64,18 @@ |---|---| | 1 baseline | сделано | | 2 URL + агрегат релиза | сделано: `src/fetch.py`, `src/release.py`, `--url`, `POST /predict {"url"}` | -| 3 title-fallback | сделано (`ПМ (LostFilm)` → дорожка с `source: "title"`); работает для HTML, у текстовых дампов заголовка нет | -| 4 kinozal-вкладка | **не сделано** в сервисе: страница релиза требует авторизации, а `get_srv_details.php?id=…&pagesd=N` уже вызывает `KinozalClient.php` в grabber. Рекомендация: grabber скачивает вкладку сам и шлёт `{"html": …}` | +| 3 title-fallback | сделано (`ПМ (LostFilm)` → дорожка с `source: "title"`); заголовок берётся из HTML (`

`/``), из текстового дампа (первая строка вида «… / год / …»), либо передаётся явно (`title`). Если на странице есть дорожка того же типа без студии, студия вписывается в неё (`source: page+title`), а не дублируется. **Эффект на e2e мал**: kinozal P/R 0.107 → 0.143, rutracker без изменений (0.826 / 0.984) — в разметке датасета студия из заголовка почти не нужна | +| 4 kinozal-вкладка | **не сделано** в сервисе: страница релиза требует авторизации, а `get_srv_details.php?id=…&pagesd=N` уже вызывает `KinozalClient.php` в grabber. Рекомендация: grabber скачивает вкладку сам и шлёт `{"html": …, "title": <заголовок раздачи>}` (во фрагменте вкладки заголовка нет) | | 5 false-positives сегментации | **не сделано** (precision rutracker 0.826 — отдельный разбор) | | 6 tiny2 + int8 | сделано: 29.3 МБ, parity 99.8% тип / 99.2% спан / 100% 18+ (`reports/onnx_parity_tiny2_int8.md`) | -| 7 тесты | `run_tests.py`: 52 ok (URL allowlist/SSRF, декодирование, title, агрегат) | +| 7 тесты | `run_tests.py`: 57 ok (URL allowlist/SSRF, декодирование, заглушки Cloudflare/логина, title, слияние, агрегат) | | 8 `target/` | сделано | ## Открытые вопросы * Подтвердить, что grabber (PHP) интегрируется по HTTP (рекомендуется), а Rust-порт — через ONNX int8 (`models/tiny2-onnx/model.int8.onnx`). -* Качество tiny2 против base по e2e на страницах не мерили — только parity. +* tiny2 vs base: held-out joint exact 0.915 против 0.946 (`reports/summary.md`); int8 сверху теряет до ~0.8% по спану (parity). e2e на страницах для tiny2 не гонялся. +* `--url` живьём не проверен: rutracker отдал 403 (бот-защита/гео), nnm — 404 на тестовый id. Проверено только на allowlist, декодировании и заглушках. +* Шаг 5 (false positives сегментации) и kinozal без авторизации остаются открытыми. +* Веса (`models/`, `tiny2-onnx`) в gitignore и лежат только в этом worktree/основном репозитории. diff --git a/src/fetch.py b/src/fetch.py index 86bd6d4..fdebdc3 100644 --- a/src/fetch.py +++ b/src/fetch.py @@ -56,6 +56,18 @@ def decode_bytes(data: bytes, content_type: str = "") -> str: return _fix_mojibake(data.decode("utf-8", errors="replace")) +def blocked_reason(html: str) -> str | None: + """Распознаёт страницу-заглушку: Cloudflare challenge или форма логина.""" + head = html[:6000] + if "challenges.cloudflare.com" in head or "<title>Один момент" in head or "<title>Just a moment" in head: + return "Cloudflare challenge" + if "takelogin.php" in html and "Перевод" not in html: + return "нужна авторизация (передайте cookie)" + if "login.php" in head and "name=\"login_username\"" in html: + return "нужна авторизация (передайте cookie)" + return None + + def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None) -> tuple[str, str]: """Скачивает страницу релиза. Возвращает (html, tracker).""" @@ -70,7 +82,11 @@ def fetch_html(url: str, *, timeout: float = 15.0, data = resp.read(MAX_BYTES + 1) if len(data) > MAX_BYTES: raise ValueError("страница слишком большая") - return decode_bytes(data, resp.headers.get("Content-Type", "")), tracker + html = decode_bytes(data, resp.headers.get("Content-Type", "")) + reason = blocked_reason(html) + if reason: + raise ValueError(f"страница недоступна: {reason}") + return html, tracker class _AllowlistRedirect(urllib.request.HTTPRedirectHandler): diff --git a/src/release.py b/src/release.py index 24fea37..b06f21b 100644 --- a/src/release.py +++ b/src/release.py @@ -41,6 +41,23 @@ def title_from_html(html: str) -> str: return "" +# заголовок раздачи в текстовом дампе: «Название / ... / 2025 / ... » +RE_TITLE_LINE = re.compile(r"^.{3,300}?\s/\s.*\b(?:19|20)\d\d\b.*\s/\s.*$") + + +def title_from_text(text: str) -> str: + """Первая строка текстового дампа, похожая на заголовок раздачи. + + Дальше по странице идут списки других раздач с такими же строками, + поэтому берётся только первое совпадение. + """ + for line in (text or "").splitlines(): + line = line.strip() + if RE_TITLE_LINE.match(line): + return line + return "" + + def title_segments(title: str) -> list[str]: """Дорожки, перечисленные в заголовке раздачи в формате «ТИП (студия)».""" out = [] @@ -80,6 +97,30 @@ def aggregate(tracks: list[dict]) -> dict: } +def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None: + """Добавляет дорожки из заголовка к дорожкам страницы (на месте). + + * студия уже есть на странице -> пропуск; + * на странице есть дорожка того же типа без студии -> студия берётся + из заголовка (source=page+title): это та же дорожка; + * иначе -> новая дорожка (source=title). + """ + for t in title_tracks: + name = (t.get("studio") or "").casefold() + if any((x.get("studio") or "").casefold() == name for x in tracks): + continue + same = next((x for x in tracks if not x.get("studio") + and x["translation_type"] == t["translation_type"]), None) + if same is not None: + same.update(studio=t["studio"], studio_span=t["studio_span"], + studio_confidence=t["studio_confidence"], + is_18plus=same["is_18plus"] or t["is_18plus"], + source="page+title") + else: + t["source"] = "title" + tracks.append(t) + + def predict_release(predictor, *, html: str | None = None, text: str | None = None, title: str | None = None) -> dict: """Предсказание для релиза целиком (html или текст страницы). @@ -93,15 +134,11 @@ def predict_release(predictor, *, html: str | None = None, tracks = predictor.predict_html(html) if html is not None else predictor.predict_page(text) for t in tracks: t["source"] = "page" - if title is None and html is not None: - title = title_from_html(html) + if title is None: + title = title_from_html(html) if html is not None else title_from_text(text) segs = title_segments(title or "") if segs: - known = {(t.get("studio") or "").casefold() for t in tracks} - for t in predictor._predict_batch(segs): - if (t.get("studio") or "").casefold() not in known: - t["source"] = "title" - tracks.append(t) + merge_title_tracks(tracks, predictor._predict_batch(segs)) result = aggregate(tracks) result["title"] = title or None return result diff --git a/src/service.py b/src/service.py index 50a9371..43faba0 100644 --- a/src/service.py +++ b/src/service.py @@ -38,6 +38,7 @@ class PredictRequest(BaseModel): text: str | None = None html: str | None = None url: str | None = None + title: str | None = None # заголовок раздачи для html (фрагмент вкладки его не содержит) cookie: str | None = None # для url (kinozal требует авторизацию) @@ -68,8 +69,9 @@ def create_app(predictor: DubPredictor) -> FastAPI: raise except OSError as e: raise HTTPException(502, f"не удалось загрузить страницу: {e}") from e - return {**predict_release(predictor, html=html), "tracker": tracker} - return predict_release(predictor, html=req.html) + return {**predict_release(predictor, html=html, title=req.title), + "tracker": tracker} + return predict_release(predictor, html=req.html, title=req.title) except ValueError as e: raise HTTPException(400, str(e)) from e diff --git a/tests/e2e_check.py b/tests/e2e_check.py index 252f01e..88ea303 100644 --- a/tests/e2e_check.py +++ b/tests/e2e_check.py @@ -16,12 +16,14 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from src.predict import DubPredictor # noqa: E402 +from src.html_extract import read_input # noqa: E402 +from src.release import predict_release # noqa: E402 from src.studio_norm import canonical # noqa: E402 ROOT = Path(__file__).resolve().parents[1] -def release_check(predictor: DubPredictor, rows: list[dict]) -> dict: +def release_check(predictor: DubPredictor, rows: list[dict], use_title: bool = True) -> dict: """E2E на текстовых дампах страниц: пары (тип, автор) по релизам.""" by_release: dict[tuple[str, str], list[dict]] = defaultdict(list) for r in rows: @@ -43,7 +45,10 @@ def release_check(predictor: DubPredictor, rows: list[dict]) -> dict: gt_rows = by_release[(src, rid)] gold = {(r["translation_type"], r["studio"] or None) for r in gt_rows} try: - tracks = predictor.predict_file(page) + if use_title: # путь релиза: описание + студии из заголовка раздачи + tracks = predict_release(predictor, text=read_input(page))["tracks"] + else: + tracks = predictor.predict_file(page) except Exception as e: # noqa: BLE001 misses.append(f"{page.name}: ошибка {e}") continue @@ -115,7 +120,13 @@ def main() -> None: .read_text(encoding="utf-8")) predictor = DubPredictor() - print("== e2e: страницы против меток ==") + print("== e2e: страницы против меток (только описание, baseline) ==") + base = release_check(predictor, rows, use_title=False) + print(f"релизов: {base['n_releases']}, precision {base['mean_precision']:.3f}, " + f"recall {base['mean_recall']:.3f}; " + + "; ".join(f"{s}: P={v['precision']:.3f} R={v['recall']:.3f}" + for s, v in base["per_source"].items())) + print("\n== e2e: страницы против меток (описание + заголовок, predict_release) ==") rel = release_check(predictor, rows) print(f"релизов проверено: {rel['n_releases']}, " f"mean precision {rel['mean_precision']:.3f}, " diff --git a/tests/run_tests.py b/tests/run_tests.py index 2dda240..eecac4a 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -201,5 +201,28 @@ 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>Один момент…") is not None) +check("нормальная страница", blocked_reason("XПеревод: 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)) + print(f"\nитог: {PASS} ok, {FAIL} failed") sys.exit(1 if FAIL else 0)