161 lines
7.5 KiB
Python
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
|