Some checks are pending
Backend tests / test (push) Waiting to run
- browse_runs (migracja 0056): jeden wiersz per scraper i przebieg browse-latest - quality_watchdog: listing pusty albo bez nowych, skok lub zanik obsady, sceny bez dlugosci i miniatur, spadek udanych odtworzen per origin; fingerprint per zrodlo i rodzaj - ingest_watchdog: prog swiezosci z mediany odstepow zrodla (3x, max 14 d) zamiast stalej - duration backfill: resolvery perverzija (JSON-LD) i mypornerleak (itemprop), paczka po rowno z originow, 404/410 zamyka wiersz od razu - parsery: galaxyporn data z JSON-LD, hentaimama dlugosc, javflix aktorki, fullmovies i hdporngg data z uploadDate
226 lines
9.6 KiB
Python
226 lines
9.6 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,
|
|
}
|
|
|
|
# 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)
|
|
]
|
|
+ [(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}
|