kinozal: догрузка вкладки «Релиз» по URL; кодек-строки «Аудио N» как источник студий
На kinozal во встроенном #tabs только «Техданные» («Перевод:» без студий), а строки «Аудио N: … (MVO, LostFilm)» лежат во вкладке «Релиз», которую страница грузит JS. Поэтому по URL определялись только типы без студий. * fetch: для kinozal скачиваются вкладки showtab(id,N) (get_srv_details.php?id=…&pagesd=N, кроме «Техданных» и «Скриншотов») и дописываются в #tabs; недоступная вкладка пропускается. * release: кодек-строки с типом озвучки берутся правилом codec_segments (сегментатор их отбрасывает — в разметке rutracker это не raw) и идут второстепенным источником (source=codec / page+codec): только дорожки со студией, дубли студии (same_studio) пропускаются, студия вписывается в дорожку того же типа без студии. «English Dubbing» и т.п. не берутся. * merge_title_tracks переведён на общий merge_extra_tracks. e2e test rutracker без изменений (P 0.988 / R 0.982); train-страницы P 0.975 -> 0.951. Тесты: 77 ok. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6a87bc993c
commit
1f9ee46a0d
4 files changed
+177
-31
No files matched your search
+1
-1
@@ -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 (`<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": <заголовок раздачи>}` (во фрагменте вкладки заголовка нет) |
|
||||
| 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`) |
|
||||
|
||||
+73
-11
@@ -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
|
||||
|
||||
|
||||
|
||||
+75
-19
@@ -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>): это та же дорожка;
|
||||
* иначе -> новая дорожка (source=<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 "")
|
||||
|
||||
@@ -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 = ('<ul><li><a onclick="showtab(7,100); return false;" href="#">Техданные</a></li>'
|
||||
'<li><a onclick="showtab(7,0); return false;" href="#">Релиз</a></li>'
|
||||
'<li><a onclick="showtab(7,2); return false;" href="#">Скриншоты</a></li></ul>'
|
||||
'<div id="tabs"><b>Перевод:</b> Профессиональный многоголосый</div>')
|
||||
check("kinozal: вкладки без «Техданных» и скриншотов", kinozal_tabs(_kz) == [("7", 0, "Релиз")])
|
||||
_kz_text = html_to_text(inline_tabs(_kz, [("Релиз", "Аудио 1: AC3 - Русский (MVO, LostFilm)<br>")]))
|
||||
check("kinozal: вкладка «Релиз» попадает в текст",
|
||||
"Перевод: Профессиональный многоголосый" in _kz_text and "(MVO, LostFilm)" in _kz_text)
|
||||
check("codec_segments: русская дорожка с типом, без иноязычной",
|
||||
codec_segments("Аудио 3: AC3, 2.0 ch - Русский (MVO, HDrezka Studio)\n"
|
||||
"Аудио 6: E-AC3, 5.1 ch, 640 Kbps - English Dubbing\n"
|
||||
"Перевод: Профессиональный") == ["AC3, 2.0 ch - Русский (MVO, HDrezka Studio)"])
|
||||
check("same_studio: варианты написания",
|
||||
same_studio("DVD", "DVD Магия") and same_studio("Михаил Яроцкий", "М.Яроцкий/Kyberpunk")
|
||||
and not same_studio("LostFilm", "HDrezka"))
|
||||
_page2 = [dict(_t("проф. многоголосый", None), studio_span=None, studio_confidence=None)]
|
||||
merge_extra_tracks(_page2, [
|
||||
dict(_t("проф. многоголосый", "HDrezka"), studio_span="HDrezka Studio", studio_confidence=1),
|
||||
dict(_t("проф. многоголосый", "LostFilm"), studio_span="LostFilm", studio_confidence=1),
|
||||
dict(_t("одноголосый", None), studio_span=None, studio_confidence=None)],
|
||||
"codec", match=same_studio)
|
||||
check("codec: студия вписана, новая добавлена, без студии — нет",
|
||||
[(x["studio"], x["source"]) for x in _page2]
|
||||
== [("HDrezka", "page+codec"), ("LostFilm", "codec")])
|
||||
|
||||
_dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n"
|
||||
"Гангстерленд / 2026 / ДБ (X) / WEB-DL")
|
||||
check("title_from_text: первая строка",
|
||||
|
||||
Reference in new issue
Block a user