Files
grabber-ai/tools/collect.py
T
DenozordecandClaude Sonnet 5.5 05537ac9e9
docker / build (push) Successful in 1m40s
Датасет и обучение: класс «люб. дубляж», раскладка многотиповых строк, язык из «Аудио», строки из «Аудио», потолок повторов, веса классов
- harvest_core: дубляж — тип, а не часть имени студии; split_multi_rows; audio_only_rows; kinozal сопоставляет все аудиодорожки
- prepare_data: --max-dup, отсев остатков многотиповых строк
- train: веса классов (корень из обратной частоты, потолок 4), --no-class-weights
- исправлены регулярки с \b, записанные как символ BS (predict.py, audio18.py)
- PARSE_VERSION 3: кэш разбора сбрасывается
- docs/dataset.md, docs/api-reference.md обновлены

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:13:07 +07:00

1850 lines
88 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""Интерактивный сборщик датасета grabber-ai (rutracker / nnm-club / kinozal).
Запуск без аргументов открывает меню:
.venv\\Scripts\\python tools\\collect.py
То же по командам (для скриптов):
python tools/collect.py windows [--sites rutracker,nnm,kinozal]
python tools/collect.py collect --site rutracker --refs 313,2366 --pages 3 --limit 100
python tools/collect.py backfill --site nnm --limit 200
python tools/collect.py build
python tools/collect.py import-legacy --dir <каталог старого harvest>
python tools/collect.py status
Как это работает
----------------
* Chrome запускается ОДИН (общий профиль и cookie), по окну на трекер. Вы
логинитесь в каждом окне сами; пароли и cookie скрипт не видит и не хранит.
Профиль лежит вне репозитория: %LOCALAPPDATA%\\dub-ai-harvest\\chrome-profile
(другой каталог: переменная DUB_CHROME_PROFILE).
* Каждая страница сохраняется ЦЕЛИКОМ (outerHTML после загрузки вкладок
kinozal) в data/raw/<трекер>/<id>.html + <id>.json. Это тот же вход, который
модель увидит в grabber, а не только текст поста.
* `build` превращает сырые страницы в data/pages/*.txt (через src.html_extract)
и добавляет слабые метки в data/labels.csv (парсеры из tools/harvest).
* id страниц: у уже размеченных раздач остаётся прежний (bare) id, у новых —
с префиксом (rt-123, nnm-123, kz-123): так id разных трекеров не пересекаются
и разбиение train/test у старых страниц не меняется.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import random
import re
import sys
import threading
import time
from collections import Counter, deque
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
RAW = DATA / "raw"
PAGES = DATA / "pages"
LABELS = DATA / "labels.csv"
POSTONLY = DATA / "pages_postonly.txt"
STATE = RAW / "_state.json"
_home = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "dub-ai-harvest"
os.environ.setdefault("DUB_HARVEST_OUT", str(_home))
os.environ.setdefault("DUB_CHROME_PROFILE", str(_home / "chrome-profile"))
# свой порт: не подключаться к чужому Chrome с отладкой на 9222 (окна открылись бы там)
os.environ.setdefault("DUB_CHROME_PORT", "9333")
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tools"))
sys.path.insert(0, str(ROOT / "tools" / "harvest"))
import harvest_core as hv # noqa: E402
import meta as metamod # noqa: E402
import ui # noqa: E402
META = DATA / "meta.csv"
DEFAULTS = {"tabs": 3, "delay": 1.0, "limit": 100, "pages": 2, "mode": "balanced", "per_page": 6, "workers": 0}
def clamp_tabs(value) -> int:
try:
return max(1, min(4, int(value)))
except (TypeError, ValueError):
return DEFAULTS["tabs"]
def settings() -> dict:
"""Настройки сбора (запоминаются между запусками): вкладок, пауза, лимит, страниц списка."""
try:
saved = json.loads((RAW / "_settings.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
saved = {}
cfg = {**DEFAULTS, **saved}
cfg["tabs"] = clamp_tabs(cfg["tabs"])
return cfg
def save_settings(cfg: dict) -> None:
cfg = {**cfg, "tabs": clamp_tabs(cfg.get("tabs"))}
RAW.mkdir(parents=True, exist_ok=True)
(RAW / "_settings.json").write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
os.environ["DUB_TABS"] = str(cfg["tabs"])
SITES = {
"rutracker": {"prefix": "rt", "host": "rutracker.org",
"home": "https://rutracker.org/forum/index.php",
"refs": "313,2366", "what": "id разделов (viewforum.php?f=…)"},
"nnm": {"prefix": "nnm", "host": "nnmclub.to",
"home": "https://nnmclub.to/forum/index.php",
"refs": "227,768", "what": "id разделов (viewforum.php?f=…)"},
"kinozal": {"prefix": "kz", "host": "kinozal.me",
"home": "https://kinozal.me/",
"refs": "https://kinozal.me/browse.php",
"what": "адреса списков (browse.php?… / top.php)"},
}
def say(msg: str = "", tag: str = "") -> None:
from rich.markup import escape
prefix = f"[{ui.SITE_STYLE.get(tag, 'dim')}]{tag}[/] " if tag else ""
ui.console.print(prefix + escape(msg))
# ---------------------------------------------------------------- метки / id
def read_labels() -> list[dict]:
if not LABELS.exists():
return []
with LABELS.open(encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def migrate_labels() -> bool:
"""labels.csv со старой шапкой (без lang/audio_raw) -> новая шапка.
cmd_build дописывает строки через DictWriter(FIELDS): под старой шапкой они сдвинули бы
все колонки. Файл переписывается один раз, прежние данные не меняются; копия — labels.bak_schema.csv.
"""
if not LABELS.exists() or LABELS.stat().st_size == 0:
return False
with LABELS.open(encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
head, rows = reader.fieldnames or [], list(reader)
if head == hv.FIELDS:
return False
import shutil
bak = LABELS.with_name("labels.bak_schema.csv")
if not bak.exists():
shutil.copy2(LABELS, bak)
with LABELS.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=hv.FIELDS, extrasaction="ignore", restval="")
w.writeheader()
w.writerows(rows)
return True
def labeled_ids(rows: list[dict] | None = None) -> dict[str, set[str]]:
"""{источник: {release_id}} по текущему data/labels.csv."""
out: dict[str, set[str]] = {s: set() for s in SITES}
for r in rows if rows is not None else read_labels():
out.setdefault(r["source"], set()).add(r["release_id"])
return out
def page_stem(source: str, rid: str, labeled: dict[str, set[str]]) -> str:
"""Имя страницы = release_id в labels.csv.
Размеченные раньше раздачи сохраняют свой bare id (их пары page/label и
разбиение train/test не меняются), если этот id не занят другим
источником. Все новые получают префикс источника.
"""
if rid in labeled.get(source, ()):
taken = any(rid in ids for s, ids in labeled.items() if s != source)
if not taken:
return rid
return f"{SITES[source]['prefix']}-{rid}"
def real_id(site: str, release_id: str) -> str:
"""release_id из labels.csv -> id раздачи на трекере (без нашего префикса)."""
pre = SITES[site]["prefix"] + "-"
return release_id[len(pre):] if release_id.startswith(pre) else release_id
def labeled_real_ids(site: str, labeled: dict[str, set[str]] | None = None) -> set[str]:
labeled = labeled if labeled is not None else labeled_ids()
return {real_id(site, i) for i in labeled.get(site, ())}
def stored_ids(site: str) -> set[str]:
d = RAW / site
if not d.exists():
return set()
return {p.stem for p in d.glob("*.json") if not p.name.endswith(".legacy.json")}
# ---------------------------------------------------------------- браузер
def open_window(url: str) -> dict:
"""Новое ОКНО Chrome (а не вкладка) через браузерный CDP-эндпоинт."""
ver = hv.http_json("/json/version")
browser = hv.CDP(ver["webSocketDebuggerUrl"])
try:
tid = browser.send("Target.createTarget", {"url": url, "newWindow": True})["targetId"]
finally:
browser.close()
for _ in range(40):
for t in hv.http_json("/json/list"):
if t.get("id") == tid:
return t
time.sleep(0.25)
raise RuntimeError("окно не появилось")
def activate(target_id: str) -> None:
"""Вынести окно на передний план."""
ver = hv.http_json("/json/version")
browser = hv.CDP(ver["webSocketDebuggerUrl"])
try:
browser.send("Target.activateTarget", {"targetId": target_id})
except RuntimeError:
pass
finally:
browser.close()
def site_target(site: str, create: bool = True) -> dict | None:
host = SITES[site]["host"]
for t in hv.http_json("/json/list"):
if t.get("type") == "page" and host in (t.get("url") or ""):
return t
return open_window(SITES[site]["home"]) if create else None
def connect_site(site: str):
if not hv.chrome_start(SITES[site]["home"]):
raise RuntimeError("не удалось запустить Chrome")
target = site_target(site)
cdp = hv.CDP(target["webSocketDebuggerUrl"])
cdp.send("Page.enable")
cdp.send("Runtime.enable")
return cdp
LOGIN_JS = """JSON.stringify((() => {
const q = s => !!document.querySelector(s);
const head = document.body ? document.body.innerText.slice(0, 4000) : '';
return {
out: q('a[href*="logout"], #logged-in-username'),
guest: q('form[action*="login"]') || /Гость!|Зарегистрируйтесь/.test(head),
title: document.title
};
})())"""
def login_state(info: dict) -> str:
"""'in' — вошли, 'guest' — гость, 'unknown' — не удалось определить."""
if info.get("out"):
return "in"
if info.get("guest"):
return "guest"
return "unknown"
def check_login(site: str) -> str:
cdp = connect_site(site)
try:
nav = hv.goto(cdp, SITES[site]["home"], min_len=300, timeout=60)
if not nav["ok"]:
return "unknown"
return login_state(json.loads(cdp.eval(LOGIN_JS) or "{}"))
finally:
cdp.close()
def cmd_windows(sites: list[str], wait: bool = True) -> None:
if not hv.chrome_start(SITES[sites[0]]["home"]):
say("Chrome не запустился (проверьте путь в harvest_core.CHROME_PATHS)")
return
say(f"Chrome: порт {hv.CHROME_PORT}, профиль {hv.CHROME_PROFILE}")
for s in sites:
activate(site_target(s)["id"])
say(f"окно {s}: {SITES[s]['home']}")
if wait:
say("\nОткрылись окна Chrome (если их не видно — проверьте панель задач).")
say("Пароль вводится ТОЛЬКО в этих окнах браузера, не в консоли.")
input("Войдите в аккаунт в каждом окне (Cloudflare тоже пройдите там) "
"и нажмите Enter здесь… ")
names = {"in": "вошли", "guest": "гость — войдите", "unknown": "не определено"}
for s in sites:
try:
st = check_login(s)
except Exception as exc: # noqa: BLE001
st = "unknown"
say(f"{exc}", s)
say(f"{s:10s}: {names[st]}")
# ---------------------------------------------------------------- сбор
def fetch_release(cdp, site: str, rid: str) -> dict:
if site == "rutracker":
rec = hv.rt_topic(cdp, rid)
elif site == "nnm":
rec = hv.nnm_topic(cdp, rid)
else:
rec = hv.kz_release(cdp, rid)
if rec.get("ok"):
if site == "kinozal":
if not (rec.get("release_tab") or "").strip(): # вкладка не успела загрузиться
rec["release_tab"] = hv.kz_read_tab(cdp, rid, 0, timeout=10)
if not (rec.get("tech_tab") or "").strip():
rec["tech_tab"] = hv.kz_read_tab(cdp, rid, 100, timeout=10)
hv.kz_read_tab(cdp, rid, 0) # вернуть вкладку «Описание» перед снимком HTML
rec["_html"] = snapshot_html(cdp)
return rec
# Снимок страницы «как на сайте»: с кодировкой, адресом (картинки грузятся с сайта) и стилями.
# Таблицы стилей выносятся в data/raw/_css/<хеш>.css (одна на все страницы), в HTML — ссылка.
SNAPSHOT_JS = (Path(__file__).resolve().parent / "snapshot.js").read_text(encoding="utf-8")
CSS_DIR_NAME = "_css"
def store_css(text: str) -> str:
"""Таблица стилей -> data/raw/_css/<хеш>.css; возвращает имя файла."""
import hashlib
name = hashlib.sha1(text.encode("utf-8")).hexdigest()[:12] + ".css"
d = RAW / CSS_DIR_NAME
d.mkdir(parents=True, exist_ok=True)
if not (d / name).exists():
(d / name).write_text(text, encoding="utf-8")
return name
def snapshot_html(cdp) -> str:
"""Читаемый снимок открытой страницы: UTF-8, <base>, стили файлами. Пусто, если не получилось."""
try:
snap = json.loads(cdp.eval(SNAPSHOT_JS) or "{}")
except (json.JSONDecodeError, TypeError):
snap = {}
html = snap.get("html") or ""
for i, css in enumerate(snap.get("css") or []):
html = html.replace(f"@@CSS{i}@@", f"../{CSS_DIR_NAME}/{store_css(css)}")
return html or (cdp.eval("document.documentElement.outerHTML") or "")
def cmd_repair_html(with_css: bool = True) -> dict:
"""Старые сохранённые страницы -> читаемые: кодировка UTF-8, <base> и стили сайта.
Время изменения файлов не меняется, поэтому кэши «уже разобрано» остаются в силе.
Стили берутся один раз с живой страницы каждого трекера (нужен Chrome и вход).
"""
import re as _re
stats = {"fixed": 0, "already": 0, "css_sites": 0}
bases = {"rutracker": "https://rutracker.org/forum/", "nnm": "https://nnmclub.to/forum/",
"kinozal": "https://kinozal.me/"}
for site in SITES:
d = RAW / site
files = [p for p in sorted(d.glob("*.html"))] if d.exists() else []
if not files:
continue
links = ""
if with_css:
try:
cdp = connect_site(site)
try:
hv.goto(cdp, SITES[site]["home"], min_len=300, timeout=60)
snap = json.loads(cdp.eval(SNAPSHOT_JS) or "{}")
finally:
cdp.close()
links = "".join(f'<link rel="stylesheet" href="../{CSS_DIR_NAME}/{store_css(c)}">'
for c in snap.get("css") or [])
stats["css_sites"] += bool(links)
except Exception as exc: # noqa: BLE001
say(f"стили {site} не получены: {exc}", site)
prog = ui.progress()
with prog:
task = prog.add_task(f"{site}: правка HTML", total=len(files))
for hp in files:
prog.advance(task)
text = hp.read_text(encoding="utf-8", errors="replace")
if _re.search(r"(?i)<meta[^>]+charset", text[:3000]):
stats["already"] += 1
continue
add = f'<meta charset="utf-8"><base href="{bases[site]}">{links}'
new, n = _re.subn(r"(?i)(<head[^>]*>)", lambda m: m.group(1) + add, text, count=1)
if not n:
new = f"<!DOCTYPE html><html><head>{add}</head><body>{text}</body></html>"
st = hp.stat()
hp.write_text(new, encoding="utf-8")
os.utime(hp, ns=(st.st_atime_ns, st.st_mtime_ns))
stats["fixed"] += 1
say("repair-html: " + ", ".join(f"{k}={v}" for k, v in stats.items()))
return stats
def has_body(site: str, rec: dict) -> bool:
if site == "kinozal":
return bool((rec.get("release_tab") or "").strip())
return len((rec.get("post") or "").strip()) >= 30
def save_release(site: str, rec: dict) -> None:
d = RAW / site
d.mkdir(parents=True, exist_ok=True)
rid = str(rec["id"])
html = rec.pop("_html", "")
(d / f"{rid}.html").write_text(html, encoding="utf-8")
rec["fetched"] = hv.stamp()
# json пишется последним: он же признак «сохранено целиком»
(d / f"{rid}.json").write_text(json.dumps(rec, ensure_ascii=False), encoding="utf-8")
_state_lock = threading.Lock()
def load_state() -> dict:
try:
return json.loads(STATE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def update_state(site: str, ref: str, **fields) -> None:
with _state_lock:
st = load_state()
st.setdefault(site, {}).setdefault(ref, {}).update(fields, updated=hv.stamp())
RAW.mkdir(parents=True, exist_ok=True)
STATE.write_text(json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
SECTIONS = RAW / "_sections.json"
def load_sections() -> dict:
try:
return json.loads(SECTIONS.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def save_sections(cache: dict) -> None:
RAW.mkdir(parents=True, exist_ok=True)
tmp = SECTIONS.with_suffix(".tmp")
tmp.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(SECTIONS)
class quiet_harvest:
"""Глушит построчный лог harvest_core (в панели с прогрессом он только мешает)."""
def __enter__(self):
self._old, hv.log = hv.log, (lambda *_a, **_k: None)
def __exit__(self, *exc):
hv.log = self._old
def ensure_sections(sites: list[str], force: bool = False) -> dict:
"""Находит разделы с зарубежными фильмами/сериалами (первый запуск) и кэширует.
Кэш: data/raw/_sections.json. Повторно ищем только с force=True.
"""
import discover
from rich.progress import (BarColumn, MofNCompleteColumn, Progress, SpinnerColumn,
TextColumn, TimeElapsedColumn)
cache = load_sections()
for s in sites:
created = cache.get("created", {}).get(s)
age = (time.time() - time.mktime(time.strptime(created, "%Y-%m-%d %H:%M:%S"))) / 86400 \
if created else None
fresh = (cache.get("version", {}).get(s) == discover.RULES_VERSION
and age is not None and age < discover.SECTIONS_TTL_DAYS)
if cache.get(s) and fresh and not force:
ui.console.print(f"[dim]Разделы {s}: из кэша, {len(cache[s])} шт., "
f"обновлено {created} (повторный поиск: «Дополнительно» → 7)[/]")
continue
old = cache.get(s) or []
probes = cache.setdefault("probes", {}).setdefault(s, {})
entries: list[dict] = []
with quiet_harvest(), Progress(
SpinnerColumn(), TextColumn("[bold]{task.description}"), BarColumn(),
MofNCompleteColumn(), TimeElapsedColumn(), TextColumn("[dim]{task.fields[cur]}"),
console=ui.console, transient=True) as prog:
task = prog.add_task(f"Поиск разделов: {s}", total=None, cur="подключение")
try:
cdp = connect_site(s)
try:
if s == "kinozal":
prog.update(task, cur="проверяю адреса списков")
entries = [{**x, "topics": None, "keep": True, "status": "ok"}
for x in discover.kinozal_plan(cdp, lambda *_a: None)]
else:
def on_probe(ref, n, probes=probes):
probes[ref] = n
save_sections(cache) # сохраняем по ходу: прерванный поиск не пропадает
entries = discover.discover_forums(
cdp, s, on_stage=lambda t: prog.update(task, cur=t),
on_step=lambda i, n, name: prog.update(task, total=n, completed=i,
cur=name[:60]),
probes=probes, on_probe=on_probe)
finally:
cdp.close()
except Exception as exc: # noqa: BLE001
ui.console.print(f"[red]{s}: поиск разделов не удался: {exc}[/]")
continue
found = [{"ref": e["ref"], "name": e["name"], "topics": e["topics"]}
for e in entries if e["keep"]]
ui.console.print(ui.sections_table(s, entries))
if old and len(found) < 0.6 * len(old) and cache.get("version", {}).get(s) == discover.RULES_VERSION:
ui.console.print(f"[yellow]{s}: найдено {len(found)} против {len(old)} — похоже на "
f"неполную загрузку, прежний список сохранён[/]")
continue
if found:
cache[s] = found
cache.setdefault("created", {})[s] = hv.stamp()
cache.setdefault("version", {})[s] = discover.RULES_VERSION
save_sections(cache)
return cache
def section_refs(site: str) -> list[str]:
return [x["ref"] for x in load_sections().get(site, [])]
def choose_refs(site: str) -> str:
found = section_refs(site)
if found:
if ask(f"{site}: найдено разделов {len(found)}. Использовать? (Y/n)", "y").lower() != "n":
return ",".join(found)
return ask(f"{site}: {SITES[site]['what']}", SITES[site]["refs"])
def list_ids(cdp, site: str, ref: str, page: int) -> tuple[bool, list[str]]:
if site == "kinozal":
return hv.kz_browse_ids(cdp, hv.kz_page_url(ref, page))
fn = hv.rt_forum_page if site == "rutracker" else hv.nnm_forum_page
ok, items = fn(cdp, ref, page)
return ok, [it["id"] for it in items]
def list_items(cdp, site: str, ref: str, page: int) -> tuple[bool, list[dict]]:
"""Страница списка раздела -> (открылась, [{id, text}]) с заголовками тем."""
if site == "kinozal":
return hv.kz_browse_items(cdp, hv.kz_page_url(ref, page))
return (hv.rt_forum_page if site == "rutracker" else hv.nnm_forum_page)(cdp, ref, page)
DEAD = RAW / "_dead.json"
def load_dead(site: str) -> set[str]:
"""id удалённых/несуществующих раздач: повторно не открываем."""
try:
return set(json.loads(DEAD.read_text(encoding="utf-8")).get(site, []))
except (OSError, json.JSONDecodeError):
return set()
def mark_dead(site: str, rid: str) -> None:
with _state_lock:
try:
data = json.loads(DEAD.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
data[site] = sorted(set(data.get(site, [])) | {rid})
RAW.mkdir(parents=True, exist_ok=True)
DEAD.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def is_dead(rec: dict) -> bool:
return not rec.get("ok") and "нет раздачи" in (rec.get("error") or "").lower()
def _on_retry(url: str, attempt: int, retries: int, reason: str) -> None:
"""Страница вернула ошибку сервера/Cloudflare: покажем повтор в панели сбора."""
for site, info in SITES.items():
if info["host"] in url:
ui.get_stats(site).state = f"повтор {attempt}/{retries}: {reason}"
return
hv.RETRY_HOOK = _on_retry
def section_name(site: str, ref: str) -> str:
for x in load_sections().get(site, []):
if x["ref"] == ref:
return x["name"]
return ref
def stats_start(site: str, limit: int) -> "ui.Stats":
"""Один трекер может пройти два этапа подряд (дозагрузка и новые): лимиты складываются."""
st = ui.get_stats(site)
if not st.started or st.finished:
st.begin(limit)
else:
st.limit += limit
st.finished = 0.0
return st
class Tabs:
"""Несколько вкладок одного трекера в общем Chrome (cookie и вход общие).
Первая — окно, где вы входили; остальные открываются фоном и закрываются в конце.
Число вкладок: переменная DUB_TABS (по умолчанию 3).
"""
def __init__(self, site: str, n: int | None = None):
n = max(1, int(n or os.environ.get("DUB_TABS", 3)))
self.main = connect_site(site)
self.cdps = [self.main]
self.extra: list[str] = []
for _ in range(n - 1):
try:
tab = hv.http_json("/json/new?about:blank", method="PUT")
cdp = hv.CDP(tab["webSocketDebuggerUrl"])
cdp.send("Page.enable")
cdp.send("Runtime.enable")
except Exception: # noqa: BLE001
break # не вышло — работаем с тем, что есть
self.cdps.append(cdp)
self.extra.append(tab["id"])
def close(self) -> None:
for cdp in self.cdps:
cdp.close()
for tid in self.extra:
try:
hv.http_json(f"/json/close/{tid}")
except Exception: # noqa: BLE001
pass
HARD_LIMIT_S = 150 # одна раздача дольше этого — считаем соединение зависшим
SLOW_S = 40 # дольше этого — помечаем в списке ошибок
def harvest_ids(cdps, site: str, ids: list[str], limit: int, delay: float,
stop: threading.Event, section: str = "") -> int:
"""Скачивает раздачи параллельно: по рабочему на вкладку. Возвращает число сохранённых."""
cdps = list(cdps) if isinstance(cdps, (list, tuple)) else [cdps]
st = ui.get_stats(site)
todo = deque(ids)
lock = threading.Lock()
done = [0]
st.state = "скачиваю"
def worker(cdp) -> None:
while not stop.is_set():
with lock:
if done[0] >= limit or not todo:
return
rid = todo.popleft()
done[0] += 1 # слот под лимит занят заранее
st.state = f"скачиваю {rid}"
saved = False
t_start = time.time()
guard = threading.Timer(HARD_LIMIT_S, cdp.abort) # сторож: зависший вызов обрывается
guard.daemon = True
guard.start()
try:
rec = fetch_release(cdp, site, rid)
if is_dead(rec): # удалена: запоминаем и идём дальше
mark_dead(site, rid)
st.skipped += 1
elif rec.get("ok") and has_body(site, rec):
rec["section_ref"] = section
save_release(site, rec)
st.ok(f"{rid} {rec.get('title') or rec.get('h1') or rec.get('page_title') or ''}")
saved = True
else:
st.fail(f"{rid}: нет текста раздачи (нужен вход? {rec.get('error', '')})")
except Exception as exc: # noqa: BLE001
st.fail(f"{rid}: {exc}")
finally:
guard.cancel()
if time.time() - t_start > HARD_LIMIT_S: # сторож сработал: соединение мертво
st.fail(f"{rid}: завис на {time.time() - t_start:.0f} с, вкладка переподключена")
try:
cdp.reconnect()
except Exception as exc: # noqa: BLE001
st.fail(f"переподключение не удалось: {exc}")
return
elif time.time() - t_start > SLOW_S:
st.fail(f"{rid}: медленно, {time.time() - t_start:.0f} с") # видно, на каких страницах
if not saved:
with lock:
done[0] -= 1 # неудача не съедает лимит
time.sleep(delay + random.random() * 0.5)
helpers = [threading.Thread(target=worker, args=(c,), daemon=True) for c in cdps[1:]]
for t in helpers:
t.start()
worker(cdps[0])
for t in helpers:
t.join()
return done[0]
def collect_ref_balanced(site: str, ref: str, cdp, tabs: "Tabs", have: set, counts, ref_quota: int,
limit_left: int, delay: float, stop: threading.Event, per_page: int,
rng: random.Random) -> int:
"""Раздел вразброс: страницы по всей глубине, на странице — темы из недопредставленных
корзин (год × качество × фильм/сериал). Возвращает число сохранённых раздач."""
import diversity as dv
stat = ui.get_stats(site)
st = load_state().get(site, {}).get(ref, {})
visited = set(st.get("visited", []))
depth = int(st.get("depth") or 0)
saved, fails = 0, 0
for _ in range(300):
if stop.is_set() or saved >= min(ref_quota, limit_left) or fails >= 3:
break
page = 0 if not depth else (next_pg[0] if (next_pg := dv.next_pages(depth, visited, 1, rng)) else None)
if page is None: # раздел обойден целиком: начинаем новый круг
update_state(site, ref, visited=[], finished=True)
break
stat.state = f"список, стр.{page + 1}" + (f"/{depth}" if depth else "")
ok, items = list_items(cdp, site, ref, page)
if not ok:
fails += 1
stat.fail(f"{stat.section}: стр.{page + 1} не открылась")
visited.add(page)
continue
visited.add(page)
seen_depth = hv.list_depth(cdp)
if not items: # дальше конца раздела
depth = max(1, page) if page else 1
update_state(site, ref, visited=sorted(v for v in visited if v < depth), depth=depth)
continue
depth = max(depth, seen_depth, page + 1)
fresh = [it for it in items if it["id"] not in have]
want = min(per_page, ref_quota - saved, limit_left - saved)
chosen = dv.pick(fresh, want, counts, rng)
n = harvest_ids(tabs.cdps, site, [it["id"] for it in chosen], want, delay, stop, section=ref)
have |= stored_ids(site)
saved += n
update_state(site, ref, visited=sorted(visited), depth=depth, next_page=0)
return saved
def run_collect(site: str, refs: list[str], pages: int, limit: int, delay: float,
stop: threading.Event, from_page: int | None = None) -> int:
"""Новые раздачи из списков разделов; уже размеченные и сохранённые пропускаются.
Лимит делится между разделами поровну, чтобы в выборку попали все форматы и годы.
"""
have = stored_ids(site) | labeled_real_ids(site) | load_dead(site)
stat = stats_start(site, limit)
stat.state = "подключение"
tabs = Tabs(site)
cdp = tabs.main
total = 0
quota = max(1, -(-limit // max(len(refs), 1)))
cfg = settings()
balanced = cfg.get("mode") == "balanced" and from_page is None
if balanced:
import diversity as dv
counts = dv.counts_from_meta(META, site)
rng = random.Random()
try:
for ref in refs:
if balanced:
if stop.is_set() or total >= limit:
break
stat.section = section_name(site, ref)
ref_quota = quota * 3 if "v=0&d=0" in ref else quota
total += collect_ref_balanced(site, ref, cdp, tabs, have, counts, ref_quota,
limit - total, delay, stop, int(cfg.get("per_page", 6)), rng)
continue
st = load_state().get(site, {}).get(ref, {})
# раздел уже дочитан до конца: новые раздачи появляются сверху, начинаем со стр.1
start = (0 if st.get("finished") else st.get("next_page", 0)) if from_page is None else from_page
if st.get("finished"):
update_state(site, ref, finished=False, next_page=0)
ref_total = 0
stat.section = section_name(site, ref)
# общие списки kinozal (все фильмы / сериалы / мульты / аниме) весят втрое больше сетки
ref_quota = quota * 3 if "v=0&d=0" in ref else quota
for k in range(pages):
if stop.is_set() or total >= limit or ref_total >= ref_quota:
break
page = start + k
stat.state = f"список, стр.{page + 1}"
ok, ids = list_ids(cdp, site, ref, page)
if not ok:
stat.fail(f"{stat.section}: стр.{page + 1} не открылась")
break
if not ids:
update_state(site, ref, finished=True)
break
fresh = [i for i in ids if i not in have]
n = harvest_ids(tabs.cdps, site, fresh, min(limit - total, ref_quota - ref_total),
delay, stop, section=ref)
have |= stored_ids(site)
total += n
ref_total += n
complete = n == len(fresh) and not stop.is_set()
update_state(site, ref, next_page=page + 1 if complete else page)
if not complete:
break
finally:
tabs.close()
stat.done("остановлено" if stop.is_set() else "готово")
return total
def backfill_ids(site: str, missing_pages_only: bool = True) -> list[str]:
"""Размеченные раздачи, у которых нет ни сохранённой страницы, ни data/pages."""
have = stored_ids(site) | load_dead(site)
labeled = labeled_ids()
ids = []
for release_id in sorted(labeled.get(site, set())):
rid = real_id(site, release_id)
if rid in have or (missing_pages_only and (PAGES / f"{release_id}.txt").exists()):
continue
ids.append(rid)
random.Random(7).shuffle(ids) # ограниченный лимит -> разнообразная выборка
return ids
def run_backfill(site: str, limit: int, delay: float, stop: threading.Event) -> int:
ids = backfill_ids(site)
stat = stats_start(site, min(limit, len(ids)))
stat.section = f"дозагрузка размеченных (осталось {len(ids)})"
if not ids:
stat.done()
return 0
stat.state = "подключение"
tabs = Tabs(site)
try:
n = harvest_ids(tabs.cdps, site, ids, limit, delay, stop, section="backfill")
finally:
tabs.close()
stat.done("остановлено" if stop.is_set() else "готово")
return n
def run_threads(jobs: dict, stop: threading.Event) -> None:
"""jobs: {site: callable}. По потоку на трекер (у каждого своё окно и свой CDP)."""
ui.STATS.clear()
sites = list(jobs)
threads = [threading.Thread(target=fn, name=s, daemon=True) for s, fn in jobs.items()]
for t in threads:
t.start()
with ui.live(sites):
try:
while any(t.is_alive() for t in threads):
time.sleep(0.25)
except KeyboardInterrupt:
stop.set()
for s in sites:
ui.get_stats(s).state = "останавливаюсь…"
for t in threads:
t.join(timeout=90)
ui.console.print(ui.dashboard(sites))
def safe(site: str, fn):
def run():
try:
fn()
except Exception as exc: # noqa: BLE001
say(f"ошибка: {exc}", site)
return run
# ---------------------------------------------------------------- сборка pages/labels
PAR_MIN = 40 # меньше задач — быстрее в одном процессе, чем поднимать пул
def worker_count() -> int:
"""Процессов для переиндексации: настройка `workers`, иначе число ядер минус два (не больше 12)."""
try:
n = int(settings().get("workers") or 0)
except (TypeError, ValueError):
n = 0
return n if n > 0 else max(1, min(12, (os.cpu_count() or 2) - 2))
class Runner:
"""Пул процессов на время переиндексации; для малых задач и workers=1 считает на месте."""
def __init__(self, workers: int | None = None) -> None:
self.workers = workers or worker_count()
self.pool = None
def map(self, fn, tasks: list, chunk: int = 8):
"""Результаты в порядке задач (по мере готовности)."""
if self.workers <= 1 or len(tasks) < PAR_MIN:
for t in tasks:
yield fn(t)
return
if self.pool is None:
from concurrent.futures import ProcessPoolExecutor
self.pool = ProcessPoolExecutor(max_workers=self.workers)
yield from self.pool.map(fn, tasks, chunksize=chunk)
def close(self) -> None:
if self.pool is not None:
self.pool.shutdown(wait=True, cancel_futures=True)
self.pool = None
def cmd_build(rewrite: bool = False, relabel: bool = False, runner: "Runner | None" = None) -> dict:
"""data/raw -> data/pages/*.txt и новые строки в data/labels.csv.
relabel: метки уже размеченных релизов, у которых сохранён HTML, строятся
заново текущими парсерами (старые строки заменяются; ручные метки и релизы
без HTML не трогаются; если парсер ничего не нашёл — остаются прежние).
Разбор идёт в нескольких процессах (настройка workers, по умолчанию ядра − 2).
"""
import rebuild_workers as rw
if migrate_labels():
say("labels.csv: добавлены колонки lang и audio_raw (копия: labels.bak_schema.csv)")
own_runner = runner is None
runner = runner or Runner()
rows = read_labels()
labeled = labeled_ids(rows)
PAGES.mkdir(parents=True, exist_ok=True)
new_rows: list[dict] = []
replaced: set[tuple[str, str]] = set() # (источник, release_id) с новыми метками
manual = set(MANUAL.read_text(encoding="utf-8").split()) if MANUAL.exists() else set()
stats = {"pages": 0, "labeled": 0, "skipped_no_labels": 0, "unchanged": 0}
postonly = set(POSTONLY.read_text(encoding="utf-8").split()) if POSTONLY.exists() else set()
built = {} if rewrite else load_json(RAW / "_built.json") # что уже разобрано: «подпись» файлов + имя страницы
t0 = time.time()
per: dict[str, dict] = {}
prog = ui.progress()
prog.start()
try:
for site in SITES:
d = RAW / site
if not d.exists():
continue
done = built.setdefault(site, {})
files = [jp for jp in sorted(d.glob("*.json"))
if not jp.name.endswith(".legacy.json") and jp.with_suffix(".html").exists()]
ps = per[site] = {"files": len(files), "unchanged": 0, "pages": 0, "relabeled": 0,
"new": 0, "no_labels": 0, "t": time.time()}
task_id = prog.add_task(f"{site}: разбор HTML", total=len(files))
# проход 1 (быстрый, на месте): что изменилось и что с ним делать
jobs: list[dict] = []
for jp in files:
hp = jp.with_suffix(".html")
sig = file_sig(jp, hp)
prev = done.get(jp.stem)
if prev and prev["sig"] == sig and (
prev["stem"] is None or (PAGES / f"{prev['stem']}.txt").exists()):
stats["unchanged"] += 1 # файлы и парсер не менялись
ps["unchanged"] += 1
prog.advance(task_id)
continue
rid = jp.stem
stem = page_stem(site, rid, labeled)
known = rid in labeled.get(site, ()) or stem in labeled.get(site, ())
page = PAGES / f"{stem}.txt"
# полная страница заменяет урезанную (текст поста) из импорта старого harvest
jobs.append({"site": site, "json": str(jp), "html": str(hp), "sig": sig, "stem": stem,
"known": known, "parse": not known or (relabel and stem not in manual),
"page": str(page) if (rewrite or not page.exists() or stem in postonly) else None})
# проход 2: разбор HTML и запись страниц — параллельно
for job, res in zip(jobs, runner.map(rw.build_one, jobs)):
prog.advance(task_id)
rid, stem = Path(job["json"]).stem, job["stem"]
done[rid] = {"sig": job["sig"], "stem": stem}
if res["error"]:
say(f"{site} {rid}: разбор не удался: {res['error']}")
if res["skipped"]:
stats["skipped_no_labels"] += 1
ps["no_labels"] += 1
done[rid]["stem"] = None
continue
if res["error"] and not job["known"]:
continue
if res["wrote"]:
stats["pages"] += 1
ps["pages"] += 1
if stem in postonly:
postonly.discard(stem)
stats["upgraded"] = stats.get("upgraded", 0) + 1
labs = res["labs"]
for r in labs:
r["release_id"] = stem
new_rows.append(r)
if labs and job["known"]:
replaced.add((site, stem))
ps["relabeled"] += 1
elif labs:
ps["new"] += 1
if labs:
stats["labeled"] += 1
finally:
prog.stop()
if replaced:
import shutil
backup = LABELS.with_name(f"labels.bak_{time.strftime('%Y%m%d_%H%M%S')}.csv")
shutil.copy2(LABELS, backup)
old = [r for r in rows if (r["source"], r["release_id"]) not in replaced]
stats["relabeled"], stats["rows_before"] = len(replaced), len(rows)
with LABELS.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=hv.FIELDS, extrasaction="ignore")
w.writeheader()
w.writerows(old)
stats["backup"] = backup.name
if new_rows:
fresh_file = not LABELS.exists() or LABELS.stat().st_size == 0
with LABELS.open("a", encoding="utf-8-sig" if fresh_file else "utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=hv.FIELDS, extrasaction="ignore")
if fresh_file:
w.writeheader()
w.writerows(new_rows)
stats["new_label_rows"] = len(new_rows)
if POSTONLY.exists():
POSTONLY.write_text("\n".join(sorted(postonly)) + ("\n" if postonly else ""), encoding="utf-8")
save_json(RAW / "_built.json", built)
try:
m = cmd_meta(rewrite, runner)
finally:
if own_runner:
runner.close()
stats["meta_rows"], stats["meta_reused"] = m["rows"], m["reused"]
ui.console.print(ui.summary_table("Итоги переиндексации", [
"трекер", "файлов HTML", "без изменений", "страниц записано", "метки пересчитаны",
"новые релизы", "без меток", "сек"],
[[s_, p_["files"], p_["unchanged"], p_["pages"], p_["relabeled"], p_["new"], p_["no_labels"],
f"{time.time() - p_['t']:.0f}"] for s_, p_ in per.items()]
+ [["meta.csv", m["rows"] + 0, f"из кэша {m['reused']}", "", "", "", "",
f"{time.time() - t0:.0f} всего, процессов {runner.workers}"]]))
say("build: " + ", ".join(f"{k}={v}" for k, v in stats.items()))
return stats
# Версия разбора: поднимите при правке парсеров (harvest_core, html_to_text) — тогда
# кэш «уже разобрано» сбросится сам и данные пересоберутся один раз.
PARSE_VERSION = 3
def load_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def save_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
def file_sig(*paths: Path) -> str:
"""Подпись входа: время и размер файлов + версия разбора."""
parts = [f"{p.stat().st_mtime_ns}:{p.stat().st_size}" for p in paths if p.exists()]
return "|".join(parts) + f"|v{PARSE_VERSION}"
def cmd_meta(rewrite: bool = False, runner: "Runner | None" = None) -> dict:
"""data/raw (и импорт старого harvest) -> data/meta.csv: одна строка на релиз.
Строки кэшируются (data/raw/_meta_cache.json) по подписи файлов, версии
извлекателя и названия раздела: разбирается только новое и изменённое.
Текст страницы берётся из data/pages (уже разобран при сборке), а не из HTML заново;
извлечение идёт в нескольких процессах.
"""
import rebuild_workers as rw
own_runner = runner is None
runner = runner or Runner()
labeled = labeled_ids()
cache = {} if rewrite else load_json(RAW / "_meta_cache.json")
fresh_cache: dict[str, dict] = {}
out: list[dict] = []
reused = 0
prog = ui.progress()
prog.start()
try:
for site in SITES:
d = RAW / site
if not d.exists():
continue
files: dict[str, Path] = {}
for jp in sorted(d.glob("*.json"), key=lambda p: not p.name.endswith(".legacy.json")):
files[jp.name.split(".")[0]] = jp # legacy идут первыми, полные их заменят
task_id = prog.add_task(f"{site}: meta.csv", total=len(files))
jobs: list[dict] = []
for key, jp in files.items():
hp = jp.with_suffix(".html")
ck = f"{site}:{key}"
sig = f"{file_sig(jp, hp)}|m{metamod.META_VERSION}"
hit = cache.get(ck)
if hit and hit["sig"] == sig:
rec_ref = hit["ref"]
name = section_name(site, rec_ref) if rec_ref and rec_ref != "backfill" else ""
if hit["section"] == name: # название раздела не менялось
out.append(hit["row"])
fresh_cache[ck] = hit
reused += 1
prog.advance(task_id)
continue
rec = json.loads(jp.read_text(encoding="utf-8"))
rid = str(rec["id"])
ref = rec.get("section_ref", "")
name = section_name(site, ref) if ref and ref != "backfill" else ""
stem = page_stem(site, rid, labeled)
jobs.append({"site": site, "rec": rec, "name": name, "html": str(hp), "ck": ck,
"sig": sig, "ref": ref, "stem": stem,
"page": str(PAGES / f"{stem}.txt") if hp.exists() else None})
for job, row in zip(jobs, runner.map(rw.meta_one, jobs)):
prog.advance(task_id)
row["release_id"] = job["stem"]
out.append(row)
fresh_cache[job["ck"]] = {"sig": job["sig"], "ref": job["ref"], "section": job["name"], "row": row}
finally:
prog.stop()
if own_runner:
runner.close()
save_json(RAW / "_meta_cache.json", fresh_cache)
out.sort(key=lambda r: (r["source"], r["release_id"]))
META.parent.mkdir(parents=True, exist_ok=True)
with META.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=metamod.FIELDS, extrasaction="ignore")
w.writeheader()
w.writerows(out)
return {"rows": len(out), "reused": reused}
def legacy_text(site: str, rec: dict) -> str:
"""Текст страницы из записи старого harvest (только то, что там сохранено)."""
if site == "kinozal":
full = rec.get("page_text") or ""
tab = (rec.get("release_tab") or "").strip()
tech = (rec.get("tech_tab") or "").strip()
parts = [full] + [t for t in (tab, tech) if t and t not in full]
return "\n\n".join(p for p in parts if p)
return f"{rec.get('title') or ''}\n\n{rec.get('post') or ''}"
def cmd_import_legacy(src_dir: str) -> dict:
"""Страницы для уже размеченных раздач из старых jsonl (без браузера).
Это урезанный вход — только текст поста (kinozal: ещё и вкладки). Такие
страницы перечислены в data/pages_postonly.txt, чтобы оценку на них можно
было отделять от полных страниц.
"""
base = Path(src_dir)
labeled = labeled_ids()
PAGES.mkdir(parents=True, exist_ok=True)
added: list[str] = []
for site in SITES:
for cand in (base / "raw" / f"{site}.jsonl", base / f"{site}.jsonl"):
if cand.exists():
break
else:
say(f"{site}: нет {site}.jsonl в {base}")
continue
n = 0
for line in cand.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
rec = json.loads(line)
rid = str(rec.get("id"))
if not rec.get("ok", True) or rid not in labeled.get(site, ()):
continue
stem = page_stem(site, rid, labeled)
page = PAGES / f"{stem}.txt"
text = legacy_text(site, rec).strip()
keep = {k: rec[k] for k in ("id", "source", "page_title", "title", "post", "h1",
"release_tab", "tech_tab") if k in rec}
if rec.get("page_text"):
keep["page_text"] = rec["page_text"][:8000]
(RAW / site).mkdir(parents=True, exist_ok=True)
(RAW / site / f"{rid}.legacy.json").write_text(
json.dumps(keep, ensure_ascii=False), encoding="utf-8")
if page.exists() or len(text) < 40:
continue
page.write_text(text, encoding="utf-8")
added.append(stem)
n += 1
say(f"{site}: страниц добавлено {n}")
if added:
prev = POSTONLY.read_text(encoding="utf-8").split() if POSTONLY.exists() else []
POSTONLY.write_text("\n".join(sorted(set(prev) | set(added))) + "\n", encoding="utf-8")
return {"added": len(added)}
# ---------------------------------------------------------------- статус
def read_meta() -> list[dict]:
if not META.exists():
return []
with META.open(encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def coverage_rows() -> list[dict]:
labeled = labeled_ids()
pages = {p.stem for p in PAGES.glob("*.txt")} if PAGES.exists() else set()
postonly = set(POSTONLY.read_text(encoding="utf-8").split()) if POSTONLY.exists() else set()
meta_cnt = Counter(r["source"] for r in read_meta())
rows = []
for site in SITES:
stems = {page_stem(site, i, labeled) for i in labeled.get(site, set())}
with_page = stems & pages
rows.append({"site": site, "labeled": len(stems), "pages": len(with_page),
"postonly": len(with_page & postonly), "raw": len(stored_ids(site)),
"meta": meta_cnt.get(site, 0), "missing": len(stems - pages)})
return rows
def cmd_status(detailed: bool = True) -> None:
"""Покрытие датасета, состав по трекерам (тип, разрешение, кодек, годы, языки), прогресс."""
from rich.columns import Columns
from rich.panel import Panel
from rich.table import Table
ui.console.print(ui.coverage_table(coverage_rows()))
if not detailed:
return
meta = read_meta()
if meta:
for site in SITES:
m = [r for r in meta if r["source"] == site]
if not m:
continue
years = Counter((r["year"][:3] + "0-е") if r["year"] else "—" for r in m)
langs = Counter(x for r in m for x in r["audio_langs"].split("+") if x)
blocks = [
ui.distribution("Тип", Counter(r["kind"] for r in m)),
ui.distribution("Разрешение", Counter(r["resolution"] or "—" for r in m)),
ui.distribution("Кодек видео", Counter(r["video_codec"] or "—" for r in m)),
ui.distribution("Годы", Counter(dict(sorted(years.items(), reverse=True)))),
ui.distribution("Языки дорожек", langs),
]
ui.console.print(Panel(Columns(blocks, equal=True, expand=True),
title=f"[bold]{site}[/] · {len(m)} релизов",
border_style=ui.SITE_STYLE[site]))
else:
say("meta.csv пока нет: запустите import-legacy или «Собрать pages и labels».")
rows = [(s, ref, v) for s, refs in load_state().items() for ref, v in refs.items()]
if rows:
t = Table(title="Прогресс по разделам", title_justify="left")
for c in ("Трекер", "Раздел", "След. стр.", "Статус"):
t.add_column(c)
for s, ref, v in rows:
t.add_row(s, section_name(s, ref)[:70], str(v.get("next_page", 0) + 1),
"список закончился" if v.get("finished") else "идёт")
ui.console.print(t)
# ---------------------------------------------------------------- меню
def ask(prompt: str, default: str = "") -> str:
return ui.ask(prompt, default).strip()
def ask_sites(default: str = "rutracker,nnm,kinozal") -> list[str]:
raw = ask("Трекеры через запятую (rutracker, nnm, kinozal)", default)
sites = [s.strip() for s in raw.split(",") if s.strip() in SITES]
return sites or list(SITES)
def wizard_collect() -> None:
"""Пункт 1: окна -> вход -> сбор -> страницы и метки -> data/processed."""
stop = threading.Event()
sites = ask_sites()
cmd_windows(sites)
say("\nЧто собирать?")
say(" n новые раздачи из списков разделов")
say(" b страницы для уже размеченных раздач (nnm, kinozal)")
say(" nb и то и другое")
mode = ask("Режим", "nb")
cfg = settings()
limit = int(ask("Максимум страниц на трекер за запуск", str(cfg["limit"])))
tabs = clamp_tabs(ask("Вкладок на трекер, параллельно (1–4; больше — быстрее, но выше риск лимитов)",
str(cfg["tabs"])))
delay = float(ask("Пауза между раздачами на одну вкладку, с", str(cfg["delay"])))
pages = int(ask("Страниц списка на раздел (для режима n)", str(cfg["pages"]))) if "n" in mode else 0
save_settings({**cfg, "limit": limit, "tabs": tabs, "delay": delay,
"pages": pages or cfg["pages"]}) # запоминаем на следующий запуск
jobs = {}
if "n" in mode:
ensure_sections(sites)
for s in sites:
refs = choose_refs(s) if "n" in mode else ""
def job(s=s, refs=refs):
if "b" in mode:
run_backfill(s, limit, delay, stop)
if "n" in mode and not stop.is_set():
run_collect(s, [r.strip() for r in refs.split(",") if r.strip()],
pages, limit, delay, stop)
jobs[s] = safe(s, job)
run_threads(jobs, stop)
say("\nСобираю data/pages и data/labels.csv…")
cmd_build()
say("Пересобираю data/processed…")
import retrain
if not retrain.prepare():
say("prepare_data завершился с ошибкой")
return
cmd_status()
say("\nДатасет собран. Дальше — пункт 2 (переобучить и протестировать).")
# ---------------------------------------------------------------- обучение по ссылкам
MANUAL = DATA / "manual_ids.txt"
MODELS_TMP = ROOT / "models" / ".compare.json"
RE_LINK = {
"rutracker": re.compile(r"rutracker\.[a-z]+/forum/viewtopic\.php\?(?:[^\s#]*&)?t=(\d+)", re.I),
"nnm": re.compile(r"nnmclub\.[a-z]+/forum/viewtopic\.php\?(?:[^\s#]*&)?t=(\d+)", re.I),
"kinozal": re.compile(r"kinozal\.[a-z]+/details\.php\?(?:[^\s#]*&)?id=(\d+)", re.I),
}
PARSERS = {"kinozal": hv.kz_parse, "rutracker": hv.rt_parse, "nnm": hv.nnm_parse}
def parse_link(url: str) -> tuple[str, str] | None:
"""Ссылка на раздачу -> (трекер, id раздачи)."""
for site, rx in RE_LINK.items():
m = rx.search(url or "")
if m:
return site, m.group(1)
return None
def store_manual(site: str, rec: dict, rows: list[dict], page_text: str) -> str:
"""Записывает проверенные вручную метки релиза.
Прежние строки этого релиза в labels.csv заменяются, страница кладётся в
data/pages, id попадает в data/manual_ids.txt (всегда train, усиление).
"""
migrate_labels()
labeled = labeled_ids()
stem = page_stem(site, str(rec["id"]), labeled)
keep = [r for r in read_labels() if not (r["source"] == site and r["release_id"] == stem)]
for r in rows:
r["release_id"] = stem
r["studio_source"] = "manual"
PAGES.mkdir(parents=True, exist_ok=True)
(PAGES / f"{stem}.txt").write_text(page_text, encoding="utf-8")
with LABELS.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=hv.FIELDS, extrasaction="ignore")
w.writeheader()
w.writerows(keep + rows)
ids = set(MANUAL.read_text(encoding="utf-8").split()) if MANUAL.exists() else set()
MANUAL.write_text("\n".join(sorted(ids | {stem})) + "\n", encoding="utf-8")
return stem
def _rows_table(rows: list[dict]):
from rich import box
from rich.table import Table
t = Table(box=box.SIMPLE_HEAD, expand=True, pad_edge=False)
for col in ("#", "студия", "тип", "18+", "кодек", "кбит/с", "строка"):
t.add_column(col, overflow="ellipsis", no_wrap=col != "строка")
for i, r in enumerate(rows, 1):
t.add_row(str(i), r["studio"], r["translation_type"],
"[red]18+[/red]" if int(r["age_18"] or 0) else "", r["codec"], r["kbps"],
(r["raw"] or "")[:70])
return t
def edit_rows(rows: list[dict]) -> list[dict]:
"""Ручная правка меток: «2 18», «3 d», «1 t проф. двухголосый», «1 s HDrezka»."""
while True:
ui.console.print(_rows_table(rows))
say("Правка: «N 18» — переключить 18+, «N d» — удалить, «N t <тип>», «N s <студия>»; "
"Enter — сохранить")
cmd = ask("Правка", "").strip()
if not cmd:
return rows
parts = cmd.split(None, 2)
try:
r = rows[int(parts[0]) - 1]
op = parts[1]
except (ValueError, IndexError):
say("не понял; пример: «2 18»")
continue
arg = parts[2] if len(parts) > 2 else ""
if op == "18":
now = not int(r["age_18"] or 0)
r["age_18"] = int(now)
r["note"] = hv.mark_18(r["note"], True) if now else (r["note"] or "").replace("18+", "").strip()
elif op == "d":
rows.remove(r)
elif op == "t" and arg:
r["translation_type"] = arg
elif op == "s" and arg:
r["studio"] = arg
else:
say("не понял; пример: «2 18»")
def wizard_links() -> None:
"""Обучение по конкретным ссылкам: страница -> метки -> ручная правка -> labels.csv."""
from src.html_extract import html_to_text
say("Вставьте ссылки на раздачи (rutracker / nnm-club / kinozal), по одной в строке.")
say("Пустая строка — закончить. Страницы берутся через ваш Chrome (нужен вход).")
links: list[tuple[str, str]] = []
while True:
line = input("> ").strip()
if not line:
break
got = parse_link(line)
if got is None:
say(f"не похоже на ссылку на раздачу: {line}")
elif got not in links:
links.append(got)
if not links:
return
saved: list[str] = []
for site in SITES:
todo = [rid for s, rid in links if s == site]
if not todo:
continue
cdp = connect_site(site)
try:
for rid in todo:
say(f"скачиваю {rid}…", site)
rec = fetch_release(cdp, site, rid)
if not rec.get("ok") or not has_body(site, rec):
say(f"{rid}: страница не загрузилась ({rec.get('reason', 'пусто')})", site)
continue
html = rec.pop("_html", "")
rows, _ = PARSERS[site](rec)
say(f"{rec.get('page_title') or rec.get('title') or rid}", site)
rows = edit_rows(rows) if rows else []
if not rows:
say("меток нет — релиз не сохранён", site)
continue
save_release(site, {**rec, "_html": html})
saved.append(store_manual(site, rec, rows, html_to_text(html)))
finally:
cdp.close()
if saved:
cmd_build()
say(f"\nДобавлено релизов: {len(saved)} ({', '.join(saved)}). Они всегда идут в train и "
"повторяются при обучении; модель переучите пунктом «Переобучить».")
if ask("Переобучить сейчас? (y/N)", "n").lower() == "y":
wizard_retrain()
class Stages:
"""План шагов с нумерацией, временем каждого шага и итоговой таблицей."""
def __init__(self, titles: list[str]) -> None:
self.titles, self.done, self.t0 = titles, [], time.time()
self.cur: tuple[int, float] | None = None
def plan(self) -> None:
ui.console.print("[bold]План:[/bold]")
for i, t in enumerate(self.titles, 1):
ui.console.print(f" [dim]{i}.[/dim] {t}")
ui.console.print()
def start(self, title: str) -> None:
self.finish()
i = len(self.done) + 1
ui.console.rule(f"[bold]шаг {i}/{len(self.titles)}[/bold] {title} "
f"[dim](всего прошло {ui.fmt_time(time.time() - self.t0)})[/dim]")
self.cur = (i, time.time(), title)
def finish(self, ok: bool = True) -> None:
if self.cur:
self.done.append((self.cur[2], time.time() - self.cur[1], ok))
self.cur = None
def summary(self) -> None:
self.finish(ok=False if self.cur else True)
ui.console.print(ui.summary_table("Итоги по шагам", ["шаг", "время", "результат"], [
[t, ui.fmt_time(sec), "ok" if ok else "прервано/ошибка"] for t, sec, ok in self.done]
+ [["всего", ui.fmt_time(time.time() - self.t0), ""]]))
def dataset_stats() -> None:
"""Состав данных перед обучением: размечено, страницы, 18+, ручные метки."""
rows = read_labels()
manual = set(MANUAL.read_text(encoding="utf-8").split()) if MANUAL.exists() else set()
by = Counter(r["source"] for r in rows)
adult = Counter(r["source"] for r in rows if str(r.get("age_18", "")).strip() in ("1", "True"))
rel = {(r["source"], r["release_id"]) for r in rows}
relc = Counter(s_ for s_, _ in rel)
ui.console.print(ui.summary_table("Данные для обучения", ["трекер", "релизов", "строк-дорожек", "из них 18+"], [
[s_, relc.get(s_, 0), by.get(s_, 0), adult.get(s_, 0)] for s_ in SITES]
+ [["всего", len(rel), len(rows), sum(adult.values())],
["ручные (пункт 6)", len(manual), "", ""]]))
def ask_profiles() -> tuple[list[str], int]:
"""Какие основы обучать и нужна ли кросс-валидация."""
import retrain
say("\nОсновы для обучения (каждая — отдельный запуск в models/runs/, models/best не трогается):")
for name, text in retrain.PROFILES.items():
say(f" {name:7s} {text}")
raw = ask("Профили через запятую", "base")
profiles = [p.strip() for p in raw.split(",") if p.strip() in retrain.PROFILES] or ["base"]
say("Кросс-валидация (5 доп. прогонов) нужна только для оценки устойчивости, на итоговую модель не влияет.")
folds = 5 if ask("С кросс-валидацией? Дольше примерно в 6 раз (y/N)", "n").lower() == "y" else 0
return profiles, folds
def wizard_retrain() -> None:
"""Пункт 2: (переиндексация) -> prepare -> обучение -> тесты -> сравнение -> подмена весов."""
import retrain
say("Данные: сохранённый HTML трекеров можно разобрать заново без захода на сайты.")
reindex = ask("Переиндексировать data/raw (страницы, метки, meta) текущими парсерами? (y/N)", "n").lower() == "y"
say("\nЧто переобучать?")
say(" 1 сегментатор страниц (минуты, GPU)")
say(" 2 сегментатор + основные модели (тип, автор, 18+, язык; выбор основы и сравнение с прежней лучшей)")
what = ask("Выбор", "1")
profiles, folds = ask_profiles() if what == "2" else ([], 0)
do_prepare = ask("Пересобрать data/processed из data/labels.csv? (Y/n)", "y").lower() != "n"
train_title = f"Обучение моделей: {', '.join(profiles)}" + (" (+ кросс-валидация)" if folds else "")
titles = ((["Переиндексация data/raw (страницы, метки, meta)"] if reindex else [])
+ (["prepare_data: data/labels.csv → data/processed"] if do_prepare else [])
+ ([train_title] if what == "2" else [])
+ ["Обучение сегментатора (models/segmenter_v2)", "Тесты проекта",
"Сравнение по трекерам (правила / старые веса / новые)"])
ui.clear()
st = Stages(titles)
st.plan()
try:
if reindex:
st.start(titles[0])
r = cmd_build(rewrite=True, relabel=True)
if r.get("relabeled"):
say(f"Метки пересчитаны у {r['relabeled']} релизов, копия прежнего файла: data/{r['backup']}")
dataset_stats()
if do_prepare:
st.start("prepare_data: data/labels.csv → data/processed")
if not retrain.prepare():
say("prepare_data завершился с ошибкой")
return
if what == "2":
st.start(train_title)
if not retrain.train_models(profiles, folds):
say("обучение основных моделей завершилось с ошибкой")
return
new = "models/segmenter_v2"
st.start("Обучение сегментатора (models/segmenter_v2)")
if not retrain.train_segmenter(new):
say("обучение сегментатора завершилось с ошибкой")
return
st.start("Тесты проекта")
tests_ok = retrain.run_tests()
dirs = (["models/segmenter"] if (ROOT / "models" / "segmenter").exists() else []) + [new]
st.start("Сравнение по трекерам (правила / старые веса / новые)")
try:
results = retrain.compare(dirs)
except RuntimeError as exc:
say(str(exc))
return
st.finish()
if len(dirs) == 2:
better, why = retrain.verdict(results, dirs[0], dirs[1])
say(f"\nНовые веса {'лучше' if better else 'НЕ лучше'} старых: {why}")
say(f"Тесты проекта: {'ok' if tests_ok else 'ЕСТЬ ПАДЕНИЯ'}")
default = "y" if better and tests_ok else "n"
else:
default = "y"
finally:
st.summary()
if ask("Заменить models/segmenter новыми весами? (y/N)", default).lower() == "y":
archive = retrain.promote(new)
say(f"Готово. Старые веса: models/{archive}" if archive else "Готово.")
else:
say(f"Оставлено как есть. Новые веса: {new}")
if what == "2":
say("\nТеперь выберите основную модель.")
wizard_models()
def _pct(x: float | None) -> str:
return "—" if x is None else f"{x * 100:.1f}"
def wizard_models() -> None:
"""Пункт 7: сравнить все модели на ОДНОЙ текущей тестовой выборке и выбрать, какая станет models/best."""
import retrain
from src import runs
if not runs.candidates():
say("Нет обученных моделей в models/.")
return
if not runs.DATASET.exists():
say("Нет data/processed/dataset.json: сначала prepare_data (пункт 2).")
return
say("Все модели оцениваются заново на текущих тестовых строках (в том входном формате, на котором они "
"обучены), поэтому цифры сравнимы. Результат кэшируется.\n")
while True:
data = retrain.evaluate_models(MODELS_TMP)
if not data:
say("оценка не удалась")
return
items = data["items"]
rows = []
for i, it in enumerate(items, 1):
m, lo, hi = it["metrics"], it["ci"][0], it["ci"][1]
tag = (it["name"] + (f" ({it['profile']})" if it["profile"] and it["profile"] != it["name"] else "")
+ (" *" if it.get("leak_risk") else ""))
rows.append([i, tag, it["backbone"].split("/")[-1], it["mb"],
f"{_pct(m['joint_exact'])} [{_pct(lo)}–{_pct(hi)}]",
_pct(m["type_macro_f1"]), _pct(m["span_exact"]), _pct(m.get("age_f1")),
_pct(m.get("lang_accuracy")), runs.verdict(it)])
ui.console.print(ui.summary_table(
f"Тестовая выборка: {data['n']} строк (отпечаток {data['fingerprint']}); числа в %",
["№", "модель", "основа", "МБ", "joint exact [95%]", "type F1", "span", "18+ F1", "язык",
"против models/best"], rows))
base = next((it for it in items if it["name"] == "best"), None)
good = [(it["metrics"]["joint_exact"], i) for i, it in enumerate(items, 1)
if it.get("vs") and it["vs"]["p"] <= 0.05 and it["vs"]["fixed"] > it["vs"]["broken"]]
if good:
say(f"Рекомендую №{max(good)[1]}: значимо лучше текущей на тех же строках.")
else:
say("Ни одна модель не лучше текущей значимо — менять best нет оснований."
if base else "models/best нет: выберите любую.")
if any(it.get("leak_risk") for it in items):
say("* модель обучена на другом разбиении данных: часть текущих тестовых строк она могла видеть "
"при обучении, её цифры завышены, и новая модель сравнивается с ней в невыгодных условиях.")
say("joint exact — строка распознана целиком (тип + автор + 18+). Разница в пределах доверительного "
"интервала — это шум теста, а не улучшение.")
raw = ask("№ модели для models/best (dN — удалить запуск, r — пересчитать, Enter — выйти)", "").lower()
if not raw:
return
if raw == "r":
retrain.evaluate_models(MODELS_TMP, refresh=True)
continue
delete = raw.startswith("d")
try:
it = items[int(raw.lstrip("d")) - 1]
except (ValueError, IndexError):
say("нет такого номера")
continue
if delete:
if not runs.deletable(it["dir"]):
say("Удалять можно только запуски (models/runs) и best_backup_*.")
elif ask(f"Удалить {it['dir']} ({it['mb']} МБ)? (y/N)", "n").lower() == "y":
runs.delete(it["dir"])
say("удалено")
continue
if it["name"] == "best":
say("Это и есть текущая models/best.")
continue
for w in runs.warnings_for(it["dir"]):
say("⚠ " + w)
if ask(f"Сделать {it['name']} моделью models/best? Прежняя уйдёт в models/best_backup_* (y/N)",
"n").lower() == "y":
archive = runs.promote(it["dir"])
say(f"Готово. Прежняя модель: models/{archive}. Перезапустите сервис, чтобы он взял новую."
if archive else "Готово.")
def banner_lines() -> list[str]:
rows = coverage_rows()
labeled = sum(r["labeled"] for r in rows)
pages = sum(r["pages"] for r in rows)
models = [d for d in ("best", "segmenter", "segmenter_v2") if (ROOT / "models" / d).exists()]
cfg = settings()
return [f"Размечено релизов: {labeled} страниц: {pages} meta: {sum(r['meta'] for r in rows)}",
f"Модели в models/: {', '.join(models) or 'нет'}",
f"Сбор: вкладок на трекер {cfg['tabs']} пауза {cfg['delay']} с лимит {cfg['limit']}",
f"Chrome: порт {hv.CHROME_PORT} профиль: {hv.CHROME_PROFILE}"]
def settings_screen() -> None:
"""Пункт «Настройки»: число вкладок (потоков) на трекер и параметры сбора."""
cfg = settings()
ui.console.print("[bold]Настройки сбора[/bold] [dim](Enter — оставить текущее)[/dim]\n")
ui.console.print("Вкладок на трекер — сколько страниц качается одновременно. "
"[dim]1 — медленно и безопасно; 3–4 — в 2–3 раза быстрее, но чаще лимиты и Cloudflare.[/dim]")
cfg["tabs"] = clamp_tabs(ask("Вкладок на трекер (1–4)", str(cfg["tabs"])))
cfg["delay"] = float(ask("Пауза между раздачами на одну вкладку, с", str(cfg["delay"])))
cfg["limit"] = int(ask("Лимит страниц на трекер за запуск", str(cfg["limit"])))
ui.console.print("\nРежим выборки: [bold]balanced[/bold] — страницы вразброс по всему разделу, в каждой "
"берутся темы из недопредставленных годов и качеств; [bold]sequential[/bold] — подряд "
"от новых к старым.")
cfg["mode"] = "sequential" if ask("Режим (balanced / sequential)", cfg["mode"]).lower().startswith("s") else "balanced"
cfg["per_page"] = max(1, int(ask("Раздач с одной страницы списка (режим balanced)", str(cfg["per_page"]))))
cfg["pages"] = int(ask("Страниц списка на раздел (режим sequential)", str(cfg["pages"])))
cfg["workers"] = max(0, int(ask("Процессов для переиндексации (0 — авто: ядра минус 2)", str(cfg["workers"]))))
save_settings(cfg)
say(f"Сохранено: вкладок {cfg['tabs']}, пауза {cfg['delay']} с, лимит {cfg['limit']}, "
f"режим {cfg['mode']}, раздач со страницы {cfg['per_page']}, страниц списка {cfg['pages']}")
def menu() -> None:
while True:
ui.clear()
ui.console.print(ui.banner(banner_lines()))
ui.console.print(ui.menu_table([
("1", "Собрать датасет [dim](окна → вход → поиск разделов → сбор → pages/labels/meta)[/dim]"),
("2", "Переобучить и протестировать [dim](обучение → тесты → сравнение → подмена весов)[/dim]"),
("3", "Статистика датасета [dim](по трекерам: тип, качество, годы, языки)[/dim]"),
("4", "Дополнительно [dim](отдельные шаги сбора, импорт, поиск разделов)[/dim]"),
("5", "Настройки [dim](вкладок на трекер, пауза, лимиты)[/dim]"),
("6", "Обучение по ссылкам [dim](добавить раздачи вручную, поправить метки, переобучить)[/dim]"),
("7", "Модели [dim](сравнить обученные на одной выборке, выбрать лучшую, откатиться)[/dim]"),
("0", "Выход")]))
choice = ask("Пункт", "").lower()
if choice in ("0", "q", ""):
return
ui.clear() # чистый экран под выбранный режим
try:
if choice == "1":
wizard_collect()
elif choice == "2":
wizard_retrain()
elif choice == "3":
cmd_status()
elif choice == "4":
advanced_menu()
continue
elif choice == "5":
settings_screen()
elif choice == "6":
wizard_links()
elif choice == "7":
wizard_models()
else:
continue
except KeyboardInterrupt:
say("\nпрервано")
except Exception as exc: # noqa: BLE001
say(f"ошибка: {exc}")
ui.pause() # дать прочитать результат до очистки
def advanced_menu() -> None:
stop = threading.Event()
while True:
ui.clear()
ui.console.print(ui.menu_table([
("1", "Открыть окна браузера и войти в аккаунты"),
("2", "Собрать новые раздачи из списков"),
("3", "Дособрать страницы для уже размеченных раздач"),
("4", "Собрать data/pages, data/labels.csv и data/meta.csv из data/raw"),
("5", "Импортировать старый harvest [dim](быстро, без браузера)[/dim]"),
("6", "Статус"),
("7", "Найти разделы с зарубежными фильмами и сериалами заново"),
("0", "Назад")]))
choice = ask("Пункт", "").lower()
if choice in ("0", "q", ""):
return
ui.clear()
stop.clear()
try:
if choice == "1":
cmd_windows(ask_sites())
elif choice == "2":
sites = ask_sites()
pages = int(ask("Страниц списка на раздел", "2"))
limit = int(ask("Максимум раздач на трекер", "50"))
delay = float(ask("Пауза между раздачами, с", "2"))
jobs = {}
for s in sites:
refs = ask(f"{s}: {SITES[s]['what']}", SITES[s]["refs"])
jobs[s] = safe(s, lambda s=s, refs=refs: run_collect(
s, [r.strip() for r in refs.split(",") if r.strip()],
pages, limit, delay, stop))
run_threads(jobs, stop)
elif choice == "3":
sites = ask_sites("kinozal,nnm")
limit = int(ask("Максимум страниц на трекер", "100"))
delay = float(ask("Пауза между раздачами, с", "2"))
run_threads({s: safe(s, lambda s=s: run_backfill(s, limit, delay, stop))
for s in sites}, stop)
elif choice == "4":
cmd_build(ask("Пересобрать всё заново, игнорируя кэш? (y/N)", "n").lower() == "y")
elif choice == "5":
default = os.environ.get("DUB_LEGACY_DIR", "")
cmd_import_legacy(ask("Каталог старого harvest (содержит raw/*.jsonl)", default))
elif choice == "6":
cmd_status()
elif choice == "7":
ensure_sections(ask_sites(), force=True)
else:
continue
except KeyboardInterrupt:
say("\nпрервано")
except Exception as exc: # noqa: BLE001
say(f"ошибка: {exc}")
ui.pause()
def main() -> int:
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
os.environ["DUB_TABS"] = str(settings()["tabs"]) # число вкладок из сохранённых настроек
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd")
p = sub.add_parser("windows")
p.add_argument("--sites", default=",".join(SITES))
p = sub.add_parser("collect")
p.add_argument("--site", required=True, choices=list(SITES))
p.add_argument("--refs", help="разделы/адреса списков через запятую")
p.add_argument("--pages", type=int, default=2)
p.add_argument("--limit", type=int, default=50)
p.add_argument("--delay", type=float, default=2.0)
p.add_argument("--from-page", type=int)
p.add_argument("--tabs", type=int, default=3, help="вкладок на трекер (параллельно)")
p = sub.add_parser("backfill")
p.add_argument("--site", required=True, choices=list(SITES))
p.add_argument("--limit", type=int, default=100)
p.add_argument("--delay", type=float, default=2.0)
p.add_argument("--tabs", type=int, default=3, help="вкладок на трекер (параллельно)")
p = sub.add_parser("repair-html", help="сделать старые сохранённые страницы читаемыми (UTF-8, стили)")
p.add_argument("--no-css", action="store_true", help="только кодировка и адрес, без браузера")
p = sub.add_parser("build")
p.add_argument("--rewrite", action="store_true")
p.add_argument("--relabel", action="store_true",
help="пересчитать метки размеченных релизов из сохранённого HTML (с бэкапом labels.csv)")
p = sub.add_parser("import-legacy")
p.add_argument("--dir", required=True)
sub.add_parser("status")
sub.add_parser("meta")
p = sub.add_parser("discover")
p.add_argument("--sites", default=",".join(SITES))
p.add_argument("--force", action="store_true", help="искать заново, игнорируя кэш")
args = ap.parse_args()
stop = threading.Event()
if args.cmd is None:
menu()
elif args.cmd == "discover":
ensure_sections([s for s in args.sites.split(",") if s in SITES], args.force)
elif args.cmd == "windows":
cmd_windows([s for s in args.sites.split(",") if s in SITES])
elif args.cmd in ("collect", "backfill") and args.tabs:
os.environ["DUB_TABS"] = str(args.tabs)
if args.cmd == "collect":
found = ",".join(section_refs(args.site))
refs = [r.strip() for r in (args.refs or found or SITES[args.site]["refs"]).split(",")
if r.strip()]
run_threads({args.site: safe(args.site, lambda: run_collect(
args.site, refs, args.pages, args.limit, args.delay, stop, args.from_page))}, stop)
elif args.cmd == "backfill":
run_threads({args.site: safe(args.site, lambda: run_backfill(
args.site, args.limit, args.delay, stop))}, stop)
elif args.cmd == "repair-html":
cmd_repair_html(not args.no_css)
elif args.cmd == "build":
cmd_build(args.rewrite, args.relabel)
elif args.cmd == "import-legacy":
cmd_import_legacy(args.dir)
elif args.cmd == "status":
cmd_status()
elif args.cmd == "meta":
say(f"meta: {cmd_meta()['rows']} строк -> {META.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())