Docker-сервис, выбор основы и сравнение моделей, сборщик датасета
docker / build (push) Failing after 39s

- Docker: Dockerfile (CPU), compose с FlareSolverr, настройка через DUB_* переменные,
  токен доступа к /predict, tools/pack_models.py, workflow Gitea Actions, docs/docker.md
- Модель грузится офлайн (config.json рядом с весами); общий код вынесен в src/spans.py,
  сервису не нужны sklearn/matplotlib/pandas
- Обучение: профили tiny2/base/large/modern, запуски в models/runs, реестр и честное
  сравнение на одной тестовой выборке (src/runs.py), пункт меню «Модели»
- Датасет: язык и аудио-параметры дорожек, 18+ по строкам «Аудио», ручные метки по ссылкам,
  переиндексация сохранённого HTML, параллельная сборка, сбалансированная выборка
- Тексты страниц (data/pages) больше не хранятся в git

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-28 17:19:10 +07:00
1 parent d155b8584b
commit efc9a4f56a
411 files changed
+87495 -27363

No files matched your search

+1849
View File
File diff suppressed because it is too large. Load diff
+172
View File
@@ -0,0 +1,172 @@
"""Автопоиск разделов трекеров с зарубежными фильмами и сериалами.
* rutracker, nnm: берём список разделов со страницы индекса, оставляем те, чьё
название подходит под INCLUDE и не попадает в EXCLUDE, и проверяем каждый
открытием первой страницы (пустые и «родительские» разделы отбрасываются).
Разные форматы (DVD / HD / UHD / 3D) и годы покрываются самими разделами
(«Зарубежное кино (UHD Video)», «Фильмы 2016-2020», «Зарубежная Классика»…).
* kinozal: разделов нет, есть фильтры browse.php (категория, формат, год).
Строится сетка «год × формат», категории чередуются, невозможные сочетания
(4K до 2012 и т. п.) пропускаются.
Результат кладётся в кэш (data/raw/_sections.json) и используется при
первом запуске сбора; пересоздать можно командой `discover --force`.
"""
from __future__ import annotations
import json
import re
import time
import harvest_core as hv
# меняется при правке правил отбора: кэш разделов со старой версией ищется заново
RULES_VERSION = 3
SECTIONS_TTL_DAYS = 30 # разделы меняются редко: раньше срока заново не ищем (кроме --force)
INDEX_URL = {
"rutracker": "https://rutracker.org/forum/index.php?c=7",
"nnm": "https://nnmclub.to/forum/index.php",
}
INCLUDE = {
"rutracker": re.compile(
r"(?i)зарубежн\w*\s+(?:кино|фильм\w*|сериал\w*|мульт\w*)|новинки и сериалы в стадии показа"
r"|^фильмы\s+20\d\d|классика мирового кинематографа|мультфильм\w*|мультсериал\w*"
r"|аниме|онгоинги"),
"nnm": re.compile(
r"(?i)зарубежн\w*\s+(?:кино|фильм\w*|сериал\w*|мульт\w*|новинк\w*|классик\w*)"
r"|старые зарубежные|зарубежная классика сериалов|мультфильм\w*|мультсериал\w*|аниме"),
}
# родительские разделы rutracker без собственных тем: подразделы берём со страницы раздела
PARENT = re.compile(r"(?i)^(?:мультфильмы|мультсериалы|аниме)$")
CARTOON = re.compile(r"(?i)мульт|аниме|онгоинг")
EXCLUDE = re.compile(
r"(?i)^зарубежное кино$|наш[еи]|русск|отечеств|ссср|ближн|азиат|индий|турец|латин|apple|iphone|ipad"
r"|олимп|предложен|помощь|книг|музык|видеоурок|игр\w*|коллекц|в оригинале|на др\. языках"
r"|без (?:русского|озвученного)|реклам|бренд|концерт|аудио|шансон|rap|rock"
r"|\bost\b|саундтрек|soundtrack|lossless|\bamv\b|клип|music|караоке|\bmp3\b|\bflac\b")
MIN_INDEX_LINKS = 100 # на индексе rutracker/nnm сотни разделов; меньше — страница не дозагрузилась
MIN_TOPICS = 5 # меньше тем на первой странице — раздел пустой или служебный
# kinozal: категории (c) чередуются; формат (v) и год (d) — сетка
KZ_BROWSE = "https://kinozal.me/browse.php?s=&g=0&c={c}&v={v}&d={d}&w=0&t=0&f=0"
# общие списки (все года и форматы) и категории для сетки год × формат
KZ_BASE_CATS = [("1002", "все фильмы"), ("46", "сериалы зарубежные"),
("21", "мульты зарубежные"), ("20", "аниме")]
KZ_GRID_CATS = KZ_BASE_CATS[:3]
KZ_FORMATS = [("3", "рипы HD", 2000), ("4", "Blu-Ray/Remux", 2006), ("7", "4K", 2012),
("1", "рипы DVD/BD", 1990), ("2", "DVD-5/9", 1990)]
KZ_YEARS = [2026, 2023, 2020, 2016, 2012, 2008, 2003, 1998]
_FORUM_LINKS_JS = r"""JSON.stringify([...document.querySelectorAll('a[href*="viewforum.php?f="]')]
.map(a => ({id: (a.getAttribute('href').match(/f=(\d+)/) || [])[1],
name: a.innerText.replace(/\s+/g, ' ').trim()})).filter(x => x.id && x.name))"""
def candidate_forums(cdp, site: str) -> list[dict]:
"""Разделы с индекса, подходящие по названию (без проверки наполнения)."""
nav = hv.goto(cdp, INDEX_URL[site], min_len=500, timeout=60)
if not nav["ok"]:
raise RuntimeError(f"{site}: индекс не открылся ({nav['title']})")
seen: dict[str, str] = {}
for _ in range(6): # индекс может дорисоваться уже после load
seen = {}
for it in json.loads(cdp.eval(_FORUM_LINKS_JS) or "[]"):
seen.setdefault(it["id"], it["name"])
if len(seen) >= MIN_INDEX_LINKS:
break
time.sleep(3)
else:
raise RuntimeError(f"{site}: индекс отдал только {len(seen)} разделов "
f"(нужно ≥ {MIN_INDEX_LINKS}) — войдите в аккаунт/пройдите Cloudflare")
return [{"ref": i, "name": n} for i, n in seen.items()
if INCLUDE[site].search(n) and not EXCLUDE.search(n)]
def subforums(cdp, site: str, ref: str) -> list[dict]:
"""Подразделы мультфильмов/аниме со страницы родительского раздела."""
base = "https://rutracker.org/forum/" if site == "rutracker" else "https://nnmclub.to/forum/"
nav = hv.goto(cdp, f"{base}viewforum.php?f={ref}", min_len=500, timeout=60)
if not nav["ok"]:
return []
out = {}
for it in json.loads(cdp.eval(_FORUM_LINKS_JS) or "[]"):
if it["id"] != ref and CARTOON.search(it["name"]) and not EXCLUDE.search(it["name"]):
out.setdefault(it["id"], it["name"])
return [{"ref": i, "name": n} for i, n in out.items()]
def probe(cdp, site: str, ref: str) -> int:
"""Число тем на первой странице раздела (0 — не открылся или пуст)."""
fn = hv.rt_forum_page if site == "rutracker" else hv.nnm_forum_page
ok, items = fn(cdp, ref, 0)
return len(items) if ok else 0
def discover_forums(cdp, site: str, on_stage=None, on_step=None, probes: dict | None = None,
on_probe=None) -> list[dict]:
"""Разделы-кандидаты с проверкой наполнения.
on_stage(text) — этап; on_step(done, total, name) — прогресс проверки;
probes {ref: число тем} — результаты прошлых проверок (повторно не открываются);
on_probe(ref, n) — вызывается после каждой новой проверки (для сохранения по ходу).
Возвращает все кандидаты с полями topics, keep, status ('ok' | '?' | 'мало').
"""
stage = on_stage or (lambda _t: None)
step = on_step or (lambda *_a: None)
probes = probes if probes is not None else {}
stage("читаю индекс разделов")
cands = candidate_forums(cdp, site)
known = {c["ref"] for c in cands}
parents = [c for c in cands if PARENT.match(c["name"])]
for i, parent in enumerate(parents):
stage(f"подразделы «{parent['name']}»")
for sub in subforums(cdp, site, parent["ref"]):
if sub["ref"] not in known:
known.add(sub["ref"])
cands.append(sub)
out = []
for i, c in enumerate(cands):
step(i, len(cands), c["name"])
n = probes.get(c["ref"]) or 0
if not n: # в кэше нет, либо прошлая проверка не удалась
n = probe(cdp, site, c["ref"])
time.sleep(1.5) # реже ловим лимиты Cloudflare
if n == 0:
n = probe(cdp, site, c["ref"])
if on_probe:
on_probe(c["ref"], n)
# 0 = не удалось проверить (оставляем), 1..MIN_TOPICS-1 = раздел почти пуст
keep = n == 0 or n >= MIN_TOPICS
out.append({**c, "topics": n, "keep": keep,
"status": "ok" if n >= MIN_TOPICS else ("?" if keep else "мало")})
step(len(cands), len(cands), "")
return out
def kinozal_grid() -> list[dict]:
"""Списки kinozal: сначала общие (все года и форматы), затем год × формат × категория."""
plan = [{"ref": KZ_BROWSE.format(c=c, v=0, d=0), "name": f"{cname}: все года и форматы"}
for c, cname in KZ_BASE_CATS]
for year in KZ_YEARS:
for v, vname, since in KZ_FORMATS:
if year < since:
continue
for c, cname in KZ_GRID_CATS:
plan.append({"ref": KZ_BROWSE.format(c=c, v=v, d=year),
"name": f"{year}, {vname}, {cname}"})
return plan
def kinozal_plan(cdp, log=print) -> list[dict]:
"""Сетка списков kinozal; первый адрес проверяется на живость."""
plan = kinozal_grid()
ok, ids = hv.kz_browse_ids(cdp, plan[0]["ref"])
log(f"kinozal: сетка из {len(plan)} списков (год × формат × категория); "
f"проверка первого: {'ok' if ok and ids else 'пусто/не открылся'} ({len(ids)} раздач)")
return plan if ok and ids else []
+109
View File
@@ -0,0 +1,109 @@
"""Разнообразная выборка раздач: годы, качество, фильмы/сериалы.
Список раздела идёт «от новых к старым», поэтому чтение подряд даёт только
свежие релизы. Здесь:
* страницы раздела берутся вразброс по всей глубине (последовательность с
золотым сечением, уже посещённые пропускаются);
* на странице выбираются не все темы, а те, чья «корзина» (годовая группа ×
качество × фильм/сериал) в датасете представлена меньше всего.
Всё работает по заголовкам тем из списка, заходить в раздачу для выбора не нужно.
"""
from __future__ import annotations
import csv
import random
import re
from collections import Counter
from pathlib import Path
RE_YEAR = re.compile(r"(?<!\d)(19[3-9]\d|20[0-3]\d)(?!\d)")
RE_SERIES = re.compile(r"(?i)сезон|сери[ия]|\bS\d{1,2}\b|\bE\d{1,3}\b|эпизод|\bTV\b|выпуск")
GOLDEN = 0.6180339887498949
QUALITY_RULES = [
("4K", re.compile(r"(?i)2160|\b4K\b|\bUHD\b")),
("1080", re.compile(r"(?i)1080|\bFHD\b")),
("720", re.compile(r"(?i)\b720|\bHD(?:TV)?\b|\bHDRip\b")),
("SD", re.compile(r"(?i)DVD|SAT|TVRip|VHS|\b480|\b576|CAMRip|TS\b")),
]
def year_of(title: str) -> int | None:
"""Год выпуска из заголовка: сначала из скобок «[2020, …]» / «(2020)», иначе первый."""
for m in re.finditer(r"[\[(]\s*(19[3-9]\d|20[0-3]\d)(?!\d)", title or ""):
return int(m.group(1))
m = RE_YEAR.search(title or "")
return int(m.group(1)) if m else None
def year_band(year: int | None) -> str:
if year is None:
return "?"
if year < 2010:
return f"{year // 10 * 10}-е"
return str(year)
def quality_of(title: str) -> str:
for name, rx in QUALITY_RULES:
if rx.search(title or ""):
return name
return "прочее"
def bucket(title: str) -> tuple[str, str, str]:
return (year_band(year_of(title)), quality_of(title),
"сериал" if RE_SERIES.search(title or "") else "фильм")
def counts_from_meta(path: Path, site: str | None = None) -> Counter:
"""Сколько релизов каждой корзины уже в датасете (по data/meta.csv)."""
out: Counter = Counter()
try:
with path.open(encoding="utf-8-sig", newline="") as f:
for r in csv.DictReader(f):
if site and r.get("source") != site:
continue
title = r.get("title") or ""
year = int(r["year"]) if str(r.get("year", "")).isdigit() else year_of(title)
q = quality_of(f"{title} {r.get('quality', '')} {r.get('resolution', '')}")
is_s = str(r.get("is_series", "")).lower() in ("true", "1")
out[(year_band(year), q, "сериал" if is_s else "фильм")] += 1
except OSError:
pass
return out
def pick(items: list[dict], n: int, counts: Counter, rng: random.Random) -> list[dict]:
"""n тем из страницы: сначала из самых малочисленных корзин (counts обновляется)."""
pool = list(items)
rng.shuffle(pool) # равные корзины -> случайный порядок
chosen: list[dict] = []
while pool and len(chosen) < n:
best = min(pool, key=lambda it: counts[bucket(it.get("text", ""))])
pool.remove(best)
counts[bucket(best.get("text", ""))] += 1
chosen.append(best)
return chosen
def next_pages(depth: int, visited: set[int], k: int, rng: random.Random) -> list[int]:
"""До k ещё не посещённых страниц, разбросанных по глубине раздела."""
if depth <= 0:
return []
left = depth - len(visited)
out: list[int] = []
x = rng.random()
tries = 0
while len(out) < min(k, left) and tries < depth * 8 + 50:
tries += 1
x = (x + GOLDEN) % 1.0
p = int(x * depth)
if p not in visited and p not in out:
out.append(p)
if len(out) < min(k, left): # хвост добираем подряд
out += [p for p in range(depth) if p not in visited and p not in out][:min(k, left) - len(out)]
return out
File diff suppressed because it is too large. Load diff
+314
View File
@@ -0,0 +1,314 @@
"""Метаданные релиза со страницы трекера (фильм / сериал / мультфильм / аниме).
extract(site, rec, section_name="") -> dict # одна строка data/meta.csv
Вход — запись сборщика (`data/raw/<трекер>/<id>.json` или старый harvest):
rutracker / nnm: поля `title` и `post`; kinozal: `h1`, `release_tab`, `tech_tab`.
Всё берётся из сохранённого текста, поэтому парсер можно улучшать и пересобирать
таблицу без повторных заходов на трекер.
Дорожки (`audio_tracks`, JSON): язык, кодек, число каналов, битрейт, тип
(DUB/MVO/DVO/AVO/VO/ORIG), студия, диапазон серий, 18+.
"""
from __future__ import annotations
import json
import re
# Поднимайте при правке извлечения: кэш строк meta.csv сбросится и пересоберётся один раз.
META_VERSION = 2
FIELDS = [
"source", "release_id", "title", "ru_title", "orig_title", "year", "kind", "is_series",
"season", "episodes", "episodes_total", "section",
"quality", "resolution", "video_codec", "hdr", "bit_depth", "fps", "container",
"size_gb", "duration_min", "duration_per_episode",
"countries", "genres", "directors", "cast", "age_limit", "mpaa", "release_group", "source_media",
"audio_langs", "orig_audio", "n_audio", "audio_tracks",
"sub_langs", "sub_forced", "sub_format",
]
LANGS = [("русск", "RUS"), ("russian", "RUS"), ("рус", "RUS"), ("rus", "RUS"),
("англий", "ENG"), ("english", "ENG"), ("eng", "ENG"),
("украин", "UKR"), ("ukrainian", "UKR"), ("ukr", "UKR"),
("япон", "JPN"), ("japanese", "JPN"), ("jpn", "JPN"),
("корей", "KOR"), ("korean", "KOR"), ("француз", "FRA"), ("french", "FRA"),
("немец", "DEU"), ("german", "DEU"), ("испан", "SPA"), ("spanish", "SPA"),
("итальян", "ITA"), ("italian", "ITA"), ("китайск", "CHI"), ("chinese", "CHI"),
("польск", "POL"), ("polish", "POL"), ("белорус", "BEL"), ("чешск", "CZE")]
CODEC_AUDIO = re.compile(
r"(?i)E-?AC-?3(?:\s*JOC)?|AC-?3|DTS-HD\s*MA|DTS-HD|DTS:X|DTS|TrueHD|FLAC|AAC|Opus|MP3|L?PCM")
TAG = re.compile(r"(?i)\b(DUB|MVO|DVO|AVO|VO|Original)\b")
TAG_WORDS = [("дублир", "DUB"), ("дубляж", "DUB"), ("многоголос", "MVO"), ("двухголос", "DVO"),
("одноголос", "AVO"), ("оригинал", "ORIG")]
RE_BITRATE = re.compile(r"(?i)(\d+(?:[.,]\d+)?)\s*(?:k|к)(?:bps|бит)")
RE_CHANNELS = re.compile(r"(?i)(\d+(?:\.\d)?)\s*(?:ch\b|канал)")
RE_LAYOUT = re.compile(r"(\d)/(\d)(?:\.\d)?")
RE_EPISODES_IN_TRACK = re.compile(r"(?i)сери[яию]\s*([\d\s,\-–]+)")
KEYS = {
"year": ("Год выпуска", "Год"),
"country": ("Страна", "Производство", "Выпущено"),
"genre": ("Жанр",),
"director": ("Режиссер", "Режиссёр"),
"cast": ("В ролях", "Актеры", "Актёры"),
"duration": ("Продолжительность",),
"quality": ("Качество видео", "Тип релиза", "Качество"),
"container": ("Контейнер",),
"size": ("Размер",),
"age": ("Возраст",),
"mpaa": ("Рейтинг MPAA",),
"group": ("Релиз от", "Релиз группы", "Релизер"),
"source_media": ("Источник", "Исходник"),
"orig_audio": ("Оригинальная аудиодорожка",),
"voice_langs": ("Язык озвучки",),
"subs": ("Субтитры",),
"sub_format": ("Формат субтитров",),
"video": ("Видео",),
}
def lang_of(word: str) -> str | None:
w = (word or "").strip().lower()
for stem, code in LANGS:
if w.startswith(stem):
return code
return None
def langs_in(text: str) -> list[str]:
out: list[str] = []
for part in re.split(r"[,;/+|]|\bи\b", text or ""):
code = lang_of(re.sub(r"[^A-Za-zА-Яа-яЁё]+", " ", part).strip().split(" ")[0] if part.strip() else "")
if code and code not in out:
out.append(code)
return out
def field(text: str, key: str) -> str:
for label in KEYS[key]:
m = re.search(rf"(?im)(?:^|\s){re.escape(label)}\s*:[ \t]*([^\n]+)", text)
if m and m.group(1).strip():
return m.group(1).strip()
return ""
def _num_range(s: str) -> str:
return re.sub(r"\s+", "", s).replace("–", "-")
def parse_title(title: str) -> dict:
t = re.sub(r"\s+", " ", title or "").strip()
parts = [p.strip() for p in t.split(" / ")]
ru = parts[0] if parts else ""
orig = ""
if len(parts) > 1 and not re.match(r"^(?:19|20)\d\d\b", parts[1]):
orig = re.split(r"\s*[\(\[]", parts[1])[0].strip()
year = re.search(r"\b(19\d\d|20[0-2]\d)\b", t)
season = re.search(r"(?i)сезон[ы]?\s*(\d+(?:\s*[-–]\s*\d+)?)", t) or re.search(r"\bS(\d{1,2})\b", t)
ep = re.search(r"(?i)сери[ияй]\s*(\d+(?:\s*[-–]\s*\d+)?)(?:\s*из\s*(\d+))?", t)
return {"ru_title": ru, "orig_title": orig, "year": year.group(1) if year else "",
"season": _num_range(season.group(1)) if season else "",
"episodes": _num_range(ep.group(1)) if ep else "",
"episodes_total": ep.group(2) if ep and ep.group(2) else ""}
def parse_video(line: str, extra: str = "") -> dict:
s = f"{line} {extra}"
codec = ""
if re.search(r"(?i)HEVC|H\.?265|x265", s):
codec = "HEVC"
elif re.search(r"(?i)AVC|H\.?264|x264", s):
codec = "AVC"
elif re.search(r"(?i)MPEG-?2", s):
codec = "MPEG2"
elif re.search(r"(?i)VC-?1", s):
codec = "VC1"
elif re.search(r"(?i)\bAV1\b", s):
codec = "AV1"
elif re.search(r"(?i)xvid|divx", s):
codec = "XviD/DivX"
res = ""
m = re.search(r"(\d{3,4})\s*[x×]\s*(\d{3,4})", s)
if m:
w = int(m.group(1))
res = "2160p" if w >= 3500 else "1080p" if w >= 1800 else "720p" if w >= 1200 else "480p"
else:
m = re.search(r"(?i)\b(2160|1440|1080|720|576|480)\s*p\b", s)
res = f"{m.group(1)}p" if m else ("2160p" if re.search(r"(?i)\b4K\b|UHD", s) else "")
hdr = []
for pat, name in ((r"HDR10\+", "HDR10+"), (r"HDR10(?!\+)", "HDR10"), (r"Dolby\s*Vision|\bDV\b", "DV"),
(r"(?<![A-Za-z0-9])HDR(?!10)", "HDR")):
if re.search(pat, s):
hdr.append(name)
bd = re.search(r"(?i)(\d{1,2})\s*-?\s*bit", s)
fps = re.search(r"(?i)(\d{2}(?:\.\d+)?)\s*(?:fps|кадр)", s)
return {"video_codec": codec, "resolution": res, "hdr": "+".join(dict.fromkeys(hdr)),
"bit_depth": bd.group(1) if bd else "", "fps": fps.group(1) if fps else ""}
_HOMOGLYPH = str.maketrans("АСЕ", "ACE") # «АС3» кириллицей на rutracker
def _track(body: str, lang: str | None = None) -> dict:
codec = CODEC_AUDIO.search(body.translate(_HOMOGLYPH))
ch = RE_CHANNELS.search(body)
channels = ""
if ch:
channels = ch.group(1).split(".")[0] if "." in ch.group(1) and ch.group(1).endswith(".0") else ch.group(1)
else:
lay = RE_LAYOUT.search(body)
if lay:
channels = str(int(lay.group(1)) + int(lay.group(2)) + (1 if re.search(r"LFE", body) else 0))
br = RE_BITRATE.search(body)
note = re.split(r"\s\|\s|\s-\s|\|", body, maxsplit=1)
note = note[1] if len(note) > 1 else ""
tag_m = TAG.search(note) or TAG.search(body)
tag = tag_m.group(1).upper() if tag_m else ""
if not tag:
low = note.lower()
tag = next((v for k, v in TAG_WORDS if k in low), "")
tag = "ORIG" if tag == "ORIGINAL" else tag
if lang is None:
lead = re.match(r"\s*([A-Za-z]{2,3})\s*:", body) # «RUS: AC3, …»
lang = lang_of(lead.group(1)) if lead else None
if lang is None:
for w in re.findall(r"[A-Za-zА-Яа-яЁё]+", note):
lang = lang_of(w)
if lang:
break
if lang is None and tag in ("DUB", "MVO", "DVO", "AVO", "VO"):
lang = "RUS"
studio = ""
m = re.search(r"\(([^()]*)\)", note)
if m:
studio = TAG.sub("", m.group(1)).strip(" ,;")
else: # rutracker: «… 192 kbps |HDrezka| 18+»
piped = re.search(r"\|\s*([^|]+?)\s*\|", body)
if piped and not re.fullmatch(r"(?i)original|оригинал|18\s*\+", piped.group(1)):
studio = TAG.sub("", piped.group(1)).strip(" ,;")
try:
from src.episodes import format_ranges, parse_episodes
episodes = format_ranges(parse_episodes(note)["ranges"])
except ImportError: # без репозитория рядом
eps = RE_EPISODES_IN_TRACK.search(note)
episodes = _num_range(eps.group(1)).strip(",") if eps else ""
return {"lang": lang or "", "codec": re.sub(r"\s+", " ", codec.group(0)).upper() if codec else "",
"channels": channels, "bitrate_kbps": int(float(br.group(1).replace(",", "."))) if br else "",
"tag": tag, "studio": studio, "episodes": episodes,
"is_18plus": "18+" in body}
def parse_audio(text: str) -> list[dict]:
tracks: list[dict] = []
# «Аудио: …», «Аудио 2: …» и rutracker-вид «Аудио RUS: AC3, 2 ch … |Студия| 18+»
for m in re.finditer(r"(?im)^\s*Аудио(?:\s*0*(\d*)\s*:[ \t]*|[^\S\n]+(?=[A-Za-z]{2,3}\s*:))([^\n]+)", text):
body = m.group(2).strip()
groups = re.findall(r"\(([^()]*(?:ch|канал)[^()]*)\)", body, flags=re.I)
if groups and not m.group(1): # kinozal: «Русский (AC3, 2 ch, …), (…), английский (…)»
lang = None
for g in re.finditer(r"(?:([A-Za-zА-Яа-яЁё]+)\s*)?\(([^()]*(?:ch|канал)[^()]*)\)", body, flags=re.I):
lang = lang_of(g.group(1)) or lang
tracks.append(_track(g.group(2), lang))
elif body:
tracks.append(_track(body))
return tracks
RE_TRANSLATION = re.compile(r"(?im)^\s*(?:Перевод|Озвучка|Озвучивание)\s*\d*\s*:[^\S\n]*([^\n]+)")
def translation_tags(text: str) -> dict[str, str]:
"""{студия: DUB/MVO/DVO/AVO} из строк «Перевод: Профессиональный (многоголосый) Студия»."""
out: dict[str, str] = {}
for m in RE_TRANSLATION.finditer(text):
body = m.group(1)
low = body.lower()
tag = next((v for k, v in TAG_WORDS if k in low), "")
studio = re.sub(r"\[.*?\]|\(.*?\)|18\s*\+", " ", body).strip(" ,;|-")
studio = studio.split("|")[-1].strip()
studio = re.sub(r"(?i)^(?:(?:профессиональн|любительск|авторск|дублирован|многоголос|двухголос|"
r"одноголос|закадров)\w*[\s,]*)+", "", studio).strip()
if tag and studio:
out.setdefault(_studio_canon(studio).casefold(), tag)
return out
def kind_of(section: str, title: str, genres: str, is_series: bool) -> str:
blob = f"{section} {genres} {title}".lower()
if re.search(r"аниме|anime|онгоинг", blob):
return "anime"
if re.search(r"мульт|анимац|animation", blob):
return "cartoon"
return "series" if is_series else "film"
def _studio_canon(s: str) -> str:
try:
from src.studio_norm import canonical
return canonical(s) or s
except Exception: # noqa: BLE001
return s
def extract(site: str, rec: dict, section_name: str = "", page_text: str = "") -> dict:
"""page_text — текст полной страницы (если сохранён HTML): добавляет размер раздачи."""
if site == "kinozal":
title = rec.get("h1") or rec.get("page_title") or ""
# жанр, страна, режиссёр — в теле страницы; вкладки идут первыми, чтобы
# их поля (качество, аудио) имели приоритет над списками «подобных раздач»
body = (rec.get("page_text") or "")[:8000]
text = "\n".join(x for x in (title, rec.get("release_tab"), rec.get("tech_tab"), body) if x)
else:
title = rec.get("title") or rec.get("page_title") or ""
text = f"{title}\n{rec.get('post') or ''}"
tp = parse_title(title)
quality = field(text, "quality")
video_line = field(text, "video")
vid = parse_video(video_line, f"{title} {quality}")
tracks = parse_audio(text)
for t in tracks:
if t["studio"]:
t["studio"] = _studio_canon(t["studio"])
by_studio = translation_tags(text) # тип озвучки из строк «Перевод»
for t in tracks:
if not t["tag"] and t["studio"]:
t["tag"] = by_studio.get(t["studio"].casefold(), "")
orig_audio = langs_in(field(text, "orig_audio"))
audio_langs = list(dict.fromkeys(
[t["lang"] for t in tracks if t["lang"]] + langs_in(field(text, "voice_langs")) + orig_audio))
subs = field(text, "subs")
size = re.search(r"(?i)([\d.,]+)\s*(ТБ|ГБ|GB|МБ|MB)", field(text, "size"))
if not size and page_text:
size = re.search(r"(?i)(?:Размер|Size)\s*[:\s]\s*([\d.,]+)\s*(ТБ|ГБ|GB|МБ|MB)", page_text)
size_gb = ""
if size:
v = float(size.group(1).replace(",", "."))
size_gb = round(v * {"ТБ": 1024, "ГБ": 1, "GB": 1, "МБ": 1 / 1024, "MB": 1 / 1024}[size.group(2).upper()], 2)
dur_line = field(text, "duration")
dur = re.search(r"(\d+):(\d\d):(\d\d)", dur_line)
genres = field(text, "genre")
is_series = bool(tp["season"] or tp["episodes"] or re.search(r"(?i)сериал", section_name)
or re.search(r"(?i)сериал", title))
age = re.search(r"(\d+)\s*\+", field(text, "age"))
cast = [c.strip() for c in re.split(r"[,;]", field(text, "cast")) if c.strip()][:8]
return {
"source": site, "release_id": str(rec.get("id", "")), "title": re.sub(r"\s+", " ", title).strip(),
"ru_title": tp["ru_title"], "orig_title": tp["orig_title"],
"year": tp["year"] or (re.search(r"\d{4}", field(text, "year")) or [""])[0],
"kind": kind_of(section_name, title, genres, is_series), "is_series": is_series,
"season": tp["season"], "episodes": tp["episodes"], "episodes_total": tp["episodes_total"],
"section": section_name or rec.get("section_ref", ""),
"quality": quality, **vid, "container": field(text, "container"),
"size_gb": size_gb, "duration_min": int(dur.group(1)) * 60 + int(dur.group(2)) if dur else "",
"duration_per_episode": bool(dur and re.search(r"(?i)сери", dur_line)),
"countries": field(text, "country"), "genres": genres, "directors": field(text, "director"),
"cast": "; ".join(cast), "age_limit": age.group(1) if age else "", "mpaa": field(text, "mpaa"),
"release_group": field(text, "group"), "source_media": field(text, "source_media"),
"audio_langs": "+".join(audio_langs), "orig_audio": "+".join(orig_audio), "n_audio": len(tracks),
"audio_tracks": json.dumps(tracks, ensure_ascii=False) if tracks else "",
"sub_langs": "+".join(langs_in(subs)), "sub_forced": bool(re.search(r"(?i)forced|форсир", subs)),
"sub_format": field(text, "sub_format"),
}
+88
View File
@@ -0,0 +1,88 @@
"""Упаковка моделей для Docker: models/best + models/segmenter -> models-bundle.tar.gz.
python tools/pack_models.py # models-bundle.tar.gz в корне проекта
python tools/pack_models.py --out dist/m.tar.gz # другой путь
python tools/pack_models.py --stage docker-models # только каталог docker-models/models (для docker build)
В архив попадает только нужное сервису: веса, токенизатор, label_maps, конфиги. Графики, eval.json,
архивные копии и запуски обучения не берутся. У старых чекпоинтов без config.json конфиг основы
берётся из кэша Hugging Face (нужен один раз на машине, где лежит модель) — после этого сервис
работает полностью офлайн.
Распаковка: tar -xzf models-bundle.tar.gz (даёт каталог models/)
"""
from __future__ import annotations
import argparse
import shutil
import sys
import tarfile
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
MAIN_FILES = ("model.pt", "dub_config.json", "label_maps.json", "tokenizer.json", "tokenizer_config.json",
"config.json", "special_tokens_map.json", "vocab.txt")
SEG_FILES = ("config.json", "model.safetensors", "tokenizer.json", "tokenizer_config.json",
"special_tokens_map.json", "vocab.txt")
def stage(dest: Path, main: Path, seg: Path) -> list[str]:
"""Собирает dest/models/{best,segmenter}; возвращает список предупреждений."""
notes: list[str] = []
if not (main / "model.pt").exists():
raise SystemExit(f"нет {main / 'model.pt'}: сначала обучите и выберите модель (tools/collect.py, пункт 7)")
out_main, out_seg = dest / "models" / "best", dest / "models" / "segmenter"
out_main.mkdir(parents=True)
for name in MAIN_FILES:
if (main / name).exists():
shutil.copy2(main / name, out_main / name)
if not (out_main / "config.json").exists():
import json
from transformers import AutoConfig
backbone = json.loads((main / "dub_config.json").read_text(encoding="utf-8"))["backbone"]
AutoConfig.from_pretrained(backbone).save_pretrained(out_main)
notes.append(f"config.json основы {backbone} взят из кэша Hugging Face")
if seg.exists():
out_seg.mkdir(parents=True)
for name in SEG_FILES:
if (seg / name).exists():
shutil.copy2(seg / name, out_seg / name)
else:
notes.append(f"нет {seg}: сервис будет сегментировать страницы правилами, а не моделью")
return notes
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--model", default=str(ROOT / "models" / "best"))
ap.add_argument("--segmenter", default=str(ROOT / "models" / "segmenter"))
ap.add_argument("--out", default=str(ROOT / "models-bundle.tar.gz"))
ap.add_argument("--stage", help="только подготовить каталог <stage>/models и не архивировать")
args = ap.parse_args()
if args.stage:
dest = Path(args.stage)
shutil.rmtree(dest / "models", ignore_errors=True)
dest.mkdir(parents=True, exist_ok=True)
for n in stage(dest, Path(args.model), Path(args.segmenter)):
print("•", n)
(dest / "models" / ".gitkeep").touch() # каталог остаётся в git пустым
print(f"готово: {dest / 'models'}")
return
with tempfile.TemporaryDirectory() as tmp:
for n in stage(Path(tmp), Path(args.model), Path(args.segmenter)):
print("•", n)
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(out, "w:gz") as tar:
tar.add(Path(tmp) / "models", arcname="models")
print(f"готово: {out} ({out.stat().st_size / 1e6:.0f} МБ)")
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
"""Рабочие функции переиндексации (tools/collect.py → build / meta).
Вынесены в отдельный лёгкий модуль, чтобы их можно было запускать в процессах
(multiprocessing на Windows импортирует модуль заново, поэтому здесь нет rich,
интерфейса и состояния сборщика). Каждая функция получает и возвращает только
простые данные.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
for _p in (_ROOT, _ROOT / "tools", _ROOT / "tools" / "harvest"):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
import harvest_core as hv # noqa: E402
import meta # noqa: E402
from src.html_extract import html_to_text # noqa: E402
PARSERS = {"kinozal": hv.kz_parse, "rutracker": hv.rt_parse, "nnm": hv.nnm_parse}
def build_one(task: dict) -> dict:
"""Один релиз: метки (если нужны) и страница data/pages/<stem>.txt (если нужна).
task: site, json, html, parse (строить метки), known (уже размечен), page (куда писать
текст страницы или None). Ответ: labs, error, skipped (нет меток у нового релиза), wrote.
"""
out = {"labs": [], "error": None, "skipped": False, "wrote": False}
rec = None
if task["parse"]:
rec = json.loads(Path(task["json"]).read_text(encoding="utf-8"))
try:
out["labs"], _ = PARSERS[task["site"]](rec)
except Exception as exc: # noqa: BLE001
out["error"] = str(exc)
if not task["known"]:
return out # новый релиз без разбора — пропуск
if not out["labs"] and not task["known"]:
out["skipped"] = True # без меток страницу в обучение не берём
return out
if task["page"]:
text = html_to_text(Path(task["html"]).read_text(encoding="utf-8"))
Path(task["page"]).write_text(text, encoding="utf-8")
out["wrote"] = True
return out
def meta_one(task: dict) -> dict:
"""Одна строка meta.csv. task: site, rec, name, html, page (готовый текст страницы или None)."""
full = ""
page = task.get("page")
if page and Path(page).exists():
full = Path(page).read_text(encoding="utf-8") # уже разобран при сборке страниц
elif task.get("html") and Path(task["html"]).exists():
full = html_to_text(Path(task["html"]).read_text(encoding="utf-8"))
return meta.extract(task["site"], task["rec"], task["name"], full)
+161
View File
@@ -0,0 +1,161 @@
"""Переобучение и проверка моделей DUB-AI (используется из tools/collect.py).
Шаги вынесены в функции, чтобы меню вызывало их по очереди:
prepare() data/labels.csv -> data/processed
train_segmenter(out_dir) обучение сегментатора страниц
train_main() основная модель (тип, автор, 18+): долго
run_tests() tests/run_tests.py
compare(dirs) правила vs сегментаторы по трекерам
promote(new_dir) новые веса -> models/segmenter (старые в архив)
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODELS = ROOT / "models"
MIN_N = 30 # трекеры с меньшим числом страниц в вывод попадают, но решение не определяют
RE_CELL = re.compile(r"(\w+) n=(\d+) P=([\d.]+) R=([\d.]+)")
def run(args: list[str]) -> int:
"""Запуск шага тем же Python; вывод идёт прямо в консоль."""
print("\n$ python " + " ".join(args), flush=True)
return subprocess.call([sys.executable, *args], cwd=ROOT)
def prepare() -> bool:
return run(["-m", "src.prepare_data"]) == 0
def train_segmenter(out_dir: str = "models/segmenter_v2", epochs: int = 4) -> bool:
return run(["-m", "src.segmenter", "--train", "--out", out_dir,
"--epochs", str(epochs)]) == 0
def backup_main() -> str | None:
"""Копия models/best перед обучением: src.train перезаписывает каталог."""
import shutil
import time
src = ROOT / "models" / "best"
if not src.exists():
return None
name = f"best_backup_{time.strftime('%Y%m%d_%H%M%S')}"
shutil.copytree(src, ROOT / "models" / name)
return name
def train_main() -> bool:
"""Основная модель: 5-fold, ~15-20 минут на GPU. Старый models/best сохраняется в models/best_backup_*."""
saved = backup_main()
if saved:
print(f"копия прежней модели: models/{saved}", flush=True)
return run(["-m", "src.train"]) == 0
PROFILES = { # те же имена, что в src/train.py:MODEL_CONFIGS (без импорта torch в меню)
"tiny2": "rubert-tiny2, 30M — самая быстрая, годится для ONNX/CPU",
"base": "ruBert-base, 178M — текущий рабочий выбор",
"large": "ruBert-large, 427M — та же семья, втрое больше: обучение ~3× дольше, инференс на CPU медленнее",
"modern": "RuModernBERT-base, 149M (2024) — новая архитектура; ONNX-экспорт проекта её не поддерживает",
}
def train_models(profiles: list[str], folds: int = 0, tag: str = "") -> bool:
"""Обучение профилей отдельными запусками в models/runs/: models/best и reports/ не меняются."""
args = ["-m", "src.train", "--runs", "--models", *profiles, "--folds", str(folds)]
if tag:
args += ["--tag", tag]
return run(args) == 0
def evaluate_models(out_json: Path, refresh: bool = False) -> dict | None:
"""Оценка всех моделей на текущей тестовой выборке (подпроцесс: GPU освобождается после выхода)."""
out_json.unlink(missing_ok=True)
args = ["-m", "src.runs", "--out", str(out_json)] + (["--refresh"] if refresh else [])
if run(args) != 0 or not out_json.exists():
return None
import json
return json.loads(out_json.read_text(encoding="utf-8"))
def run_tests() -> bool:
return run(["tests/run_tests.py"]) == 0
def _e2e(seg_dir: str) -> dict:
"""{'rules': {трекер: (n, P, R)}, 'model': {...}} на страницах, которых модель не видела."""
proc = subprocess.run([sys.executable, "tests/e2e_segmenter.py", seg_dir], cwd=ROOT,
capture_output=True, text=True, encoding="utf-8", errors="replace")
if proc.returncode != 0:
tail = "\n".join(proc.stderr.strip().splitlines()[-6:])
raise RuntimeError(f"e2e_segmenter упал на {seg_dir}:\n{tail}")
test_block = proc.stdout.split("train-страницы")[0]
out: dict[str, dict] = {}
for line in test_block.splitlines():
m = re.match(r"\s+(rules|model)\s*:(.*)", line)
if m:
out[m.group(1)] = {t: (int(n), float(p), float(r))
for t, n, p, r in RE_CELL.findall(m.group(2))}
return out
def compare(seg_dirs: list[str]) -> dict:
"""Печатает таблицу: правила и каждый сегментатор по трекерам.
Возвращает {'rules': {...}, <dir>: {...}} для решения о подмене весов.
"""
results: dict[str, dict] = {}
for d in seg_dirs:
print(f"\nзамер: {d} (несколько минут)…", flush=True)
res = _e2e(d)
results[d] = res.get("model", {})
results.setdefault("rules", res.get("rules", {}))
trackers = sorted(results["rules"])
cols = ["rules"] + list(seg_dirs)
print("\nPrecision / Recall на страницах, которых сегментатор не видел при обучении")
print(f"{'трекер':10s} {'n':>4s} " + " ".join(f"{Path(c).name:>16s}" for c in cols))
for t in trackers:
n = results["rules"][t][0]
cells = []
for c in cols:
v = results[c].get(t)
cells.append(f"{v[1]:.3f} / {v[2]:.3f}" if v else "-")
warn = " (мало страниц)" if n < MIN_N else ""
print(f"{t:10s} {n:4d} " + " ".join(f"{x:>16s}" for x in cells) + warn)
return results
def verdict(results: dict, old: str, new: str) -> tuple[bool, str]:
"""Новые веса лучше старых? Только по трекерам с n >= MIN_N.
Лучше: суммарная precision не ниже, recall не просел больше чем на 0.02
ни на одном трекере.
"""
big = [t for t, v in results["rules"].items() if v[0] >= MIN_N]
if not big:
return False, "нет трекеров с достаточным числом страниц"
dp = sum(results[new][t][1] - results[old][t][1] for t in big if t in results[new] and t in results[old])
worst_r = min(results[new][t][2] - results[old][t][2] for t in big
if t in results[new] and t in results[old])
ok = dp >= 0 and worst_r >= -0.02
return ok, (f"Δprecision (сумма по {', '.join(big)}) = {dp:+.3f}, "
f"худшее Δrecall = {worst_r:+.3f}")
def promote(new_dir: str, current: str = "models/segmenter") -> str:
"""Старые веса -> models/segmenter_old_<время>, новые -> models/segmenter."""
cur, new = ROOT / current, ROOT / new_dir
archive = ""
if cur.exists():
archive = f"{cur.name}_old_{time.strftime('%Y%m%d_%H%M%S')}"
cur.rename(cur.with_name(archive))
shutil.move(str(new), str(cur))
return archive
+37
View File
@@ -0,0 +1,37 @@
JSON.stringify((() => {
// Снимок страницы «как на сайте»: UTF-8, <base>, таблицы стилей вынесены в css[]
const root = document.documentElement.cloneNode(true);
const css = [];
const orig = [...document.querySelectorAll('link[rel~="stylesheet"]')];
const copy = [...root.querySelectorAll('link[rel~="stylesheet"]')];
orig.forEach((l, i) => {
try {
const rules = l.sheet && l.sheet.cssRules;
if (!rules) return;
let text = '';
for (const r of rules) text += r.cssText + '\n';
text = text.replace(/url\((['"]?)(?!data:|https?:|\/\/)([^)'"]+)\1\)/g, (m, q, u) => {
try { return 'url(' + q + new URL(u, l.sheet.href) + q + ')'; } catch (e) { return m; }
});
css.push(text);
copy[i].setAttribute('href', '@@CSS' + (css.length - 1) + '@@');
} catch (e) { /* таблица чужого домена: оставляем исходную ссылку */ }
});
root.querySelectorAll('script').forEach(x => x.remove()); // страница из файла не должна уходить на сайт
const head = root.querySelector('head');
root.querySelectorAll('meta[charset], meta[http-equiv="Content-Type" i]').forEach(m => m.remove());
if (head) {
const meta = document.createElement('meta');
meta.setAttribute('charset', 'utf-8');
head.insertBefore(meta, head.firstChild);
const old = root.querySelector('base');
if (old) { // «//host/path» в файле стал бы file://host/…
old.setAttribute('href', document.baseURI);
} else {
const base = document.createElement('base');
base.href = document.baseURI;
head.insertBefore(base, meta.nextSibling);
}
}
return { html: '<!DOCTYPE html>' + root.outerHTML, css };
})())
+269
View File
@@ -0,0 +1,269 @@
"""Интерфейс консоли для tools/collect.py: живая панель сбора, статистика, меню (rich)."""
from __future__ import annotations
import time
from collections import Counter, deque
from dataclasses import dataclass, field
from rich import box
from rich.console import Console, Group
from rich.live import Live
from rich.panel import Panel
from rich.progress import (BarColumn, MofNCompleteColumn, Progress, ProgressColumn,
SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn,
TimeRemainingColumn)
from rich.prompt import Prompt
from rich.table import Table
from rich.text import Text
console = Console()
SITE_STYLE = {"rutracker": "cyan", "nnm": "magenta", "kinozal": "yellow"}
STATE_STYLE = {"ожидание": "dim", "готово": "green", "ошибка": "red", "остановлено": "yellow"}
def fmt_time(sec: float | None) -> str:
if sec is None or sec != sec:
return "—"
sec = int(sec)
h, rest = divmod(sec, 3600)
m, s = divmod(rest, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
def bar(frac: float, width: int = 16, color: str = "green") -> Text:
frac = max(0.0, min(1.0, frac))
full = int(round(frac * width))
t = Text()
t.append("█" * full, style=color)
t.append("░" * (width - full), style="grey35")
return t
@dataclass
class Stats:
"""Счётчики одного трекера в текущем запуске сбора."""
site: str
limit: int = 0
state: str = "ожидание"
section: str = ""
saved: int = 0
failed: int = 0
skipped: int = 0 # удалённые раздачи («Нет раздачи с таким ID»)
started: float = 0.0
finished: float = 0.0
last: str = ""
errors: deque = field(default_factory=lambda: deque(maxlen=3))
stamps: deque = field(default_factory=lambda: deque(maxlen=12))
def begin(self, limit: int) -> None:
self.limit, self.saved, self.failed, self.skipped = limit, 0, 0, 0
self.started, self.finished = time.time(), 0.0
self.stamps.clear()
self.errors.clear()
def ok(self, title: str) -> None:
self.saved += 1
self.last = title
self.stamps.append(time.time())
def fail(self, msg: str) -> None:
self.failed += 1
self.errors.append(msg)
def done(self, state: str = "готово") -> None:
self.finished = time.time()
self.state = state
@property
def elapsed(self) -> float:
if not self.started:
return 0.0
return (self.finished or time.time()) - self.started
@property
def speed(self) -> float:
"""Страниц в минуту: по последним сохранениям, пока их мало — за всё время."""
if len(self.stamps) >= 4 and not self.finished:
span = self.stamps[-1] - self.stamps[0]
if span > 0:
return (len(self.stamps) - 1) / span * 60
return self.saved / self.elapsed * 60 if self.elapsed > 0 and self.saved else 0.0
@property
def eta(self) -> float | None:
if self.finished or self.speed <= 0 or self.limit <= self.saved:
return None
return (self.limit - self.saved) / self.speed * 60
STATS: dict[str, Stats] = {}
def get_stats(site: str) -> Stats:
return STATS.setdefault(site, Stats(site))
def dashboard(sites: list[str]) -> Panel:
"""Живая таблица по трекерам: состояние, прогресс, скорость, ETA, последняя раздача."""
t = Table(box=box.SIMPLE_HEAD, expand=True, pad_edge=False)
t.add_column("Трекер", style="bold", no_wrap=True)
t.add_column("Состояние", no_wrap=True)
t.add_column("Прогресс", no_wrap=True)
t.add_column("Сохр.", justify="right")
t.add_column("Ош.", justify="right")
t.add_column("Удал.", justify="right")
t.add_column("стр/мин", justify="right")
t.add_column("Время", justify="right")
t.add_column("ETA", justify="right")
t.add_column("Раздел / последняя раздача", overflow="ellipsis", no_wrap=True, ratio=1)
total_saved = total_failed = 0
speed_sum = 0.0
for s in sites:
st = get_stats(s)
total_saved += st.saved
total_failed += st.failed
speed_sum += st.speed if not st.finished else 0
frac = st.saved / st.limit if st.limit else 0
prog = Text.assemble(bar(frac, 14, SITE_STYLE.get(s, "green")),
f" {st.saved}/{st.limit or '?'}")
state = Text(st.state, style=STATE_STYLE.get(st.state, "white"))
info = Text(st.section or "", style="dim")
if st.last:
info.append(" ▸ " + st.last[:70], style="white")
t.add_row(Text(s, style=SITE_STYLE.get(s, "white")), state, prog, str(st.saved),
Text(str(st.failed), style="red" if st.failed else "dim"),
Text(str(st.skipped), style="yellow" if st.skipped else "dim"),
f"{st.speed:4.1f}" if st.speed else "—", fmt_time(st.elapsed) if st.started else "—",
fmt_time(st.eta), info)
foot = Text.assemble(("Итого сохранено: ", "dim"), (str(total_saved), "bold green"),
(" ошибок: ", "dim"), (str(total_failed), "red" if total_failed else "dim"),
(" общая скорость: ", "dim"), (f"{speed_sum:.1f} стр/мин", "bold"),
(" Ctrl+C — остановить после текущей раздачи", "dim italic"))
errs = [Text(f" ! {s}: {e}", style="red") for s in sites for e in list(get_stats(s).errors)[-1:]]
return Panel(Group(t, foot, *errs), title="[bold]Сбор страниц[/bold]", border_style="blue")
def live(sites: list[str]) -> Live:
return Live(dashboard(sites), console=console, refresh_per_second=4, transient=False,
get_renderable=lambda: dashboard(sites))
def distribution(title: str, counter: Counter, width: int = 18, top: int = 8) -> Table:
t = Table(box=box.SIMPLE, title=title, title_justify="left", show_header=False, pad_edge=False)
t.add_column(justify="left")
t.add_column(justify="right")
t.add_column()
items = counter.most_common(top)
mx = max((n for _, n in items), default=1)
for k, n in items:
t.add_row(str(k) or "—", str(n), bar(n / mx, width, "cyan"))
return t
def coverage_table(rows: list[dict]) -> Table:
"""rows: {site, labeled, pages, postonly, raw, meta, missing}."""
t = Table(box=box.ROUNDED, title="Покрытие датасета", title_justify="left")
for col, just in (("Трекер", "left"), ("Размечено", "right"), ("Страниц", "right"),
("Полных", "right"), ("Урезан.", "right"),
("Raw", "right"), ("Meta", "right"), ("Без стр.", "right")):
t.add_column(col, justify=just, no_wrap=True, min_width=len(col))
for r in rows:
full = r["pages"] - r["postonly"]
t.add_row(Text(r["site"], style=SITE_STYLE.get(r["site"], "white")), str(r["labeled"]),
str(r["pages"]), str(full), str(r["postonly"]), str(r["raw"]), str(r["meta"]),
Text(str(r["missing"]), style="red" if r["missing"] else "green"))
return t
def _short_ref(ref: str) -> str:
if ref.startswith("http"):
import re
m = re.search(r"c=(\d+)&v=(\d+)&d=(\d+)", ref)
return f"c={m.group(1)} v={m.group(2)} d={m.group(3)}" if m else ref[-24:]
return f"f={ref}"
def sections_table(site: str, entries: list[dict], rows: int = 14) -> Panel:
"""Итог поиска разделов: что взято, что пропущено, сколько тем на первой странице."""
kept = [e for e in entries if e.get("keep")]
skipped = [e for e in entries if not e.get("keep")]
t = Table(box=box.SIMPLE_HEAD, expand=True, pad_edge=False)
t.add_column("", width=2)
t.add_column("Раздел / список", overflow="ellipsis", no_wrap=True, ratio=1)
t.add_column("ID", style="dim", no_wrap=True)
t.add_column("Тем", justify="right")
for e in (kept[:rows] + skipped[:3]):
ok = e.get("keep")
mark = Text("✓", style="green") if e.get("status") == "ok" else (
Text("?", style="yellow") if ok else Text("✗", style="red"))
topics = "—" if e.get("topics") is None else str(e["topics"])
t.add_row(mark, e["name"], _short_ref(e["ref"]), topics)
hidden = len(kept) - min(len(kept), rows)
foot = f"взято {len(kept)}" + (f", пропущено {len(skipped)} (мало тем)" if skipped else "")
if hidden:
foot += f" [dim]… и ещё {hidden} в списке[/]"
unsure = sum(1 for e in kept if e.get("status") == "?")
if unsure:
foot += f" [yellow]? — не удалось проверить: {unsure}[/]"
return Panel(Group(t, Text.from_markup(foot)), title=f"[bold]{site}[/] · найденные разделы",
border_style=SITE_STYLE.get(site, "blue"))
def banner(lines: list[str]) -> Panel:
body = Text("\n".join(lines))
return Panel(body, title="[bold]DUB-AI · датасет и модель[/bold]", border_style="green",
padding=(0, 2))
def menu_table(items: list[tuple[str, str]]) -> Table:
t = Table(box=None, show_header=False, pad_edge=False)
t.add_column(style="bold cyan", justify="right")
t.add_column()
for key, label in items:
t.add_row(key, label)
return t
def clear() -> None:
"""Очистить терминал (если вывод не в консоль — ничего не делает)."""
if console.is_terminal:
console.clear()
def pause(text: str = "Enter — вернуться в меню") -> None:
try:
console.input(f"\n[dim]{text}…[/dim]")
except (EOFError, KeyboardInterrupt):
pass
def ask(prompt: str, default: str = "") -> str:
return Prompt.ask(f"[bold]{prompt}[/bold]", default=default or None, console=console,
show_default=bool(default)) or ""
class _Rate(ProgressColumn):
"""Скорость: элементов в секунду (по окну rich)."""
def render(self, task) -> Text:
return Text(f"{task.speed:.1f}/с" if task.speed else "—/с", style="cyan")
def progress() -> Progress:
"""Полоса прогресса: что делаем, N/M, %, скорость, прошло, осталось."""
return Progress(SpinnerColumn(), TextColumn("[bold]{task.description:<26}"), BarColumn(bar_width=26),
MofNCompleteColumn(), TaskProgressColumn(), _Rate(),
TextColumn("прошло"), TimeElapsedColumn(), TextColumn("осталось"),
TimeRemainingColumn(), console=console, transient=False)
def summary_table(title: str, columns: list[str], rows: list[list]) -> Table:
t = Table(box=box.SIMPLE_HEAD, title=title, title_justify="left", pad_edge=False)
for i, c in enumerate(columns):
t.add_column(c, justify="left" if i == 0 else "right")
for r in rows:
t.add_row(*[str(x) for x in r])
return t