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 <noreply@anthropic.com>
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 | сделано |
|
| 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 (`<h1>`/`<title>`), из текстового дампа (первая строка вида «… / год / …»), либо передаётся явно (`title`). Если на странице есть дорожка того же типа без студии, студия вписывается в неё (`source: page+title`), а не дублируется. **Эффект на e2e мал**: kinozal P/R 0.107 → 0.143, rutracker без изменений (0.826 / 0.984) — в разметке датасета студия из заголовка почти не нужна |
|
| 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 Автор` и особенности разметки |
|
| 5 false-positives сегментации | сделано: починена резка «Авторский, одноголосый» по запятой; постобработка спана (`refine_span`: до границ слова, мусор вроде `L,C,R,l,r`/кодеков → null). Остаток — промахи модели на `kbps \| DUB Автор` и особенности разметки |
|
||||||
| 9 переобучение | датасет вырос 3286 → 6864 строк; обе модели переобучены (см. ниже) |
|
| 9 переобучение | датасет вырос 3286 → 6864 строк; обе модели переобучены (см. ниже) |
|
||||||
| 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`) |
|
||||||
|
|||||||
+73
-11
@@ -6,12 +6,19 @@
|
|||||||
Публичный API:
|
Публичный API:
|
||||||
detect_tracker(url) — 'rutracker' | 'kinozal' | 'nnm' | ValueError
|
detect_tracker(url) — 'rutracker' | 'kinozal' | 'nnm' | ValueError
|
||||||
fetch_html(url, ...) — скачать страницу, вернуть (html, tracker)
|
fetch_html(url, ...) — скачать страницу, вернуть (html, tracker)
|
||||||
|
kinozal_tabs(html) — вкладки kinozal, которые грузятся JS (Релиз, …)
|
||||||
|
inline_tabs(html, t) — вписать скачанные вкладки в блок #tabs
|
||||||
decode_bytes(data) — байты -> текст (utf-8 -> cp1251, чинит «двойную»)
|
decode_bytes(data) — байты -> текст (utf-8 -> cp1251, чинит «двойную»)
|
||||||
|
|
||||||
kinozal отдаёт описание только авторизованным пользователям, поэтому для него
|
kinozal отдаёт описание только авторизованным пользователям, поэтому для него
|
||||||
передавайте cookie (`cookie="uid=...; pass=..."`) либо скачивайте страницу сами
|
передавайте cookie (`cookie="uid=...; pass=..."`) либо скачивайте страницу сами
|
||||||
(grabber уже умеет) и отдавайте сервису готовый `html`.
|
(grabber уже умеет) и отдавайте сервису готовый `html`.
|
||||||
|
|
||||||
|
На kinozal во встроенном блоке #tabs только «Техданные» (там «Перевод:» без
|
||||||
|
студий), а «Релиз» со строками «Аудио N: … (MVO, LostFilm)» грузится JS:
|
||||||
|
`showtab(id, N)` -> `get_srv_details.php?id=…&pagesd=N`. fetch_html скачивает
|
||||||
|
эти вкладки и дописывает их в #tabs, чтобы описание разбиралось целиком.
|
||||||
|
|
||||||
Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный
|
Cloudflare / защита от ботов: если задан FlareSolverr (ваш собственный
|
||||||
экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или
|
экземпляр, как у Jackett/Prowlarr) — `flaresolverr="http://host:8191"` или
|
||||||
переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или
|
переменная окружения DUB_FLARESOLVERR_URL, — то при ответе 403/503 или
|
||||||
@@ -23,10 +30,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from .html_extract import _fix_mojibake
|
from .html_extract import _fix_mojibake
|
||||||
|
|
||||||
TRACKER_HOSTS = {
|
TRACKER_HOSTS = {
|
||||||
@@ -121,32 +131,84 @@ def _fetch_flaresolverr(endpoint: str, url: str, timeout: float,
|
|||||||
return data["solution"]["response"]
|
return data["solution"]["response"]
|
||||||
|
|
||||||
|
|
||||||
def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None,
|
def _get(url: str, timeout: float, cookie: str | None,
|
||||||
flaresolverr: str | None = None) -> tuple[str, str]:
|
flaresolverr: str | None) -> str:
|
||||||
"""Скачивает страницу релиза. Возвращает (html, tracker).
|
"""Прямой запрос; при 403/503 или странице-заглушке — через FlareSolverr."""
|
||||||
|
html = None
|
||||||
Сначала прямой запрос; при 403/503 или странице-заглушке и заданном
|
|
||||||
FlareSolverr — повтор через него.
|
|
||||||
"""
|
|
||||||
tracker = detect_tracker(url)
|
|
||||||
flaresolverr = flaresolverr or os.environ.get("DUB_FLARESOLVERR_URL") or None
|
|
||||||
html, direct_error = None, None
|
|
||||||
try:
|
try:
|
||||||
html = _fetch_direct(url, timeout, cookie)
|
html = _fetch_direct(url, timeout, cookie)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code not in RETRY_CODES or not flaresolverr:
|
if e.code not in RETRY_CODES or not flaresolverr:
|
||||||
raise
|
raise
|
||||||
direct_error = e
|
|
||||||
if html is not None and blocked_reason(html) and flaresolverr:
|
if html is not None and blocked_reason(html) and flaresolverr:
|
||||||
html = None
|
html = None
|
||||||
if html is None:
|
if html is None:
|
||||||
if not flaresolverr:
|
if not flaresolverr:
|
||||||
raise ValueError("страница недоступна")
|
raise ValueError("страница недоступна")
|
||||||
html = _fetch_flaresolverr(flaresolverr, url, timeout, cookie)
|
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)
|
reason = blocked_reason(html)
|
||||||
if reason:
|
if reason:
|
||||||
hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)"
|
hint = "" if flaresolverr else " (задайте FlareSolverr: DUB_FLARESOLVERR_URL)"
|
||||||
raise ValueError(f"страница недоступна: {reason}{hint}")
|
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
|
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 = "неизвестно"
|
||||||
"""Добавляет дорожки из заголовка к дорожкам страницы (на месте).
|
|
||||||
|
|
||||||
* студия уже есть на странице -> пропуск;
|
|
||||||
* на странице есть дорожка того же типа без студии -> студия берётся
|
def _studio_tokens(name: str) -> set[str]:
|
||||||
из заголовка (source=page+title): это та же дорожка;
|
return {w for w in re.findall(r"\w+", name.casefold()) if len(w) >= 4}
|
||||||
* иначе -> новая дорожка (source=title).
|
|
||||||
|
|
||||||
|
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:
|
for t in extra:
|
||||||
name = (t.get("studio") or "").casefold()
|
if not t.get("studio") or t["translation_type"] == UNKNOWN_TYPE:
|
||||||
if any((x.get("studio") or "").casefold() == name for x in tracks):
|
continue
|
||||||
|
if any(match(x.get("studio"), t["studio"]) for x in tracks):
|
||||||
continue
|
continue
|
||||||
same = next((x for x in tracks if not x.get("studio")
|
same = next((x for x in tracks if not x.get("studio")
|
||||||
and x["translation_type"] == t["translation_type"]), None)
|
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"],
|
same.update(studio=t["studio"], studio_span=t["studio_span"],
|
||||||
studio_confidence=t["studio_confidence"],
|
studio_confidence=t["studio_confidence"],
|
||||||
is_18plus=same["is_18plus"] or t["is_18plus"],
|
is_18plus=same["is_18plus"] or t["is_18plus"],
|
||||||
source="page+title")
|
source=f"page+{source}")
|
||||||
else:
|
else:
|
||||||
t["source"] = "title"
|
t["source"] = source
|
||||||
tracks.append(t)
|
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]:
|
def model_segments(segmenter, page_text: str) -> list[str]:
|
||||||
"""Дорожки страницы: строки выбирает обученный сегментатор.
|
"""Дорожки страницы: строки выбирает обученный сегментатор.
|
||||||
|
|
||||||
@@ -136,11 +191,7 @@ def model_segments(segmenter, page_text: str) -> list[str]:
|
|||||||
"""
|
"""
|
||||||
out = []
|
out = []
|
||||||
for line in segmenter.select_lines(page_text):
|
for line in segmenter.select_lines(page_text):
|
||||||
body = RE_LINE_PREFIX.sub("", line, count=1)
|
out += _entries(line)
|
||||||
for entry in _split_entries(body):
|
|
||||||
entry = re.sub(r"\s+", " ", entry).strip()
|
|
||||||
if entry:
|
|
||||||
out.append(entry)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -152,9 +203,9 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
|
|||||||
(если он загружен и нашёл хотя бы одну строку), иначе правила; "rules" —
|
(если он загружен и нашёл хотя бы одну строку), иначе правила; "rules" —
|
||||||
только правила.
|
только правила.
|
||||||
|
|
||||||
Заголовок раздачи используется как дополнительный источник: студии,
|
Кодек-строки («Аудио N: … (MVO, LostFilm)») и заголовок раздачи —
|
||||||
названные только там («ПМ (LostFilm)»), добавляются как дорожки с
|
дополнительные источники: студии, названные только там, добавляются как
|
||||||
пометкой source=title.
|
дорожки с пометкой source=codec / source=title.
|
||||||
"""
|
"""
|
||||||
if (html is None) == (text is None):
|
if (html is None) == (text is None):
|
||||||
raise ValueError("нужно ровно одно из: html, text")
|
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 []
|
tracks = predictor._predict_batch(segs_page) if segs_page else []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
t["source"] = "page"
|
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:
|
if title is None:
|
||||||
title = title_from_html(html) if html is not None else title_from_text(text)
|
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 "")
|
||||||
|
|||||||
@@ -219,6 +219,34 @@ check("title: студия вписана в дорожку без студии"
|
|||||||
check("title: дубль студии пропущен, новая добавлена",
|
check("title: дубль студии пропущен, новая добавлена",
|
||||||
len(_page) == 3 and _page[2]["source"] == "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"
|
_dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n"
|
||||||
"Гангстерленд / 2026 / ДБ (X) / WEB-DL")
|
"Гангстерленд / 2026 / ДБ (X) / WEB-DL")
|
||||||
check("title_from_text: первая строка",
|
check("title_from_text: первая строка",
|
||||||
|
|||||||
Reference in new issue
Block a user