DUB-AI: multi-task модель (тип озвучки + автор-спан + 18+), обучение/инференс/ONNX/сервис, тесты и документация
This commit is contained in:
commit
8dbca3f4a3
2873 files changed
+44479
No files matched your search
@@ -0,0 +1,141 @@
|
||||
"""Извлечение дорожек озвучки из 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)(дубл|многоголос|двухголос|одноголос|авторск)")
|
||||
|
||||
# разделители нескольких дорожек в одной строке (перед словом типа озвучки)
|
||||
RE_SEP = re.compile(
|
||||
r"\s*(?:\+|;|/)\s*(?=Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)",
|
||||
re.I)
|
||||
|
||||
SELECTORS = ("div.post_body", "div[id^=post-text-]", ".postbody", "#tabs")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user