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 "Один момент" in head or "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("Один момент…") 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)