diff --git a/scripts/sxyprn_reap_dead.py b/scripts/sxyprn_reap_dead.py new file mode 100644 index 0000000..05ebaa6 --- /dev/null +++ b/scripts/sxyprn_reap_dead.py @@ -0,0 +1,135 @@ +"""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())