"""Извлечение дорожек озвучки из 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.+)$", re.I) # то же, но значение на СЛЕДУЮЩЕЙ строке (kinozal: «Перевод: значение») 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: """Текст узла: перевод строки только на
и блочных тегах. Инлайн-теги (, , ) строку не рвут: на rutracker имя студии — ссылка внутри строки «Перевод 1: Профессиональный (…) Студия». """ 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