#!/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 склеивает строки, поэтому
и блоки // превращаем в переводы строк вручную 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"дублир|(? 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.+)$", re.I) KZ_RE_AUDIO_X = re.compile( r"^\s*[АA]удио(?:\s*#?\s*\d+)?\s*x\s*(?P[A-ZА-Я/]\w*)\s*:\s*(?P.+)$", re.I) KZ_RE_AUDIO = re.compile(r"^\s*[АA]удио\s*:\s*(?P.+)$", re.I) KZ_RE_SUBS = re.compile(r"^\s*[СC]убтитры\s*:\s*(?P.+)$", re.I) KZ_RE_ACTORS = re.compile(r"Роли озвучивали\s*:\s*(?P[^\]]+)", 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.+)$", re.I) RT_RE_SUBS = re.compile(r"^\s*Субтитры\s*:\s*(?P.+)$", re.I) RT_RE_BRACKET = re.compile(r"\[(?P[^\]]*)\]") 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.+)$", re.I) NNM_RE_PEREVOD = re.compile(r"^\s*Перевод\s*:\s*(?P.+)$", 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"|.*(?:голос|дублир|закадр|перевод).*|.*(? 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)(? 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())