feat(sxyprn): reaper martwych scen po sygnale "Post Not Found"

Obserwacja Jana: scena sxyprn z widoczna miniatura gra, bez miniatury w ~90% nie, i
jest sporo takich smieci. Sprawdzone i potwierdzone mechanicznie.

Miniatura sxyprn NIE jest brana z zapisanego thumbnail_url (te sa podpisane czasowo,
TTL < 1h, dawno wygasly), tylko z /proxy/sxyprn-thumb/<id>, ktory NA ZYWO pobiera
strone posta i wyluskuje poster. Odtwarzanie bierze data-vnfo z DOKLADNIE tej samej
strony. Skasowany post = pusty kafelek ORAZ brak streamu. Stad korelacja widoczna w apce.

Sygnal smierci jest twardy: usuniety post zwraca HTTP 200 ze strona "Post Not Found"
(soft-404), a ekstraktor rzuca wtedy HosterDead. Tylko to oznaczamy martwym. Strona
bez data-vnfo (niepewny przypadek) zostaje nietknieta.

Oznaczamy dead_at (odwracalne), NIE kasujemy wiersza: gdyby to byl chwilowy blad albo
sxyprn wskrzesil post, wystarczy wyzerowac znacznik. Efekt w UI ten sam co kasowanie.

Wolno i grzecznie (3 watki, 0,5 s odstepu), bo sxyprn to nasz TOP origin (~12 tys.
odtworzen miesiecznie) i nie chcemy bana. Przebieg testowy 5000: 65% zywych, 35%
martwych (1755 oznaczonych), 2 niepewne, 18 min, zero oznak rate-limitu.

Pelny przemiał 188 602 zywych zrodel uruchomiony odpiety w kontenerze.
This commit is contained in:
goon-foss 2026-08-26 23:46:12 +02:00
parent c0a5d02ece
commit 62c1aaf250

135
scripts/sxyprn_reap_dead.py Normal file
View file

@ -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/<id>`, 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())