Files
DenozordecandClaude Opus 5.5 e3af778068 Студии из кодек-строк: codec_line_rows, доразметка, аудит дорожек
tools/audit_tracks.py и reports/tracks_audit.md — аудит всех дорожек
техданных по data/raw с планом. Пункт 1 плана: harvest_core.codec_line_rows
добавляет студии из строк «Аудио 2: AC3 … (MVO, LostFilm)», «Аудио Rus: … |
TVShows» (studio_source=codec), которых нет среди строк релиза; тот же шаг
в tools/relabel_studios.py (повторный запуск ничего не добавляет).
labels.csv: +6 428 строк в 2 862 релизах (kinozal 3 444, nnm 1 609,
rutracker 1 375), со студией 29 331 -> 35 759. tests/e2e_segmenter.py:
модель через E2E_MODEL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:30:52 +07:00

2229 lines
106 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""
dataset.py — один скрипт: собрать датасет «раздача -> студия озвучки» с
kinozal.me и rutracker.org, разобрать метки и слить в один CSV.
Только стандартная библиотека: Chrome запускается как процесс, общение с ним
идёт по CDP (DevTools Protocol) через WebSocket, который реализован здесь же
на сокетах. Ни Node.js, ни pip-пакеты не нужны.
Команды
-------
python dataset.py chrome # запустить Chrome (если не запущен)
python dataset.py harvest kinozal --ids 2155882 --limit 5
python dataset.py harvest kinozal --browse https://kinozal.me/top.php --limit 20
python dataset.py harvest rutracker --forums 313,252 --pages 2 --limit 100
python dataset.py parse # сырые страницы -> строки
python dataset.py merge # строки -> out/dataset/labels.csv
python dataset.py all --rutracker-forums 313 --pages 1 --limit 20
python dataset.py stats
Chrome НЕ закрывается: профиль out/chrome-profile хранит логин и cookie, а
повторный запуск не провоцирует Cloudflare. Первый раз войдите в аккаунт
rutracker вручную в открывшемся окне — этого достаточно для скачивания
торрентов и обхода лимитов гостя.
"""
from __future__ import annotations
import argparse
import base64
import csv
import json
import os
import random
import re
import socket
import struct
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from pathlib import Path
from typing import Any
_REPO = Path(__file__).resolve().parents[2]
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
from src.audio18 import audio_counts, audio_lines # noqa: E402
from src.html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries # noqa: E402
from src.trackinfo import audio_lines_all, lang_of_text, pick_for_row # noqa: E402
ROOT = Path(__file__).resolve().parent
OUT = Path(os.environ.get("DUB_HARVEST_OUT") or ROOT / "out")
RAW_DIR = OUT / "raw"
DATASET = OUT / "dataset"
CHROME_PORT = int(os.environ.get("DUB_CHROME_PORT") or 9222)
CHROME_PROFILE = Path(os.environ.get("DUB_CHROME_PROFILE") or OUT / "chrome-profile")
CHROME_PATHS = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
]
UA_HINT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"
def log(msg: str) -> None:
print(msg, flush=True)
# =====================================================================
# Состояние обхода: у каждого источника свой файл, чтобы два
# параллельных процесса (kinozal + rutracker) не затирали прогресс
# =====================================================================
STATE_FILES = {
"rutracker": OUT / "state_rutracker.json",
"kinozal": OUT / "state_kinozal.json",
"nnm": OUT / "state_nnm.json",
}
LEGACY_STATE = OUT / "state.json"
def _migrate_legacy_state() -> None:
if not LEGACY_STATE.exists():
return
try:
old = json.loads(LEGACY_STATE.read_text("utf-8"))
except (OSError, json.JSONDecodeError):
return
for src, path in STATE_FILES.items():
part = old.get(src)
if part and not path.exists():
path.write_text(json.dumps({"version": 1, src: part},
ensure_ascii=False, indent=2), "utf-8")
LEGACY_STATE.replace(LEGACY_STATE.with_suffix(".migrated.json"))
def load_state(source: str) -> dict:
_migrate_legacy_state()
path = STATE_FILES[source]
if path.exists():
try:
return json.loads(path.read_text("utf-8"))
except json.JSONDecodeError:
log(f" {path.name} повреждён — начинаю этот источник заново")
base = {"version": 1}
return base
def save_state(source: str, state: dict) -> None:
OUT.mkdir(parents=True, exist_ok=True)
path = STATE_FILES[source]
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), "utf-8")
tmp.replace(path) # атомарно: не порвётся при падении
def stamp() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
# =====================================================================
# WebSocket (RFC 6455) — минимальный клиент для CDP
# =====================================================================
class WebSocket:
def __init__(self, url: str, timeout: float = 30.0):
m = re.match(r"ws://([^/:]+):(\d+)(/.*)", url)
if not m:
raise ValueError(f"плохой ws url: {url}")
host, port, path = m.group(1), int(m.group(2)), m.group(3)
self.sock = socket.create_connection((host, port), timeout=timeout)
self.sock.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(req.encode())
self._buf = b""
while b"\r\n\r\n" not in self._buf:
chunk = self.sock.recv(4096)
if not chunk:
raise ConnectionError("handshake закрыт")
self._buf += chunk
head, self._buf = self._buf.split(b"\r\n\r\n", 1)
if b"101" not in head.split(b"\r\n")[0]:
raise ConnectionError(f"handshake failed: {head[:120]!r}")
# -- низкий уровень -------------------------------------------------
def _read(self, n: int) -> bytes:
while len(self._buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise ConnectionError("соединение закрыто")
self._buf += chunk
data, self._buf = self._buf[:n], self._buf[n:]
return data
def send(self, text: str) -> None:
payload = text.encode()
header = bytearray([0x81])
n = len(payload)
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header += struct.pack(">H", n)
else:
header.append(0x80 | 127)
header += struct.pack(">Q", n)
mask = os.urandom(4)
header += mask
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes(header) + masked)
def recv(self) -> str:
fragments = b""
while True:
b1, b2 = self._read(2)
fin, opcode = b1 & 0x80, b1 & 0x0F
masked, ln = b2 & 0x80, b2 & 0x7F
if ln == 126:
ln = struct.unpack(">H", self._read(2))[0]
elif ln == 127:
ln = struct.unpack(">Q", self._read(8))[0]
mask = self._read(4) if masked else None
data = self._read(ln)
if mask:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
if opcode == 0x9: # ping
self.sock.sendall(b"\x8a\x00")
continue
if opcode == 0x8: # close
raise ConnectionError("сервер закрыл соединение")
fragments += data
if fin:
return fragments.decode("utf-8", "replace")
def close(self) -> None:
try:
self.sock.close()
except OSError:
pass
class CDP:
"""Клиент DevTools Protocol поверх WebSocket."""
def __init__(self, ws_url: str, timeout: float = 60.0):
self.ws_url = ws_url
self.ws = WebSocket(ws_url, timeout=timeout)
self.next_id = 0
self.timeout = timeout
def reconnect(self) -> None:
"""Новое соединение с той же вкладкой (после обрыва или принудительного закрытия)."""
try:
self.ws.close()
except OSError:
pass
self.ws = WebSocket(self.ws_url, timeout=self.timeout)
self.next_id = 0
self.send("Page.enable")
self.send("Runtime.enable")
def abort(self) -> None:
"""Оборвать зависший вызов из другого потока: recv() в рабочем потоке упадёт с ошибкой."""
try:
self.ws.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.ws.close()
except OSError:
pass
def send(self, method: str, params: dict | None = None) -> dict:
self.next_id += 1
mid = self.next_id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
deadline = time.time() + self.timeout
while time.time() < deadline:
msg = json.loads(self.ws.recv())
if msg.get("id") != mid:
continue # событие, не наш ответ
if "error" in msg:
raise RuntimeError(f"{method}: {msg['error']}")
return msg.get("result", {})
raise TimeoutError(f"{method}: нет ответа за {self.timeout}s")
def eval(self, expression: str, await_promise: bool = True) -> Any:
res = self.send("Runtime.evaluate", {
"expression": expression,
"returnByValue": True,
"awaitPromise": await_promise,
})
if res.get("exceptionDetails"):
desc = res["exceptionDetails"].get("exception", {}).get("description", "eval error")
raise RuntimeError(desc)
return res.get("result", {}).get("value")
def close(self) -> None:
self.ws.close()
# =====================================================================
# Chrome / вкладки
# =====================================================================
def http_json(path: str, method: str = "GET", timeout: float = 5.0) -> Any:
req = urllib.request.Request(f"http://127.0.0.1:{CHROME_PORT}{path}", method=method)
req.add_header("User-Agent", UA_HINT)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def chrome_alive() -> bool:
try:
http_json("/json/version", timeout=1.5)
return True
except (urllib.error.URLError, OSError, json.JSONDecodeError):
return False
def find_chrome() -> str:
for p in CHROME_PATHS:
if Path(p).exists():
return p
raise FileNotFoundError("Google Chrome не найден в стандартных путях")
def chrome_start(url: str = "https://rutracker.org/forum/index.php") -> bool:
"""Запустить Chrome, если он ещё не запущен. Процесс остаётся жить."""
if chrome_alive():
return True
CHROME_PROFILE.mkdir(parents=True, exist_ok=True)
exe = find_chrome()
flags = 0
if os.name == "nt":
flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen(
[exe,
f"--remote-debugging-port={CHROME_PORT}",
f"--user-data-dir={CHROME_PROFILE}",
"--no-first-run", "--no-default-browser-check",
"--disable-blink-features=AutomationControlled",
"--test-type", # скрывает плашку «неподдерживаемый флаг командной строки»
"--lang=ru-RU", "--window-size=1400,1100", url],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=flags, close_fds=True,
)
for _ in range(80):
if chrome_alive():
return True
time.sleep(0.25)
return False
def page_target(prefer: str = "") -> dict:
"""Вкладка под конкретный сайт: своя у каждого процесса."""
pages = [t for t in http_json("/json/list") if t.get("type") == "page"]
if prefer:
for p in pages:
if prefer in (p.get("url") or ""):
return p
url = "https://rutracker.org/forum/index.php" if "rutracker" in prefer else "https://kinozal.me/"
return http_json(f"/json/new?{urllib.parse.quote(url, safe=':/?=&')}", method="PUT")
if not pages:
return http_json("/json/new?about:blank", method="PUT")
return pages[0]
def connect(prefer: str = "") -> tuple[CDP, dict]:
if not chrome_start():
raise RuntimeError("не удалось запустить Chrome")
page = page_target(prefer)
cdp = CDP(page["webSocketDebuggerUrl"])
cdp.send("Page.enable")
cdp.send("Runtime.enable")
return cdp, page
CF_MARKERS = ("just a moment", "один момент", "проверки безопасности",
"checking your browser", "выполнение проверки безопасности")
# тексты страниц удалённой/несуществующей раздачи (kinozal, rutracker, nnm)
DEAD_MARKERS = ("нет раздачи с таким id", "раздача не найдена", "тема не найдена",
"такой темы не существует", "тема удалена", "topic does not exist")
# страница списка без тем (за последней страницей раздела): это конец списка, не ошибка
EMPTY_LIST_MARKERS = ("подходящих тем или сообщений не найдено", "тем не найдено")
# страницы ошибок сервера / Cloudflare (503, 502, 504, 52x, 1015, 1020): лечатся повтором
RE_ERROR_TITLE = re.compile(
r"(?i)^\s*(?:error\s*|ошибка\s*)?(?:50[0-4]|52\d)\b|service (?:temporarily )?unavailable|bad gateway"
r"|gateway time-?out|attention required|temporarily unavailable|too many requests")
RE_ERROR_HEAD = re.compile(
r"(?i)(?:error|ошибка)\s*(?:code)?\s*:?\s*(?:50[0-4]|52\d|1015|1020)\b|service (?:temporarily )?unavailable"
r"|bad gateway|gateway time-?out|web server is (?:down|returning an unknown error)"
r"|has banned you temporarily|you are being rate limited")
RETRY_DELAYS = (3.0, 8.0, 20.0) # пауза перед 1-м, 2-м и 3-м повтором
RETRY_HOOK = None # callable(url, attempt, retries, reason) — для интерфейса
def _goto_once(cdp: CDP, url: str, min_len: int = 600, timeout: float = 45.0) -> dict:
"""Один заход: ждём контент, ловим пустой список, удалённую раздачу и страницу ошибки сервера."""
cdp.send("Page.navigate", {"url": url})
t0 = time.time()
deadline = t0 + timeout
last = {"title": "", "len": 0}
still_since = None # с какого момента длина текста не меняется
while time.time() < deadline:
time.sleep(0.4)
try:
state = json.loads(cdp.eval(
"JSON.stringify({t:document.title, r:document.readyState,"
" len:document.body?document.body.innerText.length:0})"
))
except RuntimeError:
continue
prev_len = last["len"]
last = {"title": state.get("t", ""), "len": state.get("len", 0)}
if state.get("r") == "complete" and last["len"] == prev_len:
still_since = still_since or time.time()
else:
still_since = None
# короткая, но загруженная и неменяющаяся страница (закрытая тема, «перенесено»):
# не ждём таймаут, решает вызывающий (has_body); страницу проверки Cloudflare не берём
if (still_since and time.time() - still_since > 4 and last["len"] < min_len
and not any(m in (last["title"] or "").lower() for m in CF_MARKERS)):
return {"ok": True, "short": True, **last, "url": url}
if state.get("r") == "complete" and RE_ERROR_TITLE.search(last["title"] or "") and last["len"] < 3000:
return {"ok": False, "server_error": True, "reason": (last["title"] or "ошибка")[:40],
"len": last["len"], "title": last["title"], "url": url}
if state.get("r") == "complete" and last["len"] < min_len:
# короткая страница: удалённая раздача — не ждём таймаут
head = (cdp.eval("document.body ? document.body.innerText.slice(0, 800).toLowerCase() : ''")
or "")
if RE_ERROR_HEAD.search(head):
m = RE_ERROR_HEAD.search(head)
return {"ok": False, "server_error": True, "reason": m.group(0)[:40].strip(),
"len": last["len"], "title": last["title"], "url": url}
if any(m in head for m in EMPTY_LIST_MARKERS):
return {"ok": True, "empty": True, "title": last["title"],
"len": last["len"], "url": url}
if any(m in head for m in DEAD_MARKERS):
return {"ok": False, "dead": True, "title": "нет раздачи: " + last["title"],
"len": last["len"], "url": url}
# тяжёлые темы (десятки сообщений, картинки, реклама) годами остаются в «interactive»:
# текст уже есть, ждать «complete» незачем
ready = state.get("r") == "complete" or (
state.get("r") == "interactive" and time.time() - t0 > 5)
if not ready or last["len"] < min_len:
continue
title = (last["title"] or "").lower()
if any(m in title for m in CF_MARKERS):
continue
return {"ok": True, **last, "url": url}
return {"ok": False, "timeout": True, "reason": "таймаут", **last, "url": url}
def goto(cdp: CDP, url: str, min_len: int = 600, timeout: float = 45.0, retries: int = 3) -> dict:
"""Перейти по адресу и дождаться контента; при ошибке сервера/Cloudflare повторить.
Страница ошибки (503, 502, 504, 52x, 1015…) обновляется до `retries` раз с нарастающей
паузой (3, 8, 20 с) — на практике обновление её лечит. Таймаут повторяется один раз.
Удалённая раздача и пустой список повтора не требуют.
"""
res = _goto_once(cdp, url, min_len, timeout)
for attempt in range(1, retries + 1):
if res.get("ok") or res.get("dead") or not (res.get("server_error") or res.get("timeout")):
return res
if res.get("timeout") and attempt > 1:
break
if RETRY_HOOK:
RETRY_HOOK(url, attempt, retries, res.get("reason", ""))
time.sleep(RETRY_DELAYS[min(attempt - 1, len(RETRY_DELAYS) - 1)])
res = _goto_once(cdp, url, min_len, timeout)
return res
# =====================================================================
# Сбор: kinozal
# =====================================================================
KZ_BASE = "https://kinozal.me"
def kz_page_url(base: str, page: int) -> str:
"""kinozal нумерует страницы с нуля: browse.php?...&page=N"""
clean = re.sub(r"([?&])page=\d+", "", base).rstrip("?&")
sep = "&" if "?" in clean else "?"
return f"{clean}{sep}page={page}"
def kz_browse_ids(cdp: CDP, url: str) -> tuple[bool, list[str]]:
"""(открылась ли страница, id раздач). Пустая страница != ошибка."""
nav = goto(cdp, url, min_len=800)
if not nav["ok"]:
log(f" kinozal: страница списка не открылась ({nav['title']})")
return False, []
# только ссылки на раздачи: «…/userdetails.php?id=» (профили пользователей) содержит
# подстроку «details.php?id=», но раздачей не является
ids = cdp.eval(
"JSON.stringify([...new Set([...document.querySelectorAll('a[href*=\"details.php?id=\"]')]"
".map(a => (a.getAttribute('href')||'').match(/(?:^|\\/)details\\.php\\?id=(\\d+)/))"
".filter(Boolean).map(m => m[1]))])"
)
return True, json.loads(ids or "[]")
KZ_ITEMS_JS = r"""JSON.stringify((() => {
const out = {}, order = [];
for (const a of document.querySelectorAll('a[href*="details.php?id="]')) {
const m = (a.getAttribute('href') || '').match(/(?:^|\/)details\.php\?id=(\d+)/);
if (!m) continue;
const t = (a.innerText || '').replace(/\s+/g, ' ').trim();
if (!(m[1] in out)) { out[m[1]] = ''; order.push(m[1]); }
if (t.length > out[m[1]].length) out[m[1]] = t;
}
return order.map(id => ({ id, text: out[id].slice(0, 140) }));
})())"""
def kz_browse_items(cdp: CDP, url: str) -> tuple[bool, list[dict]]:
"""Как kz_browse_ids, но с заголовками тем: [{id, text}]."""
nav = goto(cdp, url, min_len=800)
if not nav["ok"]:
log(f" kinozal: страница списка не открылась ({nav['title']})")
return False, []
return True, json.loads(cdp.eval(KZ_ITEMS_JS) or "[]")
DEPTH_JS = r"""(() => {
let last = 0;
for (const a of document.querySelectorAll('a[href]')) {
const h = a.getAttribute('href') || '';
let m = h.match(/viewforum\.php\?[^#]*[?&]?start=(\d+)/);
if (m) last = Math.max(last, Math.floor(+m[1] / 50));
if (h.startsWith('?') || /browse\.php/.test(h)) { // kinozal: ссылки вида «?c=21&page=99»
m = h.match(/[?&]page=(\d+)/);
if (m) last = Math.max(last, +m[1]);
}
}
return last + 1;
})()"""
def list_depth(cdp: CDP) -> int:
"""Сколько страниц в разделе — по ссылкам пагинации открытой страницы списка."""
try:
return max(1, int(cdp.eval(DEPTH_JS) or 1))
except (TypeError, ValueError):
return 1
KZ_TABS_JS = "document.getElementById('tabs') ? document.getElementById('tabs').innerText : ''"
def kz_read_tab(cdp: CDP, release_id: str, tab: int, timeout: float = 6.0) -> str:
"""Переключить вкладку и дождаться её содержимого (вместо фиксированной паузы 1.2 с).
Ждём, пока текст изменится и перестанет меняться; если вкладка уже была показана
(текст тот же), хватает 1.5 с.
"""
before = cdp.eval(KZ_TABS_JS) or ""
cdp.eval(f"try {{ showtab({release_id}, {tab}); }} catch (e) {{}} true")
start, prev = time.time(), None
while time.time() - start < timeout:
time.sleep(0.25)
text = cdp.eval(KZ_TABS_JS) or ""
if text and text != before and text == prev:
return text # сменился и стабилен
if text and text == before and time.time() - start >= 1.5:
return text # та же вкладка уже была открыта
prev = text
return cdp.eval(KZ_TABS_JS) or ""
def kz_release(cdp: CDP, release_id: str) -> dict:
url = f"{KZ_BASE}/details.php?id={release_id}"
nav = goto(cdp, url, min_len=800)
rec: dict = {"id": release_id, "url": url, "source": "kinozal",
"ok": nav["ok"], "page_title": nav["title"]}
if not nav["ok"]:
rec["error"] = f"не открылась: {nav['title']}"
return rec
rec["h1"] = cdp.eval("(document.querySelector('h1')||{}).innerText||''") or ""
rec["release_tab"] = kz_read_tab(cdp, release_id, 0)
rec["tech_tab"] = kz_read_tab(cdp, release_id, 100)
rec["page_text"] = cdp.eval("document.body.innerText") or ""
return rec
def harvest_kinozal(ids: list[str], browse: str | None, limit: int, delay: float,
pages: int = 1, from_page: int | None = None,
reset: bool = False) -> int:
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_file = RAW_DIR / "kinozal.jsonl"
seen = _seen_ids(raw_file)
state = load_state("kinozal")
bstate = state.setdefault("kinozal", {}).setdefault("browse", {})
if reset:
bstate.clear()
save_state("kinozal", state)
cdp, _ = connect("kinozal.me")
done = 0
try:
for rid in dict.fromkeys(ids):
if rid in seen or done >= limit:
continue
try:
rec = kz_release(cdp, rid)
except Exception as exc: # noqa: BLE001
rec = {"id": rid, "source": "kinozal", "ok": False, "error": str(exc)}
_store(raw_file, rec)
seen.add(rid)
done += 1
mark = "+" if rec.get("release_tab") else "-"
log(f" {mark} {rid} {(rec.get('page_title') or '')[:78]}")
time.sleep(delay + random.random() * 1.2)
if browse:
st = bstate.get(browse, {"next_page": 0, "releases": 0})
start = int(st.get("next_page", 0)) if from_page is None else from_page
log(f"kinozal: {browse} — продолжаю со страницы {start + 1} "
f"(собрано раздач: {st.get('releases', 0)})")
for k in range(pages):
if done >= limit:
break
page = start + k
ok, page_ids = kz_browse_ids(cdp, kz_page_url(browse, page))
if not ok:
log(f" стр.{page + 1}: не открылась — попробую в следующий раз")
break
if not page_ids:
bstate[browse] = {**st, "finished": True, "updated": stamp()}
save_state("kinozal", state)
log(f" стр.{page + 1}: пусто — список закончился")
break
fresh = [i for i in page_ids if i not in seen]
log(f" стр.{page + 1}: {len(page_ids)} раздач, новых {len(fresh)}")
completed = True
for rid in fresh:
if done >= limit:
completed = False
break
try:
rec = kz_release(cdp, rid)
except Exception as exc: # noqa: BLE001
rec = {"id": rid, "source": "kinozal", "ok": False,
"error": str(exc)}
_store(raw_file, rec)
seen.add(rid)
done += 1
mark = "+" if rec.get("release_tab") else "-"
log(f" {mark} {rid} {(rec.get('page_title') or '')[:70]}")
time.sleep(delay + random.random() * 1.2)
bstate[browse] = {
"next_page": page if not completed else page + 1,
"releases": st.get("releases", 0) + (len(fresh) if completed else 0),
"updated": stamp(),
}
save_state("kinozal", state)
st = bstate[browse]
if not completed:
log(f" лимит {limit} исчерпан, следующая страница {page + 1}")
break
log(f"kinozal: добавлено {done} записей -> {raw_file}")
return done
finally:
cdp.close()
# =====================================================================
# Сбор: rutracker
# =====================================================================
RT_BASE = "https://rutracker.org/forum/"
RT_SKIP = {"1045", "6661913", "6452965", "2234744", "2694168", "2968462", "2276119"}
# служебные темы разделов: правила, критерии, заявки — не раздачи
RT_SERVICE_RE = re.compile(
r"^\s*(==|правила|критерии|вниманию|внимание|обсуждени|заявки|помощь|faq|список|"
r"профильные|бездоказательн|создание новых|восстановление оформления|"
r"премьеры и продленки|что такое|запрет на рекламу|просьба к юзерам|"
r"осторожно|об использовании|акция|изменения|итоги|конкурс|опрос)",
re.I)
def is_service_topic(title: str) -> bool:
return bool(RT_SERVICE_RE.match(title or ""))
RT_FORUM_JS = """JSON.stringify((() => {
const SKIP = new Set(%s);
const out = [], seen = new Set();
const rows = document.querySelectorAll('table.forumline tr').length;
for (const tr of document.querySelectorAll('tr[id^="tr-"]')) {
let best = null, bestLen = 0;
for (const a of tr.querySelectorAll('a[href*="viewtopic.php?t="]')) {
const t = (a.innerText || '').replace(/\\s+/g, ' ').trim();
if (t.length > bestLen) { bestLen = t.length; best = a; }
}
if (!best) continue;
const m = (best.getAttribute('href') || '').match(/viewtopic\\.php\\?t=(\\d+)/);
if (!m || SKIP.has(m[1]) || seen.has(m[1])) continue;
const text = (best.innerText || '').replace(/\\s+/g, ' ').trim();
if (text.length <= 25) continue;
seen.add(m[1]);
out.push({ id: m[1], text: text.slice(0, 140) });
}
return { rows, items: out };
})())"""
RT_TOPIC_JS = """JSON.stringify((() => {
const pick = (sels) => { for (const s of sels) { const el = document.querySelector(s);
if (el && el.innerText.trim().length > 30) return el; } return null; };
const post = pick(['.post_body', 'div[id^="post-text-"]', '.postbody']);
const title = pick(['h1.maintitle', '#topic-title', 'a.maintitle', 'h1']);
return { title: title ? title.innerText.trim() : document.title,
post: post ? post.innerText : '',
hasTorrent: !!document.querySelector('a[href*="dl.php?t="]') };
})())"""
def rt_forum_page(cdp: CDP, forum: str, page: int) -> tuple[bool, list[dict]]:
"""(открылась ли страница, темы). Пустая страница != ошибка загрузки."""
url = f"{RT_BASE}viewforum.php?f={forum}&start={page * 50}"
for attempt in (1, 2):
nav = goto(cdp, url, min_len=1200)
if nav.get("empty"):
return True, []
if not nav["ok"]:
log(f" f={forum} стр.{page + 1}: не открылась ({nav['title']})")
else:
data = json.loads(cdp.eval(RT_FORUM_JS % json.dumps(sorted(RT_SKIP))) or "{}")
if data.get("rows"):
items = data.get("items", [])
releases = [it for it in items if not is_service_topic(it["text"])]
skipped = len(items) - len(releases)
if skipped:
log(f" f={forum} стр.{page + 1}: отброшено служебных тем: {skipped}")
return True, releases
log(f" f={forum} стр.{page + 1}: список не отрисовался (0 строк)")
if attempt == 1:
time.sleep(3)
return False, []
def rt_topic(cdp: CDP, topic_id: str) -> dict:
url = f"{RT_BASE}viewtopic.php?t={topic_id}"
nav = goto(cdp, url, min_len=900)
rec: dict = {"id": topic_id, "url": url, "source": "rutracker",
"ok": nav["ok"], "page_title": nav["title"]}
if not nav["ok"]:
rec["error"] = f"не открылась: {nav['title']}"
return rec
data = json.loads(cdp.eval(RT_TOPIC_JS) or "{}")
rec.update(data)
return rec
def _seen_ids(raw_file: Path) -> set[str]:
if not raw_file.exists():
return set()
ids = set()
for line in raw_file.read_text("utf-8").splitlines():
if line.strip():
try:
ids.add(str(json.loads(line)["id"]))
except (json.JSONDecodeError, KeyError):
pass
return ids
def _store(raw_file: Path, rec: dict) -> None:
with raw_file.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
def harvest_rutracker(forums: list[str], topics: list[str], pages: int,
limit: int, delay: float, from_page: int | None = None,
reset: bool = False) -> int:
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_file = RAW_DIR / "rutracker.jsonl"
seen = _seen_ids(raw_file)
state = load_state("rutracker")
fstate = state.setdefault("rutracker", {}).setdefault("forums", {})
if reset:
fstate.clear()
save_state("rutracker", state)
cdp, _ = connect("rutracker.org")
done = 0
try:
# явно заданные темы — вне очереди
for tid in dict.fromkeys(topics):
if tid in seen or done >= limit:
continue
try:
rec = rt_topic(cdp, tid)
except Exception as exc: # noqa: BLE001
rec = {"id": tid, "source": "rutracker", "ok": False, "error": str(exc)}
_store(raw_file, rec)
seen.add(tid)
done += 1
mark = "+" if re.search(r"Перевод|Озвучк", rec.get("post") or "", re.I) else "-"
log(f" {mark} {tid} {(rec.get('title') or '')[:78]}")
time.sleep(delay + random.random() * 0.8)
for forum in forums:
st = fstate.get(forum, {"next_page": 0, "topics": 0})
start = 0 if from_page is None else from_page
if from_page is None:
start = int(st.get("next_page", 0))
log(f"раздел {forum}: продолжаю со страницы {start + 1} "
f"(уже собрано тем: {st.get('topics', 0)})")
for k in range(pages):
if done >= limit:
break
page = start + k
ok, items = rt_forum_page(cdp, forum, page)
if not ok:
log(f" стр.{page + 1}: не открылась — попробую в следующий раз")
break
if not items:
fstate[forum] = {**st, "finished": True, "updated": stamp()}
save_state("rutracker", state)
log(f" стр.{page + 1}: тем нет — раздел пройден до конца")
break
fresh = [it for it in items if it["id"] not in seen]
log(f" стр.{page + 1}: {len(items)} тем, новых {len(fresh)}")
completed = True
for it in fresh:
if done >= limit:
completed = False
break
try:
rec = rt_topic(cdp, it["id"])
except Exception as exc: # noqa: BLE001
rec = {"id": it["id"], "source": "rutracker", "ok": False,
"error": str(exc)}
_store(raw_file, rec)
seen.add(it["id"])
done += 1
mark = "+" if re.search(r"Перевод|Озвучк", rec.get("post") or "", re.I) else "-"
log(f" {mark} {it['id']} {(rec.get('title') or '')[:70]}")
time.sleep(delay + random.random() * 0.8)
fstate[forum] = {
"next_page": page if not completed else page + 1,
"topics": st.get("topics", 0) + (len(fresh) if completed else 0),
"updated": stamp(),
}
save_state("rutracker", state) # прогресс после каждой страницы
st = fstate[forum]
if not completed:
log(f" лимит {limit} исчерпан, следующая страница {page + 1} — докачается потом")
break
log(f"rutracker: добавлено {done} тем -> {raw_file}")
return done
finally:
cdp.close()
# =====================================================================
# Сбор: nnmclub (nnm-club)
# =====================================================================
NNM_BASE = "https://nnmclub.to/forum/"
NNM_FORUM_JS = """JSON.stringify((() => {
const out = [], seen = new Set();
const rows = document.querySelectorAll('table.forumline tr').length;
for (const tr of document.querySelectorAll('table.forumline tr')) {
const a = tr.querySelector('a[href*="viewtopic.php?t="]');
if (!a) continue;
const m = (a.getAttribute('href') || '').match(/t=(\\d+)/);
if (!m || seen.has(m[1])) continue;
const text = (a.innerText || '').replace(/\\s+/g, ' ').trim();
if (text.length < 12) continue;
seen.add(m[1]);
out.push({ id: m[1], text: text.slice(0, 160) });
}
return { rows, items: out };
})())"""
NNM_TOPIC_JS = """JSON.stringify((() => {
const pick = (sels) => { for (const s of sels) { const el = document.querySelector(s);
if (el && el.innerText.trim().length > 30) return el; } return null; };
// табличная вёрстка: innerText склеивает строки, поэтому <br> и блоки
// превращаем в переводы строк вручную
const toText = (el) => {
const c = el.cloneNode(true);
c.querySelectorAll('br').forEach(b => b.replaceWith(document.createTextNode('\\n')));
c.querySelectorAll('p,div,tr,li,h1,h2,h3').forEach(n => n.append(document.createTextNode('\\n')));
return c.innerText;
};
const post = pick(['.postbody', '.post_body']);
const title = pick(['h1', '.maintitle', '#topic-title']);
return { title: title ? title.innerText.trim() : document.title,
post: post ? toText(post) : '' };
})())"""
def nnm_forum_page(cdp: CDP, forum: str, page: int) -> tuple[bool, list[dict]]:
url = f"{NNM_BASE}viewforum.php?f={forum}&start={page * 50}"
for attempt in (1, 2):
nav = goto(cdp, url, min_len=1500)
if nav.get("empty"):
return True, []
if not nav["ok"]:
log(f" nnm f={forum} стр.{page + 1}: не открылась ({nav['title']})")
else:
data = json.loads(cdp.eval(NNM_FORUM_JS) or "{}")
if data.get("rows"):
items = data.get("items", [])
releases = [it for it in items if not is_service_topic(it["text"])]
skipped = len(items) - len(releases)
if skipped:
log(f" nnm f={forum} стр.{page + 1}: отброшено служебных тем: {skipped}")
return True, releases
log(f" nnm f={forum} стр.{page + 1}: список не отрисовался (0 строк)")
if attempt == 1:
time.sleep(3)
return False, []
def nnm_topic(cdp: CDP, topic_id: str) -> dict:
url = f"{NNM_BASE}viewtopic.php?t={topic_id}"
nav = goto(cdp, url, min_len=1200)
rec: dict = {"id": topic_id, "url": url, "source": "nnm",
"ok": nav["ok"], "page_title": nav["title"]}
if not nav["ok"]:
rec["error"] = f"не открылась: {nav['title']}"
return rec
rec.update(json.loads(cdp.eval(NNM_TOPIC_JS) or "{}"))
return rec
def harvest_nnm(forums: list[str], topics: list[str], pages: int, limit: int,
delay: float, from_page: int | None = None,
reset: bool = False) -> int:
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_file = RAW_DIR / "nnm.jsonl"
seen = _seen_ids(raw_file)
state = load_state("nnm")
fstate = state.setdefault("nnm", {}).setdefault("forums", {})
if reset:
fstate.clear()
save_state("nnm", state)
cdp, _ = connect("nnmclub.to")
done = 0
try:
for tid in dict.fromkeys(topics):
if tid in seen or done >= limit:
continue
try:
rec = nnm_topic(cdp, tid)
except Exception as exc: # noqa: BLE001
rec = {"id": tid, "source": "nnm", "ok": False, "error": str(exc)}
_store(raw_file, rec)
seen.add(tid)
done += 1
mark = "+" if re.search(r"Перевод|Аудио\s*\d", rec.get("post") or "", re.I) else "-"
log(f" {mark} {tid} {(rec.get('title') or '')[:78]}")
time.sleep(delay + random.random() * 0.8)
for forum in forums:
st = fstate.get(forum, {"next_page": 0, "topics": 0})
start = int(st.get("next_page", 0)) if from_page is None else from_page
log(f"раздел nnm {forum}: продолжаю со страницы {start + 1} "
f"(уже собрано тем: {st.get('topics', 0)})")
for k in range(pages):
if done >= limit:
break
page = start + k
ok, items = nnm_forum_page(cdp, forum, page)
if not ok:
log(f" стр.{page + 1}: не открылась — попробую в следующий раз")
break
if not items:
fstate[forum] = {**st, "finished": True, "updated": stamp()}
save_state("nnm", state)
log(f" стр.{page + 1}: тем нет — раздел пройден до конца")
break
fresh = [it for it in items if it["id"] not in seen]
log(f" стр.{page + 1}: {len(items)} тем, новых {len(fresh)}")
completed = True
for it in fresh:
if done >= limit:
completed = False
break
try:
rec = nnm_topic(cdp, it["id"])
except Exception as exc: # noqa: BLE001
rec = {"id": it["id"], "source": "nnm", "ok": False,
"error": str(exc)}
_store(raw_file, rec)
seen.add(it["id"])
done += 1
mark = "+" if re.search(r"Перевод|Аудио\s*\d", rec.get("post") or "", re.I) else "-"
log(f" {mark} {it['id']} {(rec.get('title') or '')[:70]}")
time.sleep(delay + random.random() * 0.8)
fstate[forum] = {
"next_page": page if not completed else page + 1,
"topics": st.get("topics", 0) + (len(fresh) if completed else 0),
"updated": stamp(),
}
save_state("nnm", state)
st = fstate[forum]
if not completed:
log(f" лимит {limit} исчерпан, следующая страница {page + 1}")
break
log(f"nnm: добавлено {done} тем -> {raw_file}")
return done
finally:
cdp.close()
# =====================================================================
# Разбор: общие нормализаторы
# =====================================================================
CYR_TO_LAT = str.maketrans({"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H",
"К": "K", "М": "M", "О": "O", "Р": "P", "Т": "T", "Х": "X"})
STUDIO_ALIASES = {
"hdrezka": "HDrezka", "hd rezka": "HDrezka", "hdrezka studio": "HDrezka",
"red head sound": "Red Head Sound", "read head sound": "Red Head Sound",
"redhead sound": "Red Head Sound",
"tvshows": "TVShows", "tv shows": "TVShows",
"lostfilm": "LostFilm", "lost film": "LostFilm",
"кубик в кубе": "Кубик в Кубе", "кубик": "Кубик в Кубе",
"jaskier": "Jaskier", "newcomers": "NewComers", "new comers": "NewComers",
"movie dubbing": "Movie Dubbing",
"videofilm int": "Videofilm Int", "videofilm international": "Videofilm Int",
"мосфильм-мастер": "Мосфильм-Мастер", "мосфильм мастер": "Мосфильм-Мастер",
"moviedalen": "MovieDalen", "movie dalen": "MovieDalen",
"paramount comedy": "Paramount Comedy",
"winmedia": "WinMedia", "viruse project": "ViruseProject",
"viruseproject": "ViruseProject", "ultradoxstudio": "Ultradox",
"ultradox studio": "Ultradox",
"а. гаврилов": "Андрей Гаврилов", "гаврилов": "Андрей Гаврилов",
"ю. сербин": "Юрий Сербин", "сербин": "Юрий Сербин",
"дмитрий есарев": "Дмитрий Есарев", "есарев": "Дмитрий Есарев",
"а. киреев": "Андрей Киреев", "киреев": "Андрей Киреев",
"володарский": "Леонид Володарский", "леонид володарский": "Леонид Володарский",
"живов": "Юрий Живов", "ю. живов": "Юрий Живов",
"яроцкий": "Михаил Яроцкий", "м. яроцкий": "Михаил Яроцкий",
"нтв +": "НТВ+",
}
STUDIO_PLACEHOLDERS = {"official", "официальный", "official dub", "офиц.",
"не требуется", "ru", "rus", "нет", "ориг.", "оригинал"}
SOURCE_LIKE = {"itunes", "blu-ray", "blu ray", "bluray", "netflix", "amazon",
"dvd", "hdtv", "web-dl", "webdl", "webrip", "bd", "bdrip",
"uhd", "original", "оригинал", "bd cee", "dvd r5", "r5"}
STUDIO_NOISE = re.compile(
r"\b(закадров\w*|многоголос\w*|двухголос\w*|одноголос\w*|"
r"профессиональн\w*|любительск\w*|авторск\w*|дублированн\w*|"
r"неофициальн\w*|официальн\w*|полный|перевод|роли озвучивали)\b", re.I)
# «дубляж» как тип, а не часть имени студии («Дубляжная», «Продубляж», «Дубляж-Студия»)
RE_DUB_TYPE = r"дублир|(?<![а-яё])дубляж(?![а-яё])"
ROLE_PATTERNS = [(RE_DUB_TYPE, "дубляж"), (r"многоголос", "многоголосый"),
(r"двухголос", "двухголосый"), (r"одноголос", "одноголосый"),
(r"субтитр", "субтитры"), (r"оригинал", "оригинал")]
# 18+ у kinozal/rutracker/nnm стоит в строке дорожки; отдельная строка
# «Возраст: 18+» — это рейтинг фильма, её флагом дорожки считать нельзя
RE_18 = re.compile(r"\b18\s*\+")
# «АVO, Ю. Сербин»: аббревиатура перевода перед именем студии
LEAD_TAG_TOKEN = re.compile(r"^[A-Za-zА-Яа-я]{2,4}\b[\s:.,|\-–—]*")
TAG_NAMES = {"AVO", "DVO", "MVO", "DUB", "VO", "ЛО", "ЛД", "ЛМ", "ДБ", "ПМ", "ПД", "АП"}
def _is_lead_tag(text: str) -> bool:
m = re.match(r"^([A-Za-zА-Яа-я]{2,4})\b", text or "")
return bool(m and m.group(1).translate(CYR_TO_LAT).upper() in TAG_NAMES)
def has_18(text: str) -> bool:
return bool(RE_18.search(text or ""))
def mark_18(note: str, age: bool) -> str:
if age and "18+" not in (note or ""):
return (note + " 18+").strip()
return (note or "").strip()
def clean_studio(raw: str) -> tuple[str, str]:
note = ""
s = (raw or "").strip().strip("|").strip()
s = re.sub(r"\[[^\]]*\]", " ", s)
s = re.sub(r"\([^)]*\)", " ", s)
if re.search(r"\b18\s*\+", s):
note = "18+"
s = re.sub(r"\b18\s*\+", " ", s)
s = STUDIO_NOISE.sub(" ", s)
s = re.sub(r"[«»\"']", "", s)
s = re.sub(r"\s*\([^)]*$", "", s) # незакрытая скобка в названии студии
s = re.sub(r"\s*\|\s*(RU|RUS|Rus|UKR|ENG)\b.*$", " ", s, flags=re.I)
s = s.replace("|", " ")
s = re.sub(r"\s+", " ", s).strip(" |.-–—:,")
s = LEAD_TAG_TOKEN.sub("", s) if _is_lead_tag(s) else s
s = s.strip("()[]{},;: ").strip() # остатки скобок от «(MVO, X) (18+)»
s = re.sub(r"\s+Studio$", "", s, flags=re.I)
if not s.strip(" ,.-–—|"):
s = ""
return s, note
def canonical_studio(s: str) -> tuple[str, str]:
key = re.sub(r"\s+", " ", (s or "").strip().lower())
if key in STUDIO_PLACEHOLDERS:
return "", "официальный дубляж"
return STUDIO_ALIASES.get(key, (s or "").strip()), ""
def translation_type(text: str) -> tuple[str, str]:
t = (text or "").lower()
kind = "неизвестно"
for pat, name in ROLE_PATTERNS:
if re.search(pat, t):
kind = name
break
if kind == "дубляж":
if re.search(r"любительск|псевдо", t): # любительский дубляж — отдельный класс, не «проф.»
return "люб. дубляж", "люб."
return "проф. дубляж", "проф."
if "профессиональн" in t:
pref = "проф."
elif "любительск" in t:
pref = "люб."
elif "авторск" in t:
pref = "авт."
else:
pref = ""
if "авторск" in t and kind == "одноголосый":
pref = "авт."
return f"{pref} {kind}".strip(), pref
# ---- kinozal -------------------------------------------------------
KZ_RE_TRANSLATION = re.compile(
r"^\s*(?:Перевод|Озвучиван\w*|Озвучк\w*)\s*(?:#\s*\d+|\d+)?\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)
KZ_RE_SUBS = re.compile(r"^\s*[СC]убтитры\s*:\s*(?P<body>.+)$", re.I)
KZ_RE_ACTORS = re.compile(r"Роли озвучивали\s*:\s*(?P<names>[^\]]+)", re.I)
RE_CODEC = re.compile(r"\b(E-?AC-?3|AC-?3|DTS-?HD|DTS|AAC|FLAC|MP3|OPUS|PCM|TrueHD)\b", re.I)
RE_LANG = re.compile(r"\b(Russian|Русский|English|Английский|Ukrainian|Украинский)\b", re.I)
def kz_split_title(title: str) -> dict:
parts = [p.strip() for p in (title or "").split(" / ")]
year, shorthand = "", ""
for i, p in enumerate(parts):
if re.fullmatch(r"(19|20)\d{2}(\s*-\s*(19|20)\d{2})?", p):
year = p
shorthand = parts[i + 1] if i + 1 < len(parts) else ""
break
return {"ru_title": parts[0] if parts else "", "year": year, "shorthand": shorthand}
def kz_audio(lang: str, body: str) -> dict:
tag, note = "", ""
m = re.search(r"\|([^|]*)\|", body)
if m:
tag, n1 = clean_studio(m.group(1))
tag, n2 = canonical_studio(tag)
note = " ".join(x for x in (n1, n2) if x)
body = body[:m.start()] + " " + body[m.end():]
flat = body.translate(CYR_TO_LAT)
codec = ""
cm = RE_CODEC.search(flat)
if cm:
codec = cm.group(1).upper().replace(" ", "")
codec = {"EAC3": "E-AC3", "AC3": "AC3"}.get(codec.replace("-", ""), codec)
ch = ""
cm2 = re.search(r"(\d+)\s*ch\b", flat, re.I)
if cm2:
ch = cm2.group(1)
khz = ""
km = re.search(r"(\d+(?:\.\d+)?)\s*(?:kHz|кГц)", body, re.I)
if km:
khz = str(int(float(km.group(1))))
kbps = ""
bm = re.search(r"(\d+)\s*(?:kbps|кбит\s*/?\s*с?)", body, re.I)
if bm:
kbps = bm.group(1)
return {"lang": lang.upper(), "codec": codec, "channels": ch, "khz": khz,
"kbps": kbps, "tag": tag, "note": note,
"age_18": int(has_18(body) or "18+" in note)}
def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
release_text = (rec.get("release_tab") or "").strip()
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())
if has_transl(release_text):
source, studio_source = release_text, "release"
else:
source, studio_source = tech_text, "tech"
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()
actors = ""
am = KZ_RE_ACTORS.search(body)
if am:
actors = am.group("names").strip(" .")
role, candidates = "", []
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})
continue
# аудиодорожки и субтитры могут быть в любой из вкладок
for line in (source + "\n" + tech_text).splitlines():
line = line.strip()
m = KZ_RE_AUDIO_X.match(line)
if m:
audios.append(kz_audio(m.group("lang"), m.group("body")))
continue
m = KZ_RE_AUDIO.match(line)
if m and not audios:
body = m.group("body")
lang = "RUS"
lm = RE_LANG.search(body)
if lm:
lang = {"russian": "RUS", "русский": "RUS", "english": "ENG",
"английский": "ENG", "ukrainian": "UKR",
"украинский": "UKR"}[lm.group(1).lower()]
body = body[:lm.start()] + body[lm.end():]
audios.append(kz_audio(lang, body.strip(" :,()")))
continue
m = KZ_RE_SUBS.match(line)
if m and not subs:
subs = m.group("body").strip()
info = kz_split_title((rec.get("page_title") or "").split(" :: ")[0])
is_series = bool(re.search(r"сезон|серии", rec.get("page_title") or "", re.I))
ru_audio = [a for a in audios if a["lang"].startswith("RU")]
rows, used = [], set()
for t in translations:
if t["type"] in ("оригинал", "субтитры"):
continue
match = None
for j, a in enumerate(audios): # по имени студии — среди всех языков (украинский дубляж и т.п.)
if j in used:
continue
tag, st = a["tag"].lower(), t["studio"].lower()
if tag and st and (tag == st or tag.startswith(st) or st.startswith(tag)):
match = (j, a)
break
if match is None:
for j, a in enumerate(audios): # без имени — только безымянная русская дорожка
if j not in used and not a["tag"] and a["lang"].startswith("RU"):
match = (j, a)
break
if match:
used.add(match[0])
a = match[1] if match else {}
age_18 = bool(a.get("age_18") or has_18(t["line"]))
note = " ".join(x for x in (t["note"], a.get("note", "")) if x).strip()
rows.append({
"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
"year": info["year"], "quality": (tech_text.split("\n")[0] or "").replace("Качество:", "").strip(),
"is_series": is_series, "studio": t["studio"] or "(не указана)",
"translation_type": t["type"], "professional": t["pro"],
"role_raw": t["role"], "actors": t["actors"],
"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"],
"lang": (a.get("lang") or "RUS") if match else "",
})
rel = {"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
"year": info["year"], "quality": rows[0]["quality"] if rows else "",
"is_series": is_series, "n_translations": len(rows),
"ok": bool(rows),
"studios": " | ".join(sorted({r["studio"] for r in rows}))}
return rows, rel
# ---- rutracker -----------------------------------------------------
RT_RE_FIELD = re.compile(
r"^\s*(?:Перевод|Озвучка|Озвучивание|Озвучено|Звук)\s*(?:#\s*\d+|\d+)?\s*:\s*(?P<body>.+)$", re.I)
RT_RE_SUBS = re.compile(r"^\s*Субтитры\s*:\s*(?P<body>.+)$", re.I)
RT_RE_BRACKET = re.compile(r"\[(?P<body>[^\]]*)\]")
RT_RE_SEP = re.compile(r"\s*(?:\+|;|/)\s*(?=Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)", re.I)
def rt_split_entries(body: str) -> list[str]:
text = body.strip()
numbered = re.split(r"\s(?=\d\.\s)", text)
parts = [re.sub(r"^\d\.\s*", "", p) for p in numbered] if len(numbered) > 1 else RT_RE_SEP.split(text)
return [p.strip(" .") for p in parts if p and p.strip(" .")]
def rt_parse_entry(text: str) -> dict:
role, rest = "", text
pm = re.search(r"\(([^)]*)\)", text)
if pm:
role = pm.group(1)
rest = text[pm.end():].strip(" |.-–—:") or text[:pm.start()].strip(" |.-–—:")
studio, note = clean_studio(rest)
studio, extra = canonical_studio(studio)
note = " ".join(x for x in (note, extra) if x).strip()
age_18 = has_18(text)
note = mark_18(note, age_18)
if studio.lower() in SOURCE_LIKE:
note = (note + f" источник: {studio}").strip()
studio = ""
kind, pro = translation_type(text)
return {"role": role, "studio": studio, "type": kind, "pro": pro,
"note": note, "age_18": int(age_18), "raw": text}
def rt_parse_title(title: str) -> dict:
ru = (title or "").split(" / ")[0].strip()
year, quality = "", ""
m = RT_RE_BRACKET.search(title or "")
if m:
inside = [p.strip() for p in m.group("body").split(",")]
for p in inside:
if re.fullmatch(r"(19|20)\d{2}", p):
year = p
quality = inside[-1] if inside else ""
is_series = bool(re.search(r"Сезон\s*:|Серии\s*:\s*\d", title or "", re.I))
return {"ru_title": ru, "year": year, "quality": quality, "is_series": is_series}
def audio18_rows(rows: list[dict], post: str) -> list[dict]:
"""Строки «Аудио … |Студия| 18+» дополняют строки «Перевод».
У студии может быть две аудиодорожки (обычная и 18+) при одной строке
«Перевод»: тогда добавляется отдельная строка 18+. Кодек, каналы, частота
и битрейт берутся из соответствующей строки «Аудио».
"""
studios = sorted({r["studio"] for r in rows if r["studio"]})
lines = audio_lines(post, studios)
extra = []
for studio in studios:
mine = [r for r in rows if r["studio"] == studio]
auds = [a for a in lines if a["studio"] == studio]
plain = [a for a in auds if not a["adult"]]
adult = [a for a in auds if a["adult"]]
for r, a in zip(mine, plain or adult):
for k in ("codec", "channels", "khz", "kbps"):
r[k] = r[k] or a[k]
if not adult or any(int(r["age_18"] or 0) for r in mine):
continue
if plain:
row = dict(mine[0])
for k in ("codec", "channels", "khz", "kbps"):
row[k] = adult[0][k]
row["age_18"], row["note"] = 1, mark_18(row["note"], True)
row["raw"] = f"{row['raw']} 18+"
extra.append(row)
else:
for r in mine:
r["age_18"], r["note"] = 1, mark_18(r["note"], True)
return rows + extra
def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
post = rec.get("post") or ""
info = rt_parse_title(rec.get("page_title") or rec.get("title") or "")
entries, subs = [], ""
for line in post.splitlines():
m = RT_RE_FIELD.match(line)
if m:
for part in rt_split_entries(m.group("body")):
e = rt_parse_entry(part)
if e["type"] in ("оригинал", "субтитры"):
continue
if not e["studio"] and e["type"] == "неизвестно":
continue
entries.append(e)
continue
m = RT_RE_SUBS.match(line)
if m and not subs:
subs = m.group("body").strip()
rows = [{
"source": "rutracker", "release_id": rec["id"], "ru_title": info["ru_title"],
"year": info["year"], "quality": info["quality"], "is_series": info["is_series"],
"studio": e["studio"] or "(не указана)", "translation_type": e["type"],
"professional": e["pro"], "role_raw": e["role"], "actors": "",
"note": e["note"], "age_18": e.get("age_18", 0),
"codec": "", "channels": "", "khz": "", "kbps": "",
"subtitles": subs, "studio_source": "release", "raw": e["raw"],
} for e in entries]
rows = audio18_rows(rows, post)
rel = {"source": "rutracker", "release_id": rec["id"], "ru_title": info["ru_title"],
"year": info["year"], "quality": info["quality"], "is_series": info["is_series"],
"n_translations": len(rows), "ok": bool(rows),
"studios": " | ".join(sorted({r["studio"] for r in rows}))}
return rows, rel
# ---- 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_LANG_RE = re.compile(r"(Русский|Английский|Украинский)", re.I)
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)
NNM_RE_KBP = re.compile(r"(\d+)\s*(?:Kbps|Кбит)", re.I)
NNM_RE_CH = re.compile(r"(\d+)\s*ch\b", re.I)
NNM_RE_LAYOUT = re.compile(r"\b(\d)\.(\d)\b")
NNM_RE_YEAR = re.compile(r"\((\d{4})")
NNM_RE_QUAL = re.compile(r"\b(BDRemux|BDRip|WEB-?DL|WEBRip|HDRip|DVDRip|SATRip|UHD)\b|(\d{3,4}p)", re.I)
# порядок важен: AVO/DVO/MVO содержат «VO» внутри
NNM_TAG_TYPES = [
("DUB", "проф. дубляж", "проф."),
("ДБ", "проф. дубляж", "проф."),
("MVO", "проф. многоголосый", "проф."),
("ПМ", "проф. многоголосый", "проф."),
("DVO", "проф. двухголосый", "проф."),
("ПД", "проф. двухголосый", "проф."),
("AVO", "авт. одноголосый", "авт."),
("АП", "авт. одноголосый", "авт."),
("ЛМ", "люб. многоголосый", "люб."),
("ЛД", "люб. двухголосый", "люб."),
("ЛО", "люб. одноголосый", "люб."),
("VO", "одноголосый", ""),
]
def nnm_tag_type(tag: str) -> tuple[str, str]:
up = (tag or "").upper().replace(".", "")
for needle, label, pro in NNM_TAG_TYPES:
if needle in up:
return label, pro
return "", ""
NNM_LEAD_TAG = re.compile(r"^(AVO|DVO|MVO|DUB|VO|ЛО|ЛД|ЛМ|ДБ|ПМ|ПД|АП)\b[\s:.,|\-–—]*", re.I)
NNM_TAG_NAMES = {needle for needle, _, _ in NNM_TAG_TYPES}
NNM_LEAD_TOKEN = re.compile(r"^([A-Za-zА-Яа-я]{2,4})\b[\s:.,|\-–—]*")
def nnm_studio(name: str) -> tuple[str, str]:
"""Имя из скобок может начинаться с аббревиатуры: 'AVO Юрий Немахов'."""
cleaned, note = clean_studio(name)
# в скобках иногда не имя, а техпараметры: 'DTS / 5.1 / 48 kHz / 1536 kbps'
if re.search(r"kHz|кГц|kbps|Кбит|\b\d\.\d\b|\b\d+/\d+\b", cleaned, re.I):
return "", note
cleaned = NNM_LEAD_TAG.sub("", cleaned)
# 'АVO, Ю. Сербин' — кириллическая «А» в латинской аббревиатуре
m = NNM_LEAD_TOKEN.match(cleaned)
if m and m.group(1).translate(CYR_TO_LAT).upper() in NNM_TAG_NAMES:
cleaned = cleaned[m.end():]
cleaned = cleaned.strip("()[]{},;: ").strip() # остатки скобок от «(MVO, X) (18+)»
cleaned, extra = canonical_studio(cleaned)
return cleaned, " ".join(x for x in (note, extra) if x).strip()
# слова, которые в «Перевод: <тип> - <студия>» не студия: тип, кратность, язык
NNM_PART_TYPEISH = re.compile(
r"(?i)голос|дубл|закадр|авторск|любител|профессион|русск|оригинал|субтитр|лицензи"
r"|^\W*[xх]?\d+[xх]?\W*$")
def nnm_part_studio(part: str) -> str:
"""Студия из части строки «Перевод:» nnm: «Многоголосый профессиональный - Jaskier»,
«Дублированный (Невафильм)», «Авторский (Живов» -> имя; «Дублированный (Лицензия)»,
«Одноголосый x2», «Двуголосый профессиональный» -> ""."""
if len(part) > 90 or re.search(r"(?i)премьер|\bдата\b", part):
return "" # склеилось соседнее поле описания
m = re.search(r"\s[-–—:]\s*(.+)$", part)
cands = ([m.group(1)] if m else []) + [c for c in re.findall(r"\(([^()]*)\)?", part) if c]
for cand in cands:
studio, _ = clean_studio(cand)
studio, _ = canonical_studio(studio)
if len(studio) >= 2 and not NNM_PART_TYPEISH.search(studio) \
and studio.lower() not in SOURCE_LIKE:
return studio
return ""
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 ""
ru_title = title.split(" / ")[0].strip()
ym = NNM_RE_YEAR.search(title)
year = ym.group(1) if ym else ""
qm = NNM_RE_QUAL.search(title)
quality = (qm.group(1) or qm.group(2)) if qm else ""
is_series = bool(re.search(r"сезон|серии|\(S\d+|сериал", title, re.I))
rows: list[dict] = []
perevod_types: list[str] = []
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)
tag = tm.group(1).upper() if tm else ""
if not tag: # формат «... - дубляж» без аббревиатуры
low = rest.lower()
if "дубляж" in low:
tag = "DUB"
elif "многоголос" in low:
tag = "MVO"
elif "двухголос" in 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 = "неизвестно", ""
codec_m = NNM_RE_CODEC.search(body)
kb_m = NNM_RE_KBP.search(body)
ch_m = NNM_RE_CH.search(body)
channels = ch_m.group(1) if ch_m else ""
if not channels: # формат «6.0» / «5.1»
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)
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
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,
})
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) не нужны; иначе
# оставляем 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"] != "(не указана)"]
# дорожка без имени и без типа («- Русский») ничего не даёт датасету
rows = [r for r in rows
if not (r["studio"] == "(не указана)" and r["translation_type"] == "неизвестно")]
# одна и та же строка «Перевод:» может встретиться дважды
seen_keys, uniq = set(), []
for r in rows:
key = (r["studio"].lower(), r["translation_type"], r["kbps"], r["codec"])
if key in seen_keys:
continue
seen_keys.add(key)
uniq.append(r)
rows = uniq
rel = {"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
"year": year, "quality": quality, "is_series": is_series,
"n_translations": len(rows), "ok": bool(rows),
"studios": " | ".join(sorted({r["studio"] for r in rows}))}
return rows, rel
# =====================================================================
# Стадии: parse / merge / stats
# =====================================================================
RAW_SOURCES = [
("kinozal", RAW_DIR / "kinozal.jsonl", OUT / "harvest" / "raw.jsonl"),
("rutracker", RAW_DIR / "rutracker.jsonl", OUT / "rutracker" / "raw.jsonl"),
("nnm", RAW_DIR / "nnm.jsonl", OUT / "nnm" / "raw.jsonl"),
]
# ---- язык и строка «Аудио» для меток (общий разбор src/trackinfo.py) ---------
def enrich_rows(rows: list[dict], text: str) -> list[dict]:
"""К строкам разметки: lang и audio_raw из подходящей строки «Аудио» (с тем же 18+)."""
lines = audio_lines_all(text)
for r in rows:
info = pick_for_row(r.get("raw") or "", bool(int(r.get("age_18") or 0)), lines)
r["lang"], r["audio_raw"] = r.get("lang") or "", ""
if info:
r["audio_raw"] = info["raw"]
r["lang"] = info["lang"] or lang_of_text(r.get("raw") or "")
for key in ("codec", "channels", "kbps"):
r[key] = r.get(key) or info[key]
else:
r["lang"] = r["lang"] or lang_of_text(r.get("raw") or "")
return rows
def kz_audio_only_rows(rec: dict, text: str) -> list[dict]:
"""kinozal: «Аудио 1: AC3, 6 ch, 448 Кбит/с - Русский дубляж (Студия)» без строк «Перевод»."""
title = rec.get("page_title") or rec.get("h1") or ""
info = kz_split_title(title.split(" :: ")[0])
is_series = bool(re.search(r"сезон|серии", title, re.I))
rows = []
for a in audio_lines_all(text):
if not a["names"] or a["original"] or a["commentary"]:
continue
descr = re.split(r"(?i)(?:кбит/с|kbps)\s*[-–—]\s*", a["raw"], maxsplit=1)[-1]
kind, pro = translation_type(descr)
if kind == "неизвестно" or kind.endswith("неизвестно"):
continue
studio, note = clean_studio(a["names"][0])
studio, extra = canonical_studio(studio)
rows.append({
"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
"year": info["year"], "quality": "", "is_series": is_series,
"studio": studio or "(не указана)", "translation_type": kind, "professional": pro,
"role_raw": descr, "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), a["adult"]),
"age_18": int(a["adult"]), "codec": a["codec"], "channels": a["channels"], "khz": "",
"kbps": a["kbps"], "subtitles": "", "studio_source": "audio",
"raw": descr, "lang": a["lang"], "audio_raw": a["raw"]})
return rows
def split_multi_rows(rows: list[dict]) -> list[dict]:
"""Строка с несколькими дорожками («Проф. многоголосый, авторский (одноголосый)») -> по строке на дорожку.
Режет теми же правилами, что сервис при разборе страницы (src.html_extract._split_entries), чтобы
обучение и инференс видели одинаковые фрагменты. Одну дорожку («Многоголосый, профессиональный -
LostFilm») не трогает. nnm tech-строки уже по одной на дорожку (role_raw) — тоже не трогает.
"""
out: list[dict] = []
for r in rows:
if r.get("studio_source") == "tech" and (r.get("role_raw") or "").strip():
out.append(r)
continue
body = RE_LINE_PREFIX.sub("", (r.get("raw") or "").strip())
parts = [p for p in _split_entries(re.sub(r"\s+", " ", body)) if RE_TYPE_HINT.search(p)]
if len(parts) < 2:
out.append(r)
continue
base_studio = (r.get("studio") or "").strip()
for part in parts:
e = rt_parse_entry(part)
if e["type"] in ("оригинал", "субтитры"):
continue
new = dict(r)
keep = base_studio not in ("", "(не указана)") and base_studio.casefold() in part.casefold()
new.update(raw=part, translation_type=e["type"], professional=e["pro"],
role_raw=e["role"], studio=base_studio if keep else (e["studio"] or "(не указана)"),
age_18=int(has_18(part) or (keep and int(r.get("age_18") or 0))))
new["note"] = mark_18(e["note"] or "", bool(new["age_18"]))
out.append(new)
return out
def audio_only_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
"""Строки «Аудио … |MVO, Студия|» с языком, которых нет среди строк разметки (украинский дубляж и т.п).
Берутся только дорожки с блоком |…| (тип, студия) и определимым типом; оригинал и комментарии —
не обучающие строки. Имена из скобок («(L,C,R,l,r)» — раскладка каналов) сюда не попадают.
"""
def key(x: str) -> str:
return re.sub(r"[\W_]+", "", (x or "").casefold())
have = key(" ".join(((r.get("studio") or "") + " " + (r.get("raw") or "")) for r in rows))
out = []
for a in audio_lines_all(text):
if a["original"] or a["commentary"]:
continue
m = re.search(r"\|([^|]+)\|", a["raw"])
if not m:
continue
chunk = m.group(1)
tokens = [t.strip() for t in re.split(r"\s*[,;/]\s*", chunk) if t.strip()]
kind = pro = ""
for t in tokens:
if t.upper().replace(".", "") in NNM_TAG_NAMES: # короткий тег: DUB, MVO, ПМ …
kind, pro = nnm_tag_type(t)
break
k, pr = translation_type(t)
if not k.endswith("неизвестно"):
kind, pro = k, pr
break
if not kind:
continue
studio = ""
for t in tokens:
if t.upper().replace(".", "") in NNM_TAG_NAMES or re.search(r"(?i)голос|дубл|оригинал|original", t):
continue
st, note = clean_studio(t)
st, extra = canonical_studio(st)
if len(st) >= 3:
studio = st
break
if not studio or key(studio) in have or re.match(r"(?i)(?:blu-?ray|bd|dvd|web)\b", studio): # источник, а не студия
continue
base = rows[0] if rows else {}
out.append({
"source": base.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": studio, "translation_type": kind, "professional": pro,
"role_raw": chunk.strip(), "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), a["adult"]),
"age_18": int(a["adult"]), "codec": a["codec"], "channels": a["channels"], "khz": "",
"kbps": a["kbps"], "subtitles": "", "studio_source": "audio",
"raw": a["raw"], "lang": a["lang"], "audio_raw": a["raw"]})
have += key(studio)
return out
def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
"""kinozal: студии из заголовка раздачи «… / ПМ (LostFilm, HDrezka Studio), ДБ (X) / …».
На kinozal студии часто названы только в заголовке и в строках «Аудио Rus: … | LostFilm»
(без типа), а «Перевод:» во «Техданных» — без студий. Строка-вход — тот же синтетический
сегмент, что строит сервис из заголовка (src.release.title_segments); строка «Аудио» с этой
студией (если есть) — в audio_raw (позитив для сегментатора).
"""
from src.release import RE_TITLE_TRACK, TITLE_TYPES
def key(x: str) -> str:
return re.sub(r"[\W_]+", "", (x or "").casefold())
title = (rec.get("h1") or rec.get("page_title") or "").split(" :: ")[0]
have = key(" ".join(((r.get("studio") or "") + " " + (r.get("raw") or "")) for r in rows))
lines = [ln for ln in audio_lines_all(text) if not ln["original"] and not ln["commentary"]]
base = rows[0] if rows else {}
info = kz_split_title(title)
out = []
for abbr, names in RE_TITLE_TRACK.findall(title):
for name in re.split(r"\s*[,;]\s*", names.strip()):
studio, note = clean_studio(name)
studio, extra = canonical_studio(studio)
k = key(studio)
if len(k) < 2 or k in have or studio.lower() in SOURCE_LIKE:
continue
kind, pro = translation_type(TITLE_TYPES[abbr])
a = next((ln for ln in lines if k in key(ln["raw"])), None)
out.append({
"source": "kinozal", "release_id": rec.get("id", ""),
"ru_title": base.get("ru_title") or info["ru_title"],
"year": base.get("year") or info["year"], "quality": base.get("quality", ""),
"is_series": base.get("is_series", bool(re.search(r"сезон|серии", title, re.I))),
"studio": studio, "translation_type": kind, "professional": pro,
"role_raw": TITLE_TYPES[abbr], "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), bool(a and a["adult"])),
"age_18": int(bool(a and a["adult"])),
"codec": a["codec"] if a else "", "channels": a["channels"] if a else "", "khz": "",
"kbps": a["kbps"] if a else "", "subtitles": "", "studio_source": "title",
"raw": f"{TITLE_TYPES[abbr]} | {name.strip()}",
"lang": (a["lang"] or "RUS") if a else "RUS", "audio_raw": a["raw"] if a else ""})
have += k
return out
# кодек-строка одной дорожки: «Аудио 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)")
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"
r"|француз|french|немецк|german|итальян|испан|корей|китайск")
# не студия: язык, тех. параметры, тип, источник, пометки
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"|.*(?:голос|дублир|закадр|перевод).*|.*(?<![а-яё])дубляж(?![а-яё]).*|звук.*"
r"|.*(?:blu-?ray|itunes|netflix|amazon|\bbd\b|\bdvd\b|web-?dl).*)$")
# имя из скобок кодек-строки, которого нет в словаре: «(L,C,R,l,r)», «(АС3 …)», «(1-8 серии)», «(поздний)»
CODEC_NAME_NOISE = re.compile(
r"(?i)^(?:[lrc]|sl|sr|ls|rs|lfe)\b|\b(?:[lrc]|lfe)\s*[,;]|^(?:cee|eur|vhs|cbr|vbr|lfe|stereo|custom"
r"|line|production|россия|сек|[aа]{1,2}[сc]\w?|мр\w?|front|side)\b|\bmono\b|dolby|поздн|ранн|отдельн|тифло|комментар"
r"|сезон|сери[ия]|эпизод|файл|контейнер|bits?\b|sound\s*track|sdh|forced|вставк|сэмпл|sample|неизвестн|unknown")
def _lang_key(word: str) -> str:
w = word.lower()
return "RUS" if w.startswith(("русс", "russ", "rus")) else "UKR" if w.startswith(("укр", "ukr")) else w[:4]
def _known_studios() -> re.Pattern | None:
"""Словарь студий из data/labels.csv (≥ 5 строк): имена, которые kinozal пишет без скобок
(«Аудио Rus: … | LostFilm», «… - Русский, LostFilm», «… MVO DniproFilm / HDRezka Studio»)."""
global _KNOWN_RX
if _KNOWN_RX is None:
path = _REPO / "data" / "labels.csv"
cnt: Counter = Counter()
if path.exists():
with path.open(encoding="utf-8-sig", newline="") as f:
for r in csv.DictReader(f):
if r.get("studio_source") == "codec": # словарь — без своих же строк: повторный прогон стабилен
continue
st = (r.get("studio") or "").strip()
if len(st) >= 3 and st != "(не указана)" and not CODEC_NOT_STUDIO.match(st) \
and st.lower() not in SOURCE_LIKE and not CODEC_NAME_NOISE.search(st):
cnt[st] += 1
names = sorted((s for s, c in cnt.items() if c >= 5), key=len, reverse=True)
_KNOWN_RX = re.compile(r"(?i)(?<![\w])(" + "|".join(map(re.escape, names)) + r")(?![\w])") \
if names else re.compile(r"(?!x)x")
return _KNOWN_RX
_KNOWN_RX: re.Pattern | None = None
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:» со списком, где этой студии нет. Строка-вход — сегмент кодек-строки, как его
режет сервис (`src.release._entries`); тип — из самой строки (MVO, «дубляж»), иначе
единственный тип релиза, иначе «неизвестно». Сводка из нескольких языков
(«Аудио: Русский (AC3…), украинский (AC3…)»), оригинал и комментарии — пропускаются.
"""
from src.release import _entries, same_studio
from src.trackinfo import parse_audio_line
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 {}
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:
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):
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 ("", "")
if not kind:
kind, pro = translation_type(line)
if kind.endswith("неизвестно"):
kind, pro = "", ""
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:
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):
continue
if CODEC_NAME_NOISE.search(studio) or not in_vocab and re.search(r"\d", studio):
continue
if any(same_studio(studio, x) for x in have):
continue
entries = _entries(line)
seg = next((e for e in entries if name.lower() in e.lower()), entries[0] if entries else 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": studio, "translation_type": kind, "professional": pro,
"role_raw": "", "actors": "",
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), info["adult"]),
"age_18": int(info["adult"]), "codec": info["codec"], "channels": info["channels"],
"khz": "", "kbps": info["kbps"], "subtitles": "", "studio_source": "codec",
"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
def _with_audio(core, texts):
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
def parse(rec: dict) -> tuple[list[dict], dict]:
rows, rel = core(rec)
text = texts(rec)
if rows:
rows = enrich_rows(split_multi_rows(rows), text)
rows += audio_only_rows(rec, text, rows)
elif core is _kz_parse_core:
rows = kz_audio_only_rows(rec, text)
if core is _kz_parse_core:
rows += kz_title_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
parse.__name__ = core.__name__.lstrip("_").replace("_core", "")
return parse
kz_parse = _with_audio(_kz_parse_core, lambda r: "\n".join(
x for x in ((r.get("release_tab") or ""), (r.get("tech_tab") or "")) if x))
rt_parse = _with_audio(_rt_parse_core, lambda r: r.get("post") or "")
nnm_parse = _with_audio(_nnm_parse_core, lambda r: r.get("post") or "")
FIELDS = ["source", "release_id", "ru_title", "year", "quality", "is_series",
"studio", "translation_type", "professional", "role_raw", "actors",
"note", "age_18", "codec", "channels", "khz", "kbps", "subtitles",
"studio_source", "raw", "lang", "audio_raw"]
def read_jsonl(paths: list[Path]) -> list[dict]:
rows: dict[str, dict] = {}
for p in paths:
if not p.exists():
continue
for line in p.read_text("utf-8").splitlines():
if not line.strip():
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
# одна тема может лежать и в старом, и в новом файле —
# оставляем последнюю версию
rows[str(rec.get("id"))] = rec
return list(rows.values())
def cmd_parse(_: argparse.Namespace) -> int:
RAW_DIR.mkdir(parents=True, exist_ok=True)
DATASET.mkdir(parents=True, exist_ok=True)
total_rows = 0
for source, new_raw, legacy in RAW_SOURCES:
records = read_jsonl([new_raw, legacy])
if not records:
continue
parser = {"kinozal": kz_parse, "rutracker": rt_parse, "nnm": nnm_parse}[source]
rows, rels = [], []
for rec in records:
if not rec.get("ok"):
continue
try:
r, rel = parser(rec)
except Exception as exc: # noqa: BLE001
log(f" {source} {rec.get('id')}: parse error {exc}")
continue
rows.extend(r)
rels.append(rel)
(DATASET / f"{source}_entries.csv").write_text("", "utf-8")
with (DATASET / f"{source}_entries.csv").open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
w.writerows(rows)
with (DATASET / f"{source}_releases.csv").open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=["source", "release_id", "ru_title", "year",
"quality", "is_series", "n_translations",
"studios", "ok"])
w.writeheader()
w.writerows(rels)
total_rows += len(rows)
log(f"{source:9s}: тем {len(records):4d}, с переводами {sum(1 for r in rels if r['ok']):4d}, "
f"записей {len(rows):4d}")
log(f"итого строк: {total_rows}")
return 0
def cmd_merge(_: argparse.Namespace) -> int:
rows, seen = [], set()
for source, *_ in RAW_SOURCES:
path = DATASET / f"{source}_entries.csv"
if not path.exists():
continue
with path.open(encoding="utf-8-sig", newline="") as f:
for r in csv.DictReader(f):
row = {k: (r.get(k) or "").strip() for k in FIELDS}
key = (row["source"], row["release_id"], row["studio"].lower(),
row["translation_type"], row["raw"][:120].lower())
if key in seen:
continue
seen.add(key)
rows.append(row)
DATASET.mkdir(parents=True, exist_ok=True)
out = DATASET / "labels.csv"
with out.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
w.writerows(rows)
stats = dataset_stats(rows)
(DATASET / "stats.json").write_text(json.dumps(stats, ensure_ascii=False, indent=2), "utf-8")
print_stats(stats)
log(f"\n-> {out}")
return 0
def dataset_stats(rows: list[dict]) -> dict:
studios = Counter(r["studio"] for r in rows if r["studio"] != "(не указана)")
adult = [r for r in rows if str(r.get("age_18", "")).strip() in ("1", "True")]
return {
"rows": len(rows),
"releases": len({(r["source"], r["release_id"]) for r in rows}),
"films": sum(1 for r in rows if str(r["is_series"]).lower() not in ("true", "1")),
"series": sum(1 for r in rows if str(r["is_series"]).lower() in ("true", "1")),
"with_studio": len(rows) - sum(1 for r in rows if r["studio"] == "(не указана)"),
"rows_18plus": len(adult),
"18plus_by_studio": dict(Counter(r["studio"] for r in adult).most_common(10)),
"unique_studios": len(studios),
"by_studio": dict(studios.most_common(50)),
"by_type": dict(Counter(r["translation_type"] for r in rows).most_common()),
"by_source": dict(Counter(r["source"] for r in rows)),
}
def print_stats(stats: dict) -> None:
log(f"строк : {stats['rows']} {stats['by_source']}")
log(f"раздач : {stats['releases']} (фильмы {stats['films']} / сериалы {stats['series']})")
log(f"со студией : {stats['with_studio']} ({100 * stats['with_studio'] / max(stats['rows'], 1):.0f}%)")
log(f"студий : {stats['unique_studios']}")
if "rows_18plus" in stats:
log(f"дорожек 18+ : {stats['rows_18plus']} {stats.get('18plus_by_studio', {})}")
log("\nтоп-15 студий:")
for s, n in list(stats["by_studio"].items())[:15]:
log(f" {n:4d} {s}")
log("\nтипы переводов:")
for t, n in list(stats["by_type"].items())[:8]:
log(f" {n:4d} {t}")
def cmd_status(_: argparse.Namespace) -> int:
_migrate_legacy_state()
if not any(p.exists() for p in STATE_FILES.values()):
log("прогресса пока нет: файлы out/state_*.json появятся после первого harvest")
return 0
forums = load_state("rutracker").get("rutracker", {}).get("forums", {})
log("rutracker:")
if not forums:
log(" — ещё ничего не собрано")
for f, st in sorted(forums.items(), key=lambda x: str(x[0])):
done = " [раздел пройден]" if st.get("finished") else ""
log(f" f={f:<5} следующая страница {int(st.get('next_page', 0)) + 1:>3}"
f" новых тем {st.get('topics', 0):>4} {st.get('updated', '')}{done}")
browse = load_state("kinozal").get("kinozal", {}).get("browse", {})
log("\nkinozal:")
if not browse:
log(" — ещё ничего не собрано")
for url, st in browse.items():
done = " [список закончился]" if st.get("finished") else ""
log(f" {url}")
log(f" следующая страница {int(st.get('next_page', 0)) + 1}"
f" раздач {st.get('releases', 0)} {st.get('updated', '')}{done}")
nnm_forums = load_state("nnm").get("nnm", {}).get("forums", {})
log("\nnnmclub:")
if not nnm_forums:
log(" — ещё ничего не собрано")
for f, st in sorted(nnm_forums.items(), key=lambda x: str(x[0])):
done = " [раздел пройден]" if st.get("finished") else ""
log(f" f={f:<5} следующая страница {int(st.get('next_page', 0)) + 1:>3}"
f" новых тем {st.get('topics', 0):>4} {st.get('updated', '')}{done}")
log("\nсостояние: " + ", ".join(p.name for p in STATE_FILES.values()))
return 0
def cmd_stats(args: argparse.Namespace) -> int:
path = DATASET / "labels.csv"
if not path.exists():
log("нет out/dataset/labels.csv — сначала: python dataset.py merge")
return 1
with path.open(encoding="utf-8-sig", newline="") as f:
rows = list(csv.DictReader(f))
print_stats(dataset_stats(rows))
if getattr(args, "adult", False):
adult = [r for r in rows if r.get("age_18") == "1"]
log(f"\nдорожки 18+ ({len(adult)}):")
for r in adult:
log(f" {r['source']:<9} | {r['studio']:<20} | {r['translation_type']:<18} | "
f"{r['ru_title'][:40]} [{r['release_id']}]")
return 0
# =====================================================================
# CLI
# =====================================================================
def main() -> int:
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
ap = argparse.ArgumentParser(description="Сборщик датасета студий озвучки")
sub = ap.add_subparsers(dest="cmd", required=True)
p_chrome = sub.add_parser("chrome", help="запустить/проверить Chrome")
p_chrome.add_argument("--url", default="https://rutracker.org/forum/index.php")
p_h = sub.add_parser("harvest", help="собрать страницы")
p_h.add_argument("site", choices=["kinozal", "rutracker", "nnm"])
p_h.add_argument("--ids", default="")
p_h.add_argument("--topics", default="", help="id тем rutracker (алиас --ids)")
p_h.add_argument("--browse", default=None, help="URL списка kinozal")
p_h.add_argument("--forums", default="", help="id разделов rutracker: 313,252")
p_h.add_argument("--pages", type=int, default=1,
help="сколько страниц пройти за этот запуск (с сохранённой)")
p_h.add_argument("--from-page", type=int, default=None,
help="принудительно начать с этой страницы (1 = первая)")
p_h.add_argument("--reset", action="store_true",
help="забыть сохранённый прогресс и начать с первой страницы")
p_h.add_argument("--limit", type=int, default=50)
p_h.add_argument("--delay", type=float, default=1.6)
p_h.add_argument("--no-build", action="store_true",
help="не пересобирать labels.csv после сбора")
sub.add_parser("parse", help="разобрать сырые страницы в строки")
sub.add_parser("merge", help="слить источники в out/dataset/labels.csv")
sub.add_parser("stats", help="показать статистику датасета").add_argument(
"--adult", action="store_true", help="вывести все дорожки с 18+")
sub.add_parser("status", help="где остановился сбор (out/state.json)")
p_all = sub.add_parser("all", help="Chrome + сбор + разбор + слияние")
p_all.add_argument("--kinozal-ids", default="")
p_all.add_argument("--kinozal-browse", default=None)
p_all.add_argument("--kinozal-limit", type=int, default=0)
p_all.add_argument("--rutracker-forums", default="313,252")
p_all.add_argument("--rutracker-topics", default="")
p_all.add_argument("--nnm-forums", default="")
p_all.add_argument("--nnm-topics", default="")
p_all.add_argument("--pages", type=int, default=1)
p_all.add_argument("--from-page", type=int, default=None)
p_all.add_argument("--reset", action="store_true")
p_all.add_argument("--limit", type=int, default=50)
p_all.add_argument("--delay", type=float, default=1.6)
args = ap.parse_args()
if args.cmd == "chrome":
if chrome_start(args.url):
pages = [p for p in http_json("/json/list") if p.get("type") == "page"]
log(f"Chrome работает на :{CHROME_PORT}, профиль {CHROME_PROFILE}")
for p in pages:
log(f" вкладка: {p.get('title', '')} {p.get('url', '')}")
return 0
log("не удалось запустить Chrome")
return 1
if args.cmd == "harvest":
ids = [x for x in re.split(r"[,\s]+", args.ids or args.topics) if x]
from_page = None if args.from_page is None else max(0, args.from_page - 1)
if args.site == "kinozal":
harvest_kinozal(ids, args.browse, args.limit, args.delay,
args.pages, from_page, args.reset)
else:
forums = [x for x in re.split(r"[,\s]+", args.forums) if x]
collector = harvest_nnm if args.site == "nnm" else harvest_rutracker
collector(forums, ids, args.pages, args.limit, args.delay,
from_page, args.reset)
if not args.no_build:
log("\nпересобираю датасет...")
cmd_parse(args)
cmd_merge(args)
return 0
if args.cmd == "parse":
return cmd_parse(args)
if args.cmd == "merge":
return cmd_merge(args)
if args.cmd == "stats":
return cmd_stats(args)
if args.cmd == "status":
return cmd_status(args)
if args.cmd == "all":
if not chrome_start():
log("не удалось запустить Chrome")
return 1
ids = [x for x in re.split(r"[,\s]+", args.kinozal_ids) if x]
from_page = None if args.from_page is None else max(0, args.from_page - 1)
if args.kinozal_browse or ids:
harvest_kinozal(ids, args.kinozal_browse, args.kinozal_limit or args.limit,
args.delay, args.pages, from_page, args.reset)
forums = [x for x in re.split(r"[,\s]+", args.rutracker_forums) if x]
topics = [x for x in re.split(r"[,\s]+", args.rutracker_topics) if x]
harvest_rutracker(forums, topics, args.pages, args.limit, args.delay,
from_page, args.reset)
nnm_forums = [x for x in re.split(r"[,\s]+", args.nnm_forums) if x]
nnm_topics = [x for x in re.split(r"[,\s]+", args.nnm_topics) if x]
if nnm_forums or nnm_topics:
harvest_nnm(nnm_forums, nnm_topics, args.pages, args.limit, args.delay,
from_page, args.reset)
cmd_parse(args)
return cmd_merge(args)
return 1
if __name__ == "__main__":
raise SystemExit(main())