Przebieg po 151 tys. sprawdzonych zrodel przepadl, bo sam ubilem go wlasnym `docker compose restart worker` przy wdrazaniu poprawek seekplayera. Log urwal sie o 06:54, kontener wystartowal o 06:58. Proces zostal jako pusta skorupa (jeden watek, zero postepu), a wygladalo to na zawieszenie: sxyprn odpowiadal normalnie w 0,2 s, baza byla bez blokad. Trzy zmiany, kazda usuwa jedna slabosc, ktora to ukrywala albo pogarszala: * PORCJAMI po 500, ze swieza pula na kazda porcje. `pool.map` na calej liscie kolejkowal 188 tys. zadan naraz, wiec przerwanie w polowie nie zostawialo zadnego punktu zaczepienia, a pamiec rosla bez potrzeby. * JEDEN `UPDATE` na porcje zamiast jednego na wiersz. * `flush()` po kazdej porcji. Log do pliku byl buforowany i milczal godzinami, przez co martwy przebieg wygladal identycznie jak pracujacy. W docstringu jawne ostrzezenie: to zadanie NIE MOZE chodzic w kontenerze workera, bo kazdy deploy backendu je ubija. Odpalac przez `docker compose run -d --rm worker`, czyli w osobnym kontenerze. Wznawianie jest darmowe, bo bierzemy tylko zywe zrodla. Stan: 52 998 zrodel juz oznaczonych martwymi, wznowione na pozostalych 138 154.
152 lines
6.2 KiB
Python
152 lines
6.2 KiB
Python
"""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.
|
|
|
|
UWAGA NA RESTART: `docker compose restart worker` UBIJA to zadanie, jesli chodzi w
|
|
kontenerze workera (2026-08-27: stracilem tak przebieg po 151 tys. sprawdzonych, log
|
|
urwal sie 4 minuty przed restartem). Odpalaj przez `docker compose run -d --rm worker`,
|
|
bo to OSOBNY kontener, ktorego restart workera nie dotyka. Wznawianie jest darmowe:
|
|
bierzemy tylko zywe zrodla, wiec ponowne odpalenie samo pomija juz oznaczone.
|
|
|
|
Użycie:
|
|
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)
|
|
|
|
# Porcjami, ze SWIEZA pula na kazda porcje. `pool.map` na calej liscie kolejkowal
|
|
# 188 tys. zadan naraz, wiec postep byl widoczny dopiero na koncu, a przerwanie w
|
|
# polowie nie zostawialo zadnego punktu zaczepienia.
|
|
_PORCJA = 500
|
|
for start in range(0, len(rows), _PORCJA):
|
|
porcja = rows[start : start + _PORCJA]
|
|
do_ubicia: list = []
|
|
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
|
|
for sid, verdict in pool.map(work, porcja):
|
|
cnt[verdict] += 1
|
|
if verdict == _DEAD:
|
|
do_ubicia.append(sid)
|
|
if do_ubicia and not a.dry_run:
|
|
# Jeden UPDATE na porcje zamiast jednego na wiersz.
|
|
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 = ANY(:ids) AND dead_at IS NULL"
|
|
),
|
|
{"ids": do_ubicia},
|
|
).rowcount
|
|
log.info(
|
|
"[%d/%d] żywe=%d martwe=%d nieznane=%d, ubite=%d (%.0f min)",
|
|
min(start + _PORCJA, len(rows)), len(rows),
|
|
cnt[_ALIVE], cnt[_DEAD], cnt[_UNKNOWN], ubite, (time.time() - t0) / 60,
|
|
)
|
|
for h in logging.getLogger().handlers:
|
|
h.flush() # bez tego log do pliku milczal godzinami (buforowanie)
|
|
|
|
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())
|