Слияние студии из заголовка, title в сервисе, детект заглушек, e2e через predict_release
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8cbd800ec7
commit
6be369c415
7 files changed
+112
-19
No files matched your search
+3
-2
@@ -110,16 +110,17 @@ $b = [Text.Encoding]::UTF8.GetBytes((@{ text = "Перевод: Дублиров
|
|||||||
### grabber (PHP)
|
### grabber (PHP)
|
||||||
|
|
||||||
kinozal требует авторизации, поэтому grabber скачивает страницу сам
|
kinozal требует авторизации, поэтому grabber скачивает страницу сам
|
||||||
(`KinozalClient`) и отправляет сервису готовый HTML:
|
(`KinozalClient`) и отправляет сервису готовый HTML вместе с заголовком раздачи:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$ch = curl_init('http://dub-ai:8000/predict');
|
$ch = curl_init('http://dub-ai:8000/predict');
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
|
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
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);
|
$release = json_decode(curl_exec($ch), true);
|
||||||
|
// $tab — ответ get_srv_details.php (заголовка в нём нет, поэтому передаём title)
|
||||||
// $release['studios'], $release['translation_types'], $release['is_18plus']
|
// $release['studios'], $release['translation_types'], $release['is_18plus']
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -64,15 +64,18 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 1 baseline | сделано |
|
| 1 baseline | сделано |
|
||||||
| 2 URL + агрегат релиза | сделано: `src/fetch.py`, `src/release.py`, `--url`, `POST /predict {"url"}` |
|
| 2 URL + агрегат релиза | сделано: `src/fetch.py`, `src/release.py`, `--url`, `POST /predict {"url"}` |
|
||||||
| 3 title-fallback | сделано (`ПМ (LostFilm)` → дорожка с `source: "title"`); работает для 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": …}` |
|
| 4 kinozal-вкладка | **не сделано** в сервисе: страница релиза требует авторизации, а `get_srv_details.php?id=…&pagesd=N` уже вызывает `KinozalClient.php` в grabber. Рекомендация: grabber скачивает вкладку сам и шлёт `{"html": …, "title": <заголовок раздачи>}` (во фрагменте вкладки заголовка нет) |
|
||||||
| 5 false-positives сегментации | **не сделано** (precision rutracker 0.826 — отдельный разбор) |
|
| 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`) |
|
| 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/` | сделано |
|
| 8 `target/` | сделано |
|
||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
|
|
||||||
* Подтвердить, что grabber (PHP) интегрируется по HTTP (рекомендуется), а
|
* Подтвердить, что grabber (PHP) интегрируется по HTTP (рекомендуется), а
|
||||||
Rust-порт — через ONNX int8 (`models/tiny2-onnx/model.int8.onnx`).
|
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
@@ -56,6 +56,18 @@ def decode_bytes(data: bytes, content_type: str = "") -> str:
|
|||||||
return _fix_mojibake(data.decode("utf-8", errors="replace"))
|
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,
|
def fetch_html(url: str, *, timeout: float = 15.0,
|
||||||
cookie: str | None = None) -> tuple[str, str]:
|
cookie: str | None = None) -> tuple[str, str]:
|
||||||
"""Скачивает страницу релиза. Возвращает (html, tracker)."""
|
"""Скачивает страницу релиза. Возвращает (html, tracker)."""
|
||||||
@@ -70,7 +82,11 @@ def fetch_html(url: str, *, timeout: float = 15.0,
|
|||||||
data = resp.read(MAX_BYTES + 1)
|
data = resp.read(MAX_BYTES + 1)
|
||||||
if len(data) > MAX_BYTES:
|
if len(data) > MAX_BYTES:
|
||||||
raise ValueError("страница слишком большая")
|
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):
|
class _AllowlistRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
|||||||
+44
-7
@@ -41,6 +41,23 @@ def title_from_html(html: str) -> str:
|
|||||||
return ""
|
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]:
|
def title_segments(title: str) -> list[str]:
|
||||||
"""Дорожки, перечисленные в заголовке раздачи в формате «ТИП (студия)»."""
|
"""Дорожки, перечисленные в заголовке раздачи в формате «ТИП (студия)»."""
|
||||||
out = []
|
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,
|
def predict_release(predictor, *, html: str | None = None,
|
||||||
text: str | None = None, title: str | None = None) -> dict:
|
text: str | None = None, title: str | None = None) -> dict:
|
||||||
"""Предсказание для релиза целиком (html или текст страницы).
|
"""Предсказание для релиза целиком (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)
|
tracks = predictor.predict_html(html) if html is not None else predictor.predict_page(text)
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
t["source"] = "page"
|
t["source"] = "page"
|
||||||
if title is None and html is not None:
|
if title is None:
|
||||||
title = title_from_html(html)
|
title = title_from_html(html) if html is not None else title_from_text(text)
|
||||||
segs = title_segments(title or "")
|
segs = title_segments(title or "")
|
||||||
if segs:
|
if segs:
|
||||||
known = {(t.get("studio") or "").casefold() for t in tracks}
|
merge_title_tracks(tracks, predictor._predict_batch(segs))
|
||||||
for t in predictor._predict_batch(segs):
|
|
||||||
if (t.get("studio") or "").casefold() not in known:
|
|
||||||
t["source"] = "title"
|
|
||||||
tracks.append(t)
|
|
||||||
result = aggregate(tracks)
|
result = aggregate(tracks)
|
||||||
result["title"] = title or None
|
result["title"] = title or None
|
||||||
return result
|
return result
|
||||||
+4
-2
@@ -38,6 +38,7 @@ class PredictRequest(BaseModel):
|
|||||||
text: str | None = None
|
text: str | None = None
|
||||||
html: str | None = None
|
html: str | None = None
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
|
title: str | None = None # заголовок раздачи для html (фрагмент вкладки его не содержит)
|
||||||
cookie: str | None = None # для url (kinozal требует авторизацию)
|
cookie: str | None = None # для url (kinozal требует авторизацию)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,8 +69,9 @@ def create_app(predictor: DubPredictor) -> FastAPI:
|
|||||||
raise
|
raise
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise HTTPException(502, f"не удалось загрузить страницу: {e}") from e
|
raise HTTPException(502, f"не удалось загрузить страницу: {e}") from e
|
||||||
return {**predict_release(predictor, html=html), "tracker": tracker}
|
return {**predict_release(predictor, html=html, title=req.title),
|
||||||
return predict_release(predictor, html=req.html)
|
"tracker": tracker}
|
||||||
|
return predict_release(predictor, html=req.html, title=req.title)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e)) from e
|
raise HTTPException(400, str(e)) from e
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -16,12 +16,14 @@ from pathlib import Path
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from src.predict import DubPredictor # noqa: E402
|
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
|
from src.studio_norm import canonical # noqa: E402
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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 на текстовых дампах страниц: пары (тип, автор) по релизам."""
|
"""E2E на текстовых дампах страниц: пары (тип, автор) по релизам."""
|
||||||
by_release: dict[tuple[str, str], list[dict]] = defaultdict(list)
|
by_release: dict[tuple[str, str], list[dict]] = defaultdict(list)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -43,7 +45,10 @@ def release_check(predictor: DubPredictor, rows: list[dict]) -> dict:
|
|||||||
gt_rows = by_release[(src, rid)]
|
gt_rows = by_release[(src, rid)]
|
||||||
gold = {(r["translation_type"], r["studio"] or None) for r in gt_rows}
|
gold = {(r["translation_type"], r["studio"] or None) for r in gt_rows}
|
||||||
try:
|
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
|
except Exception as e: # noqa: BLE001
|
||||||
misses.append(f"{page.name}: ошибка {e}")
|
misses.append(f"{page.name}: ошибка {e}")
|
||||||
continue
|
continue
|
||||||
@@ -115,7 +120,13 @@ def main() -> None:
|
|||||||
.read_text(encoding="utf-8"))
|
.read_text(encoding="utf-8"))
|
||||||
predictor = DubPredictor()
|
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)
|
rel = release_check(predictor, rows)
|
||||||
print(f"релизов проверено: {rel['n_releases']}, "
|
print(f"релизов проверено: {rel['n_releases']}, "
|
||||||
f"mean precision {rel['mean_precision']:.3f}, "
|
f"mean precision {rel['mean_precision']:.3f}, "
|
||||||
|
|||||||
@@ -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: студии", [x["studio"] for x in agg["studios"]] == ["MovieDalen", "LostFilm"])
|
||||||
check("aggregate: пусто", aggregate([])["is_18plus"] is False)
|
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")
|
print(f"\nитог: {PASS} ok, {FAIL} failed")
|
||||||
sys.exit(1 if FAIL else 0)
|
sys.exit(1 if FAIL else 0)
|
||||||
Reference in new issue
Block a user