"""Oznacza martwe źródła sxyprn (skasowane posty), których apka i tak nie odtworzy. Obserwacja Jana (2026-08-26): scena sxyprn z widoczną miniaturą gra, a bez miniatury w ok. 90% nie. Sprawdzone i potwierdzone mechanicznie: miniatura sxyprn NIE jest brana z zapisanego `thumbnail_url` (te są podpisane czasowo, z TTL < 1h, i dawno wygasły), tylko z `/proxy/sxyprn-thumb/`, który NA ŻYWO pobiera stronę posta i wyłuskuje poster. Odtwarzanie bierze `data-vnfo` z DOKŁADNIE tej samej strony. Skasowany post = brak postera = pusty kafelek ORAZ brak odtwarzania. Stąd korelacja, którą widać w apce. Sygnał śmierci jest twardy i jednoznaczny: sxyprn na usunięty post zwraca HTTP 200 ze stroną „Post Not Found" (soft-404). Ekstraktor rzuca wtedy `HosterDead`. Tylko to oznaczamy martwym. Strona bez `data-vnfo` (inny, niepewny przypadek) NIE jest ruszana, bo to bywa przejściowe. Oznaczamy `dead_at` (odwracalne), a NIE kasujemy wiersza: gdyby sxyprn kiedyś wskrzesił post albo gdyby to był chwilowy błąd, wystarczy wyzerować znacznik. Efekt w UI jest ten sam co skasowanie: źródło znika z listy, a scena tylko-sxyprn bez żywego źródła chowa się za filtrem `has_playback`. UWAGA na obciążenie: sxyprn to nasze NAJLEPSZE źródło (top origin, ~12 tys. odtworzeń miesięcznie). Dlatego niska równoległość i odstęp między pobraniami, żeby nie oberwać rate-limitem ani banem IP. To celowo wolne. Wznawialne za darmo: bierzemy tylko żywe źródła, więc ponowny przebieg pomija już oznaczone. Użycie (w kontenerze workera): python3 /srv/scripts/sxyprn_reap_dead.py --limit 300 --dry-run python3 /srv/scripts/sxyprn_reap_dead.py --limit 5000 """ from __future__ import annotations import argparse import collections import logging import sys import time from concurrent.futures import ThreadPoolExecutor from sqlalchemy import text from app.db import session_scope from app.extractors._models import HosterDead from app.extractors.tubes import sxyprn log = logging.getLogger("sxyprn-reap") _ORIGIN = "tube:sxyprncom" # Wolno i grzecznie: sxyprn to top źródło, nie chcemy bana. Trzy wątki, odstęp przed # każdym pobraniem. Efektywnie kilka żądań na sekundę. _CONCURRENCY = 3 _SPACING_SEC = 0.5 _TIMEOUT = 20.0 _ALIVE, _DEAD, _UNKNOWN = "alive", "dead", "unknown" def _probe(page_url: str) -> str: """alive / dead / unknown. Tylko „Post Not Found" (HosterDead) to pewna śmierć.""" time.sleep(_SPACING_SEC) try: res = sxyprn.extract(page_url, timeout=_TIMEOUT) except HosterDead: return _DEAD except Exception: # Sieć, timeout, rate-limit: NIEPEWNE, zostawiamy źródło nietknięte. return _UNKNOWN if res and getattr(res[0], "link", None): return _ALIVE # Brak data-vnfo: niepewne (mogła się zmienić struktura strony), nie ruszamy. return _UNKNOWN def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser() p.add_argument("--limit", type=int, default=0, help="ile źródeł sprawdzić (0 = wszystkie żywe)") p.add_argument("--dry-run", action="store_true", help="nie oznaczaj, tylko policz") a = p.parse_args(argv) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout ) for noisy in ("httpx", "httpcore", "urllib3"): logging.getLogger(noisy).setLevel(logging.WARNING) sql = f""" SELECT id, page_url FROM playback_sources WHERE origin = '{_ORIGIN}' AND dead_at IS NULL AND page_url IS NOT NULL ORDER BY id """ if a.limit: sql += f" LIMIT {int(a.limit)}" with session_scope() as s: rows = s.execute(text(sql)).fetchall() log.info("do sprawdzenia: %d źródeł sxyprn", len(rows)) cnt: collections.Counter = collections.Counter() ubite = 0 t0 = time.time() def work(row): sid, page_url = row return sid, _probe(page_url) with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool: for i, (sid, verdict) in enumerate(pool.map(work, rows), 1): cnt[verdict] += 1 if verdict == _DEAD and not a.dry_run: with session_scope() as s: ubite += s.execute( text( "UPDATE playback_sources SET dead_at = now(), " "dead_reason = 'sxyprn-reap: Post Not Found' " "WHERE id = :id AND dead_at IS NULL" ), {"id": sid}, ).rowcount if i % 200 == 0: log.info( "[%d/%d] żywe=%d martwe=%d nieznane=%d, ubite=%d (%.0f min)", i, len(rows), cnt[_ALIVE], cnt[_DEAD], cnt[_UNKNOWN], ubite, (time.time() - t0) / 60, ) total = sum(cnt.values()) log.info( "KONIEC: żywe=%d (%.0f%%) martwe=%d (%.0f%%) nieznane=%d (%.0f%%) | ubite=%d | %.0f min", cnt[_ALIVE], 100 * cnt[_ALIVE] / max(1, total), cnt[_DEAD], 100 * cnt[_DEAD] / max(1, total), cnt[_UNKNOWN], 100 * cnt[_UNKNOWN] / max(1, total), ubite, (time.time() - t0) / 60, ) return 0 if __name__ == "__main__": raise SystemExit(main())