From 96e5fa0d75948f3c5cf4b72ba90e21449960737b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 27 Oct 2026 18:01:22 +0700 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20html=5Fto=5Ftext=20(?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=B4=D0=B8=D1=8F-=D1=81=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B5),?= =?UTF-8?q?=20BLOCK=20=D1=82=D0=B5=D0=B3=D0=B8,=20=D1=81=D1=85=D0=BB=D0=BE?= =?UTF-8?q?=D0=BF=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BA=20=D0=B1=D0=B5=D0=B7=20=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D0=B8,=20FlareSolverr-=D1=84=D0=BE=D0=BB?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/plan.md | 8 ++++++++ src/fetch.py | 19 +++++++++++++++---- src/html_extract.py | 25 +++++++++++++++++++++---- src/release.py | 4 ++++ tests/run_tests.py | 19 +++++++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index dc81cf9..ac69c6d 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -93,3 +93,11 @@ Для e2e есть страницы только старых релизов (`data/pages`), для новых строк их нет. Если порог 99% по спану критичен — для встраивания брать fp32 tiny2 (116 МБ). + +## Сегментация страницы: правила, а не нейросеть + +Нейромодель получает уже вырезанный текст одной дорожки. Вырезает его +`src/html_extract.py` — регулярками («Перевод N:», разделители, запятые). +Поэтому ошибки вида «Дубляж, Продубляж» режется на две дорожки — это ошибки правил. +Следующий шаг (не сделан): токен-классификатор по строкам страницы, который +сам находит границы дорожек. diff --git a/src/fetch.py b/src/fetch.py index 9d99e06..2f174d6 100644 --- a/src/fetch.py +++ b/src/fetch.py @@ -38,6 +38,8 @@ TRACKER_HOSTS = { USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0 Safari/537.36") MAX_BYTES = 5_000_000 +# коды, при которых имеет смысл повтор через FlareSolverr (отказ/Cloudflare) +RETRY_CODES = (403, 429, 500, 502, 503, 520, 521, 522, 523, 524) def detect_tracker(url: str) -> str: @@ -72,7 +74,8 @@ def blocked_reason(html: str) -> str | None: 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: + # у rutracker форма логина есть в шапке и у обычной страницы темы + if "login.php" in head and "name=\"login_username\"" in html and "post_body" not in html and "Перевод" not in html: return "нужна авторизация (передайте cookie)" return None @@ -103,8 +106,16 @@ def _fetch_flaresolverr(endpoint: str, url: str, timeout: float, req = urllib.request.Request( endpoint.rstrip("/") + "/v1", data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=timeout + 15) as resp: - data = json.loads(resp.read(MAX_BYTES * 2).decode("utf-8")) + try: + with urllib.request.urlopen(req, timeout=timeout + 15) as resp: + data = json.loads(resp.read(MAX_BYTES * 2).decode("utf-8")) + except urllib.error.HTTPError as e: + # FlareSolverr при неудаче решения отвечает HTTP 500 с JSON-описанием + try: + msg = json.loads(e.read().decode("utf-8")).get("message", "") + except Exception: # noqa: BLE001 + msg = "" + raise ValueError(f"FlareSolverr: HTTP {e.code} {msg}".strip()) from e if data.get("status") != "ok": raise ValueError(f"FlareSolverr: {data.get('message', 'ошибка')}") return data["solution"]["response"] @@ -123,7 +134,7 @@ def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | None = None, try: html = _fetch_direct(url, timeout, cookie) except urllib.error.HTTPError as e: - if e.code not in (403, 429, 503) or not flaresolverr: + 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: diff --git a/src/html_extract.py b/src/html_extract.py index b752231..44f10c5 100644 --- a/src/html_extract.py +++ b/src/html_extract.py @@ -40,7 +40,7 @@ RE_TYPE_HINT = re.compile( # корневые слова типов — для разбиения перечислений через запятую # корни «голосности»: «Авторский, одноголосый» — одна дорожка (авторск — лишь # уточнение), поэтому по запятым режем только перечисления голосностей -RE_TYPE_ROOT = re.compile(r"(?i)(дубл|многоголос|двухголос|одноголос)") +RE_TYPE_ROOT = re.compile(r"(?i)\b(дубл|многоголос|двухголос|одноголос)") # разделители нескольких дорожек в одной строке (перед словом типа озвучки) RE_SEP = re.compile( @@ -50,15 +50,32 @@ RE_SEP = re.compile( SELECTORS = ("div.post_body", "div[id^=post-text-]", ".postbody", "#tabs") +BLOCK_TAGS = ("p", "div", "tr", "li", "ul", "ol", "table", "hr", "h1", "h2", "h3", + "h4", "h5", "h6", "blockquote", "pre", "dt", "dd") + + +def _node_text(node) -> str: + """Текст узла: перевод строки только на
и блочных тегах. + + Инлайн-теги (, , ) строку не рвут: на rutracker имя студии — + ссылка внутри строки «Перевод 1: Профессиональный (…) Студия». + """ + for br in node.find_all("br"): + br.replace_with("\n") + for tag in node.find_all(BLOCK_TAGS): + tag.insert_before("\n") + tag.insert_after("\n") + return node.get_text("") + + def html_to_text(html: str) -> str: """HTML -> текст: контейнер поста/вкладки описания либо весь документ.""" soup = BeautifulSoup(html, "lxml") for sel in SELECTORS: node = soup.select_one(sel) if node and node.get_text(strip=True): - return node.get_text("\n") - body = soup.body or soup - return body.get_text("\n") + return _node_text(node) + return _node_text(soup.body or soup) def _split_entries(body: str) -> list[str]: diff --git a/src/release.py b/src/release.py index b06f21b..3d77634 100644 --- a/src/release.py +++ b/src/release.py @@ -86,6 +86,10 @@ def aggregate(tracks: list[dict]) -> dict: else: merged[k] = dict(t) uniq = list(merged.values()) + # дорожка без студии, при наличии дорожки того же типа со студией, — как + # правило, та же дорожка в виде кодек-строки («AC3 … | MVO, AlexFilm») + typed = {t["translation_type"] for t in uniq if t.get("studio")} + uniq = [t for t in uniq if t.get("studio") or t["translation_type"] not in typed] studios = [{"studio": t["studio"], "translation_type": t["translation_type"], "is_18plus": t["is_18plus"]} for t in uniq if t.get("studio")] diff --git a/tests/run_tests.py b/tests/run_tests.py index 1a7c774..ef5d2fb 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -282,5 +282,24 @@ finally: _srv.shutdown() +check("rutracker: форма логина в шапке не блокирует тему", + blocked_reason('
x
') is None) +check("rutracker: голая страница логина блокируется", + blocked_reason('') is not None) + +_rt = ('
Перевод 1: Профессиональный ' + '(дублированный) Продубляж
Перевод 2: Профессиональный ' + '(многоголосый закадровый) AlexFilm
Субтитры: Русские
') +check("html: студия-ссылка внутри строки не рвёт дорожку", + extract_segments(html_to_text(_rt)) == ["Профессиональный (дублированный) Продубляж", + "Профессиональный (многоголосый закадровый) AlexFilm"]) +check("«Дубляж, Продубляж» не режется", + len(_split_entries("Russian AC3 |Дубляж, Продубляж|")) == 1) + +_agg2 = aggregate([_t("проф. дубляж", "Продубляж"), _t("проф. дубляж", None), + _t("проф. двухголосый", None)]) +check("aggregate: дорожка без студии того же типа схлопывается", + [x["translation_type"] for x in _agg2["tracks"]] == ["проф. дубляж", "проф. двухголосый"]) + print(f"\nитог: {PASS} ok, {FAIL} failed") sys.exit(1 if FAIL else 0)