Files
grabber-ai/src/html_extract.py
T

161 lines
7.5 KiB
Python

"""Извлечение дорожек озвучки из HTML-страницы или текста релиза.
Логика сегментации портирована из парсеров датасета (dataset.py: kz_parse /
rt_parse / nnm_parse) и источник-агностична: работает для rutracker, kinozal,
nnm и простого текста.
Публичный API:
html_to_text(html) — HTML -> текст (селекторы поста/вкладок, fallback всё)
extract_segments(text) — текст страницы -> список текстов дорожек
read_input(path) — чтение файла с угадыванием кодировки (utf-8/cp1251)
"""
from __future__ import annotations
import re
from pathlib import Path
from bs4 import BeautifulSoup
# строка дорожки: «Перевод:», «Перевод 2:», «Озвучивание:», «Аудио 1:», …
RE_LINE = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Аудио)"
r"\s*(?:#\s*\d+|\d+)?\s*[:.]\s*(?P<body>.+)$", re.I)
# то же, но значение на СЛЕДУЮЩЕЙ строке (kinozal: «<b>Перевод:</b> значение»)
RE_KEY_ONLY = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Аудио)"
r"\s*(?:#\s*\d+|\d+)?\s*:\s*$", re.I)
# префикс строки дорожки (для получения «чистого» текста дорожки)
RE_LINE_PREFIX = re.compile(
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*|Озвучен\w*|Звук|Аудио)"
r"\s*(?:#\s*\d+|\d+)?\s*[:.]\s*", re.I)
# признаки того, что в тексте есть информация о типе озвучки/авторе
RE_TYPE_HINT = re.compile(
r"(?i)(дубл|многоголос|двухголос|одноголос|закадр|авторск|профессион"
r"|любител|\bAVO\b|\bDVO\b|\bMVO\b|\bDUB\b|\bVO\b|ДБ|ПМ|ПД|АП|ЛМ|ЛД|ЛО)")
# корневые слова типов — для разбиения перечислений через запятую
# корни «голосности»: «Авторский, одноголосый» — одна дорожка (авторск — лишь
# уточнение), поэтому по запятым режем только перечисления голосностей
RE_TYPE_ROOT = re.compile(r"(?i)\b(дубл|многоголос|двухголос|одноголос)")
# разделители нескольких дорожек в одной строке (перед словом типа озвучки)
RE_SEP = re.compile(
r"\s*(?:\+|;|/)\s*(?=Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)",
re.I)
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_text(node)
return _node_text(soup.body or soup)
def _split_entries(body: str) -> list[str]:
"""Разбивает тело строки на отдельные дорожки.
Поддержаны форматы датасета: нумерация «1. … 2. …», разделители «+ ; /»
перед словами типа, а также перечисление типов через запятую
(«Перевод: Одноголосый, многоголосый закадровый»).
"""
text = body.strip()
numbered = re.split(r"\s(?=\d\.\s)", text)
if len(numbered) > 1:
parts = [re.sub(r"^\d\.\s*", "", p) for p in numbered]
else:
parts = RE_SEP.split(text)
# дополнительное разбиение по запятым, если каждая часть похожа на тип
out: list[str] = []
for p in parts:
comma = [c.strip(" .") for c in p.split(",")]
# режем по запятым только перечисление ТИПОВ («Одноголосый, дублированный»),
# но не «(многоголосый, закадровый)» — нужно ≥2 частей с корнем типа
if len(comma) > 1 and sum(bool(RE_TYPE_ROOT.search(c)) for c in comma if c) >= 2:
out += [c for c in comma if c]
else:
out.append(p.strip(" ."))
return [p for p in out if p]
def extract_segments(text: str) -> list[str]:
"""Текст страницы -> список текстов дорожек (префикс уже снят)."""
segments: list[str] = []
lines = (text or "").splitlines()
i = 0
while i < len(lines):
line = lines[i].strip()
m = RE_LINE.match(line)
if m:
body, i = m.group("body"), i + 1
elif RE_KEY_ONLY.match(line):
# значение дорожки — на следующей непустой строке
j = i + 1
while j < len(lines) and not lines[j].strip():
j += 1
if j >= len(lines):
break
body, i = lines[j].strip(), j + 1
else:
i += 1
continue
for entry in _split_entries(body):
if RE_TYPE_HINT.search(entry):
segments.append(re.sub(r"\s+", " ", entry).strip())
return segments
def read_input(path: str | Path) -> str:
"""Читает файл, угадывая кодировку (utf-8 -> cp1251) и чиня «двойную».
Двойная кодировка — utf-8-текст, прочитанный как cp1251 и пересохранённый
в utf-8 («РљР°С‡РµСЃС‚РІРѕ»); восстанавливается обратным преобразованием.
"""
data = Path(path).read_bytes()
for enc in ("utf-8", "cp1251"):
try:
return _fix_mojibake(data.decode(enc))
except UnicodeDecodeError:
continue
return _fix_mojibake(data.decode("utf-8", errors="replace"))
def _fix_mojibake(text: str) -> str:
non_ascii = [c for c in text if ord(c) > 127]
if not non_ascii:
return text
# в mojibake половина не-ASCII — это «Р»/«С» (байты 0xD0/0xD1 заголовков utf-8)
ratio = sum(c in "РС" for c in non_ascii) / len(non_ascii)
if ratio > 0.3:
try:
return text.encode("cp1251").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
return text