goon/app/scheduler/ingest_watchdog.py
goon-foss a872fc3a88
Some checks are pending
Backend tests / test (push) Waiting to run
fix(perverzija): manifest HLS z AES-128 skladany w /proxy/hls, proxy bez gzip, porndoe poza alarmem, skrypt dlugosci porntrex
- xtremestream od 12.10 szyfruje segmenty (AES-128-CBC, IV = numer segmentu, klucz spod
  tokenu zwiazanego z IP i UA): producent manifestu bierze xs1.php, przepisuje sciezki
  na _enc i dokleja EXT-X-KEY; klucz oddaje /proxy/hls/<token>/key.bin na wlasny token
- proxy: Accept-Encoding identity (skompresowany manifest bez naglowka konczyl na
  "Input does not start with the #EXTM3U header"), play.m3u8 z nazwy pliku jako manifest
- ingest_watchdog: porndoe jako zrodlo zamrozone (nowe sceny przekierowuja z kazdego IP)
- scripts/porntrex_duration_walk.py: dlugosci z listingu /latest-updates/N/, z dry-run
2026-10-14 14:57:29 +02:00

235 lines
10 KiB
Python

"""Per-origin freshness watchdog, alert gdy aktywne źródło przestało dawać nowe treści.
Globalny monitor źródeł (ingest_runs per `Source`) tego NIE łapie, bo wszystkie tube
scrapery dzielą jeden `Source` = "tube-scraper", pojedynczy origin może zamarznąć
(np. freshporno: scraper browsował z roota `/`, który KVS rotuje → cold-session
dostawała stary zestaw → new=0/skipped=N przez 2 dni), a zagregowany run nadal
raportuje success. Sygnał per-origin: `max(created_at)` na źródłach playbacku.
Pokrywamy TRZY klasy, każda z własnym progiem:
- **browse** (`ALL_BROWSE_SCRAPERS`), crawlowane codziennie z listingu, próg 48h.
- **search** (`ALL_DIRECT_SCRAPERS`), performer-driven, nierówna kadencja (~30d
refresh per performer), próg wyższy (domyślnie 7d).
- **movies** (`_MOVIE_CONNECTORS`), dodane 2026-08-03. Wcześniej filmy NIE BYŁY
pokryte w ogóle: streamporn.vip stał 23 dni (czytał listing nieposortowany po
dacie) i żaden automat nie miał jak tego zauważyć.
Tag obecny w kilku listach liczymy wg najostrzejszego progu.
**Eskalacja zamiast jednego cichego issue.** Do 2026-08-03 zdarzenie szło zawsze jako
`warning` ze stabilnym fingerprintem per origin. Fingerprint jest tam po to, żeby nie
tworzyć nowego issue co 6h, ale skutkiem ubocznym było JEDNO issue przy pierwszym
wystąpieniu, potem tylko rosnący licznik. Sentry powiadamia o nowych i regresjach, nie
o kolejnych wystąpieniach otwartego issue, a reguły alertów zwykle celują w `error`,
nie `warning`. Efekt: vjav stał 12 dni, watchdog go poprawnie wypisywał co cykl, i nikt
się nie dowiedział.
Teraz fingerprint zawiera KUBEŁEK WIEKU, więc przekroczenie każdego kolejnego progu
zakłada nowe issue (= nowe powiadomienie), a w obrębie kubełka dalej nie ma spamu.
Od 7 dni ciszy poziom idzie na `error`, żeby wpaść w standardowe reguły alertów.
Patrz [[reference_kvs_root_rotates_use_latest_updates]] dla klasy błędu, którą to łapie.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
#: Kubełki wieku ciszy (godziny, malejąco) → etykieta + poziom Sentry. Wejście w
#: kolejny kubełek zmienia fingerprint, czyli zakłada NOWE issue i wysyła alert.
_BUCKETS: tuple[tuple[int, str, str], ...] = (
(720, "30d+", "error"),
(168, "7d+", "error"),
(48, "2d+", "warning"),
(0, "swiezo", "warning"),
)
def _bucket(age_h: float) -> tuple[str, str]:
for floor, label, level in _BUCKETS:
if age_h >= floor:
return label, level
return "swiezo", "warning"
# Serwisy, ktore publikuja rzadziej niz co dwa dni: prog 48 h robil z nich staly alarm
# (sprawdzone 2026-10-13: xanimeporn 1 do 3 wpisow na tydzien z RSS, hentaimama wydaje
# odcinki raz w tygodniu, obie listy w calosci znane w bazie). Prog w godzinach, reczny
# podlog; do tego kazde zrodlo dostaje prog z wlasnej kadencji (patrz _cadence_threshold_h).
_BROWSE_MAX_AGE_OVERRIDE_H: dict[str, int] = {
"xanimeporn": 168,
"hentaimama": 240,
}
# Zrodla swiadomie zamrozone: alarm nic nie wniesie, dopoki ktos nie zmieni stanu rzeczy.
# porndoe (2026-10-14): kazdy nowy /watch/ z sitemapy przekierowuje na /videos z kazdego IP
# (proxy PL i UK, z ciastkami sesji i po zdjeciu bramki wieku), darmowy katalog stoi na
# 11.09; stare 64 tys. scen gra. Kontrola reczna co jakis czas, nie co 6 h w Sentry.
_FROZEN_BROWSE: dict[str, str] = {
"porndoecom": "2026-10-14 nowe sceny przekierowuja na /videos z kazdego IP",
}
# Prog z kadencji: mediana odstepu (w dobach) miedzy dniami z nowymi pozycjami w ostatnich
# 60 dniach, razy 3, nie mniej niz prog klasy i nie wiecej niz 14 dni. Zrodlo codzienne
# zostaje przy 48 h, tygodniowe dostaje ok. 14 dni zamiast alarmu co dobe (2026-10-14).
_ADAPTIVE_FACTOR = 3.0
_ADAPTIVE_CAP_H = 14 * 24
_ADAPTIVE_MIN_ACTIVE_DAYS = 5
_ADAPTIVE_LOOKBACK_DAYS = 60
def _cadence_threshold_h(session, *, origin: str, table: str, base_h: int) -> int:
import statistics
if table == "movie_playback_sources":
where = "(origin = :o OR origin LIKE :p)"
params: dict = {"o": origin, "p": f"{origin}:%", "lb": _ADAPTIVE_LOOKBACK_DAYS}
else:
where = "origin = :o"
params = {"o": origin, "lb": _ADAPTIVE_LOOKBACK_DAYS}
try:
rows = session.execute(
text(
f"SELECT DISTINCT created_at::date AS d FROM {table} WHERE {where} "
"AND created_at > now() - make_interval(days => :lb) ORDER BY d"
),
params,
).all()
except Exception: # pragma: no cover
log.exception("ingest-watchdog: kadencja %s nieznana, prog klasy", origin)
return base_h
days = [r[0] for r in rows]
if len(days) < _ADAPTIVE_MIN_ACTIVE_DAYS:
return base_h
gaps = [(b - a).days for a, b in zip(days, days[1:])]
if not gaps:
return base_h
median_gap_h = statistics.median(gaps) * 24.0
return int(max(base_h, min(_ADAPTIVE_CAP_H, _ADAPTIVE_FACTOR * median_gap_h)))
def run_ingest_freshness_watchdog(
*,
max_age_hours: int = 48,
search_max_age_hours: int = 168,
movie_max_age_hours: int = 72,
min_history: int = 100,
to_sentry: bool = True,
) -> dict[str, Any]:
"""Sprawdź każde aktywne źródło: czy dostało nową treść < próg dla swojej klasy.
`min_history` odsiewa świeżo dodane źródła bez ustalonej kadencji (za mało pozycji,
by wiedzieć, czy cisza to anomalia).
`to_sentry=False` daje sam odczyt (przydatne do ręcznego sprawdzenia bez
zaśmiecania issue).
"""
from app.connectors import get_movie_connectors # noqa: PLC0415
from app.connectors.direct_scrapers import ( # noqa: PLC0415
ALL_BROWSE_SCRAPERS,
ALL_DIRECT_SCRAPERS,
)
browse_tags = {cls.sitetag for cls in ALL_BROWSE_SCRAPERS}
search_tags = {cls.sitetag for cls in ALL_DIRECT_SCRAPERS} - browse_tags
# (origin, tabela, próg_h, klasa)
checks: list[tuple[str, str, int, str]] = (
[
(f"tube:{t}", "playback_sources", _BROWSE_MAX_AGE_OVERRIDE_H.get(t, max_age_hours), "browse")
for t in sorted(browse_tags)
if t not in _FROZEN_BROWSE
]
+ [(f"tube:{t}", "playback_sources", search_max_age_hours, "search")
for t in sorted(search_tags)]
)
try:
for name, _cls in get_movie_connectors():
checks.append((name, "movie_playback_sources", movie_max_age_hours, "movies"))
except Exception as e: # pragma: no cover - rejestr filmów niedostępny
log.warning("ingest-watchdog: nie udało się pobrać konektorów filmowych: %s", e)
now = datetime.now(UTC)
stale: list[dict[str, Any]] = []
with session_scope() as s:
for origin, table, threshold, kind in checks:
# Filmy trzymają origin jako `<connector>:<host>`, sceny jako dokładne
# `tube:<sitetag>`, stąd LIKE dla filmów, równość dla scen.
if table == "movie_playback_sources":
sql = (
f"SELECT max(created_at) AS newest, count(*) AS total FROM {table} "
"WHERE origin = :o OR origin LIKE :p"
)
params = {"o": origin, "p": f"{origin}:%"}
else:
sql = (
f"SELECT max(created_at) AS newest, count(*) AS total FROM {table} "
"WHERE origin = :o"
)
params = {"o": origin}
row = s.execute(text(sql), params).one()
newest, total = row.newest, row.total
if total < min_history or newest is None:
continue
age_h = (now - newest).total_seconds() / 3600.0
if age_h >= threshold:
# Dopiero przy przekroczeniu progu klasy liczymy kadencje (jedno zapytanie
# per podejrzane zrodlo, nie per wszystkie).
threshold = _cadence_threshold_h(s, origin=origin, table=table, base_h=threshold)
if age_h >= threshold:
label, level = _bucket(age_h)
stale.append(
{
"origin": origin,
"age_hours": round(age_h, 1),
"total": total,
"kind": kind,
"max_age_hours": threshold,
"bucket": label,
"level": level,
}
)
if not stale:
log.info("ingest-watchdog: wszystkie %d źródła świeże", len(checks))
return {"checked": len(checks), "stale": []}
stale.sort(key=lambda x: -x["age_hours"])
log.warning(
"ingest-watchdog: %d/%d źródeł bez nowych treści: %s",
len(stale), len(checks),
", ".join(f"{x['origin']}({x['age_hours']}h,{x['kind']})" for x in stale),
)
if to_sentry:
try:
import sentry_sdk
for x in stale:
with sentry_sdk.push_scope() as scope:
scope.level = x["level"]
scope.set_tag("ingest_origin", x["origin"])
scope.set_tag("ingest_scraper_kind", x["kind"])
scope.set_tag("ingest_stale_bucket", x["bucket"])
scope.set_extra("age_hours", x["age_hours"])
scope.set_extra("total_items", x["total"])
scope.set_extra("max_age_hours", x["max_age_hours"])
# Kubełek W fingerprincie: nowe issue (= nowy alert) przy każdym
# kolejnym progu, ale bez spamu co 6h w obrębie tego samego progu.
scope.fingerprint = ["ingest-stale-origin", x["origin"], x["bucket"]]
sentry_sdk.capture_message(
f"ingest-watchdog: {x['origin']} ({x['kind']}) bez nowych treści "
f"({x['age_hours']:.0f}h, próg {x['max_age_hours']}h)"
)
except Exception: # pragma: no cover - Sentry off / brak DSN
log.exception("ingest-watchdog: Sentry capture failed")
return {"checked": len(checks), "stale": stale}