Слияние студии из заголовка, title в сервисе, детект заглушек, e2e через predict_release

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 17:05:30 +07:00
1 parent 8cbd800ec7
commit 6be369c415
7 files changed
+112 -19

No files matched your search

+3 -2
View File
@@ -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']
```
+7 -4
View File
@@ -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 (`<h1>`/`<title>`), из текстового дампа (первая строка вида «… / год / …»), либо передаётся явно (`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/основном репозитории.
+17 -1
View File
@@ -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):
+44 -7
View File
@@ -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
+4 -2
View File
@@ -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
+14 -3
View File
@@ -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}, "
+23
View File
@@ -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>Один момент…</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))
print(f"\nитог: {PASS} ok, {FAIL} failed")
sys.exit(1 if FAIL else 0)