Compare commits
7 commits
4417ae0ff0
...
4fd94de72d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd94de72d | ||
|
|
81f0074d34 | ||
|
|
6199577ae7 | ||
|
|
6e056b81c2 | ||
|
|
532c7e2bb4 | ||
|
|
3f8f38202a | ||
|
|
c586a8ad32 |
10 changed files with 476 additions and 65 deletions
|
|
@ -681,7 +681,13 @@ 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.
|
||||||
return "tn.sextu.com" in url
|
if "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:
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,12 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ 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 —
|
||||||
|
|
@ -58,4 +59,12 @@ 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),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ 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
|
||||||
|
|
@ -58,6 +59,47 @@ 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
|
||||||
|
|
@ -74,21 +116,45 @@ 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)
|
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
|
||||||
|
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 = []
|
self._sorted_ids = None
|
||||||
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)
|
res = browser_get(sm, timeout=self._timeout, proxy=self._proxy)
|
||||||
|
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)
|
||||||
|
|
@ -108,6 +174,8 @@ 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"))
|
||||||
|
|
@ -164,6 +232,29 @@ 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()
|
||||||
|
|
@ -177,7 +268,14 @@ class VjavScraper(BaseBrowseScraper):
|
||||||
out: list[RawScene] = []
|
out: list[RawScene] = []
|
||||||
for vid in chunk:
|
for vid in chunk:
|
||||||
try:
|
try:
|
||||||
res = browser_get(self._meta_url(vid), timeout=self._timeout, headers={"Referer": _BASE + "/"})
|
# Proxy także tutaj — API metadanych siedzi na tym samym hoście, więc
|
||||||
|
# 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:
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,22 @@ 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"
|
||||||
|
|
|
||||||
225
app/connectors/yourdailypornmovies.py
Normal file
225
app/connectors/yourdailypornmovies.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"""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")},
|
||||||
|
)
|
||||||
|
|
@ -1,20 +1,32 @@
|
||||||
"""Per-sitetag freshness watchdog — alert gdy aktywny tube przestał dawać nowe sceny.
|
"""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
|
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 playback_sources danego
|
raportuje success. Sygnał per-origin: `max(created_at)` na źródłach playbacku.
|
||||||
`tube:<sitetag>`. Jak zamrożony > próg → alert (report 14f3a655 2026-06-15).
|
|
||||||
|
|
||||||
Pokrywamy DWIE klasy scraperów, każda z własnym progiem:
|
Pokrywamy TRZY klasy, 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). Bez tego pokrycia kilka
|
refresh per performer), próg wyższy (domyślnie 7d).
|
||||||
search-tubów (sxyland, latestpornvideo, perverzija, fpoxxx, mypornerleak, porndish)
|
- **movies** (`_MOVIE_CONNECTORS`) — dodane 2026-08-03. Wcześniej filmy NIE BYŁY
|
||||||
zamarzło cicho 2026-05-07/06-07/06-13 i nic nie krzyknęło do Sentry.
|
pokryte w ogóle: streamporn.vip stał 23 dni (czytał listing nieposortowany po
|
||||||
Tag obecny w obu listach (xvideoscom, epornercom — i browse i search) liczymy jako
|
dacie) i żaden automat nie miał jak tego zauważyć.
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
|
|
@ -30,58 +42,86 @@ 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żdy aktywny scraper: czy origin dostał nową scenę < próg dla swojej klasy.
|
"""Sprawdź każde aktywne źródło: czy dostało nową treść < próg dla swojej klasy.
|
||||||
|
|
||||||
Skanujemy sitetagi z `ALL_BROWSE_SCRAPERS` (próg `max_age_hours`, domyślnie 48h) oraz
|
`min_history` odsiewa świeżo dodane źródła bez ustalonej kadencji (za mało pozycji,
|
||||||
z `ALL_DIRECT_SCRAPERS` (performer-driven search, próg `search_max_age_hours`, domyślnie
|
by wiedzieć, czy cisza to anomalia).
|
||||||
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).
|
|
||||||
|
|
||||||
Stale origin → Sentry `capture_message` ze stabilnym fingerprintem per origin
|
`to_sentry=False` daje sam odczyt (przydatne do ręcznego sprawdzenia bez
|
||||||
(wiek + próg w extra, nie w tytule — inaczej każdy run = nowe issue). Zwraca
|
zaśmiecania issue).
|
||||||
{checked, stale:[{origin, age_hours, total, kind, max_age_hours}]}.
|
|
||||||
"""
|
"""
|
||||||
from app.connectors.direct_scrapers import (
|
from app.connectors import get_movie_connectors # noqa: PLC0415
|
||||||
|
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
|
||||||
|
|
||||||
# (sitetag, próg_h, klasa) — klasa tylko do logów/extra w Sentry.
|
# (origin, tabela, próg_h, klasa)
|
||||||
checks: list[tuple[str, int, str]] = [
|
checks: list[tuple[str, str, int, str]] = (
|
||||||
(tag, max_age_hours, "browse") for tag in sorted(browse_tags)
|
[(f"tube:{t}", "playback_sources", max_age_hours, "browse") for t in sorted(browse_tags)]
|
||||||
] + [(tag, search_max_age_hours, "search") for tag in sorted(search_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)
|
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 tag, threshold, kind in checks:
|
for origin, table, threshold, kind in checks:
|
||||||
origin = f"tube:{tag}"
|
# Filmy trzymają origin jako `<connector>:<host>`, sceny jako dokładne
|
||||||
row = s.execute(
|
# `tube:<sitetag>` — stąd LIKE dla filmów, równość dla scen.
|
||||||
text(
|
if table == "movie_playback_sources":
|
||||||
"SELECT max(created_at) AS newest, count(*) AS total "
|
sql = (
|
||||||
"FROM playback_sources WHERE origin = :o"
|
f"SELECT max(created_at) AS newest, count(*) AS total FROM {table} "
|
||||||
),
|
"WHERE origin = :o OR origin LIKE :p"
|
||||||
{"o": origin},
|
)
|
||||||
).one()
|
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
|
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,
|
||||||
|
|
@ -89,39 +129,43 @@ 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 stale:
|
if not stale:
|
||||||
log.warning(
|
log.info("ingest-watchdog: wszystkie %d źródła świeże", len(checks))
|
||||||
"ingest-watchdog: %d/%d origin(s) bez nowych scen (browse>%dh / search>%dh): %s",
|
return {"checked": len(checks), "stale": []}
|
||||||
len(stale), len(checks), max_age_hours, search_max_age_hours,
|
|
||||||
", ".join(f"{x['origin']}({x['age_hours']}h,{x['kind']})" for x in 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:
|
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 = "warning"
|
scope.level = x["level"]
|
||||||
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_scenes", x["total"])
|
scope.set_extra("total_items", x["total"])
|
||||||
scope.set_extra("max_age_hours", x["max_age_hours"])
|
scope.set_extra("max_age_hours", x["max_age_hours"])
|
||||||
# Fingerprint per origin → jedno trwałe issue na zamrożony tube,
|
# Kubełek W fingerprincie: nowe issue (= nowy alert) przy każdym
|
||||||
# nie fragmentowane przez zmienny wiek.
|
# kolejnym progu, ale bez spamu co 6h w obrębie tego samego progu.
|
||||||
scope.fingerprint = ["ingest-stale-origin", x["origin"]]
|
scope.fingerprint = ["ingest-stale-origin", x["origin"], x["bucket"]]
|
||||||
sentry_sdk.capture_message(
|
sentry_sdk.capture_message(
|
||||||
f"ingest-watchdog: {x['origin']} ({x['kind']}) bez nowych scen "
|
f"ingest-watchdog: {x['origin']} ({x['kind']}) bez nowych treści "
|
||||||
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}
|
||||||
|
|
|
||||||
|
|
@ -324,17 +324,20 @@ def _job_title_duration_dedup() -> None:
|
||||||
log.exception("[scheduler] title-duration dedup failed")
|
log.exception("[scheduler] title-duration dedup failed")
|
||||||
|
|
||||||
|
|
||||||
def _job_ingest_watchdog(max_age_hours: int, search_max_age_hours: int) -> None:
|
def _job_ingest_watchdog(
|
||||||
"""Per-origin freshness watchdog — alert do Sentry gdy aktywny tube przestał dawać
|
max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int
|
||||||
nowe sceny > próg (browse: max_age_hours, search: search_max_age_hours). Globalny
|
) -> None:
|
||||||
monitor (jeden Source 'tube-scraper') tego nie łapie; pojedynczy origin może zamarznąć
|
"""Per-origin freshness watchdog → Sentry. Obejmuje sceny (browse/search) ORAZ
|
||||||
przy success-runie (report 14f3a655)."""
|
filmy (od 2026-08-03; wcześniej filmy nie były pokryte i streamporn.vip stał 23 dni
|
||||||
|
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"]))
|
||||||
|
|
@ -538,8 +541,9 @@ 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),
|
lambda: _job_ingest_watchdog(wd_max_age, wd_search_max_age, wd_movie_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,
|
||||||
|
|
@ -547,8 +551,8 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
"scheduler: ingest-watchdog every %dh (browse max_age=%dh, search max_age=%dh)",
|
"scheduler: ingest-watchdog every %dh (browse=%dh, search=%dh, movies=%dh)",
|
||||||
cfg["ingest_watchdog_hours"], wd_max_age, wd_search_max_age,
|
cfg["ingest_watchdog_hours"], wd_max_age, wd_search_max_age, wd_movie_max_age,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cfg.get("hetzner_monitor_hours"):
|
if cfg.get("hetzner_monitor_hours"):
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,10 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ 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.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue