Фикс html_to_text (студия-ссылка в строке), BLOCK теги, схлопывание дорожек без студии, FlareSolverr-фолбэки
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
dce219aeee
commit
96e5fa0d75
5 files changed
+67
-8
No files matched your search
@@ -93,3 +93,11 @@
|
||||
|
||||
Для e2e есть страницы только старых релизов (`data/pages`), для новых строк их нет.
|
||||
Если порог 99% по спану критичен — для встраивания брать fp32 tiny2 (116 МБ).
|
||||
|
||||
## Сегментация страницы: правила, а не нейросеть
|
||||
|
||||
Нейромодель получает уже вырезанный текст одной дорожки. Вырезает его
|
||||
`src/html_extract.py` — регулярками («Перевод N:», разделители, запятые).
|
||||
Поэтому ошибки вида «Дубляж, Продубляж» режется на две дорожки — это ошибки правил.
|
||||
Следующий шаг (не сделан): токен-классификатор по строкам страницы, который
|
||||
сам находит границы дорожек.
|
||||
+15
-4
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user