goon/app/scheduler/clips_ingest.py
goon-foss 8971d81ef9 fix(clips): skasowany klip uruchamia sprawdzenie reszty klipow tworcy
Tworcy kasuja zwykle cale konto naraz, a przemial przy 1000 klipow na
godzine robi okrazenie katalogu w okolo tydzien. Zgloszenie d343a9b6:
strona tworcy z pustymi kafelkami, 120 "zywych" klipow, kazdy 403.
Po oznaczeniu martwego klipu (przemial albo zgloszenie z apki) od razu
HEAD na pozostalych zywych klipach tego tworcy niesprawdzanych od 6 h,
do 600 na przebieg. Sprawdzone na produkcji: 120 z 120 oznaczone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:38:54 +02:00

372 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Ingest klipów (feed Swipe): upsert do `clips` + `clip_tags`, joby RedGifs.
Potok celowo prosty, bez `_ingest_iter_into_run`: klipy nie mają dedupu, matchera
performerów ani fingerprintów, a `(source, source_id)` jednoznacznie identyfikuje rekord.
Upsert `ON CONFLICT ... DO UPDATE` odświeża lajki/widoki/adresy i **zdejmuje `dead_at`**
(klip, który źródło znów podaje, żyje). Tagi tylko dokładamy (`DO NOTHING`), źródło ich
nie odbiera.
Dwa tryby:
- `run_redgifs_latest(pages)`: globalne „najnowsze", co godzinę z schedulera. 5 stron =
~500 klipów/run, RedGifs publikuje kilkaset na godzinę, więc to łapie prawie wszystko.
- `run_redgifs_niches(...)`: backfill po niszach (społecznościach), ręcznie ze skryptu
albo z `docker compose run` (patrz notatka o restarcie workera). Każde zapytanie widzi
maks. 10 000 wyników, dlatego katalog chodzi się per nisza, nie jednym listingiem.
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
from sqlalchemy import text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.connectors.clips.redgifs import RateLimited, RawClip, RedgifsClient, map_gif
from app.db import session_scope
from app.models.clip import Clip, ClipTag
log = logging.getLogger(__name__)
def upsert_clips(session: Session, clips: Iterable[RawClip]) -> dict[str, int]:
seen = new = 0
for c in clips:
seen += 1
stmt = (
pg_insert(Clip)
.values(
source=c.source,
source_id=c.source_id,
title=c.title,
creator=c.creator,
creator_verified=c.creator_verified,
duration_sec=c.duration_sec,
width=c.width,
height=c.height,
has_audio=c.has_audio,
content_type=c.content_type,
niches=c.niches,
hd_url=c.hd_url,
sd_url=c.sd_url,
poster_url=c.poster_url,
thumb_url=c.thumb_url,
hls=c.hls,
likes=c.likes,
views=c.views,
published_at=c.published_at,
)
.on_conflict_do_update(
constraint="uq_clips_source_source_id",
set_={
"title": c.title,
"creator": c.creator,
"creator_verified": c.creator_verified,
"duration_sec": c.duration_sec,
"has_audio": c.has_audio,
"niches": c.niches,
"hd_url": c.hd_url,
"sd_url": c.sd_url,
"poster_url": c.poster_url,
"thumb_url": c.thumb_url,
"likes": c.likes,
"views": c.views,
"dead_at": None,
"dead_reason": None,
"updated_at": text("now()"),
},
)
# xmax = 0 tylko dla świeżo wstawionego wiersza (sztuczka Postgresa na
# rozróżnienie INSERT od UPDATE w jednym zapytaniu).
.returning(Clip.id, text("(xmax = 0) AS inserted"))
)
row = session.execute(stmt).one()
clip_id, inserted = row[0], bool(row[1])
if inserted:
new += 1
if c.tags:
session.execute(
pg_insert(ClipTag)
.values([{"clip_id": clip_id, "tag": t} for t in c.tags])
.on_conflict_do_nothing()
)
return {"seen": seen, "new": new, "updated": seen - new}
def _merge(total: dict[str, int], part: dict[str, int]) -> None:
for k, v in part.items():
total[k] = total.get(k, 0) + v
def run_redgifs_latest(*, pages: int = 5) -> dict[str, int]:
"""Globalne „najnowsze" RedGifs, `pages` stron po 100. Zatrzymuje się na pierwszej
stronie w całości już znanej (kolejne są starsze), żeby godzinny run nie mielił
w kółko tych samych 500 klipów."""
total: dict[str, int] = {"seen": 0, "new": 0, "updated": 0, "errors": 0}
client = RedgifsClient()
try:
for page in range(1, pages + 1):
try:
gifs = client.latest(page=page)
except Exception as e:
log.warning("clips/redgifs latest: strona %d padla: %s", page, e)
total["errors"] += 1
break
clips = [c for c in (map_gif(g) for g in gifs) if c is not None]
if not clips:
break
with session_scope() as session:
part = upsert_clips(session, clips)
_merge(total, part)
if part["new"] == 0 and page > 1:
break
finally:
client.close()
log.info("clips/redgifs latest: %s", total)
return total
def run_redgifs_niches(
*, niche_pages: int = 1, per_niche_pages: int = 1, order: str = "latest", skip: int = 0
) -> dict[str, int]:
"""Backfill po niszach: `niche_pages` stron listy nisz (100 na stronę, wg API
najpopularniejsze pierwsze), dla każdej niszy `per_niche_pages` stron gifów."""
total: dict[str, int] = {"seen": 0, "new": 0, "updated": 0, "errors": 0, "niches": 0}
client = RedgifsClient()
try:
niche_ids: list[str] = []
for p in range(1, niche_pages + 1):
try:
data = client.niches(page=p)
except Exception as e:
log.warning("clips/redgifs niches: lista strona %d padla: %s", p, e)
total["errors"] += 1
break
niche_ids.extend(str(n.get("id")) for n in data.get("niches") or [] if n.get("id"))
for nid in niche_ids[skip:]:
total["niches"] += 1
for page in range(1, per_niche_pages + 1):
try:
gifs = client.niche_gifs(nid, order=order, page=page)
except RateLimited as e:
# Limit trwaly: koniec przebiegu, wznowienie przez --skip <niches>.
# Pierwszy backfill (2026-09-14) mielil 40 nisz po kolei w 429.
log.warning("clips/redgifs niches: %s; przerywam po %d niszach", e, total["niches"])
total["errors"] += 1
return total
except Exception as e:
log.warning("clips/redgifs niche %s strona %d padla: %s", nid, page, e)
total["errors"] += 1
break
clips = [c for c in (map_gif(g) for g in gifs) if c is not None]
if not clips:
break
with session_scope() as session:
_merge(total, upsert_clips(session, clips))
if total["niches"] % 20 == 0:
log.info("clips/redgifs niches: %d nisz, %s", total["niches"], total)
finally:
client.close()
log.info("clips/redgifs niches: %s", total)
return total
# ---- konserwacja (raz na dobe) ---------------------------------------------------------
_EVENTS_RETENTION_DAYS = 90
_DEAD_CODES = {403, 404, 410}
_REAP_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
def _head_dead(http, url: str) -> str | None:
"""'http 403' itp., gdy plik klipu zniknal; None, gdy zyje albo nie wiadomo (blad sieci)."""
import httpx
try:
r = http.head(url)
except httpx.HTTPError:
return None
return f"http {r.status_code}" if r.status_code in _DEAD_CODES else None
def check_clips_now(clip_ids: list) -> int:
"""Natychmiastowe sprawdzenie klipow zgloszonych przez apke jako zepsute (zdarzenie
`error`). Ten sam test co reaper, tylko bez czekania na kolejke. Zwraca ile oznaczono."""
import httpx
from sqlalchemy import select
if not clip_ids:
return 0
with session_scope() as session:
rows = session.execute(
select(Clip.id, Clip.hd_url, Clip.sd_url).where(Clip.id.in_(clip_ids), Clip.dead_at.is_(None))
).all()
marked_ids: list = []
with httpx.Client(headers=_REAP_UA, timeout=15, follow_redirects=True) as http:
for cid, hd, sd in rows:
url = hd or sd
reason = _head_dead(http, url) if url else None
if reason is None:
continue
with session_scope() as session:
session.execute(
text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"),
{"r": f"{reason} (apka)", "id": cid},
)
marked_ids.append(cid)
marked = len(marked_ids)
if marked:
log.info("clips: %d zgloszonych z apki oznaczone jako martwe", marked)
sweep_creators(marked_ids)
return marked
#: Ile klipow jednego przebiegu zamiatania i jak dawno sprawdzony klip bierzemy ponownie.
_SWEEP_CAP = 600
_SWEEP_RECHECK_HOURS = 6
def sweep_creators(dead_clip_ids: list, *, cap: int = _SWEEP_CAP, sleep: float = 0.1) -> int:
"""Po znalezieniu skasowanego klipu sprawdza od razu pozostale zywe klipy jego tworcy.
Tworcy kasuja zwykle cale konto naraz, a przemial przy 1000 klipow na godzine robi
okrazenie katalogu w okolo tydzien. 2026-10-06 @thenaughtywoman mial 115 martwych
i 120 "zywych", z ktorych kazdy oddawal 403, wiec strona tworcy pokazywala puste
kafelki, a feed podsuwal czarne klipy. Kazdy klip sprawdzamy osobno (HEAD), nic nie
oznaczamy w ciemno. Sprawdzone zywe dostaja swiezy updated_at, wiec ten sam tworca
zgloszony z kilku telefonow naraz nie jest mielony w kolko. Zwraca ile oznaczono.
"""
import time as _time
from datetime import UTC, datetime, timedelta
import httpx
from sqlalchemy import select
if not dead_clip_ids:
return 0
recheck_before = datetime.now(UTC) - timedelta(hours=_SWEEP_RECHECK_HOURS)
with session_scope() as session:
creators = [
c for (c,) in session.execute(
select(Clip.creator).where(Clip.id.in_(dead_clip_ids), Clip.creator.is_not(None)).distinct()
).all()
]
if not creators:
return 0
rows = session.execute(
select(Clip.id, Clip.hd_url, Clip.sd_url)
.where(
Clip.creator.in_(creators),
Clip.dead_at.is_(None),
Clip.updated_at < recheck_before,
)
.order_by(Clip.updated_at.asc())
.limit(cap)
).all()
dead: list[tuple] = []
alive: list = []
with httpx.Client(headers=_REAP_UA, timeout=15, follow_redirects=True) as http:
for cid, hd, sd in rows:
url = hd or sd
if not url:
continue
reason = _head_dead(http, url)
if reason:
dead.append((cid, reason))
else:
alive.append(cid)
_time.sleep(sleep)
with session_scope() as session:
for cid, reason in dead:
session.execute(
text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"),
{"r": f"{reason} (tworca)", "id": cid},
)
if alive:
session.execute(text("UPDATE clips SET updated_at = now() WHERE id = ANY(:ids)"), {"ids": alive})
if rows:
log.info("clips: zamiatanie %d tworcow, sprawdzone %d, martwe %d", len(creators), len(rows), len(dead))
return len(dead)
def reap_dead(*, limit: int = 2000, apply: bool = True, sleep: float = 0.2) -> dict[str, int]:
"""Reaper martwych klipow: tworcy kasuja klipy, RedGifs oddaje wtedy 404/410 na pliku.
HEAD na `hd_url` (albo `sd_url`) dla najdawniej sprawdzanych zywych klipow
(`updated_at`); 403/404/410 → `dead_at`. Bledy sieci NIE oznaczaja smierci (patrz
notatka o dwoch kontrolach przy przemiale zrodel). Klip, ktory zrodlo znow poda
w listingu, upsert odzywia. Sprawdzone i zywe dostaja swiezy `updated_at`, zeby
kolejny przebieg bral inne.
"""
import time as _time
import httpx
from sqlalchemy import select
checked = dead = errors = 0
dead_ids: list[tuple] = []
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=_REAP_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(sleep)
if apply and rows:
dead_set = {d[0] for d in dead_ids}
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},
)
alive = [cid for cid, _hd, _sd in rows if cid not in dead_set]
if alive:
session.execute(text("UPDATE clips SET updated_at = now() WHERE id = ANY(:ids)"), {"ids": alive})
dead += sweep_creators(list(dead_set))
out = {"checked": checked, "dead": dead, "errors": errors, "applied": int(apply)}
log.info("clips reaper: %s", out)
return out
def decay_affinity() -> int:
"""Nocne zapominanie: wszystkie wagi ×0,97, |w| < 0,2 znika. Zwraca liczbe urzadzen."""
from app.clips_feed import decay_weights
from app.models.clip_feed import ClipAffinity
n = 0
with session_scope() as session:
for row in session.query(ClipAffinity).all():
row.tag_weights = decay_weights(dict(row.tag_weights or {}))
row.niche_weights = decay_weights(dict(row.niche_weights or {}))
row.creator_weights = decay_weights(dict(row.creator_weights or {}))
n += 1
return n
def run_clips_maintenance() -> dict[str, int]:
out: dict[str, int] = {}
out["affinity_rows"] = decay_affinity()
with session_scope() as session:
res = session.execute(
text("DELETE FROM clip_feed_events WHERE created_at < now() - make_interval(days => :d)"),
{"d": _EVENTS_RETENTION_DAYS},
)
out["events_pruned"] = int(res.rowcount or 0)
out.update({f"reap_{k}": v for k, v in reap_dead(limit=2000, apply=True).items()})
log.info("clips maintenance: %s", out)
return out