diff --git a/docs/plan.md b/docs/plan.md
index ac69c6d..8ed19a3 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -65,7 +65,7 @@
| 1 baseline | сделано |
| 2 URL + агрегат релиза | сделано: `src/fetch.py`, `src/release.py`, `--url`, `POST /predict {"url"}` |
| 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": <заголовок раздачи>}` (во фрагменте вкладки заголовка нет) |
+| 4 kinozal-вкладка | сделано: `fetch_html` для kinozal скачивает вкладки `showtab(id,N)` (`get_srv_details.php?id=…&pagesd=N`, кроме «Техданных» и «Скриншотов») и дописывает их в `#tabs`. Кодек-строки «Аудио N: … (MVO, LostFilm)» сегментатор отбрасывает (в разметке rutracker это не `raw`), поэтому они берутся правилом `codec_segments` как второстепенный источник (`source: codec` / `page+codec`): только дорожки со студией, студия-дубль (`same_studio`) пропускается. e2e test rutracker без изменений (P 0.988 / R 0.982); train-страницы P 0.975 → 0.951 |
| 5 false-positives сегментации | сделано: починена резка «Авторский, одноголосый» по запятой; постобработка спана (`refine_span`: до границ слова, мусор вроде `L,C,R,l,r`/кодеков → null). Остаток — промахи модели на `kbps \| DUB Автор` и особенности разметки |
| 9 переобучение | датасет вырос 3286 → 6864 строк; обе модели переобучены (см. ниже) |
| 6 tiny2 + int8 | сделано: 29.3 МБ, parity 99.8% тип / 99.2% спан / 100% 18+ (`reports/onnx_parity_tiny2_int8.md`) |
diff --git a/src/fetch.py b/src/fetch.py
index 2f174d6..607dc2f 100644
--- a/src/fetch.py
+++ b/src/fetch.py
@@ -6,12 +6,19 @@
Публичный API:
detect_tracker(url) — 'rutracker' | 'kinozal' | 'nnm' | ValueError
fetch_html(url, ...) — скачать страницу, вернуть (html, tracker)
+ kinozal_tabs(html) — вкладки kinozal, которые грузятся JS (Релиз, …)
+ inline_tabs(html, t) — вписать скачанные вкладки в блок #tabs
decode_bytes(data) — байты -> текст (utf-8 -> cp1251, чинит «двойную»)
kinozal отдаёт описание только авторизованным пользователям, поэтому для него
передавайте cookie (`cookie="uid=...; pass=..."`) либо скачивайте страницу сами
(grabber уже умеет) и отдавайте сервису готовый `html`.
+На kinozal во встроенном блоке #tabs только «Техданные» (там «Перевод:» без
+студий), а «Релиз» со строками «Аудио N: … (MVO, LostFilm)» грузится JS:
+`showtab(id, N)` -> `get_srv_details.php?id=…&pagesd=N`. fetch_html скачивает
+эти вкладки и дописывает их в #tabs, чтобы описание разбиралось целиком.
+
Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный
экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или
переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или
@@ -23,10 +30,13 @@ from __future__ import annotations
import json
import os
+import re
import urllib.error
import urllib.request
from urllib.parse import urlparse
+from bs4 import BeautifulSoup
+
from .html_extract import _fix_mojibake
TRACKER_HOSTS = {
@@ -121,32 +131,84 @@ def _fetch_flaresolverr(endpoint: str, url: str, timeout: float,
return data["solution"]["response"]
-def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None,
- flaresolverr: str | None = None) -> tuple[str, str]:
- """Скачивает страницу релиза. Возвращает (html, tracker).
-
- Сначала прямой запрос; при 403/503 или странице-заглушке и заданном
- FlareSolverr — повтор через него.
- """
- tracker = detect_tracker(url)
- flaresolverr = flaresolverr or os.environ.get("DUB_FLARESOLVERR_URL") or None
- html, direct_error = None, None
+def _get(url: str, timeout: float, cookie: str | None,
+ flaresolverr: str | None) -> str:
+ """Прямой запрос; при 403/503 или странице-заглушке — через FlareSolverr."""
+ html = None
try:
html = _fetch_direct(url, timeout, cookie)
except urllib.error.HTTPError as e:
if e.code not in RETRY_CODES or not flaresolverr:
raise
- direct_error = e
if html is not None and blocked_reason(html) and flaresolverr:
html = None
if html is None:
if not flaresolverr:
raise ValueError("страница недоступна")
html = _fetch_flaresolverr(flaresolverr, url, timeout, cookie)
+ return html
+
+
+# «showtab(2155882,0)»; 100 — «Техданные», они уже встроены в страницу
+RE_KZ_TAB = re.compile(r"showtab\((\d+)\s*,\s*(\d+)\)[^>]*>([^<]*)<")
+KZ_INLINE_TAB = 100
+KZ_SKIP_TABS = ("скриншот",)
+
+
+def kinozal_tabs(html: str) -> list[tuple[str, int, str]]:
+ """Вкладки описания kinozal, которые грузятся JS: [(id, N, название)]."""
+ out, seen = [], set()
+ for rid, n, label in RE_KZ_TAB.findall(html):
+ n = int(n)
+ if n == KZ_INLINE_TAB or n in seen or label.strip().lower().startswith(KZ_SKIP_TABS):
+ continue
+ seen.add(n)
+ out.append((rid, n, label.strip()))
+ return out
+
+
+def inline_tabs(html: str, tabs: list[tuple[str, str]]) -> str:
+ """Дописывает фрагменты вкладок [(название, html)] в конец блока #tabs."""
+ soup = BeautifulSoup(html, "lxml")
+ node = soup.select_one("#tabs")
+ if node is None or not tabs:
+ return html
+ for label, fragment in tabs:
+ box = soup.new_tag("div", attrs={"class": "dub-tab", "data-tab": label})
+ frag = BeautifulSoup(fragment, "lxml")
+ for child in list((frag.body or frag).contents):
+ box.append(child)
+ node.append(box)
+ return str(soup)
+
+
+def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None,
+ flaresolverr: str | None = None) -> tuple[str, str]:
+ """Скачивает страницу релиза. Возвращает (html, tracker).
+
+ Сначала прямой запрос; при 403/503 или странице-заглушке и заданном
+ FlareSolverr — повтор через него. Для kinozal дополнительно скачиваются
+ вкладки описания («Релиз», «Внимание»); недоступная вкладка пропускается.
+ """
+ tracker = detect_tracker(url)
+ flaresolverr = flaresolverr or os.environ.get("DUB_FLARESOLVERR_URL") or None
+ html = _get(url, timeout, cookie, flaresolverr)
reason = blocked_reason(html)
if reason:
hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)"
raise ValueError(f"страница недоступна: {reason}{hint}")
+ if tracker == "kinozal":
+ parsed = urlparse(url)
+ tabs = []
+ for rid, n, label in kinozal_tabs(html):
+ tab_url = f"{parsed.scheme}://{parsed.netloc}/get_srv_details.php?id={rid}&pagesd={n}"
+ try:
+ frag = _get(tab_url, timeout, cookie, flaresolverr)
+ except (urllib.error.URLError, ValueError, OSError):
+ continue
+ if not blocked_reason(frag):
+ tabs.append((label, frag))
+ html = inline_tabs(html, tabs)
return html, tracker
diff --git a/src/release.py b/src/release.py
index 359a815..0b7f2f9 100644
--- a/src/release.py
+++ b/src/release.py
@@ -104,17 +104,39 @@ def aggregate(tracks: list[dict]) -> dict:
}
-def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
- """Добавляет дорожки из заголовка к дорожкам страницы (на месте).
+UNKNOWN_TYPE = "неизвестно"
- * студия уже есть на странице -> пропуск;
- * на странице есть дорожка того же типа без студии -> студия берётся
- из заголовка (source=page+title): это та же дорожка;
- * иначе -> новая дорожка (source=title).
+
+def _studio_tokens(name: str) -> set[str]:
+ return {w for w in re.findall(r"\w+", name.casefold()) if len(w) >= 4}
+
+
+def same_studio(a: str | None, b: str | None) -> bool:
+ """Одна студия в разном написании: «DVD» ~ «DVD Магия», «НТВ» ~ «НТВ+»,
+ «Михаил Яроцкий» ~ «М.Яроцкий/Kyberpunk» (общее слово ≥ 4 букв)."""
+ a, b = (a or "").casefold().strip(), (b or "").casefold().strip()
+ if not a or not b:
+ return False
+ return a in b or b in a or bool(_studio_tokens(a) & _studio_tokens(b))
+
+
+def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str,
+ match=lambda a, b: (a or "").casefold() == (b or "").casefold()) -> None:
+ """Добавляет дорожки второстепенного источника к дорожкам страницы (на месте).
+
+ * такая студия уже есть (по `match`) -> пропуск;
+ * есть дорожка того же типа без студии -> студия вписывается в неё
+ (source=page+): это та же дорожка;
+ * иначе -> новая дорожка (source=).
+
+ Дорожки без студии или с типом «неизвестно» не берутся: тип без студии
+ страница уже даёт, а в кодек-строках он часто грубее («одноголосый»
+ вместо «авт. одноголосый»).
"""
- for t in title_tracks:
- name = (t.get("studio") or "").casefold()
- if any((x.get("studio") or "").casefold() == name for x in tracks):
+ for t in extra:
+ if not t.get("studio") or t["translation_type"] == UNKNOWN_TYPE:
+ continue
+ if any(match(x.get("studio"), t["studio"]) 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)
@@ -122,12 +144,45 @@ def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> 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")
+ source=f"page+{source}")
else:
- t["source"] = "title"
+ t["source"] = source
tracks.append(t)
+def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
+ """Дорожки из заголовка («ПМ (LostFilm)») -> к дорожкам страницы."""
+ merge_extra_tracks(tracks, title_tracks, "title")
+
+
+# кодек-строка дорожки с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»;
+# «Аудио 6: E-AC3 … English Dubbing» не подходит — нет русского маркера типа
+RE_CODEC_TRACK = re.compile(
+ r"(?i)^\s*(?:Аудио|Audio)\s*(?:№|#)?\s*\d*\s*:.*(?:\b(?:MVO|DVO|AVO|DUB|VO)\b"
+ r"|дубл|многоголос|двухголос|одноголос|закадр|авторск)")
+
+
+def _entries(line: str) -> list[str]:
+ body = RE_LINE_PREFIX.sub("", line, count=1)
+ return [e for e in (re.sub(r"\s+", " ", x).strip() for x in _split_entries(body)) if e]
+
+
+def codec_segments(page_text: str) -> list[str]:
+ """Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)»).
+
+ Сегментатор обучен на `raw` датасета, где у rutracker это строки
+ «Перевод N:», поэтому кодек-строки он отбрасывает. Они нужны там, где
+ студии есть только в них (kinozal: вкладка «Релиз» против «Перевод:» без
+ студий в «Техданных»), и идут второстепенным источником, см.
+ `merge_extra_tracks`.
+ """
+ out = []
+ for line in (page_text or "").splitlines():
+ if RE_CODEC_TRACK.match(line):
+ out += _entries(line)
+ return out
+
+
def model_segments(segmenter, page_text: str) -> list[str]:
"""Дорожки страницы: строки выбирает обученный сегментатор.
@@ -136,11 +191,7 @@ def model_segments(segmenter, page_text: str) -> list[str]:
"""
out = []
for line in segmenter.select_lines(page_text):
- body = RE_LINE_PREFIX.sub("", line, count=1)
- for entry in _split_entries(body):
- entry = re.sub(r"\s+", " ", entry).strip()
- if entry:
- out.append(entry)
+ out += _entries(line)
return out
@@ -152,9 +203,9 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
(если он загружен и нашёл хотя бы одну строку), иначе правила; "rules" —
только правила.
- Заголовок раздачи используется как дополнительный источник: студии,
- названные только там («ПМ (LostFilm)»), добавляются как дорожки с
- пометкой source=title.
+ Кодек-строки («Аудио N: … (MVO, LostFilm)») и заголовок раздачи —
+ дополнительные источники: студии, названные только там, добавляются как
+ дорожки с пометкой source=codec / source=title.
"""
if (html is None) == (text is None):
raise ValueError("нужно ровно одно из: html, text")
@@ -169,6 +220,11 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
tracks = predictor._predict_batch(segs_page) if segs_page else []
for t in tracks:
t["source"] = "page"
+ seen = set(segs_page)
+ segs_codec = [x for x in codec_segments(page_text) if x not in seen]
+ if segs_codec:
+ merge_extra_tracks(tracks, predictor._predict_batch(segs_codec), "codec",
+ match=same_studio)
if title is None:
title = title_from_html(html) if html is not None else title_from_text(text)
segs = title_segments(title or "")
diff --git a/tests/run_tests.py b/tests/run_tests.py
index e4de1ac..b0e1b43 100644
--- a/tests/run_tests.py
+++ b/tests/run_tests.py
@@ -219,6 +219,34 @@ check("title: студия вписана в дорожку без студии"
check("title: дубль студии пропущен, новая добавлена",
len(_page) == 3 and _page[2]["source"] == "title")
+from src.fetch import inline_tabs, kinozal_tabs # noqa: E402
+from src.release import codec_segments, merge_extra_tracks, same_studio # noqa: E402
+
+_kz = ('