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