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 <[email protected]>
173 lines
10 KiB
Python
173 lines
10 KiB
Python
"""Автопоиск разделов трекеров с зарубежными фильмами и сериалами.
|
|
|
|
* 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 []
|