diff --git a/scripts/superporn_sitemap_backfill.py b/scripts/superporn_sitemap_backfill.py new file mode 100644 index 0000000..e851dda --- /dev/null +++ b/scripts/superporn_sitemap_backfill.py @@ -0,0 +1,180 @@ +"""Jednorazowe nadrobienie katalogu superporn.com z sitemapy. + +Dlaczego w ogóle: deep-crawl przemielił listing HTML do końca (kursor 548, status +`exhausted`), a mimo to sitemapa ma sceny, których nie mamy. Pomiar 2026-08-20 na +ośmiu submapach po 60 losowych adresów: **46% angielskich URL-i nieznanych**, przy +czym w nowszych submapach nawet 75%. Przy około 29 tys. angielskich adresów daje to +rzędu 13 tys. scen do odzyskania. Listing po prostu nie wystawia całego katalogu. + +Dlaczego skrypt, a nie job w schedulerze: superporn NIC już nie publikuje. Submapy są +ułożone chronologicznie, a najnowszy wpis ma datę 2025-01-28, czyli serwis stoi od +ponad roku. To jest nadrobienie zaległości raz, nie stała maszyneria. + +Wznawianie za darmo: bierzemy wyłącznie adresy, których nie ma w `playback_sources`, +więc ponowne odpalenie samo pomija to, co już weszło. Żadnego pliku stanu. + +Sceny lecą z `backfill=True`, bo to katalog z lat 2022-2025. Bez tego stary import +udawałby świeżość w „+N nowych" i w ulubionych (patrz `scenes.backfill`). + +Użycie (w kontenerze workera): + python3 /srv/scripts/superporn_sitemap_backfill.py --limit 200 --dry-run + python3 /srv/scripts/superporn_sitemap_backfill.py +""" +from __future__ import annotations + +import argparse +import logging +import re +import sys +import time +from concurrent.futures import ThreadPoolExecutor + +from sqlalchemy import text + +from app.connectors.direct_scrapers import ALL_BROWSE_SCRAPERS, SCRAPER_SOURCE_NAME +from app.db import session_scope +from app.extractors import browser_get +from app.ingest import _process_scene, get_or_create_source +from app.models.source import SourceKind + +log = logging.getLogger("superporn-backfill") + +_SITEMAP_INDEX = "https://www.superporn.com/sitemap.xml" +_LOC_RE = re.compile(r"([^<]+)") +# Bierzemy TYLKO angielskie adresy. Sitemapa zawiera te same sceny pod /nl/, /fr/, /es/ +# i tak dalej, a to ta sama treść, nie nowa podaż. +_EN_SCENE_RE = re.compile(r"^https://www\.superporn\.com/video/") +# Ile stron szczegółów ciągniemy naraz. Bright Data to ryczałt, więc kosztu za GB nie ma, +# ale nie ma też powodu walić w serwis serią. +_CONCURRENCY = 5 + + +def _scraper(): + by_tag = {c.sitetag: c for c in ALL_BROWSE_SCRAPERS} + return by_tag["superporncom"]() + + +def _video_submaps(sc) -> list[str]: + """Submapy wideo, NAJNOWSZE PIERWSZE (numeracja rośnie chronologicznie).""" + idx = browser_get(_SITEMAP_INDEX, timeout=60, proxy=sc._proxy).text + maps = [m for m in _LOC_RE.findall(idx) if "videos." in m] + + def _num(u: str) -> int: + m = re.search(r"videos\.(\d+)\.xml", u) + return int(m.group(1)) if m else -1 + + return sorted(maps, key=_num, reverse=True) + + +def _unknown_urls(urls: list[str]) -> list[str]: + if not urls: + return [] + with session_scope() as s: + known = { + r[0] + for r in s.execute( + text("SELECT page_url FROM playback_sources WHERE page_url = ANY(:u)"), + {"u": urls}, + ).fetchall() + } + return [u for u in urls if u not in known] + + +def _fetch_parse(args: tuple): + sc, url = args + try: + r = browser_get(url, timeout=sc._timeout, proxy=sc._proxy) + if r.status_code >= 400: + return None + return sc._parse_detail(url, r.text) + except Exception as e: + log.debug("detail padl %s: %s", url, e) + return None + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser() + p.add_argument("--limit", type=int, default=0, help="max scen do zapisania (0 = bez limitu)") + p.add_argument("--dry-run", action="store_true", help="nie zapisuj, tylko policz") + p.add_argument("--max-maps", type=int, default=0, help="ogranicz liczbę submap (test)") + a = p.parse_args(argv) + + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout + ) + # Bez tego httpx loguje KAŻDY fetch miniaturki i postęp tonie w szumie. + for noisy in ("httpx", "httpcore", "urllib3"): + logging.getLogger(noisy).setLevel(logging.WARNING) + sc = _scraper() + if not sc._proxy: + log.error("brak proxy, superporn bez niego oddaje CF 403; przerywam") + return 1 + + submaps = _video_submaps(sc) + if a.max_maps: + submaps = submaps[: a.max_maps] + log.info("submap wideo: %d (od najnowszej)", len(submaps)) + + with session_scope() as s: + source_id = get_or_create_source( + s, kind=SourceKind.scraper, name=SCRAPER_SOURCE_NAME + ).id + + counters = {"seen": 0, "new": 0, "updated": 0, "skipped": 0, "errors": 0} + saved = scanned = unknown_total = 0 + t0 = time.time() + + for i, sm in enumerate(submaps, 1): + try: + body = browser_get(sm, timeout=60, proxy=sc._proxy).text + except Exception as e: + log.warning("submapa %s nie pobrana: %s", sm, e) + continue + urls = [u for u in _LOC_RE.findall(body) if _EN_SCENE_RE.match(u)] + scanned += len(urls) + todo = _unknown_urls(urls) + unknown_total += len(todo) + log.info( + "[%d/%d] %s: EN=%d nieznanych=%d (zapisane dotad: %d, %.0f min)", + i, len(submaps), sm.rsplit("/", 1)[-1], len(urls), len(todo), saved, + (time.time() - t0) / 60, + ) + if not todo or a.dry_run: + continue + + if a.limit: + todo = todo[: max(0, a.limit - saved)] + if not todo: + break + + with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool: + raws = list(pool.map(_fetch_parse, [(sc, u) for u in todo])) + + for raw in raws: + if raw is None: + counters["errors"] += 1 + continue + counters["seen"] += 1 + try: + # backfill=True: to katalog 2022-2025, nie ma udawać nowosci. + _process_scene( + source_id=source_id, raw_scene=raw, counters=counters, backfill=True + ) + saved += 1 + except Exception: + counters["errors"] += 1 + log.exception("ingest padl") + + if a.limit and saved >= a.limit: + log.info("limit %d osiagniety", a.limit) + break + + log.info( + "KONIEC: przejrzane EN=%d, nieznanych=%d, zapisane=%d, counters=%s, czas=%.0f min", + scanned, unknown_total, saved, counters, (time.time() - t0) / 60, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())