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

+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