Docker-сервис, выбор основы и сравнение моделей, сборщик датасета
docker / build (push) Failing after 39s
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:
1 parent
d155b8584b
commit
efc9a4f56a
411 files changed
+87495
-27363
No files matched your search
+1849
File diff suppressed because it is too large.
Load diff
@@ -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 []
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user