Фикс html_to_text (студия-ссылка в строке), BLOCK теги, схлопывание дорожек без студии, FlareSolverr-фолбэки

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 18:01:22 +07:00
1 parent dce219aeee
commit 96e5fa0d75
5 files changed
+67 -8

No files matched your search

+8
View File
@@ -93,3 +93,11 @@
Для e2e есть страницы только старых релизов (`data/pages`), для новых строк их нет.
Если порог 99% по спану критичен — для встраивания брать fp32 tiny2 (116 МБ).
## Сегментация страницы: правила, а не нейросеть
Нейромодель получает уже вырезанный текст одной дорожки. Вырезает его
`src/html_extract.py` — регулярками («Перевод N:», разделители, запятые).
Поэтому ошибки вида «Дубляж, Продубляж» режется на две дорожки — это ошибки правил.
Следующий шаг (не сделан): токен-классификатор по строкам страницы, который
сам находит границы дорожек.
+15 -4
View File
@@ -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:
+21 -4
View File
@@ -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:
"""Текст узла: перевод строки только на <br> и блочных тегах.
Инлайн-теги (<a>, <span>, <b>) строку не рвут: на rutracker имя студии —
ссылка внутри строки «Перевод 1: Профессиональный (…) <a>Студия</a>».
"""
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]:
+4
View File
@@ -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")]
+19
View File
@@ -282,5 +282,24 @@ finally:
_srv.shutdown()
check("rutracker: форма логина в шапке не блокирует тему",
blocked_reason('<html><a href="login.php"></a><input name="login_username"><div class="post_body">x</div></html>') is None)
check("rutracker: голая страница логина блокируется",
blocked_reason('<html><a href="login.php"></a><input name="login_username"></html>') is not None)
_rt = ('<div class="post_body"><span class="post-b">Перевод 1</span>: Профессиональный '
'(дублированный) <a href="#">Продубляж</a><br><span>Перевод 2</span>: Профессиональный '
'(многоголосый закадровый) <a>AlexFilm</a><br>Субтитры: Русские</div>')
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)