#!/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