Фикс html_to_text (студия-ссылка в строке), BLOCK теги, схлопывание дорожек без студии, FlareSolverr-фолбэки
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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`), для новых строк их нет.
|
Для e2e есть страницы только старых релизов (`data/pages`), для новых строк их нет.
|
||||||
Если порог 99% по спану критичен — для встраивания брать fp32 tiny2 (116 МБ).
|
Если порог 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 "
|
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
||||||
MAX_BYTES = 5_000_000
|
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:
|
def detect_tracker(url: str) -> str:
|
||||||
@@ -72,7 +74,8 @@ def blocked_reason(html: str) -> str | None:
|
|||||||
return "Cloudflare challenge"
|
return "Cloudflare challenge"
|
||||||
if "takelogin.php" in html and "Перевод" not in html:
|
if "takelogin.php" in html and "Перевод" not in html:
|
||||||
return "нужна авторизация (передайте cookie)"
|
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 "нужна авторизация (передайте cookie)"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -103,8 +106,16 @@ def _fetch_flaresolverr(endpoint: str, url: str, timeout: float,
|
|||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
endpoint.rstrip("/") + "/v1", data=json.dumps(body).encode(),
|
endpoint.rstrip("/") + "/v1", data=json.dumps(body).encode(),
|
||||||
headers={"Content-Type": "application/json"})
|
headers={"Content-Type": "application/json"})
|
||||||
with urllib.request.urlopen(req, timeout=timeout + 15) as resp:
|
try:
|
||||||
data = json.loads(resp.read(MAX_BYTES * 2).decode("utf-8"))
|
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":
|
if data.get("status") != "ok":
|
||||||
raise ValueError(f"FlareSolverr: {data.get('message', 'ошибка')}")
|
raise ValueError(f"FlareSolverr: {data.get('message', 'ошибка')}")
|
||||||
return data["solution"]["response"]
|
return data["solution"]["response"]
|
||||||
@@ -123,7 +134,7 @@ def fetch_html(url: str, *, timeout: float = 15.0, cookie: str | 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 (403, 429, 503) or not flaresolverr:
|
if e.code not in RETRY_CODES or not flaresolverr:
|
||||||
raise
|
raise
|
||||||
direct_error = e
|
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:
|
||||||
|
|||||||
+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(
|
RE_SEP = re.compile(
|
||||||
@@ -50,15 +50,32 @@ RE_SEP = re.compile(
|
|||||||
SELECTORS = ("div.post_body", "div[id^=post-text-]", ".postbody", "#tabs")
|
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:
|
def html_to_text(html: str) -> str:
|
||||||
"""HTML -> текст: контейнер поста/вкладки описания либо весь документ."""
|
"""HTML -> текст: контейнер поста/вкладки описания либо весь документ."""
|
||||||
soup = BeautifulSoup(html, "lxml")
|
soup = BeautifulSoup(html, "lxml")
|
||||||
for sel in SELECTORS:
|
for sel in SELECTORS:
|
||||||
node = soup.select_one(sel)
|
node = soup.select_one(sel)
|
||||||
if node and node.get_text(strip=True):
|
if node and node.get_text(strip=True):
|
||||||
return node.get_text("\n")
|
return _node_text(node)
|
||||||
body = soup.body or soup
|
return _node_text(soup.body or soup)
|
||||||
return body.get_text("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def _split_entries(body: str) -> list[str]:
|
def _split_entries(body: str) -> list[str]:
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ def aggregate(tracks: list[dict]) -> dict:
|
|||||||
else:
|
else:
|
||||||
merged[k] = dict(t)
|
merged[k] = dict(t)
|
||||||
uniq = list(merged.values())
|
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"],
|
studios = [{"studio": t["studio"], "translation_type": t["translation_type"],
|
||||||
"is_18plus": t["is_18plus"]}
|
"is_18plus": t["is_18plus"]}
|
||||||
for t in uniq if t.get("studio")]
|
for t in uniq if t.get("studio")]
|
||||||
|
|||||||
@@ -282,5 +282,24 @@ finally:
|
|||||||
_srv.shutdown()
|
_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")
|
print(f"\nитог: {PASS} ok, {FAIL} failed")
|
||||||
sys.exit(1 if FAIL else 0)
|
sys.exit(1 if FAIL else 0)
|
||||||
Reference in new issue
Block a user