goon/scripts/clips_reap_dead.py
goon-foss 4f5de92bfc feat(clips): tabele klipow do feedu Swipe + ingest RedGifs (etap 2)
Nowy wertykal krotkich pionowych klipow tworcow, osobno od scen: inne kolumny
(tworca, audio, lajki, nisze), inny cykl zycia, inna taksonomia, brak filtra
60 s. Tabele `clips` (unikalne source+source_id) i `clip_tags` z indeksem
(tag, clip_id) pod skan po tagu od losowego uuid w przyszlym feedzie.

Zrodlo nr 1: RedGifs, oficjalne API v2 z tokenem tymczasowym (zwiazany z IP
i UA, na 401 odnowienie). Globalne `order=latest` bez search_text daje ~100
na strone, kazde zapytanie widzi maks. 10 000 wynikow, wiec katalog chodzi
sie po niszach (skrypt backfillu). Pliki media.redgifs.com sa publiczne i bez
podpisu (206 z VPS-a i z telefonu), do bazy wchodzi gotowy adres.

Scheduler: job clips-latest co godzine (5 stron), zatrzymuje sie na stronie
bez nowych. Reaper (HEAD, 403/404/410 -> dead_at) jako skrypt, upsert
odzywia klip, ktory zrodlo znow podaje. API: GET /clips i /clips/stats do
pilota; wlasciwy feed, pamiec widzianych i telemetria to etap 3.

Sprawdzone tego dnia i odlozone: nsfwswipe (lustro v.redd.it, bez audio,
stoi od 19.08), Pornhub Shorties (zrodlo nr 2, HLS z tokenem 2 h, Referer
na segmentach, 0/110 performerow z kanonu).
2026-09-14 16:32:57 +02:00

83 lines
3 KiB
Python

"""Reaper martwych klipów: twórcy kasują klipy, RedGifs oddaje wtedy 404/410 na pliku.
Sprawdza HEAD `hd_url` (albo `sd_url`) dla próbki żywych klipów: najpierw te najdawniej
sprawdzane (`updated_at`), bo ingest `latest` i tak odświeża świeże. 404/410/403 →
`dead_at` + `dead_reason`; błędy sieci nie oznaczają śmierci (patrz notatka o dwóch
kontrolach przy przemiale źródeł). Klip, który źródło znów poda w listingu, upsert
odżywia (zdejmuje `dead_at`).
Uruchomienie (kontener worker):
python -m scripts.clips_reap_dead --limit 500 # dry-run
python -m scripts.clips_reap_dead --limit 500 --yes # zapisz
"""
from __future__ import annotations
import argparse
import logging
import time
import httpx
from sqlalchemy import select, text
from app.db import session_scope
from app.models.clip import Clip
log = logging.getLogger(__name__)
_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
_DEAD_CODES = {403, 404, 410}
def run(*, limit: int, apply: bool) -> dict[str, int]:
checked = dead = errors = 0
dead_ids: list = []
with session_scope() as session:
rows = session.execute(
select(Clip.id, Clip.hd_url, Clip.sd_url)
.where(Clip.dead_at.is_(None))
.order_by(Clip.updated_at.asc())
.limit(limit)
).all()
with httpx.Client(headers=_UA, timeout=20, follow_redirects=True) as http:
for cid, hd, sd in rows:
url = hd or sd
if not url:
continue
checked += 1
try:
r = http.head(url)
except httpx.HTTPError:
errors += 1
continue
if r.status_code in _DEAD_CODES:
dead += 1
dead_ids.append((cid, f"http {r.status_code}"))
time.sleep(0.2)
if apply:
with session_scope() as session:
for cid, reason in dead_ids:
session.execute(
text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"),
{"r": reason, "id": cid},
)
# Sprawdzone i zywe: odswiez updated_at, zeby kolejny run bral inne.
alive = [cid for cid, _hd, _sd in rows if cid not in {d[0] for d in dead_ids}]
if alive:
session.execute(
text("UPDATE clips SET updated_at = now() WHERE id = ANY(:ids)"), {"ids": alive}
)
out = {"checked": checked, "dead": dead, "errors": errors, "applied": int(apply)}
print(out)
return out
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=500)
ap.add_argument("--yes", action="store_true", help="zapisz dead_at (bez tego dry-run)")
a = ap.parse_args()
run(limit=a.limit, apply=a.yes)
if __name__ == "__main__":
main()