Датасет: полное покрытие data/raw (71 339 строк из 30 401 релиза), правки парсеров и аудит
docker / build (push) Successful in 1m20s

Переразбор всех страниц data/raw: legacy-страницы импорта, форматы префиксов ([*]Перевод (1):, Озвучка:, №2, диапазоны), дорожки только в строках «Аудио» (в т.ч. «(Русский, X)», «| Rus | Звук: …»), «Язык озвучки», несколько студий в одной строке, студия без типа. Кодек-строки: «Аудио #N Русский:», «Аудио pic RUS:», «Звук N:», «Аудиодорожка 01:», кириллические АVO/МVO. «Авторский» без числа голосов -> авт. одноголосый, «двугоголосый». Чистка студий: языки, кодеки, «по заказу», «в составе контейнера», отказы от озвучки.

Итог: страниц с метками 30 260 (85.5 %), страниц с дорожкой без меток 1 259 -> 223; data/pages 30 444; после prepare_data 60 944 строки (train 54 686 / test 6 258).

Новый tools/coverage_audit.py + reports/coverage_audit.md, docs/dataset-coverage.md. Переобучен models/best (base): на общем тесте type acc 0.976 против 0.949 у прежней, span 0.674 против 0.643; e2e kinozal 0.805/0.867, rt 0.771/0.740, nnm 0.752/0.693.
This commit is contained in:
Denozordec committed 2026-11-09 13:29:53 +07:00
1 parent d2cf488b9c
commit 0f359837c5
21 files changed
+38789 -22512

No files matched your search

+408 -102
View File
@@ -996,6 +996,46 @@ def harvest_nnm(forums: list[str], topics: list[str], pages: int, limit: int,
CYR_TO_LAT = str.maketrans({"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H",
"К": "K", "М": "M", "О": "O", "Р": "P", "Т": "T", "Х": "X"})
# Декоративный префикс строки дорожки: «[*]Перевод (1):», «►Перевод:», «• Озвучка»,
# «- Перевод:», «№2:». Снимается до сопоставления с ключевым словом, сама строка
# (`raw`) остаётся в датасете как на странице.
RE_LEAD_DECOR = re.compile(r"^\s*(?:\[\s*\*?\s*\]|[*•·►→▸▪◦]+|[-–—]\s*|№\s*(?=\d))+\s*")
# номер дорожки: «(1)», «1-4», «№2», «#3», «1» — один шаблон для «Перевод»/«Аудио»
RE_TRACK_NUM = (r"(?:\(\s*\d+(?:\s*[-–]\s*\d+)?\s*\)|№\s*\d+(?:\s*[-–]\s*\d+)?"
r"|#\s*\d+(?:\s*[-–]\s*\d+)?|\d+(?:\s*[-–]\s*\d+)?)")
# язык в начале имени студии: «Русский, - AlexFilm», «Украинский, AMC»
RE_LANG_PREFIX = re.compile(
r"(?i)^(?:русск\w*|украин\w*|russian|ukrainian|english|англ\w*|японск\w*|китайск\w*"
r"|корейск\w*|немецк\w*|французск\w*|итальянск\w*|испанск\w*|казахск\w*)\s*[,:]\s*[-–—]?\s*")
# «X по заказу Y» — студия X, заказчик в метке не нужен
RE_ON_ORDER = re.compile(r"(?i)^(.+?)\s+по\s+заказу\s+.+$")
# «Перевод: Отсутствует», «Без слов», «Немой», «Не требуется» — дорожки озвучки нет
RE_NO_DUB = re.compile(
r"(?i)\b(?:отсутств\w*|отсутсву\w*|не\s*требу\w*|немо[йе]\b|без\s*слов|без\s*перевода"
r"|нет\s*(?:перевода|озвучки)|оригинал\w*\s*(?:дорожк\w*|звук\w*|аудио)\b"
r"|без\s*диалог\w*|нет\s*диалог\w*)\b")
# то же, но только если строка НАЧИНАЕТСЯ с «нет перевода»: «Профессиональный …,
# оставлена оригинальная дорожка» — это дорожка с пометкой, а не отказ от озвучки
RE_NO_DUB_LINE = re.compile(
r"(?i)^\W*(?:отсутств\w*|отсутсву\w*|не\s*требу\w*|немо[йе]\b|без\s*слов|без\s*перевода"
r"|нет\s*(?:перевода|озвучки)|без\s*диалог\w*|нет\s*диалог\w*)\b")
# пост nnm/rutracker: «Перевод:» может склеиться со следующим полем
RE_FIELD_CUT = re.compile(
r"\s+(?:Описание|Жанр|Год(?:\s*выпуска)?|Страна|Режиссер|Режиссёр|Актеры|Актёры"
r"|Продолжительность|Субтитры|Размер|Качество|Видео|Аудио|Формат)\s*:")
def trim_field(body: str) -> str:
"""Обрезать значение поля на следующем поле поста («… || AniDB», «… Описание:»)."""
return RE_FIELD_CUT.split((body or "").split("||")[0], maxsplit=1)[0].strip()
def strip_lead(line: str) -> str:
"""Снять декоративный префикс («[*]», «►», «•»): ключевое слово встаёт в начало."""
return RE_LEAD_DECOR.sub("", line or "", count=1)
STUDIO_ALIASES = {
"hdrezka": "HDrezka", "hd rezka": "HDrezka", "hdrezka studio": "HDrezka",
"red head sound": "Red Head Sound", "read head sound": "Red Head Sound",
@@ -1033,7 +1073,7 @@ STUDIO_NOISE = re.compile(
# «дубляж» как тип, а не часть имени студии («Дубляжная», «Продубляж», «Дубляж-Студия»)
RE_DUB_TYPE = r"дублир|(?<![а-яё])дубляж(?![а-яё])"
ROLE_PATTERNS = [(RE_DUB_TYPE, "дубляж"), (r"многоголос", "многоголосый"),
(r"двухголос", "двухголосый"), (r"одноголос", "одноголосый"),
(r"дву[хг]?о?голос", "двухголосый"), (r"одноголос", "одноголосый"),
(r"субтитр", "субтитры"), (r"оригинал", "оригинал")]
# 18+ у kinozal/rutracker/nnm стоит в строке дорожки; отдельная строка
@@ -1063,6 +1103,11 @@ def mark_18(note: str, age: bool) -> str:
def clean_studio(raw: str) -> tuple[str, str]:
note = ""
s = (raw or "").strip().strip("|").strip()
s = RE_LANG_PREFIX.sub("", s) # «Русский, - AlexFilm» -> «AlexFilm»
s = re.sub(r"(?i)^(?:озвучил\w*|озвучено|озвучивание|перевёл|перевел)\s*[-–—:]?\s*", "", s).strip()
m = RE_ON_ORDER.match(s) # «X по заказу Y» -> «X»
if m:
s = m.group(1)
s = re.sub(r"\[[^\]]*\]", " ", s)
s = re.sub(r"\([^)]*\)", " ", s)
if re.search(r"\b18\s*\+", s):
@@ -1100,6 +1145,8 @@ def translation_type(text: str) -> tuple[str, str]:
if re.search(r"любительск|псевдо", t): # любительский дубляж — отдельный класс, не «проф.»
return "люб. дубляж", "люб."
return "проф. дубляж", "проф."
if kind == "неизвестно" and "авторск" in t:
kind = "одноголосый" # «Авторский (Павел Санаев)» — один голос
if "профессиональн" in t:
pref = "проф."
elif "любительск" in t:
@@ -1115,7 +1162,8 @@ def translation_type(text: str) -> tuple[str, str]:
# ---- kinozal -------------------------------------------------------
KZ_RE_TRANSLATION = re.compile(
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*)\s*(?:#\s*\d+|\d+)?\s*:\s*(?P<body>.+)$", re.I)
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*|Дубляж)\s*" + RE_TRACK_NUM +
r"?\s*:\s*(?P<body>.+)$", re.I)
KZ_RE_AUDIO_X = re.compile(
r"^\s*[АA]удио(?:\s*#?\s*\d+)?\s*x\s*(?P<lang>[A-ZА-Я/]\w*)\s*:\s*(?P<body>.+)$", re.I)
KZ_RE_AUDIO = re.compile(r"^\s*[АA]удио\s*:\s*(?P<body>.+)$", re.I)
@@ -1172,7 +1220,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
tech_text = (rec.get("tech_tab") or "").strip()
def has_transl(t: str) -> bool:
return any(KZ_RE_TRANSLATION.match(l) for l in t.splitlines())
return any(KZ_RE_TRANSLATION.match(strip_lead(l)) for l in t.splitlines())
if has_transl(release_text):
source, studio_source = release_text, "release"
@@ -1182,39 +1230,45 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
translations: list[dict] = []
audios: list[dict] = []
subs = ""
# переводы берём только из приоритетной вкладки, иначе «Техданные»
# добавляют вторую, обезличенную строку «Перевод: Дублированный»
for line in source.splitlines():
line = line.strip()
m = KZ_RE_TRANSLATION.match(line)
if m:
body = m.group("body").strip()
def parse_tab(text: str, src: str) -> None:
"""«Перевод:» из вкладки: одна строка может называть несколько студий."""
for raw in text.splitlines():
line = strip_lead(raw.strip())
m = KZ_RE_TRANSLATION.match(line)
if not m:
continue
body = trim_field(m.group("body"))
if not body or RE_NO_DUB_LINE.match(body):
continue
actors = ""
am = KZ_RE_ACTORS.search(body)
if am:
actors = am.group("names").strip(" .")
role, candidates = "", []
role = ""
pm = re.search(r"\(([^)]*)\)", body)
if pm:
role = pm.group(1)
candidates = [body[pm.end():].strip(" |.-–—"), role,
body[:pm.start()].strip(" |.-–—")]
else:
candidates = [body]
studio, note = "", ""
for cand in candidates:
s1, n1 = clean_studio(cand)
s1, n2 = canonical_studio(s1)
if s1:
studio, note = s1, " ".join(x for x in (n1, n2) if x)
break
kind, pro = translation_type(body)
if re.search(r"не требуется", body, re.I):
kind = "оригинал"
translations.append({"studio": studio, "type": kind, "pro": pro,
"role": role, "actors": actors, "note": note,
"line": line})
for name in part_studios(body) or [""]:
translations.append({"studio": name, "type": kind, "pro": pro,
"role": role, "actors": actors, "note": "",
"line": raw.strip(), "src": src})
# переводы из обеих вкладок: «Техданные» дают тип, «Релиз» — студии
parse_tab(release_text, "release")
parse_tab(tech_text, "tech")
named_types = {t["type"] for t in translations if t["studio"]}
seen_t, uniq_t = set(), []
for t in translations:
if not t["studio"] and t["type"] in named_types:
continue # обезличенный дубль строки со студией
key = (t["type"], t["studio"].casefold(), t["line"])
if key in seen_t:
continue
seen_t.add(key)
uniq_t.append(t)
translations = uniq_t
# аудиодорожки и субтитры могут быть в любой из вкладок
for line in (source + "\n" + tech_text).splitlines():
@@ -1274,7 +1328,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
"note": mark_18(note, age_18), "age_18": int(age_18),
"codec": a.get("codec", ""), "channels": a.get("channels", ""),
"khz": a.get("khz", ""), "kbps": a.get("kbps", ""),
"subtitles": subs, "studio_source": studio_source, "raw": t["line"],
"subtitles": subs, "studio_source": t["src"], "raw": t["line"],
"lang": (a.get("lang") or "RUS") if match else "",
})
rel = {"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
@@ -1287,9 +1341,11 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
# ---- rutracker -----------------------------------------------------
RT_RE_FIELD = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук)\s*(?:#\s*\d+|\d+)?\s*:\s*(?P<body>.+)$", re.I)
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Дубляж)\s*" + RE_TRACK_NUM +
r"?\s*[:.]\s*(?P<body>.+)$", re.I)
RT_RE_BARE = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук)\s*(?:#\s*\d+|\d+)?\s*:\s*$", re.I)
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук|Дубляж)\s*" + RE_TRACK_NUM +
r"?\s*:\s*$", re.I)
RT_RE_TYPE_START = re.compile(
r"^\s*(?:Профессиональн|Любительск|Авторск|Дублирован|Многоголос|Двухголос|Одноголос)", re.I)
RT_RE_SUBS = re.compile(r"^\s*Субтитры\s*:\s*(?P<body>.+)$", re.I)
@@ -1397,19 +1453,26 @@ def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
for i, line in enumerate(lines):
if i in taken:
continue
m = RT_RE_FIELD.match(line)
bodies = [m.group("body")] if m else (rt_bare_list(lines, i, taken) if RT_RE_BARE.match(line) else [])
head = strip_lead(line)
m = RT_RE_FIELD.match(head)
bodies = [trim_field(m.group("body"))] if m else (
rt_bare_list(lines, i, taken) if RT_RE_BARE.match(head) else [])
if bodies:
for body in bodies:
for part in rt_split_entries(body):
if RE_NO_DUB_LINE.match(part):
continue
e = rt_parse_entry(part)
if e["type"] in ("оригинал", "субтитры"):
continue
if not e["studio"] and e["type"] == "неизвестно":
# несколько студий в одной части: «(многоголосый) — Jaskier, TVShows, LostFilm»
names = part_studios(part) or ([e["studio"]] if e["studio"] else [])
if e["type"] == "неизвестно" and not names:
continue
entries.append(e)
for name in names or [""]:
entries.append({**e, "studio": name})
continue
m = RT_RE_SUBS.match(line)
m = RT_RE_SUBS.match(head)
if m and not subs:
subs = m.group("body").strip()
rows = [{
@@ -1430,9 +1493,14 @@ def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
# ---- nnmclub -------------------------------------------------------
NNM_RE_AUDIO = re.compile(r"^\s*Аудио\s*(?:#?\s*\d+)?\s*:\s*(?P<body>.+)$", re.I)
NNM_RE_PEREVOD = re.compile(r"^\s*Перевод\s*:\s*(?P<body>.+)$", re.I)
NNM_RE_AUDIO = re.compile(
r"^\s*(?:(?:Аудио|Audio)\s*[^:\n]{0,12}?|Звук\s*(?:[#№]\s*\d+|\d+)?)\s*[:.]\s*(?P<body>.+)$", re.I)
NNM_RE_PEREVOD = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Дубляж)\s*" + RE_TRACK_NUM +
r"?\s*:\s*(?P<body>.+)$", re.I)
NNM_LANG_RE = re.compile(r"(Русский|Английский|Украинский)", re.I)
# «Язык озвучки: Русский (Berofu, Takera, Euji [AniMaunt])» — имена без строки «Аудио»
NNM_RE_VOICE_LANG = re.compile(r"(?i)^\s*Язык\s*озвучк\w*\s*[:.]\s*(?P<body>.+)$")
NNM_LANG_MAP = {"русский": "RUS", "английский": "ENG", "украинский": "UKR"}
NNM_TAG_RE = re.compile(r"\b(DUB|MVO|DVO|AVO|VO|ДБ|ПМ|ПД|АП|ЛМ|ЛД|ЛО)\b", re.I)
NNM_RE_CODEC = re.compile(r"\b(Opus|AC-?3|E-?AC-?3|AAC|DTS-?HD|DTS|FLAC|MP3|TrueHD)\b", re.I)
@@ -1511,6 +1579,153 @@ def nnm_part_studio(part: str) -> str:
return ""
# ---------------------------------------------------------------- студии из перечислений
# корни «голосности»: по ним решается, режется ли перечисление через запятую
RE_TYPE_ROOT = re.compile(r"(?i)\b(дубл|многоголос|дву[хг]?голос|одноголос)")
# «Звук: X», «Озвучка: двухголосая (X)» — хвост с типом/именем в кодек-строке
RE_DESC_TAIL = re.compile(r"(?i)\b(?:звук|озвучк\w*|озвучиван\w*|перевод|дубляж)\s*[:.]\s*(.+)$")
# язык в начале скобки: «(Русский, Anilibria)» -> «Anilibria»
RE_PAREN_LANG = re.compile(
r"(?i)^\s*(?:русск\w*|russian|rus|ru|украин\w*|ukrainian|ukr|англ\w*|english|eng"
r"|японск\w*|japanese|jap|китайск\w*|корейск\w*|немецк\w*|французск\w*|итальянск\w*"
r"|испанск\w*|казахск\w*|оригинал\w*|original)\s*[,;:]?\s*")
NOT_STUDIO_WORD = re.compile(
r"(?i)^(?:по умолчанию|внешн\w*|встроенн\w*|в контейнере|в составе|оригинал\w*|комментар\w*"
r"|сезон\w*|сери\w*|эпизод\w*|фильм\w*|часть\w*|диск\w*|дорожк\w*|звук\w*|перевод\w*)$")
STUDIO_SEP = re.compile(r"\s*[,;|/]\s*")
SOURCE_RE = re.compile(r"(?i)^(?:blu-?ray|bd\b|dvd|web-?dl|webrip|hdtv|uhd|itunes|netflix|amazon|r5)")
RE_CODEC_PARAM_ANY = re.compile(
r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby|ddp?)\b"
r"|\d+(?:[.,]\d+)?\s*(?:kbps|кбит|kb/s|khz|кгц|гц|hz|ch|канал\w*)\b")
def is_studio_junk(s: str) -> bool:
"""Не имя студии: источник, тип, язык, техпараметры, «по умолчанию», число."""
low = (s or "").strip().lower()
return (len(low) < 3 or low in SOURCE_LIKE or low in STUDIO_PLACEHOLDERS
or bool(NOT_STUDIO_WORD.match(low)) or bool(CODEC_NOT_STUDIO.match(low))
or bool(SOURCE_RE.match(low)) or bool(RE_NO_DUB.search(low))
or bool(RE_CODEC_PARAM_ANY.match(low))
or bool(re.search(r"(?i)anidb|\bmal\b|shikimori|world\s*art|описан|список|эпизод|поверх"
r"|\bсери[ияй]\b|\bсезон\w*\b|неизвестн\w*", low))
or bool(re.fullmatch(r"[\d\s.,:/\-]+", low)))
def split_studios(text: str) -> list[str]:
"""Перечисление студий -> имена: «Jaskier, TVShows, HDrezka Studio, LostFilm»."""
out: list[str] = []
for part in STUDIO_SEP.split((text or "").strip(" |.-–—")):
st, _ = clean_studio(part.strip(" .|"))
st, _ = canonical_studio(st)
if st and not is_studio_junk(st) and st.casefold() not in {x.casefold() for x in out}:
out.append(st)
return out
def split_translation_parts(body: str) -> list[str]:
"""Значение «Перевод:» -> части-дорожки, не разрезая скобки.
«Профессиональный (многоголосый, закадровый), Авторский» — две дорожки;
«Авторский, одноголосый» — одна (второе слово уточняет первую);
«Профессиональное (многоголосое) / Любительское (одноголосое, Живов)» — две.
"""
text = (body or "").strip()
if not text:
return []
parts, depth, cur = [], 0, ""
for ch in text:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth = max(0, depth - 1)
if ch in ",/" and depth == 0:
parts.append(cur)
cur = ""
else:
cur += ch
parts.append(cur)
parts = [p.strip() for p in parts if p.strip()]
roots = sum(bool(RE_TYPE_ROOT.search(p)) for p in parts)
starts = sum(bool(RT_RE_TYPE_START.match(p)) for p in parts)
return parts if len(parts) > 1 and (roots >= 2 or starts >= 2) else [text]
def part_studios(part: str) -> list[str]:
"""Студии из части «Перевод:»: хвост после тире, скобки, остаток без слов типа."""
names: list[str] = []
def add(chunk: str) -> None:
for n in split_studios(chunk):
if n.casefold() not in {x.casefold() for x in names}:
names.append(n)
m = re.search(r"\s[-–—:]\s*(.+)$", part)
if m:
add(m.group(1))
for pm in re.finditer(r"\(([^()]*)\)", part):
chunk = RE_PAREN_LANG.sub("", pm.group(1))
if chunk and not NNM_PART_TYPEISH.search(chunk):
add(chunk)
rest = re.sub(r"\([^()]*\)", " ", part)
rest = re.sub(r"(?i)\b(?:профессиональн\w*|любительск\w*|авторск\w*|дублированн\w*|многоголос\w*"
r"|дву[хг]?голос\w*|одноголос\w*|закадров\w*|перевод\w*|озвучк\w*|х?\d+\s*[xх])\b",
" ", rest)
add(rest)
return names[:8]
RE_LANG_TOKEN = re.compile(
r"(?i)(?<![a-zа-яё])(русск\w*|russian|rus|ru|украин\w*|ukrainian|ukr"
r"|англ\w*|english|eng|японск\w*|japanese|jap|китайск\w*|корейск\w*|немецк\w*"
r"|французск\w*|итальянск\w*|испанск\w*|казахск\w*|оригинал\w*|original)(?![a-zа-яё])")
def lang_tokens(text: str) -> list[str]:
"""Коды языков, названных в строке: «| Rus |», «(Русский, Anilibria)», «RUS(int)»."""
out: list[str] = []
for m in RE_LANG_TOKEN.finditer(text or ""):
w = m.group(1).lower()
code = ("RUS" if w.startswith(("русск", "russ", "rus")) or w == "ru"
else "UKR" if w.startswith(("укра", "ukra", "ukr")) or w == "uk"
else "ENG" if w.startswith(("англ", "engl", "eng")) or w == "en"
else "JPN" if w.startswith(("япон", "japan", "jap"))
else "OTHER")
if code not in out:
out.append(code)
return out
def audio_names(body: str) -> list[str]:
"""Имена студий из строки «Аудио …».
«Аудио 4: AC3 … (внешние) (Русский, Anilibria)» -> Anilibria;
«Аудио: AAC … | Rus | Звук: Zendos & Eladiel» -> Zendos & Eladiel;
«Аудио 2: … (MVO, LostFilm)» -> LostFilm.
"""
names: list[str] = []
def add(chunk: str) -> None:
chunk = (chunk or "").strip()
if not chunk or RE_CODEC_PARAM_ANY.search(chunk) and not RE_TYPE_ROOT.search(chunk):
return
for n in split_studios(chunk):
if n.casefold() not in {x.casefold() for x in names}:
names.append(n)
for seg in re.split(r"\|", body):
seg = seg.strip()
if not seg:
continue
dm = RE_DESC_TAIL.search(seg)
add(dm.group(1) if dm else seg)
for pm in re.finditer(r"\(([^()]*)\)", body):
chunk = RE_PAREN_LANG.sub("", pm.group(1))
if chunk and not NNM_PART_TYPEISH.search(chunk):
add(chunk)
return names[:8]
def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
post = rec.get("post") or ""
title = rec.get("page_title") or rec.get("title") or ""
@@ -1526,37 +1741,36 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
for line in post.splitlines():
line = line.strip()
m = NNM_RE_AUDIO.match(line)
if m:
body = m.group("body").strip()
lm = NNM_LANG_RE.search(body)
if not lm or NNM_LANG_MAP.get(lm.group(1).lower()) != "RUS":
continue # английскую/украинскую дорожку пропускаем
rest = body[lm.end():]
tm = NNM_TAG_RE.search(rest)
m = NNM_RE_AUDIO.match(strip_lead(line))
if m and not re.match(r"(?i)^\s*аудио\s*кодек", strip_lead(line)):
body = trim_field(m.group("body"))
if RE_NO_DUB_LINE.match(body):
continue
langs = lang_tokens(body)
if not langs or not ({"RUS", "UKR"} & set(langs)):
continue # английская/японская дорожка
lang = "RUS" if "RUS" in langs else "UKR"
flat = body.translate(CYR_TO_LAT)
tm = NNM_TAG_RE.search(flat)
tag = tm.group(1).upper() if tm else ""
low = body.lower()
if not tag: # формат «... - дубляж» без аббревиатуры
low = rest.lower()
if "дубляж" in low:
tag = "DUB"
elif "многоголос" in low:
tag = "MVO"
elif "двухголос" in low:
elif re.search(r"дву[хг]?голос", low):
tag = "DVO"
elif "авторск" in low:
tag = "AVO"
elif "одноголос" in low:
tag = "VO"
name_raw = ""
pm = re.search(r"\((.*)\)", rest)
if pm:
name_raw = pm.group(1).strip()
studio, note = nnm_studio(name_raw)
age_18 = has_18(rest)
note = mark_18(note, age_18)
ttype, pro = nnm_tag_type(tag)
if not ttype:
ttype, pro = "неизвестно", ""
names = audio_names(body)
if not names and ttype == "неизвестно":
continue # ни типа, ни имени — строке нечего дать датасету
codec_m = NNM_RE_CODEC.search(body)
kb_m = NNM_RE_KBP.search(body)
ch_m = NNM_RE_CH.search(body)
@@ -1565,46 +1779,78 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
lay = NNM_RE_LAYOUT.search(body)
if lay:
channels = str(int(lay.group(1)) + (1 if lay.group(2) != "0" else 0))
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": studio or "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": tag, "actors": name_raw,
"note": note,
"age_18": int(age_18),
"codec": (codec_m.group(1).upper() if codec_m else ""),
"channels": channels,
"khz": "",
"kbps": (kb_m.group(1) if kb_m else ""),
"subtitles": "", "studio_source": "release", "raw": line,
})
continue
m = NNM_RE_PEREVOD.match(line)
if m:
for part in re.split(r"[,/]", m.group("body")):
part = part.strip()
if not part:
continue
ttype, pro = translation_type(part)
if ttype in ("неизвестно", "оригинал", "субтитры"):
continue
perevod_types.append(part)
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
age_18 = has_18(body)
for name in names or [""]:
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": nnm_part_studio(part) or "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": part, "actors": "",
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
"codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": "", "studio_source": "tech", "raw": line,
"studio": name or "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": tag, "actors": name,
"note": mark_18("", age_18),
"age_18": int(age_18),
"codec": (codec_m.group(1).upper() if codec_m else ""),
"channels": channels,
"khz": "",
"kbps": (kb_m.group(1) if kb_m else ""),
"subtitles": "", "studio_source": "release", "raw": line, "lang": lang,
})
continue
m = NNM_RE_PEREVOD.match(strip_lead(line))
if m:
body = trim_field(m.group("body"))
for part in split_translation_parts(body):
if RE_NO_DUB_LINE.match(part):
continue
ttype, pro = translation_type(part)
if ttype in ("оригинал", "субтитры"):
continue
names = part_studios(part)
if ttype == "неизвестно" and not names:
continue
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
for name in names or [""]:
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": name or "(не указана)", "translation_type": ttype,
"professional": pro, "role_raw": part, "actors": name,
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
"codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": "", "studio_source": "tech", "raw": line,
})
# страницы, где дорожка описана только полем «Язык озвучки: Русский (Актеры [Студия])»
if not rows:
for line in post.splitlines():
m = NNM_RE_VOICE_LANG.match(strip_lead(line.strip()))
if not m:
continue
body = trim_field(m.group("body"))
if "RUS" not in lang_tokens(body):
continue
names = audio_names(body)
for team in re.findall(r"\[([^\[\]]{2,60})\]", body):
for n in split_studios(team):
if n.casefold() not in {x.casefold() for x in names}:
names.append(n)
for name in names[:8]:
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"studio": name, "translation_type": "неизвестно", "professional": "",
"role_raw": "", "actors": "", "note": mark_18("", has_18(line)),
"age_18": int(has_18(line)), "codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": "", "studio_source": "release", "raw": line.strip(), "lang": "RUS",
})
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) не нужны; иначе
# оставляем tech-строки: типы и студии, названные в «Перевод:»
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) того же типа
# не нужны; типы, которых в «Аудио» нет, остаются (авторская дорожка и т. п.)
if any(r["studio"] != "(не указана)" and r["studio_source"] == "release" for r in rows):
rows = [r for r in rows if r["studio_source"] == "release" and r["studio"] != "(не указана)"]
rel_types = {r["translation_type"] for r in rows
if r["studio_source"] == "release" and r["studio"] != "(не указана)"}
rows = [r for r in rows
if r["studio_source"] == "release" or r["translation_type"] not in rel_types]
# дорожка без имени и без типа («- Русский») ничего не даёт датасету
rows = [r for r in rows
@@ -1815,7 +2061,8 @@ def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
# кодек-строка одной дорожки: «Аудио 2: …», «Аудио Rus: …», «Аудио pic RUS: …», «Звук #1: …»,
# «• №3: Русский (АС3 …)», «Russian (Dolby AC3, 6 ch) …»
RE_CODEC_LINE = re.compile(
r"(?i)^\s*[•#№*\-\s]*(?:(?:аудио\w*|audio|звук\w*)\b[^:\n]{0,24}:|№\s*\d+\s*:|(?:russian|русск\w*|украин\w*)\b)")
r"(?i)^\s*[•#№*\-\s]*(?:(?:аудио\w*|audio|звук\w*|track|битрейт\s*аудио|title|заголовок)\b[^:\n]{0,24}:"
r"|№\s*\d+(?:\s*[-–]\s*\d+)?\s*:|#\s*\d+\s*:|(?:russian|русск\w*|украин\w*)\b)")
RE_CODEC_PARAM = re.compile(r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2)\b|\d\s*(?:kbps|кбит|kb/s)")
RE_LANG_WORD = re.compile(
r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b|україн|англ|english|\beng\b|япон|japan|казах|kazakh"
@@ -1824,7 +2071,8 @@ RE_LANG_WORD = re.compile(
CODEC_NOT_STUDIO = re.compile(
r"(?i)^(?:rus|ukr|eng|ru|ua|int|ext|pic|dub|mvo|dvo|avo|vo|лицензи\w*|ненормативн\w*.*|.*лексик\w*"
r"|присутствует.*|внешн\w*|в составе.*|серии.*|эп\w*\.?\s*\d.*|[\d\s.,:/\-]+|.*\b(?:kbps|ch|khz|кбит)\b.*"
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original)\w*.*"
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original|японск|китайск"
r"|корейск|немецк|французск|итальянск|испанск|казахск)\w*.*"
r"|.*(?:голос|дублир|закадр|перевод).*|.*(?<![а-яё])дубляж(?![а-яё]).*|звук.*"
r"|.*(?:blu-?ray|itunes|netflix|amazon|\bbd\b|\bdvd\b|web-?dl).*)$")
@@ -1866,15 +2114,46 @@ def _known_studios() -> re.Pattern | None:
_KNOWN_RX: re.Pattern | None = None
def _rec_base(rec: dict) -> dict:
"""Метаданные релиза, когда размеченных строк ещё нет (для строк из кодек-линий)."""
src = rec.get("source") or ""
title = rec.get("page_title") or rec.get("title") or ""
if src == "kinozal":
info = kz_split_title(title.split(" :: ")[0])
return {"source": src, "ru_title": info["ru_title"], "year": info["year"], "quality": "",
"is_series": bool(re.search(r"сезон|серии", title, re.I))}
info = rt_parse_title(title)
return {"source": src, "ru_title": info["ru_title"], "year": info["year"],
"quality": info["quality"], "is_series": info["is_series"]}
def _line_names(line: str, info: dict, known: re.Pattern) -> list[tuple[str, bool]]:
"""Кандидаты в студии кодек-строки: словарь, `|…|`, `<…>`, `[…]`, `(…)`."""
cands: list[tuple[str, bool]] = [(m.group(1), True) for m in known.finditer(line)]
cands += [(n, False) for n in info["names"]]
for rx in (r"\|([^|]{2,60})\|", r"<([^<>]{2,60})>", r"\[([^\[\]]{2,60})\]", r"\(([^()]{2,80})\)"):
cands += [(m.group(1), False) for m in re.finditer(rx, line)]
seen, out = set(), []
for name, voc in cands:
k = name.strip().casefold()
if k and k not in seen:
seen.add(k)
out.append((name, voc))
return out
def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern | None = None) -> list[dict]:
"""Студии из кодек-строк русских/украинских дорожек, которых нет среди строк разметки.
"""Студии и типы из кодек-строк русских/украинских дорожек.
kinozal: «Перевод: Профессиональный (многоголосый закадровый)» без студий + «Аудио 2: AC3 …
(MVO, LostFilm)», «Аудио Rus: … | TVShows»; nnm/rutracker — те же кодек-строки рядом с
«Перевод N:» со списком, где этой студии нет. Строка-вход — сегмент кодек-строки, как его
«Перевод N:» со списком, где этой студии нет, и строки вида «Аудио 1: язык: Русский,
перевод: многоголосый закадровый, кодек: AC3». Строка-вход — сегмент кодек-строки, как его
режет сервис (`src.release._entries`); тип — из самой строки (MVO, «дубляж»), иначе
единственный тип релиза, иначе «неизвестно». Сводка из нескольких языков
(«Аудио: Русский (AC3…), украинский (AC3…)»), оригинал и комментарии — пропускаются.
единственный тип релиза, иначе «неизвестно». Если у релиза ещё нет ни одной строки, из
кодек-строки берётся и дорожка без имени (тип без студии) — так размечиваются страницы,
где дорожки описаны только строками «Аудио». Сводки из нескольких языков, оригинал и
комментарии пропускаются.
"""
from src.release import _entries, same_studio
from src.trackinfo import parse_audio_line
@@ -1882,18 +2161,21 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
known = known if known is not None else _known_studios()
have = [r["studio"] for r in rows if r.get("studio") and r["studio"] != "(не указана)"]
types = {r["translation_type"] for r in rows} - {"неизвестно", ""}
base = rows[0] if rows else {}
base = rows[0] if rows else _rec_base(rec)
out: list[dict] = []
for line in (text or "").splitlines():
line = re.sub(r"\s+", " ", line).strip()
if len(line) > 300 or not RE_CODEC_LINE.match(line) or not RE_CODEC_PARAM.search(line):
continue
if len({_lang_key(m.group(0)) for m in RE_LANG_WORD.finditer(line)}) > 1:
if RE_NO_DUB_LINE.match(line):
continue
langs = set(lang_tokens(line))
if len(langs & {"RUS", "UKR", "ENG", "JPN", "OTHER"}) > 1:
continue
info = parse_audio_line(re.sub(r"(?i)^\s*[^:]{0,30}:\s*", "", line, count=1))
if info["original"] or info["commentary"] or info["lang"] not in ("RUS", "UKR", ""):
continue
if not info["lang"] and not re.search(r"(?i)\brus\b|\bukr\b", line):
if not info["lang"] and not (langs & {"RUS", "UKR"}) and not re.search(r"(?i)\brus\b|\bukr\b", line):
continue
tag = NNM_TAG_RE.search(line) or NNM_TAG_RE.search(line.translate(CYR_TO_LAT)) # «АVO» кириллицей
kind, pro = nnm_tag_type(tag.group(1)) if tag else ("", "")
@@ -1904,11 +2186,11 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
if not kind:
kind = next(iter(types)) if len(types) == 1 else "неизвестно"
pro = kind.split()[0] if kind.split()[0] in ("проф.", "люб.", "авт.") else ""
cands = [(m.group(1), True) for m in known.finditer(line)] + [(n, False) for n in info["names"]]
for name, in_vocab in cands:
names = _line_names(line, info, known)
for name, in_vocab in names:
studio, note = clean_studio(name)
studio, extra = canonical_studio(studio)
if len(studio) < 3 or studio.lower() in SOURCE_LIKE or CODEC_NOT_STUDIO.match(studio):
if len(studio) < 3 or is_studio_junk(studio):
continue
if CODEC_NAME_NOISE.search(studio) or not in_vocab and re.search(r"\d", studio):
continue
@@ -1928,7 +2210,31 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
"raw": seg, "lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
"audio_raw": line})
have.append(studio)
return out
if not rows and not names and kind and kind != "неизвестно":
# дорожка без имени: датасету ценен хотя бы тип; только для релизов без строк вовсе
entries = _entries(line)
out.append({
"source": base.get("source") or rec.get("source", ""), "release_id": rec.get("id", ""),
"ru_title": base.get("ru_title", ""), "year": base.get("year", ""),
"quality": base.get("quality", ""), "is_series": base.get("is_series", False),
"studio": "(не указана)", "translation_type": kind, "professional": pro,
"role_raw": "", "actors": "",
"note": mark_18("", info["adult"]),
"age_18": int(info["adult"]), "codec": info["codec"], "channels": info["channels"],
"khz": "", "kbps": info["kbps"], "subtitles": "", "studio_source": "codec",
"raw": entries[0] if entries else line,
"lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
"audio_raw": line})
# один и тот же тип/студия из соседних строк-дублей
seen, uniq = set(), []
for r in out:
key = (r["studio"].casefold(), r["translation_type"],
re.sub(r"\s+", " ", r["raw"]).strip().casefold())
if key in seen:
continue
seen.add(key)
uniq.append(r)
return uniq
def _with_audio(core, texts):
@@ -1943,8 +2249,8 @@ def _with_audio(core, texts):
rows = kz_audio_only_rows(rec, text)
if core is _kz_parse_core:
rows += kz_title_rows(rec, text, rows)
rows += codec_line_rows(rec, text, rows)
if rows:
rows += codec_line_rows(rec, text, rows)
rel["n_translations"], rel["ok"] = len(rows), True
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
return rows, rel