Compare commits

..

No commits in common. "4fd94de72d43ad2717533aa275521bdd5ad6b11f" and "4417ae0ff03b0508d44e64f3361cd1e88c50cc4c" have entirely different histories.

10 changed files with 65 additions and 476 deletions

View file

@ -681,13 +681,7 @@ def _is_rotting_thumb(url: str) -> bool:
# tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze # tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze
# image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy # image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła. # jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
if "tn.sextu.com" in url: return "tn.sextu.com" in url
return True
# fastpic.org (galaxyporn i inne re-uploadery) — hosting zrywa połączenie zamiast
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
return "fastpic.org" in url
def _needs_proxy(url: str) -> bool: def _needs_proxy(url: str) -> bool:

View file

@ -123,12 +123,6 @@ class Settings(BaseSettings):
sched_phash_blacklist_hours: int = Field( sched_phash_blacklist_hours: int = Field(
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS" default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
) )
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
# w ogóle — streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
ingest_watchdog_movie_max_age_hours: int = Field(
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
)
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie", # Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania. # „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off. # Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.

View file

@ -40,7 +40,6 @@ def get_movie_connectors() -> list[tuple[str, type]]:
) )
from app.connectors.freeomovie import FreeoMovieConnector from app.connectors.freeomovie import FreeoMovieConnector
from app.connectors.paradisehill import ParadisehillConnector from app.connectors.paradisehill import ParadisehillConnector
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do # Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content — # niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content —
@ -59,12 +58,4 @@ def get_movie_connectors() -> list[tuple[str, type]]:
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD, # freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD,
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02. # title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
("freeomovie", FreeoMovieConnector), ("freeomovie", FreeoMovieConnector),
# yourdailypornmovies.ws (własny motyw WP, nie dooplay) — ocena 2026-07-28.
# SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
# gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed.
("yourdailypornmovies", YourDailyPornMoviesConnector),
] ]

View file

@ -31,7 +31,6 @@ from app.connectors.base import (
RawStudio, RawStudio,
RawTag, RawTag,
) )
from app.config import get_settings
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get from app.extractors import browser_get
from app.normalize.text import slugify from app.normalize.text import slugify
@ -59,47 +58,6 @@ def _parse_duration(value: str | None) -> int | None:
return sec or None return sec or None
# --- bramka jakości tytułu ----------------------------------------------------------
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
# w apce nieodnajdywalne — nie mają ani tytułu, ani performera, po którym da się trafić.
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
# bo sprawdzała tylko jeden z czterech wzorców).
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
_JUNK_HEX_ID = re.compile(r"^x\d+x[0-9A-F]{12,}$") # x1x84BDE66B5F8862EDA6
_JUNK_CODE_MASH = re.compile(r"^[A-Za-z]{2,4}\d{2,4}-[A-Z]{6,}\d{6,}$") # QCT062-KPUDYLMLBL913295279
def _has_cjk(text: str) -> bool:
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły za mało
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
wyszła przy pomiarze pierwszej wersji bramki bez tego wyjątku poszłoby 708 z 708
tytułów CJK z próbki."""
return sum(1 for c in text if " " <= c <= "鿿" or "" <= c <= "") >= 3
def _is_junk_title(title: str) -> bool:
"""True gdy tytuł nie niesie żadnej informacji (id, hash, walnięcie w klawiaturę).
Zmierzone na próbce 6000 tytułów vjav: odrzuca 76% świeżych i 27% starych, przy
ZERO odrzuconych tytułach CJK. Przepuszczone przykłady to normalne zdania
(Maki Horiguchis Erotic Shower...", „200GANA-2482 Japanese Amateur")."""
t = (title or "").strip()
if not t:
return True
if _JUNK_ID_JAV.search(t) or _JUNK_HEX_ID.search(t) or _JUNK_CODE_MASH.search(t):
return True
if _has_cjk(t):
return False
letters = [c for c in t if "a" <= c.lower() <= "z"]
if len(letters) >= 12:
vowels = sum(1 for c in letters if c.lower() in "aeiouy")
if vowels / len(letters) < 0.22:
return True
return " " not in t and len(t) >= 20
def _parse_post_date(value: str | None) -> date | None: def _parse_post_date(value: str | None) -> date | None:
if not value: if not value:
return None return None
@ -116,45 +74,21 @@ class VjavScraper(BaseBrowseScraper):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache id-ów wideo (posortowane malejąco = newest first), per instancja/run. # Cache id-ów wideo (posortowane malejąco = newest first), per instancja/run.
self._sorted_ids: list[int] | None = None self._sorted_ids: list[int] | None = None
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
# działania, nie optymalizacją — bez klucza scraper się wyłącza.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
# ---- browse: sitemap id-walk ------------------------------------------------ # ---- browse: sitemap id-walk ------------------------------------------------
def _load_ids(self) -> None: def _load_ids(self) -> None:
"""`self._sorted_ids = []` znaczy „katalog pusty", a to woła `crawl_page` do
zwrócenia [], czyli koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
wyglądał na zdrowy. Zostawiamy None = nie wiadomo", co crawl_page tłumaczy na
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
try: try:
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy) res = browser_get(_SITEMAP_INDEX, timeout=self._timeout)
if getattr(res, "status_code", 200) >= 400:
log.warning("vjav: sitemap index HTTP %s — przerywam", res.status_code)
self._sorted_ids = None
return
idx = res.text if hasattr(res, "text") else res idx = res.text if hasattr(res, "text") else res
except Exception as e: except Exception as e:
log.warning("vjav: sitemap index fetch fail: %s", e) log.warning("vjav: sitemap index fetch fail: %s", e)
self._sorted_ids = None self._sorted_ids = []
return return
maps = list(dict.fromkeys(_SITEMAP_RE.findall(idx))) maps = list(dict.fromkeys(_SITEMAP_RE.findall(idx)))
if not maps:
log.warning("vjav: sitemap index bez sitemap wideo (%d B) — przerywam", len(idx))
self._sorted_ids = None
return
ids: set[int] = set() ids: set[int] = set()
for sm in maps: for sm in maps:
try: try:
res = browser_get(sm, timeout=self._timeout, proxy=self._proxy) res = browser_get(sm, timeout=self._timeout)
if getattr(res, "status_code", 200) >= 400:
log.info("vjav: sitemap %s HTTP %s", sm, res.status_code)
continue
body = res.text if hasattr(res, "text") else res body = res.text if hasattr(res, "text") else res
except Exception as e: except Exception as e:
log.info("vjav: sitemap %s fetch fail: %s", sm, e) log.info("vjav: sitemap %s fetch fail: %s", sm, e)
@ -174,8 +108,6 @@ class VjavScraper(BaseBrowseScraper):
slug = (v.get("dir") or "").strip() slug = (v.get("dir") or "").strip()
if not title or not slug: if not title or not slug:
return None return None
if _is_junk_title(title):
return None
page_url = f"{_BASE}/videos/{vid}/{slug}/" page_url = f"{_BASE}/videos/{vid}/{slug}/"
duration_sec = _parse_duration(v.get("duration")) duration_sec = _parse_duration(v.get("duration"))
release_date = _parse_post_date(v.get("post_date")) release_date = _parse_post_date(v.get("post_date"))
@ -232,29 +164,6 @@ class VjavScraper(BaseBrowseScraper):
raw={"source": "vjav_api", "id": vid}, raw={"source": "vjav_api", "id": vid},
) )
def latest_scenes(self, *, max_pages: int = 5):
"""Własna iteracja, bo bazowa przerywa na pierwszej pustej stronie.
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć koniec
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
kończyło CAŁY run źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
2-5. To ta sama pułapka [] znaczy wyczerpane" co w `_load_ids`.
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
"""
if self._sorted_ids is None:
self._load_ids()
if not self._sorted_ids:
return
for page in range(1, max_pages + 1):
if (page - 1) * _PER_PAGE >= len(self._sorted_ids):
return # realny koniec katalogu
scenes = self.crawl_page(page)
if scenes is None:
return # transient fail
yield from scenes
def crawl_page(self, page: int) -> list[RawScene] | None: def crawl_page(self, page: int) -> list[RawScene] | None:
if self._sorted_ids is None: if self._sorted_ids is None:
self._load_ids() self._load_ids()
@ -268,14 +177,7 @@ class VjavScraper(BaseBrowseScraper):
out: list[RawScene] = [] out: list[RawScene] = []
for vid in chunk: for vid in chunk:
try: try:
# Proxy także tutaj — API metadanych siedzi na tym samym hoście, więc res = browser_get(self._meta_url(vid), timeout=self._timeout, headers={"Referer": _BASE + "/"})
# obejmuje je ten sam 429 co sitemap.
res = browser_get(
self._meta_url(vid),
timeout=self._timeout,
headers={"Referer": _BASE + "/"},
proxy=self._proxy,
)
body = res.text if hasattr(res, "text") else res body = res.text if hasattr(res, "text") else res
v = json.loads(body).get("video") v = json.loads(body).get("video")
except Exception as e: except Exception as e:

View file

@ -454,22 +454,6 @@ class StreampornVipConnector(DooplayConnector):
name = "streampornvip" name = "streampornvip"
base_url = "https://streamporn.vip" base_url = "https://streamporn.vip"
def _listing_path(self, page: int) -> str:
"""Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od
2026-07-11 (0 nowych przez 23 dni), ale strona żyła i konektor parsował
poprawnie tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dało
roczniki 2012-2015, strona główna wyłącznie 2026 (po 10-11 źródeł playbacku).
`?orderby=date` motyw ignoruje (odpowiedź identyczna z domyślną).
To ta sama klasa błędu co przy freshporno: właściwa strona, zły endpoint
listingu patrz [[reference_kvs_root_rotates_use_latest_updates]].
Strony 1 i 2 współdzielą ~44 linki (sidebar/widgety), ale treść główna jest
stronicowana normalnie; dedup po URL w `fetch_movies` i tak je odsiewa.
"""
return "/" if page == 1 else f"/page/{page}/"
class PandamoviesConnector(DooplayConnector): class PandamoviesConnector(DooplayConnector):
name = "pandamovies" name = "pandamovies"

View file

@ -1,225 +0,0 @@
"""yourdailypornmovies.ws — konektor filmów. Dodany 2026-07-28.
WordPress z własnym motywem (NIE dooplay), więc nie da się podpiąć pod
`DooplayConnector` jak mangoporn/pandamovies/streamporn.
**Sprawdzony na duplikat przed dodaniem** (2026-07-28), bo poprzedni kandydat
(speedporn.net) okazał się kolejną nakładką na `pandanetwork.club`, czyli 0 nowych
filmów. Tutaj z 40 filmów ze strony głównej: 13 mieliśmy po tytule, 9 po id produktu
Adult Empire, a **18 (45%) było nowych**. Inna pula treści: miniaturki to oficjalne
okładki DVD z `imgs1cdn.adultempire.com`, nie re-hosty z pandanetwork.
Katalog: min. 8000 pozycji (strona 200 nadal oddaje wyniki, po 40 na stronę), sięga
2003 roku, strona 1 ma bieżący rok.
**Pułapka: `/tag/` w całym HTML to 2177 linków** to chmura tagów w sidebarze,
identyczny wzorzec jak przy sexu. Tagi (i obsada) MUSZĄ być czytane z bloków
`div.xmll`, inaczej każdy film dostanie te same dwa tysiące śmieci.
**`/director/` trzyma w praktyce STUDIO**, nie reżysera (New Sensations", „Brazzers").
Bierzemy to jako studio, bo tak jest użyte, a reżyserów strona i tak nie podaje.
Czego strona nie ma: długości filmu.
Playback: 3 hostery w iframe. `voe.sx` i `mxdrop.sx` zweryfikowane, że resolwują
istniejącymi modułami (`app/extractors/hosters/voe.py` m3u8, `mixdrop.py` mp4).
`vidara.to` jest nam nieznany zapisujemy go jako zwykły embed, telefon otworzy go
w WebView, ale nie liczymy na niego.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from collections.abc import Iterator
from datetime import datetime
from urllib.parse import urlparse
from app.connectors.base import (
BaseMovieConnector,
RawMovie,
RawPerformer,
RawPlaybackSource,
RawStudio,
RawTag,
)
from app.extractors import browser_get
from app.models.source import SourceKind
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://yourdailypornmovies.ws"
# Karta listingu: id produktu Adult Empire w miniaturce + URL + tytuł + rok.
_CARD_RE = re.compile(
r'<div class="movie"><div class="imagen">\s*'
r'<img src="(?P<poster>https://imgs\d*cdn\.adultempire\.com/products/\d+/(?P<ae>\d+)h\.jpg)"'
r'[^>]*>\s*<a href="(?P<url>https://yourdailypornmovies\.ws/[^"]+)">.*?'
r'</div><h2>(?P<title>[^<]+)</h2>\s*<span class="year">(?P<year>\d{4})</span>',
re.DOTALL,
)
# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd — patrz pułapka w docstringu.
_XMLL_RE = re.compile(r'<div class="xmll">.*?</div>', re.DOTALL)
_ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)</a>')
_TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)</a>')
_DIRECTOR_RE = re.compile(r'href="[^"]*/director/[^"]+"[^>]*>([^<]+)</a>')
_YEAR_RE = re.compile(r'href="[^"]*/fecha-estreno/(\d{4})/"')
_OG_DESC_RE = re.compile(r'<meta property="og:description" content="([^"]*)"')
_IFRAME_RE = re.compile(r'<iframe[^>]+src="((?:https?:)?//[^"]+)"', re.IGNORECASE)
# Hosty w iframe, które NIE są odtwarzaczem (reklamy).
_AD_HOSTS = ("realsrv", "magsrv", "exosrv", "juicyads", "google", "doubleclick")
def _host_label(url: str) -> str | None:
host = (urlparse(url).hostname or "").lower().replace("www.", "")
if not host:
return None
parts = host.split(".")
return parts[-2] if len(parts) >= 2 else parts[0]
class YourDailyPornMoviesConnector(BaseMovieConnector):
kind = SourceKind.scraper
name = "yourdailypornmovies"
base_url = _BASE
_MAX_PAGES_DELTA = 3
_MAX_PAGES_FULL = 40
def __init__(self, *, timeout: float = 30.0) -> None:
self._timeout = timeout
def close(self) -> None:
pass
def _fetch(self, url: str) -> str:
if not url.startswith("http"):
url = _BASE + url
r = browser_get(url, timeout=self._timeout, follow_redirects=True)
if r.status_code >= 400:
raise RuntimeError(f"{r.status_code} for {url}")
return r.text
def fetch_movies(
self, *, since: datetime | None = None, limit: int | None = None
) -> Iterator[RawMovie]:
seen = 0
seen_urls: set[str] = set()
max_pages = self._MAX_PAGES_DELTA if since is not None else self._MAX_PAGES_FULL
for page in range(1, max_pages + 1):
path = "/" if page == 1 else f"/page/{page}/"
try:
listing = self._fetch(path)
except Exception as e:
log.warning("%s listing page=%d failed: %s", self.name, page, e)
return
cards = list(_CARD_RE.finditer(listing))
if not cards:
log.info("%s: pusta strona=%d, stop", self.name, page)
return
for m in cards:
url = m.group("url")
if url in seen_urls:
continue
seen_urls.add(url)
try:
movie = self._parse_detail(m, url)
except Exception as e:
log.warning("%s detail failed %s: %s", self.name, url, e)
continue
if movie is None:
continue
yield movie
seen += 1
if limit is not None and seen >= limit:
return
def _parse_detail(self, card: re.Match[str], url: str) -> RawMovie | None:
detail = self._fetch(url)
title = html_mod.unescape(card.group("title")).strip()
if not title:
return None
# Playback najpierw — bez niego film jest bezużyteczny, nie tworzymy orphana.
playback: list[RawPlaybackSource] = []
seen_host: set[str] = set()
for m in _IFRAME_RE.finditer(detail):
embed = m.group(1)
if embed.startswith("//"):
embed = "https:" + embed
host = _host_label(embed)
if not host or host in seen_host or any(a in embed for a in _AD_HOSTS):
continue
seen_host.add(host)
playback.append(
RawPlaybackSource(
origin=f"{self.name}:{host}",
page_url=embed,
embed_url=embed,
)
)
if not playback:
return None
# Obsada, tagi i studio WYŁĄCZNIE z bloków `div.xmll` (chmura tagów z sidebara
# ma 2177 linków i zaśmieciłaby każdy film tym samym zestawem).
blocks = " ".join(_XMLL_RE.findall(detail))
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
for name in _ACTOR_RE.findall(blocks):
name = html_mod.unescape(name).strip()
slug = slugify(name)
if slug and slug not in seen_perf:
seen_perf.add(slug)
performers.append(
RawPerformer(external_id=f"{self.name}:performer:{slug}", name=name)
)
tags: list[RawTag] = []
seen_tag: set[str] = set()
for name in _TAG_RE.findall(blocks):
name = html_mod.unescape(name).strip()
slug = slugify(name)
if slug and slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.name}:tag:{slug}", name=name, slug=slug))
studio = None
dm = _DIRECTOR_RE.search(blocks)
if dm:
sname = html_mod.unescape(dm.group(1)).strip()
if sname and slugify(sname):
studio = RawStudio(
external_id=f"{self.name}:studio:{slugify(sname)}",
name=sname,
slug=slugify(sname),
)
ym = _YEAR_RE.search(detail)
year = int(ym.group(1)) if ym else int(card.group("year"))
desc = None
om = _OG_DESC_RE.search(detail)
if om:
desc = html_mod.unescape(om.group(1)).strip() or None
slug = urlparse(url).path.strip("/").split("/")[-1]
return RawMovie(
external_id=slug,
title=title,
description=desc,
release_year=year,
url=url,
poster_url=card.group("poster"),
studio=studio,
performers=performers,
tags=tags,
playback_sources=playback,
# id produktu Adult Empire — mocniejsza kotwica do dedupu niż tytuł
# (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał).
raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
)

View file

@ -1,32 +1,20 @@
"""Per-origin freshness watchdog — alert gdy aktywne źródło przestało dawać nowe treści. """Per-sitetag freshness watchdog — alert gdy aktywny tube przestał dawać nowe sceny.
Globalny monitor źródeł (ingest_runs per `Source`) tego NIE łapie, bo wszystkie tube 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ąć scrapery dzielą jeden `Source` = "tube-scraper" pojedynczy origin może zamarznąć
(np. freshporno: scraper browsował z roota `/`, który KVS rotuje cold-session (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 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. raportuje success. Sygnał per-origin: `max(created_at)` na playback_sources danego
`tube:<sitetag>`. Jak zamrożony > próg alert (report 14f3a655 2026-06-15).
Pokrywamy TRZY klasy, każda z własnym progiem: Pokrywamy DWIE klasy scraperów, każda z własnym progiem:
- **browse** (`ALL_BROWSE_SCRAPERS`) crawlowane codziennie z listingu, próg 48h. - **browse** (`ALL_BROWSE_SCRAPERS`) crawlowane codziennie z listingu, próg 48h.
- **search** (`ALL_DIRECT_SCRAPERS`) performer-driven, nierówna kadencja (~30d - **search** (`ALL_DIRECT_SCRAPERS`) performer-driven, nierówna kadencja (~30d
refresh per performer), próg wyższy (domyślnie 7d). refresh per performer), próg wyższy (domyślnie 7d). Bez tego pokrycia kilka
- **movies** (`_MOVIE_CONNECTORS`) dodane 2026-08-03. Wcześniej filmy NIE BYŁY search-tubów (sxyland, latestpornvideo, perverzija, fpoxxx, mypornerleak, porndish)
pokryte w ogóle: streamporn.vip stał 23 dni (czytał listing nieposortowany po zamarzło cicho 2026-05-07/06-07/06-13 i nic nie krzyknęło do Sentry.
dacie) i żaden automat nie miał jak tego zauważyć. Tag obecny w obu listach (xvideoscom, epornercom i browse i search) liczymy jako
browse (ostrzejszy próg).
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. Patrz [[reference_kvs_root_rotates_use_latest_updates]] dla klasy błędu, którą to łapie.
""" """
@ -42,86 +30,58 @@ from app.db import session_scope
log = logging.getLogger(__name__) 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"
def run_ingest_freshness_watchdog( def run_ingest_freshness_watchdog(
*, *,
max_age_hours: int = 48, max_age_hours: int = 48,
search_max_age_hours: int = 168, search_max_age_hours: int = 168,
movie_max_age_hours: int = 72,
min_history: int = 100, min_history: int = 100,
to_sentry: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Sprawdź każde aktywne źródło: czy dostało nową treść < próg dla swojej klasy. """Sprawdź każdy aktywny scraper: czy origin dostał nową scenę < próg dla swojej klasy.
`min_history` odsiewa świeżo dodane źródła bez ustalonej kadencji (za mało pozycji, Skanujemy sitetagi z `ALL_BROWSE_SCRAPERS` (próg `max_age_hours`, domyślnie 48h) oraz
by wiedzieć, czy cisza to anomalia). z `ALL_DIRECT_SCRAPERS` (performer-driven search, próg `search_max_age_hours`, domyślnie
7d nierówna kadencja, 48h dawałoby false-positivy). Tag w obu listach liczymy jako
browse (ostrzejszy próg). Nie skanujemy wszystkich origin-ów, żeby nie alarmować o
legacy/jednorazowych źródłach. `min_history` odsiewa świeżo dodane tuby bez ustalonej
kadencji (za mało scen, by wiedzieć czy cisza to anomalia).
`to_sentry=False` daje sam odczyt (przydatne do ręcznego sprawdzenia bez Stale origin Sentry `capture_message` ze stabilnym fingerprintem per origin
zaśmiecania issue). (wiek + próg w extra, nie w tytule inaczej każdy run = nowe issue). Zwraca
{checked, stale:[{origin, age_hours, total, kind, max_age_hours}]}.
""" """
from app.connectors import get_movie_connectors # noqa: PLC0415 from app.connectors.direct_scrapers import (
from app.connectors.direct_scrapers import ( # noqa: PLC0415
ALL_BROWSE_SCRAPERS, ALL_BROWSE_SCRAPERS,
ALL_DIRECT_SCRAPERS, ALL_DIRECT_SCRAPERS,
) )
browse_tags = {cls.sitetag for cls in ALL_BROWSE_SCRAPERS} browse_tags = {cls.sitetag for cls in ALL_BROWSE_SCRAPERS}
# Search-tuby dzielące tag z browse (xvideoscom, epornercom) idą pod browse-próg.
search_tags = {cls.sitetag for cls in ALL_DIRECT_SCRAPERS} - browse_tags search_tags = {cls.sitetag for cls in ALL_DIRECT_SCRAPERS} - browse_tags
# (origin, tabela, próg_h, klasa) # (sitetag, próg_h, klasa) — klasa tylko do logów/extra w Sentry.
checks: list[tuple[str, str, int, str]] = ( checks: list[tuple[str, int, str]] = [
[(f"tube:{t}", "playback_sources", max_age_hours, "browse") for t in sorted(browse_tags)] (tag, max_age_hours, "browse") for tag in sorted(browse_tags)
+ [(f"tube:{t}", "playback_sources", search_max_age_hours, "search") ] + [(tag, search_max_age_hours, "search") for tag in sorted(search_tags)]
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) now = datetime.now(UTC)
stale: list[dict[str, Any]] = [] stale: list[dict[str, Any]] = []
with session_scope() as s: with session_scope() as s:
for origin, table, threshold, kind in checks: for tag, threshold, kind in checks:
# Filmy trzymają origin jako `<connector>:<host>`, sceny jako dokładne origin = f"tube:{tag}"
# `tube:<sitetag>` — stąd LIKE dla filmów, równość dla scen. row = s.execute(
if table == "movie_playback_sources": text(
sql = ( "SELECT max(created_at) AS newest, count(*) AS total "
f"SELECT max(created_at) AS newest, count(*) AS total FROM {table} " "FROM playback_sources WHERE origin = :o"
"WHERE origin = :o OR origin LIKE :p" ),
) {"o": origin},
params = {"o": origin, "p": f"{origin}:%"} ).one()
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 newest, total = row.newest, row.total
if total < min_history or newest is None: if total < min_history or newest is None:
continue continue
age_h = (now - newest).total_seconds() / 3600.0 age_h = (now - newest).total_seconds() / 3600.0
if age_h >= threshold: if age_h >= threshold:
label, level = _bucket(age_h)
stale.append( stale.append(
{ {
"origin": origin, "origin": origin,
@ -129,43 +89,39 @@ def run_ingest_freshness_watchdog(
"total": total, "total": total,
"kind": kind, "kind": kind,
"max_age_hours": threshold, "max_age_hours": threshold,
"bucket": label,
"level": level,
} }
) )
if not stale: if stale:
log.info("ingest-watchdog: wszystkie %d źródła świeże", len(checks)) log.warning(
return {"checked": len(checks), "stale": []} "ingest-watchdog: %d/%d origin(s) bez nowych scen (browse>%dh / search>%dh): %s",
len(stale), len(checks), max_age_hours, search_max_age_hours,
stale.sort(key=lambda x: -x["age_hours"]) ", ".join(f"{x['origin']}({x['age_hours']}h,{x['kind']})" for x in stale),
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: try:
import sentry_sdk import sentry_sdk
for x in stale: for x in stale:
with sentry_sdk.push_scope() as scope: with sentry_sdk.push_scope() as scope:
scope.level = x["level"] scope.level = "warning"
scope.set_tag("ingest_origin", x["origin"]) scope.set_tag("ingest_origin", x["origin"])
scope.set_tag("ingest_scraper_kind", x["kind"]) 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("age_hours", x["age_hours"])
scope.set_extra("total_items", x["total"]) scope.set_extra("total_scenes", x["total"])
scope.set_extra("max_age_hours", x["max_age_hours"]) scope.set_extra("max_age_hours", x["max_age_hours"])
# Kubełek W fingerprincie: nowe issue (= nowy alert) przy każdym # Fingerprint per origin → jedno trwałe issue na zamrożony tube,
# kolejnym progu, ale bez spamu co 6h w obrębie tego samego progu. # nie fragmentowane przez zmienny wiek.
scope.fingerprint = ["ingest-stale-origin", x["origin"], x["bucket"]] scope.fingerprint = ["ingest-stale-origin", x["origin"]]
sentry_sdk.capture_message( sentry_sdk.capture_message(
f"ingest-watchdog: {x['origin']} ({x['kind']}) bez nowych treści " f"ingest-watchdog: {x['origin']} ({x['kind']}) bez nowych scen "
f"({x['age_hours']:.0f}h, próg {x['max_age_hours']}h)" f"({x['age_hours']:.0f}h, próg {x['max_age_hours']}h)"
) )
except Exception: # pragma: no cover - Sentry off / brak DSN except Exception: # pragma: no cover - Sentry off / brak DSN
log.exception("ingest-watchdog: Sentry capture failed") log.exception("ingest-watchdog: Sentry capture failed")
else:
log.info(
"ingest-watchdog: wszystkie %d origin świeże (browse<%dh / search<%dh)",
len(checks), max_age_hours, search_max_age_hours,
)
return {"checked": len(checks), "stale": stale} return {"checked": len(checks), "stale": stale}

View file

@ -324,20 +324,17 @@ def _job_title_duration_dedup() -> None:
log.exception("[scheduler] title-duration dedup failed") log.exception("[scheduler] title-duration dedup failed")
def _job_ingest_watchdog( def _job_ingest_watchdog(max_age_hours: int, search_max_age_hours: int) -> None:
max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int """Per-origin freshness watchdog — alert do Sentry gdy aktywny tube przestał dawać
) -> None: nowe sceny > próg (browse: max_age_hours, search: search_max_age_hours). Globalny
"""Per-origin freshness watchdog → Sentry. Obejmuje sceny (browse/search) ORAZ monitor (jeden Source 'tube-scraper') tego nie łapie; pojedynczy origin może zamarznąć
filmy (od 2026-08-03; wcześniej filmy nie były pokryte i streamporn.vip stał 23 dni przy success-runie (report 14f3a655)."""
niezauważony). Globalny monitor (jeden Source 'tube-scraper') tego nie łapie;
pojedynczy origin może zamarznąć przy success-runie (report 14f3a655)."""
try: try:
from app.scheduler.ingest_watchdog import run_ingest_freshness_watchdog from app.scheduler.ingest_watchdog import run_ingest_freshness_watchdog
res = run_ingest_freshness_watchdog( res = run_ingest_freshness_watchdog(
max_age_hours=max_age_hours, max_age_hours=max_age_hours,
search_max_age_hours=search_max_age_hours, search_max_age_hours=search_max_age_hours,
movie_max_age_hours=movie_max_age_hours,
) )
if res["stale"]: if res["stale"]:
log.warning("[scheduler] ingest-watchdog: %d stale origin(s)", len(res["stale"])) log.warning("[scheduler] ingest-watchdog: %d stale origin(s)", len(res["stale"]))
@ -541,9 +538,8 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
if cfg.get("ingest_watchdog_hours"): if cfg.get("ingest_watchdog_hours"):
wd_max_age = cfg.get("ingest_watchdog_max_age_hours") or 48 wd_max_age = cfg.get("ingest_watchdog_max_age_hours") or 48
wd_search_max_age = cfg.get("ingest_watchdog_search_max_age_hours") or 168 wd_search_max_age = cfg.get("ingest_watchdog_search_max_age_hours") or 168
wd_movie_max_age = cfg.get("ingest_watchdog_movie_max_age_hours") or 72
sched.add_job( sched.add_job(
lambda: _job_ingest_watchdog(wd_max_age, wd_search_max_age, wd_movie_max_age), lambda: _job_ingest_watchdog(wd_max_age, wd_search_max_age),
IntervalTrigger(hours=cfg["ingest_watchdog_hours"], start_date=INTERVAL_ANCHOR), IntervalTrigger(hours=cfg["ingest_watchdog_hours"], start_date=INTERVAL_ANCHOR),
id="ingest_watchdog", id="ingest_watchdog",
replace_existing=True, replace_existing=True,
@ -551,8 +547,8 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
coalesce=True, coalesce=True,
) )
log.info( log.info(
"scheduler: ingest-watchdog every %dh (browse=%dh, search=%dh, movies=%dh)", "scheduler: ingest-watchdog every %dh (browse max_age=%dh, search max_age=%dh)",
cfg["ingest_watchdog_hours"], wd_max_age, wd_search_max_age, wd_movie_max_age, cfg["ingest_watchdog_hours"], wd_max_age, wd_search_max_age,
) )
if cfg.get("hetzner_monitor_hours"): if cfg.get("hetzner_monitor_hours"):

View file

@ -233,10 +233,6 @@ def run_forever() -> int:
"ingest_watchdog_search_max_age_hours": getattr( "ingest_watchdog_search_max_age_hours": getattr(
settings, "ingest_watchdog_search_max_age_hours", 168 settings, "ingest_watchdog_search_max_age_hours", 168
), ),
# Filmy — wcześniej BEZ pokrycia watchdogiem; streamporn.vip stał 23 dni.
"ingest_watchdog_movie_max_age_hours": getattr(
settings, "ingest_watchdog_movie_max_age_hours", 72
),
# Hetzner Cloud bandwidth monitor — alert do Sentry przy progach % included. # Hetzner Cloud bandwidth monitor — alert do Sentry przy progach % included.
# No-op gdy brak HETZNER_API_TOKEN/SERVER_ID (sam job może być on). # No-op gdy brak HETZNER_API_TOKEN/SERVER_ID (sam job może być on).
"hetzner_monitor_hours": getattr(settings, "sched_hetzner_monitor_hours", 6) or None, "hetzner_monitor_hours": getattr(settings, "sched_hetzner_monitor_hours", 6) or None,

View file

@ -43,6 +43,7 @@ export const CHANGELOG: ChangelogEntry[] = [
id: '2026-07-26b', id: '2026-07-26b',
date: 'July 2026', date: 'July 2026',
items: [ items: [
'The screen no longer turns off while a video is playing. It still dims normally when you pause.',
'youporn thumbnails that had gone blank now load again.', 'youporn thumbnails that had gone blank now load again.',
], ],
}, },