Compare commits

..

4 commits
v0.4.3 ... main

Author SHA1 Message Date
goon-foss
a872fc3a88 fix(perverzija): manifest HLS z AES-128 skladany w /proxy/hls, proxy bez gzip, porndoe poza alarmem, skrypt dlugosci porntrex
Some checks are pending
Backend tests / test (push) Waiting to run
- 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
goon-foss
09f389100a feat(watchdog): jakosc zrodel, progi adaptacyjne, dlugosci perverzija i mypornerleak, poprawki parserow
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
2026-10-14 12:13:13 +02:00
goon-foss
1ad16ceae9 fix(watchporn): nowy motyw strony, obsada tylko z bloku castrow, studio z /sites/, chipy jako tagi
Stary globalny regex /models/ gubil prawdziwa obsade (nazwa w spanie, pusty tekst
linku) i dopinal performerow z kart related (srednio 12 na scene). Kategorie
to teraz gatunki, nie studio; tagi bez przedrostka #.
2026-10-13 16:48:25 +02:00
goon-foss
cbec97fc79 release: 0.4.3 (version endpoint, changelog, strona)
Some checks are pending
Backend tests / test (push) Waiting to run
2026-10-13 11:28:58 +02:00
22 changed files with 1023 additions and 46 deletions

View file

@ -0,0 +1,41 @@
"""Przebiegi browse-latest per scraper (watchdog listingu).
Revision ID: 0056_browse_runs
Revises: 0055_survey_responses
Create Date: 2026-10-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0056_browse_runs"
down_revision: str | None = "0055_survey_responses"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"browse_runs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("sitetag", sa.String(64), nullable=False),
sa.Column(
"started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("elapsed_s", sa.Float, nullable=True),
sa.Column("seen", sa.Integer, nullable=False, server_default="0"),
sa.Column("new", sa.Integer, nullable=False, server_default="0"),
sa.Column("updated", sa.Integer, nullable=False, server_default="0"),
sa.Column("skipped", sa.Integer, nullable=False, server_default="0"),
sa.Column("errors", sa.Integer, nullable=False, server_default="0"),
sa.Column("failed", sa.Boolean, nullable=False, server_default=sa.false()),
sa.Column("note", sa.Text, nullable=True),
)
op.create_index("ix_browse_runs_sitetag_started", "browse_runs", ["sitetag", "started_at"])
def downgrade() -> None:
op.drop_index("ix_browse_runs_sitetag_started", table_name="browse_runs")
op.drop_table("browse_runs")

View file

@ -267,6 +267,12 @@ def _build_headers(referer: str | None) -> dict[str, str]:
"User-Agent": DEFAULT_UA, "User-Agent": DEFAULT_UA,
"Accept": "*/*", "Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9", "Accept-Language": "en-US,en;q=0.9",
# Bez tego httpx prosi o gzip, a `proxy_stream` przepuszcza cialo surowe (aiter_raw)
# i zdejmuje Content-Encoding (HOP_BY_HOP): odtwarzacz dostawal skompresowane bajty
# manifestu bez naglowka i konczyl na "Input does not start with the #EXTM3U header"
# (xtremestream przez Cloudflare, 189 zdarzen w 7 dni do 2026-10-14). Strumienie
# wideo i tak sie nie kompresuja, wiec identity nic nie kosztuje.
"Accept-Encoding": "identity",
} }
if referer: if referer:
h["Referer"] = _ascii_safe_url(referer) h["Referer"] = _ascii_safe_url(referer)
@ -785,6 +791,32 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
# Hoster-producent: manifest nie ma własnego URL-a (loadvid oddaje jego TREŚĆ # Hoster-producent: manifest nie ma własnego URL-a (loadvid oddaje jego TREŚĆ
# dopiero na POST /videos/resolve-token), więc zamiast GET-a wołamy producenta. # dopiero na POST /videos/resolve-token), więc zamiast GET-a wołamy producenta.
producer = payload.get("mp") producer = payload.get("mp")
if producer == "xtremestream":
# Manifest ma segmenty AES-128 i klucz pod WZGLEDNYM `key.bin` (ten sam token, ta
# sama trasa), wiec nie absolutyzujemy: segmenty juz sa pelnymi adresami CDN, a klucz
# telefon pobiera spod `/proxy/hls/<token>/key.bin`. Szczegoly: hosters/xtremestream.py.
import anyio
from app.extractors.hosters import xtremestream
if _basename.endswith("key.bin"):
key = await anyio.to_thread.run_sync(functools.partial(xtremestream.fetch_key, target))
if not key:
raise HTTPException(status_code=502, detail="key fetch failed")
return Response(
content=key,
media_type="application/octet-stream",
headers=_with_cast_cors({"Cache-Control": "no-store"}),
)
manifest = await anyio.to_thread.run_sync(
functools.partial(xtremestream.build_manifest, target, key_uri="key.bin")
)
if manifest is None:
raise HTTPException(status_code=502, detail="manifest producer failed")
return Response(
content=manifest,
media_type="application/vnd.apple.mpegurl",
headers=_with_cast_cors({"Cache-Control": "no-store"}),
)
if producer: if producer:
manifest = await _produce_manifest(producer, target) manifest = await _produce_manifest(producer, target)
if manifest is None: if manifest is None:
@ -871,7 +903,10 @@ async def proxy_stream(
# Powód: pornhat `get_file/.../<id>.mp4/` 302 → CDN m3u8 manifest mimo `.mp4` # Powód: pornhat `get_file/.../<id>.mp4/` 302 → CDN m3u8 manifest mimo `.mp4`
# w path. Bez content-type check proxy traktuje jako binary, mobile dostaje # w path. Bez content-type check proxy traktuje jako binary, mobile dostaje
# m3u8 z RAW CDN URLs (IP-bound do VPS) → "no extractors" w ExoPlayer. # m3u8 z RAW CDN URLs (IP-bound do VPS) → "no extractors" w ExoPlayer.
path_suggests_m3u8 = path_lower.endswith(".m3u8") # `_basename` to `play.m3u8` nadane w `_proxify_link` z typu zrodla, wiec manifest bez
# `.m3u8` w sciezce upstreamu (xtremestream `xs1.php?data=`) tez trafia do przepisania,
# zamiast leciec dalej jako plik z absolutnymi adresami segmentow.
path_suggests_m3u8 = path_lower.endswith(".m3u8") or _basename.lower().endswith(".m3u8")
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True) client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
try: try:

View file

@ -7,6 +7,7 @@ Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`,
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import date
from app.connectors.base import ( from app.connectors.base import (
RawFingerprint, RawFingerprint,
@ -28,6 +29,7 @@ _SCENE_URL_RE = re.compile(r'href="(https://www\.fullmovies\.xxx/videos/[a-z0-9\
_NETWORK_LINK_RE = re.compile( _NETWORK_LINK_RE = re.compile(
r'href="https://www\.fullmovies\.xxx/networks/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE r'href="https://www\.fullmovies\.xxx/networks/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE
) )
_UPLOAD_DATE_RE = re.compile(r'"uploadDate"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
_MODEL_LINK_RE = re.compile( _MODEL_LINK_RE = re.compile(
r'href="https://www\.fullmovies\.xxx/models/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE r'href="https://www\.fullmovies\.xxx/models/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE
) )
@ -68,6 +70,14 @@ class FullmoviesScraper(BaseBrowseScraper):
dur_meta = meta_content(detail_html, property="video:duration") dur_meta = meta_content(detail_html, property="video:duration")
if dur_meta and dur_meta.isdigit(): if dur_meta and dur_meta.isdigit():
duration_sec = int(dur_meta) duration_sec = int(dur_meta)
# Data z JSON-LD `"uploadDate": "2026-10-13T20:48:46Z"` (audyt 2026-10-14: 0 dat).
release_date = None
um = _UPLOAD_DATE_RE.search(detail_html)
if um:
try:
release_date = date(int(um.group(1)), int(um.group(2)), int(um.group(3)))
except ValueError:
release_date = None
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/. # Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
# Sidebar listuje WSZYSTKIE sieci → `_NETWORK_LINK_RE.finditer().first()` zawsze # Sidebar listuje WSZYSTKIE sieci → `_NETWORK_LINK_RE.finditer().first()` zawsze
@ -122,6 +132,7 @@ class FullmoviesScraper(BaseBrowseScraper):
external_id=f"{self.sitetag}:{scene_url}", external_id=f"{self.sitetag}:{scene_url}",
title=title, title=title,
description=description, description=description,
release_date=release_date,
duration_sec=duration_sec, duration_sec=duration_sec,
url=scene_url, url=scene_url,
studio=studio, studio=studio,

View file

@ -62,6 +62,9 @@ _IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
_TITLE_STUDIO_DATE_RE = re.compile(r"^(?P<studio>[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P<y>\d{2})\s+(?P<m>\d{2})\s+(?P<d>\d{2})\s+(?P<rest>.+)$") _TITLE_STUDIO_DATE_RE = re.compile(r"^(?P<studio>[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P<y>\d{2})\s+(?P<m>\d{2})\s+(?P<d>\d{2})\s+(?P<rest>.+)$")
# `[Onlyfans] Some Title` / `[Darkkotv] …` # `[Onlyfans] Some Title` / `[Darkkotv] …`
_TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$") _TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$")
# Data z JSON-LD strony, gdy tytul jej nie niesie (audyt 2026-10-14: 66% scen z 7 dni
# bez daty i bez dlugosci; data jest w `"datePublished":"2026-10-13T18:40:00+03:00"`).
_JSONLD_DATE_RE = re.compile(r'"datePublished"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
# `… [2018-09-28]` # `… [2018-09-28]`
_TITLE_ISO_DATE_RE = re.compile(r"\[(\d{4})-(\d{2})-(\d{2})\]") _TITLE_ISO_DATE_RE = re.compile(r"\[(\d{4})-(\d{2})-(\d{2})\]")
@ -178,6 +181,11 @@ class GalaxyPornScraper(BaseBrowseScraper):
release_date = date(int(m.group(1)), int(m.group(2)), int(m.group(3))) release_date = date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
except ValueError: except ValueError:
release_date = None release_date = None
if release_date is None and (m := _JSONLD_DATE_RE.search(detail_html)):
try:
release_date = date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
except ValueError:
release_date = None
# Guard: prefiks nie może być nazwiskiem performera (wtedy to nie studio). # Guard: prefiks nie może być nazwiskiem performera (wtedy to nie studio).
if studio_name and slugify(studio_name) not in seen_p: if studio_name and slugify(studio_name) not in seen_p:
studio = RawStudio( studio = RawStudio(

View file

@ -10,6 +10,7 @@ factory, wyłącz; analogia do shyfap).
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import date
from urllib.parse import urljoin from urllib.parse import urljoin
from app.connectors.base import ( from app.connectors.base import (
@ -32,6 +33,7 @@ _SCENE_URL_RE = re.compile(r'href="(https://www\.hdporn\.gg/videos/[a-z0-9\-]+/)
_NETWORK_LINK_RE = re.compile( _NETWORK_LINK_RE = re.compile(
r'href="https://www\.hdporn\.gg/networks/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE r'href="https://www\.hdporn\.gg/networks/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE
) )
_UPLOAD_DATE_RE = re.compile(r'"uploadDate"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
_MODEL_LINK_RE = re.compile( _MODEL_LINK_RE = re.compile(
r'href="https://www\.hdporn\.gg/models/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE r'href="https://www\.hdporn\.gg/models/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE
) )
@ -75,6 +77,14 @@ class HDPornGGScraper(BaseBrowseScraper):
dur_meta = meta_content(detail_html, property="video:duration") dur_meta = meta_content(detail_html, property="video:duration")
if dur_meta and dur_meta.isdigit(): if dur_meta and dur_meta.isdigit():
duration_sec = int(dur_meta) duration_sec = int(dur_meta)
# Data z JSON-LD `"uploadDate": "2026-10-13T16:51:32Z"` (audyt 2026-10-14: 0 dat).
release_date = None
um = _UPLOAD_DATE_RE.search(detail_html)
if um:
try:
release_date = date(int(um.group(1)), int(um.group(2)), int(um.group(3)))
except ValueError:
release_date = None
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/. # Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
# Sidebar listuje WSZYSTKIE sieci → pierwszy match zawsze ten sam (Brazzers) dla # Sidebar listuje WSZYSTKIE sieci → pierwszy match zawsze ten sam (Brazzers) dla
@ -130,6 +140,7 @@ class HDPornGGScraper(BaseBrowseScraper):
external_id=f"{self.sitetag}:{scene_url}", external_id=f"{self.sitetag}:{scene_url}",
title=title, title=title,
description=description, description=description,
release_date=release_date,
duration_sec=duration_sec, duration_sec=duration_sec,
url=scene_url, url=scene_url,
studio=studio, studio=studio,

View file

@ -49,6 +49,10 @@ _OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]
_GENRES_BLOCK_RE = re.compile(r'<div class="sgeneros">(.*?)</div>', re.DOTALL) _GENRES_BLOCK_RE = re.compile(r'<div class="sgeneros">(.*?)</div>', re.DOTALL)
_ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*</a>") _ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*</a>")
_DATE_RE = re.compile(r'<span class="date">\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{4})') _DATE_RE = re.compile(r'<span class="date">\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{4})')
# Dlugosc odcinka w sekundach siedzi w konfiguracji skryptu historii motywu dooplay:
# `var dtHist = {..., "episode":"77667", "duration":"953", ...}`. Bez niej 100% scen
# mialo NULL (audyt 2026-10-14), a apka filtruje min 60 s.
_DT_DURATION_RE = re.compile(r'"duration"\s*:\s*"(\d{2,6})"')
_SERIES_RE = re.compile(r'href="https://hentaimama\.io/tvshows/([a-z0-9-]+)/"') _SERIES_RE = re.compile(r'href="https://hentaimama\.io/tvshows/([a-z0-9-]+)/"')
# Uwaga: atrybuty bywają w innej kolejności niż `<meta property=... content=...>`, # Uwaga: atrybuty bywają w innej kolejności niż `<meta property=... content=...>`,
# a wartość ma na końcu \r — stąd luźny wzorzec i strip() przy użyciu. # a wartość ma na końcu \r — stąd luźny wzorzec i strip() przy użyciu.
@ -112,17 +116,21 @@ class HentaiMamaScraper(BaseBrowseScraper):
dm = _DATE_RE.search(detail_html) dm = _DATE_RE.search(detail_html)
om = _OG_IMAGE_RE.search(detail_html) om = _OG_IMAGE_RE.search(detail_html)
slug = scene_url.rstrip("/").rsplit("/", 1)[-1] slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
dur_m = _DT_DURATION_RE.search(detail_html)
duration_sec = int(dur_m.group(1)) if dur_m and int(dur_m.group(1)) > 0 else None
return RawScene( return RawScene(
external_id=f"{self.sitetag}:{slug}", external_id=f"{self.sitetag}:{slug}",
title=title, title=title,
release_date=_parse_date(dm.group(1) if dm else None), release_date=_parse_date(dm.group(1) if dm else None),
duration_sec=duration_sec,
url=scene_url, url=scene_url,
tags=tags, tags=tags,
playback_sources=[ playback_sources=[
RawPlaybackSource( RawPlaybackSource(
origin=f"tube:{self.sitetag}", origin=f"tube:{self.sitetag}",
page_url=scene_url, page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=om.group(1).strip() if om else None, thumbnail_url=om.group(1).strip() if om else None,
) )
], ],

View file

@ -18,6 +18,7 @@ import re
from app.connectors.base import RawPlaybackSource, RawScene, RawTag from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.connectors.base import RawPerformer
from app.connectors.direct_scrapers._playtube import _parse_iso_date from app.connectors.direct_scrapers._playtube import _parse_iso_date
_BASE = "https://javflix.cc" _BASE = "https://javflix.cc"
@ -30,6 +31,11 @@ _NON_POST = (
"/terms", "/faq", "/policy", "/disclaimer", "/sitemap", "/terms", "/faq", "/policy", "/disclaimer", "/sitemap",
) )
_CODE_RE = re.compile(r"^([a-z]+-?\d+[a-z]?)", re.IGNORECASE) _CODE_RE = re.compile(r"^([a-z]+-?\d+[a-z]?)", re.IGNORECASE)
# Aktorki: tylko z bloku `<div id="video-actors">Actress: <a href=".../actor/<slug>/">`,
# jeden na scene zwykle. Do 2026-10-14 scraper ich nie bral (0 performerow na scene),
# a JAV bez aktorki nie da sie laczyc z kanonem ani filtrowac po osobie.
_ACTORS_BLOCK_RE = re.compile(r'<div id="video-actors">(.*?)</div>', re.IGNORECASE | re.DOTALL)
_ACTOR_RE = re.compile(r'href="https://javflix\.cc/actor/([a-z0-9\-]+)/"[^>]*>([^<]+)</a>', re.IGNORECASE)
def _itemprop(html: str, name: str) -> str | None: def _itemprop(html: str, name: str) -> str | None:
@ -96,11 +102,24 @@ class JavflixScraper(BaseBrowseScraper):
code = cm.group(1).upper() code = cm.group(1).upper()
tags.append(RawTag(external_id=f"javcode:{code}", name=code, slug=cm.group(1).lower())) tags.append(RawTag(external_id=f"javcode:{code}", name=code, slug=cm.group(1).lower()))
performers: list[RawPerformer] = []
ab = _ACTORS_BLOCK_RE.search(detail_html)
if ab:
seen_p: set[str] = set()
for am in _ACTOR_RE.finditer(ab.group(1)):
a_slug = am.group(1)
a_name = html.unescape(am.group(2)).strip()
if a_slug in seen_p or not a_name:
continue
seen_p.add(a_slug)
performers.append(RawPerformer(external_id=f"{self.sitetag}:actor:{a_slug}", name=a_name))
return RawScene( return RawScene(
external_id=f"{self.sitetag}:{scene_url}", external_id=f"{self.sitetag}:{scene_url}",
title=title, title=title,
release_date=release_date, release_date=release_date,
url=scene_url, url=scene_url,
performers=performers,
tags=tags, tags=tags,
playback_sources=[ playback_sources=[
RawPlaybackSource( RawPlaybackSource(

View file

@ -32,6 +32,21 @@ _SCENE_URL_RE = re.compile(r'href="(https://watchporn\.to/video/\d+/[a-z0-9\-]+/
_MODEL_RE = re.compile(r'href="https://watchporn\.to/models/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE) _MODEL_RE = re.compile(r'href="https://watchporn\.to/models/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE)
_TAG_RE = re.compile(r'href="https://watchporn\.to/tags/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE) _TAG_RE = re.compile(r'href="https://watchporn\.to/tags/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE)
_CAT_RE = re.compile(r'href="https://watchporn\.to/categories/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE) _CAT_RE = re.compile(r'href="https://watchporn\.to/categories/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE)
# Nowy motyw od ok. 2026-10-10 (prefiks klas `wp-`). Obsada sceny siedzi TYLKO w bloku
# `<div class="wp-castrow">`, nazwa w `<span class="wp-cast__n">` (nie w tekscie linku),
# a karty "related" ponizej maja wlasne linki `/models/` z nazwa w tekscie. Stary globalny
# regex gubil prawdziwa obsade (pusty tekst linku) i dokladal performerow z kart related:
# srednio 12,5 na scene zamiast 2 (10 do 13.10, 193 sceny). Kategorie to teraz gatunki
# (chipy `wp-vchip`), studio jest w linku `/sites/<slug>/` i w JSON-LD "publisher".
_NEW_THEME_MARK = 'class="wp-vi__panel"'
_CASTROW_RE = re.compile(r'<div class="wp-castrow">(.*?)</div>', re.IGNORECASE | re.DOTALL)
_CAST_RE = re.compile(
r'href="https://watchporn\.to/models/([a-z0-9\-]+)/"[^>]*>.*?<span class="wp-cast__n">([^<]+)</span>',
re.IGNORECASE | re.DOTALL,
)
_CHIPS_RE = re.compile(r'<div class="wp-chips">(.*?)</div>', re.IGNORECASE | re.DOTALL)
_SITE_RE = re.compile(r'href="https://watchporn\.to/sites/([a-z0-9\-]+)/"', re.IGNORECASE)
_PUBLISHER_RE = re.compile(r'"publisher"\s*:\s*\{[^}]*?"name"\s*:\s*"([^"]+)"', re.DOTALL)
_DUR_RE = re.compile(r'"duration"\s*:\s*"([^"]+)"') _DUR_RE = re.compile(r'"duration"\s*:\s*"([^"]+)"')
_UPLOAD_RE = re.compile(r'"uploadDate"\s*:\s*"([^"]+)"') _UPLOAD_RE = re.compile(r'"uploadDate"\s*:\s*"([^"]+)"')
_VIDEO_ID_RE = re.compile(r"/video/(\d+)/") _VIDEO_ID_RE = re.compile(r"/video/(\d+)/")
@ -74,9 +89,18 @@ class WatchPornScraper(BaseBrowseScraper):
um = _UPLOAD_RE.search(detail_html) um = _UPLOAD_RE.search(detail_html)
release_date = _parse_iso_date(um.group(1)) if um else None release_date = _parse_iso_date(um.group(1)) if um else None
new_theme = _NEW_THEME_MARK in detail_html
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
seen_p: set[str] = set() seen_p: set[str] = set()
for m in _MODEL_RE.finditer(detail_html): if new_theme:
# Tylko blok obsady; brak bloku = scena bez obsady, NIE wracamy do globalnego
# regexu, bo ten zbiera karty related.
crm = _CASTROW_RE.search(detail_html)
cast_iter = _CAST_RE.finditer(crm.group(1)) if crm else iter(())
else:
cast_iter = _MODEL_RE.finditer(detail_html)
for m in cast_iter:
slug = m.group(1) slug = m.group(1)
name = html.unescape(m.group(2)).strip() name = html.unescape(m.group(2)).strip()
if slug.isdigit() or slug in seen_p or not name: if slug.isdigit() or slug in seen_p or not name:
@ -86,19 +110,38 @@ class WatchPornScraper(BaseBrowseScraper):
tags: list[RawTag] = [] tags: list[RawTag] = []
seen_t: set[str] = set() seen_t: set[str] = set()
for m in _TAG_RE.finditer(detail_html):
slug = m.group(1) def _add_tag(slug: str, raw_name: str) -> None:
name = html.unescape(m.group(2)).strip() name = html.unescape(raw_name).strip().lstrip("#").strip()
if slug in seen_t or not name: if slug in seen_t or not name:
continue return
seen_t.add(slug) seen_t.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)) tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
for m in _TAG_RE.finditer(detail_html):
_add_tag(m.group(1), m.group(2))
if new_theme:
# Kategorie-chipy sceny (Blowjob, MILF...) to gatunki, ida do tagow.
chm = _CHIPS_RE.search(detail_html)
if chm:
for m in _CAT_RE.finditer(chm.group(1)):
_add_tag(m.group(1), m.group(2))
thumbnail_url = ( thumbnail_url = (
meta_content(detail_html, property="og:image") or _derive_thumb(scene_url) meta_content(detail_html, property="og:image") or _derive_thumb(scene_url)
) )
studio: RawStudio | None = None studio: RawStudio | None = None
if new_theme:
sm = _SITE_RE.search(detail_html)
if sm:
pm = _PUBLISHER_RE.search(detail_html)
cname = html.unescape(pm.group(1)).strip() if pm else sm.group(1).replace("-", " ").title()
if cname:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(cname)}", name=cname, slug=slugify(cname)
)
else:
cm = _CAT_RE.search(detail_html) cm = _CAT_RE.search(detail_html)
if cm: if cm:
cname = html.unescape(cm.group(2)).strip() cname = html.unescape(cm.group(2)).strip()

View file

@ -141,12 +141,29 @@ def _extract_ldjson_only(html: str, page_url: str) -> int | None:
return None return None
def _extract_itemprop_only(html: str, page_url: str) -> int | None:
"""mypornerleak: `<meta itemprop="duration" content="P0DT0H12M13S">` w <head> to
dlugosc TEJ sceny; karty powiazane maja tylko `<span class="duration">40:33</span>`,
ktorych tu nie czytamy. Sprawdzone 2026-10-14 na stronach z nowego listingu REST
(ingest przez wp-json nie niesie dlugosci, stad 76 tys. scen bez niej)."""
if (m := _ITEMPROP_DURATION_RE.search(html)):
v = m.group(1).strip()
if v.upper().startswith("P") and (parsed := _parse_iso8601(v)) is not None:
if 0 < parsed <= _MAX_SANE_DURATION_SEC:
return parsed
return None
#: Origin → wzorzec. Dopisuj TYLKO to, co sprawdziłeś na próbce prawdziwych stron; #: Origin → wzorzec. Dopisuj TYLKO to, co sprawdziłeś na próbce prawdziwych stron;
#: porntrex i mypornerleak celowo tu nie ma, patrz `app/models/duration_backfill_queue.py`. #: porntrex celowo tu nie ma, patrz `app/models/duration_backfill_queue.py`.
#: perverzija: ingest z wp-json nie ma dlugosci, strona ma JSON-LD VideoObject
#: `"duration":"PT73M2S"` (2026-10-14, 21 tys. scen bez dlugosci).
ORIGIN_DURATION_RESOLVERS = { ORIGIN_DURATION_RESOLVERS = {
"tube:xhamstercom": _extract_xhamster, "tube:xhamstercom": _extract_xhamster,
"tube:xvideoscom": _extract_ldjson_only, "tube:xvideoscom": _extract_ldjson_only,
"tube:xnxxcom": _extract_ldjson_only, "tube:xnxxcom": _extract_ldjson_only,
"tube:perverzijacom": _extract_ldjson_only,
"tube:mypornerleakcom": _extract_itemprop_only,
} }

View file

@ -0,0 +1,153 @@
"""xtremestream (player perverzija): manifest HLS z segmentami AES-128 skladany po stronie backendu.
Od ok. 2026-10-12 player (`ftk_engine.js`, zaciemniony, z blokada devtools) nie gra juz
z `xs1.php`: segmenty pod starymi adresami `/cdn/down/<id>/<q>/<q><i>.html` oddaja 404,
a prawdziwe leza pod `/cdn/down/<id>/<q>_enc/<q><i>.html` i sa zaszyfrowane AES-128-CBC,
IV = numer segmentu (16 bajtow big-endian), dopelnienie PKCS7, czyli dokladnie tak jak
standardowe `#EXT-X-KEY:METHOD=AES-128` bez atrybutu IV (sprawdzone 2026-10-14: openssl
z kluczem i IV=0 daje poprawny MPEG-TS, ffprobe widzi h264 i aac).
Klucz (16 B) oddaje `proto_api.php?data=<id>&op=key&vt=<vt>` z naglowkiem `X-XS-P: 1`.
`vt` to token ze strony playera (`XSFTK.boot(id, vt, player)`), zwiazany z IP i UA
pobierajacego (ten sam vt z innego IP = 403 `forbidden-vt`), ale klucz jest jeden na film,
niezalezny od vt (sprawdzone z dwoch IP). Segmenty i stary manifest `xs1.php` (nadal
serwowany, z dobrymi dlugosciami i rotacja hostow cdn[i % n]) nie sa zwiazane z IP.
Dlatego telefon nic nie musi robic: backend bierze `xs1.php`, przepisuje sciezki na `_enc`,
dokleja `#EXT-X-KEY` z URI wzglednym `key.bin`, a `/proxy/hls/<token>/key.bin` oddaje klucz
pobrany na wlasny vt. Telefon ciagnie segmenty prosto z CDN (przez VPS leci ~50 kB manifestu
i 16 B klucza). Czesc katalogu (starszy storage) ma nadal zwykle segmenty: wtedy manifest
idzie bez klucza, tak jak dotad.
"""
from __future__ import annotations
import logging
import re
import time
from urllib.parse import parse_qs, quote, urlparse
import httpx
log = logging.getLogger(__name__)
_UA = (
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/128.0.0.0 Mobile Safari/537.36"
)
_PLAYER_RE = re.compile(
r"https?://(?P<host>[a-z0-9\-]+\.xtremestream\.[a-z]+)/player/index\.php\?data=(?P<id>[0-9a-f]{16,64})",
re.IGNORECASE,
)
_BOOT_RE = re.compile(r'XSFTK\.boot\("[0-9a-f]+",\s*"([^"]+)"')
# `https://pervl7.xspcdn01.click/cdn/down/<id>/720/7200.html` -> grupy: prefiks, jakosc, numer
_SEG_RE = re.compile(r"^(https?://[^/]+/cdn/down/[0-9a-f]+/)(\d+p?)/\2(\d+)\.html\s*$")
_DEFAULT_QUALITY = "720"
_KEY_TTL_SEC = 6 * 3600
_key_cache: dict[str, tuple[bytes, float]] = {}
def parse_player(player_url: str) -> tuple[str, str] | None:
m = _PLAYER_RE.search(player_url)
return (m.group("host"), m.group("id")) if m else None
def quality_of(player_url: str) -> str:
q = parse_qs(urlparse(player_url).query).get("q", [_DEFAULT_QUALITY])[0]
return q if re.fullmatch(r"\d{3,4}", q) else _DEFAULT_QUALITY
def _to_enc(line: str) -> str:
m = _SEG_RE.match(line)
if not m:
return line
return f"{m.group(1)}{m.group(2)}_enc/{m.group(2)}{m.group(3)}.html"
def _reachable(url: str, timeout: float) -> bool:
try:
r = httpx.get(url, headers={"User-Agent": _UA, "Range": "bytes=0-0"}, timeout=timeout)
return r.status_code in (200, 206)
except httpx.HTTPError:
return False
def fetch_key(player_url: str, *, timeout: float = 20.0) -> bytes | None:
"""16-bajtowy klucz AES filmu, z pamiecia podreczna na 6 h (klucz nie zalezy od vt)."""
parsed = parse_player(player_url)
if not parsed:
return None
host, vid = parsed
cached = _key_cache.get(vid)
if cached and time.time() - cached[1] < _KEY_TTL_SEC:
return cached[0]
index_url = f"https://{host}/player/index.php?data={vid}"
try:
page = httpx.get(
index_url,
headers={"User-Agent": _UA, "Referer": "https://tube.perverzija.com/"},
timeout=timeout,
follow_redirects=True,
)
m = _BOOT_RE.search(page.text)
if not m:
log.info("xtremestream: brak tokenu vt na %s (http %s)", index_url, page.status_code)
return None
vt = m.group(1)
# UA musi byc ten sam co przy stronie playera, vt jest pod niego liczony.
k = httpx.get(
f"https://{host}/player/proto_api.php?data={vid}&op=key&vt={quote(vt, safe='')}",
headers={"User-Agent": _UA, "X-XS-P": "1", "Referer": index_url},
timeout=timeout,
)
except httpx.HTTPError as e:
log.info("xtremestream: pobranie klucza padlo dla %s: %s", vid, e)
return None
if k.status_code != 200 or len(k.content) != 16:
log.info("xtremestream: klucz dla %s http %s, %d B", vid, k.status_code, len(k.content))
return None
_key_cache[vid] = (k.content, time.time())
return k.content
def build_manifest(player_url: str, *, key_uri: str = "key.bin", timeout: float = 20.0) -> str | None:
"""Manifest HLS do odegrania przez telefon: segmenty `_enc` z CDN i klucz pod `key_uri`
(wzgledny wobec adresu manifestu). None, gdy xs1.php nie oddaje manifestu albo zaden
wariant segmentow (zaszyfrowany ani zwykly) nie odpowiada."""
parsed = parse_player(player_url)
if not parsed:
return None
host, vid = parsed
q = quality_of(player_url)
index_url = f"https://{host}/player/index.php?data={vid}"
try:
m = httpx.get(
f"https://{host}/player/xs1.php?data={vid}&q={q}",
headers={"User-Agent": _UA, "Referer": index_url},
timeout=timeout,
)
except httpx.HTTPError as e:
log.info("xtremestream: xs1.php padlo dla %s: %s", vid, e)
return None
text = m.text
if m.status_code != 200 or not text.lstrip(" \t\r\n").startswith("#EXTM3U"):
log.info("xtremestream: xs1.php dla %s http %s, nie-manifest (%d B)", vid, m.status_code, len(m.content))
return None
lines = text.splitlines()
segments = [l for l in lines if l.startswith("http")]
if not segments:
return None
first_enc = _to_enc(segments[0])
if first_enc == segments[0] or not _reachable(first_enc, timeout):
# Starszy storage: zwykle segmenty, manifest bez zmian. Jesli i te nie odpowiadaja,
# film jest martwy po stronie hostera.
return text if _reachable(segments[0], timeout) else None
out: list[str] = []
key_done = False
for line in lines:
out.append(_to_enc(line) if line.startswith("http") else line)
if not key_done and line.startswith("#EXT-X-MEDIA-SEQUENCE"):
out.append(f'#EXT-X-KEY:METHOD=AES-128,URI="{key_uri}"')
key_done = True
if not key_done:
out.insert(1, f'#EXT-X-KEY:METHOD=AES-128,URI="{key_uri}"')
return "\n".join(out) + "\n"

View file

@ -68,11 +68,16 @@ def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | Non
host, vid = m.group("host"), m.group("id") host, vid = m.group("host"), m.group("id")
player_url = f"https://{host}/player/index.php?data={vid}" player_url = f"https://{host}/player/index.php?data={vid}"
# Od 2026-10-12 segmenty sa szyfrowane (AES-128, klucz spod tokenu zwiazanego z IP),
# wiec manifest sklada backend w `/proxy/hls` (producent `xtremestream`, patrz
# extractors/hosters/xtremestream.py). Link to adres playera z jakoscia w zapytaniu;
# producent bierze z niego id i `q`. `mobile_direct_ok` zostaje: segmenty NIE sa
# IP-bound (test z dwoch IP 2026-10-14), przez VPS leci manifest i 16 B klucza.
out: list[StreamSource] = [] out: list[StreamSource] = []
for q in _QUALITIES: for q in _QUALITIES:
out.append( out.append(
StreamSource( StreamSource(
link=f"https://{host}/player/xs1.php?data={vid}&q={q}", link=f"{player_url}&q={q}",
quality=f"{q}p", quality=f"{q}p",
type="m3u8", type="m3u8",
referer=player_url, referer=player_url,
@ -80,11 +85,7 @@ def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | Non
"extractor": "perverzija", "extractor": "perverzija",
"player": player_url, "player": player_url,
"q": q, "q": q,
# Segmenty NIE są IP-bound i nie potrzebują Referera: segment pobrany "manifest_producer": "xtremestream",
# z domowego łącza po manifeście wziętym na VPS oddał 200 i 76 kB
# zaczynające się od 0x47 (sync MPEG-TS), test 2026-08-20. Dzięki temu
# przez VPS idzie sam manifest (~16 kB) przez /proxy/hls, a segmenty
# telefon ciągnie prosto z xspcdn. Bez tej flagi całe wideo szło proxy.
"mobile_direct_ok": True, "mobile_direct_ok": True,
}, },
) )

View file

@ -135,9 +135,9 @@ def version() -> dict[str, str | int | None]:
# expo-video przy zamykaniu z ostatnich (Sentry 147164135, zgłoszenie 2026-09-15). # expo-video przy zamykaniu z ostatnich (Sentry 147164135, zgłoszenie 2026-09-15).
# Podbijaj RAZEM z `version` przy każdym wydaniu APK. # Podbijaj RAZEM z `version` przy każdym wydaniu APK.
return { return {
"version": "0.4.2", "version": "0.4.3",
"runtime_version": "1.4", "runtime_version": "1.4",
"apk_build": 16, "apk_build": 17,
"apk_url": apk_url, "apk_url": apk_url,
} }

View file

@ -1,5 +1,6 @@
from app.models.base import Base from app.models.base import Base
from app.models.blacklist import BlacklistedPerformer, BlacklistedStudio, BlacklistedTag from app.models.blacklist import BlacklistedPerformer, BlacklistedStudio, BlacklistedTag
from app.models.browse_run import BrowseRun
from app.models.bug_report import BugReport from app.models.bug_report import BugReport
from app.models.clip import Clip, ClipTag from app.models.clip import Clip, ClipTag
from app.models.clip_feed import ClipAffinity, ClipBlacklist, ClipFavorite, ClipFeedEvent, ClipSeen from app.models.clip_feed import ClipAffinity, ClipBlacklist, ClipFavorite, ClipFeedEvent, ClipSeen
@ -44,6 +45,7 @@ from app.models.tag_vertical_count import TagVerticalCount
__all__ = [ __all__ = [
"Base", "Base",
"BrowseRun",
"BlacklistedPerformer", "BlacklistedPerformer",
"BlacklistedStudio", "BlacklistedStudio",
"BlacklistedTag", "BlacklistedTag",

34
app/models/browse_run.py Normal file
View file

@ -0,0 +1,34 @@
"""Przebieg browse-latest per scraper (sitetag), zeby watchdog odroznial trzy rozne cisze:
listing pusty (fetch albo parser listingu padl), listing pelny ale wszystko znane (zly
endpoint, rotacja karuzeli) i zwykly brak nowych publikacji. ingest_runs tego nie daje,
bo wszystkie tuby dziela jeden Source "tube-scraper".
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class BrowseRun(Base):
__tablename__ = "browse_runs"
__table_args__ = (Index("ix_browse_runs_sitetag_started", "sitetag", "started_at"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
sitetag: Mapped[str] = mapped_column(String(64), nullable=False)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
elapsed_s: Mapped[float | None] = mapped_column(Float, nullable=True)
seen: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
new: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
errors: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
failed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)

View file

@ -21,12 +21,37 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from app.connectors.direct_scrapers import ALL_BROWSE_SCRAPERS, SCRAPER_SOURCE_NAME from app.connectors.direct_scrapers import ALL_BROWSE_SCRAPERS, SCRAPER_SOURCE_NAME
from app.db import session_scope
from app.models.browse_run import BrowseRun
from app.models.source import SourceKind from app.models.source import SourceKind
from app.scheduler.performer_driven import _ingest_iter_into_run from app.scheduler.performer_driven import _ingest_iter_into_run
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _record_run(sitetag: str, counters: dict | None, elapsed: float, failed: bool, note: str | None) -> None:
"""Jeden wiersz browse_runs per scraper i przebieg; czyta go watchdog listingu.
Blad zapisu nie moze zatrzymac browse (log i dalej)."""
c = counters or {}
try:
with session_scope() as s:
s.add(
BrowseRun(
sitetag=sitetag,
elapsed_s=round(elapsed, 1),
seen=int(c.get("seen", 0)),
new=int(c.get("new", 0)),
updated=int(c.get("updated", 0)),
skipped=int(c.get("skipped", 0)),
errors=int(c.get("errors", 0)),
failed=failed,
note=(note or None) and note[:500],
)
)
except Exception: # pragma: no cover - tabela moze nie istniec przed migracja 0056
log.exception("browse-latest: zapis browse_runs dla %s nie powiodl sie", sitetag)
@dataclass @dataclass
class BrowseCounters: class BrowseCounters:
scrapers_run: int = 0 scrapers_run: int = 0
@ -62,9 +87,11 @@ def run_browse_latest(*, max_pages: int = 5) -> BrowseCounters:
"browse-latest: %s done in %.1fs counters=%s", "browse-latest: %s done in %.1fs counters=%s",
scraper.sitetag, elapsed, c, scraper.sitetag, elapsed, c,
) )
_record_run(scraper.sitetag, c, elapsed, failed=False, note=None)
except Exception as e: except Exception as e:
counters.total_errors += 1 counters.total_errors += 1
log.exception("browse-latest: %s failed: %s", scraper.sitetag, e) log.exception("browse-latest: %s failed: %s", scraper.sitetag, e)
_record_run(scraper.sitetag, None, time.time() - t0, failed=True, note=f"{type(e).__name__}: {e}")
log.info("browse-latest done: %s", counters) log.info("browse-latest done: %s", counters)
return counters return counters

View file

@ -74,44 +74,89 @@ def plan_duration_backfill(limit: int | None = None) -> int:
def _fetch_one(row: tuple) -> tuple: def _fetch_one(row: tuple) -> tuple:
"""(scene_id, duration|None). Sieć i parsowanie, bez dotykania bazy.""" """(scene_id, duration|None, gone). Sieć i parsowanie, bez dotykania bazy.
`gone` = strona odpowiedziała 404/410, więc kolejne próby nic nie dadzą i wiersz
można zamknąć od razu zamiast odwiedzać martwy adres trzy razy. Błąd sieci, 5xx
albo 403 (limit, geo) zostają zwykłą nieudaną próbą.
"""
scene_id, page_url, origin = row scene_id, page_url, origin = row
from app.extractors._fetch import browser_get from app.extractors._fetch import browser_get
try: try:
r = browser_get(page_url, timeout=_FETCH_TIMEOUT, follow_redirects=True) r = browser_get(page_url, timeout=_FETCH_TIMEOUT, follow_redirects=True)
if r.status_code in (404, 410):
return (scene_id, None, True)
if r.status_code >= 400: if r.status_code >= 400:
return (scene_id, None) return (scene_id, None, False)
return (scene_id, extract_duration_for_origin(origin, r.text, page_url)) return (scene_id, extract_duration_for_origin(origin, r.text, page_url), False)
except Exception as e: # pojedyncza strona nie może wywrócić paczki except Exception as e: # pojedyncza strona nie może wywrócić paczki
log.debug("duration-backfill: %s padlo (%s)", page_url, e) log.debug("duration-backfill: %s padlo (%s)", page_url, e)
return (scene_id, None) return (scene_id, None, False)
def _take_batch(session, *, batch: int) -> list[tuple]:
"""Paczka po rowno z kazdego originu, ktory ma jeszcze cos w kolejce.
Kolejnosc dopisania (`created_at`) jako jedyny klucz oznaczalaby, ze origin dopisany
pozniej (perverzija, mypornerleak, 2026-10-14) czeka, az 900 tys. wierszy xhamster,
xvideos i xnxx sie wyczerpie. Zamiast tego limit dzielimy miedzy originy z otwartymi
wierszami (najmniejsze kolejki dostaja swoj udzial w calosci, reszta dzieli resztę),
a wewnatrz originu zostaje stary porzadek: najpierw nieprobowane, potem najstarsze.
"""
remaining = session.execute(
text(
"""
SELECT origin, count(*) AS n
FROM duration_backfill_queue
WHERE done_at IS NULL AND attempts < :max_attempts
GROUP BY origin
"""
),
{"max_attempts": _MAX_ATTEMPTS},
).fetchall()
pending = sorted(((int(n), o) for o, n in remaining if n), key=lambda t: t[0])
rows: list[tuple] = []
left = batch
for i, (n, origin) in enumerate(pending):
quota = min(n, max(1, left // (len(pending) - i)))
if quota <= 0:
break
rows += [
tuple(r)
for r in session.execute(
text(
"""
SELECT scene_id, page_url, origin
FROM duration_backfill_queue
WHERE origin = :origin AND done_at IS NULL AND attempts < :max_attempts
ORDER BY attempts, created_at
LIMIT :quota
"""
),
{"origin": origin, "max_attempts": _MAX_ATTEMPTS, "quota": quota},
).fetchall()
]
left -= quota
if left <= 0:
break
return rows
def run_duration_backfill(*, batch: int = _DEFAULT_BATCH) -> dict[str, int]: def run_duration_backfill(*, batch: int = _DEFAULT_BATCH) -> dict[str, int]:
"""Bierze paczkę z kolejki, dolicza długość, zapisuje. Pusta kolejka = no-op.""" """Bierze paczkę z kolejki, dolicza długość, zapisuje. Pusta kolejka = no-op."""
with session_scope() as s: with session_scope() as s:
rows = s.execute( rows = _take_batch(s, batch=batch)
text(
"""
SELECT scene_id, page_url, origin
FROM duration_backfill_queue
WHERE done_at IS NULL AND attempts < :max_attempts
ORDER BY attempts, created_at
LIMIT :batch
"""
),
{"max_attempts": _MAX_ATTEMPTS, "batch": batch},
).fetchall()
if not rows: if not rows:
return {"taken": 0, "filled": 0, "missed": 0} return {"taken": 0, "filled": 0, "missed": 0, "gone": 0}
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool: with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
results = list(pool.map(_fetch_one, [tuple(r) for r in rows])) results = list(pool.map(_fetch_one, [tuple(r) for r in rows]))
filled = [(sid, dur) for sid, dur in results if dur] filled = [(sid, dur) for sid, dur, _gone in results if dur]
missed = [sid for sid, dur in results if not dur] gone = [sid for sid, dur, is_gone in results if not dur and is_gone]
missed = [sid for sid, dur, is_gone in results if not dur and not is_gone]
with session_scope() as s: with session_scope() as s:
for scene_id, dur in filled: for scene_id, dur in filled:
@ -130,6 +175,17 @@ def run_duration_backfill(*, batch: int = _DEFAULT_BATCH) -> dict[str, int]:
), ),
{"id": scene_id}, {"id": scene_id},
) )
if gone:
s.execute(
text(
"""
UPDATE duration_backfill_queue
SET attempts = :max_attempts, done_at = now()
WHERE scene_id = ANY(:ids)
"""
),
{"ids": gone, "max_attempts": _MAX_ATTEMPTS},
)
if missed: if missed:
# Wyczerpane próby zamykamy, żeby kolejka nie kręciła się w kółko na martwych. # Wyczerpane próby zamykamy, żeby kolejka nie kręciła się w kółko na martwych.
s.execute( s.execute(
@ -145,4 +201,9 @@ def run_duration_backfill(*, batch: int = _DEFAULT_BATCH) -> dict[str, int]:
{"ids": missed, "max_attempts": _MAX_ATTEMPTS}, {"ids": missed, "max_attempts": _MAX_ATTEMPTS},
) )
return {"taken": len(rows), "filled": len(filled), "missed": len(missed)} return {
"taken": len(rows),
"filled": len(filled),
"missed": len(missed),
"gone": len(gone),
}

View file

@ -61,12 +61,59 @@ def _bucket(age_h: float) -> tuple[str, str]:
# Serwisy, ktore publikuja rzadziej niz co dwa dni: prog 48 h robil z nich staly alarm # 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 # (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. # 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] = { _BROWSE_MAX_AGE_OVERRIDE_H: dict[str, int] = {
"xanimeporn": 168, "xanimeporn": 168,
"hentaimama": 240, "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( def run_ingest_freshness_watchdog(
*, *,
@ -98,6 +145,7 @@ def run_ingest_freshness_watchdog(
[ [
(f"tube:{t}", "playback_sources", _BROWSE_MAX_AGE_OVERRIDE_H.get(t, max_age_hours), "browse") (f"tube:{t}", "playback_sources", _BROWSE_MAX_AGE_OVERRIDE_H.get(t, max_age_hours), "browse")
for t in sorted(browse_tags) for t in sorted(browse_tags)
if t not in _FROZEN_BROWSE
] ]
+ [(f"tube:{t}", "playback_sources", search_max_age_hours, "search") + [(f"tube:{t}", "playback_sources", search_max_age_hours, "search")
for t in sorted(search_tags)] for t in sorted(search_tags)]
@ -132,6 +180,10 @@ def run_ingest_freshness_watchdog(
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:
# 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: if age_h >= threshold:
label, level = _bucket(age_h) label, level = _bucket(age_h)
stale.append( stale.append(

View file

@ -455,6 +455,15 @@ def _job_ingest_watchdog(
log.warning("[scheduler] ingest-watchdog: %d stale origin(s)", len(res["stale"])) log.warning("[scheduler] ingest-watchdog: %d stale origin(s)", len(res["stale"]))
except Exception: except Exception:
log.exception("[scheduler] ingest-watchdog failed") log.exception("[scheduler] ingest-watchdog failed")
# Jakosc: listing (browse_runs), parser (performerzy, dlugosc, miniatury), odtwarzanie.
# Osobny try, zeby awaria jednego sprawdzenia nie zdjela drugiego.
try:
from app.scheduler.quality_watchdog import run_quality_watchdogs
q = run_quality_watchdogs()
log.info("[scheduler] quality-watchdog: %s", q["summary"])
except Exception:
log.exception("[scheduler] quality-watchdog failed")
def _job_hetzner_monitor() -> None: def _job_hetzner_monitor() -> None:

View file

@ -0,0 +1,252 @@
"""Watchdogi jakosci zrodel, uzupelnienie freshness z ingest_watchdog.py.
Freshness widzi tylko "brak nowych pozycji". Cztery klasy awarii, ktore przez to przechodzily
(wszystkie z 10.2026): watchporn zmienil motyw i parser dopinal 12 performerow na scene
z kart "related" (ingest "zdrowy", new>0); galaxyporn, latestpornvideo i porndish oddaja
sceny bez dlugosci (apka filtruje min 60 s, wiec sa niewidoczne, a licznik new rosnie);
porndoe oddawal 31 tych samych scen (listing pelny, new=0, nie wiadomo czy to serwis stoi,
czy czytamy zla strone); perverzija grala w 50% przy "zywych" zrodlach.
Trzy sprawdzenia, kazde z wlasnym fingerprintem w Sentry (jedno issue na zrodlo i rodzaj,
bez spamu co 6 h):
- **listing** (browse_runs): ostatnie przebiegi scrapera z seen=0 albo wyjatkiem = listing
pusty lub blad; dlugi ciag seen>0 i new=0 u zrodla publikujacego codziennie = czytamy
zla strone (rotacja karuzeli, endpoint nie sortowany po dacie).
- **parser** (playback_sources + scenes z 24 h wobec 14 dni wczesniej): skok lub zanik
performerow na scene, udzial scen bez dlugosci, bez miniatury. Lapie zmiane motywu
strony w pierwszej dobie, bez czekania na zgloszenie z apki.
- **playback** (playback_events 24 h wobec 7 dni): spadek udanych odtworzen per origin.
`to_sentry=False` daje sam odczyt (reczne sprawdzenie bez zasmiecania issue).
"""
from __future__ import annotations
import logging
import statistics
from collections import defaultdict
from typing import Any
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
# listing
_LISTING_EMPTY_STREAK = 3 # tyle ostatnich przebiegow z seen=0/bledem z rzedu
_LISTING_ALLKNOWN_STREAK = 8 # tyle przebiegow (48 h przy co 6 h) z seen>0 i new=0
_LISTING_ALLKNOWN_MAX_GAP_DAYS = 2.0 # tylko zrodla publikujace co najmniej co 2 dni
# parser
_PARSER_MIN_RECENT = 10
_PARSER_MIN_BASE = 30
# playback
_PLAYBACK_MIN_ATTEMPTS = 30
_PLAYBACK_MIN_BASE_ATTEMPTS = 50
def _cadence_gap_days(session, origin: str, lookback_days: int = 60) -> float | None:
"""Mediana odstepu (w dobach) miedzy dniami, w ktorych zrodlo dalo nowe pozycje."""
rows = session.execute(
text(
"SELECT DISTINCT created_at::date AS d FROM playback_sources "
"WHERE origin = :o AND created_at > now() - make_interval(days => :lb) ORDER BY d"
),
{"o": origin, "lb": lookback_days},
).all()
days = [r[0] for r in rows]
if len(days) < 5:
return None
gaps = [(b - a).days for a, b in zip(days, days[1:])]
return float(statistics.median(gaps)) if gaps else None
def check_listing_health(session) -> list[dict[str, Any]]:
rows = session.execute(
text(
"SELECT sitetag, started_at, seen, new, skipped, failed, note FROM browse_runs "
"WHERE started_at > now() - interval '60 hours' ORDER BY sitetag, started_at DESC"
)
).all()
by_tag: dict[str, list] = defaultdict(list)
for r in rows:
by_tag[r.sitetag].append(r)
out: list[dict[str, Any]] = []
for sitetag, runs in by_tag.items():
last = runs[:_LISTING_EMPTY_STREAK]
if len(last) >= _LISTING_EMPTY_STREAK and all(r.failed or r.seen == 0 for r in last):
note = next((r.note for r in last if r.note), None)
out.append(
{
"sitetag": sitetag,
"problem": "empty",
"runs": len(last),
"note": note,
"level": "warning",
"message": (
f"listing-watchdog: {sitetag} listing pusty albo blad w {len(last)} "
f"przebiegach z rzedu" + (f" ({note[:120]})" if note else "")
),
}
)
continue
last = runs[:_LISTING_ALLKNOWN_STREAK]
if len(last) >= _LISTING_ALLKNOWN_STREAK and all(
(not r.failed) and r.seen > 0 and r.new == 0 for r in last
):
gap = _cadence_gap_days(session, f"tube:{sitetag}")
if gap is not None and gap <= _LISTING_ALLKNOWN_MAX_GAP_DAYS:
seen = sum(r.seen for r in last)
out.append(
{
"sitetag": sitetag,
"problem": "all_known",
"runs": len(last),
"seen": seen,
"gap_days": gap,
"level": "warning",
"message": (
f"listing-watchdog: {sitetag} {len(last)} przebiegi z rzedu, {seen} pozycji "
f"i 0 nowych, a zrodlo publikuje co {gap:.0f} d: zla strona listingu "
f"(rotacja, brak sortu po dacie) albo serwis stanal"
),
}
)
return out
def check_parser_quality(session) -> list[dict[str, Any]]:
rows = session.execute(
text(
"""
WITH w AS (
SELECT ps.origin, ps.scene_id, ps.thumbnail_url,
(ps.created_at > now() - interval '24 hours') AS recent
FROM playback_sources ps
WHERE ps.origin LIKE 'tube:%' AND ps.created_at > now() - interval '15 days'
)
SELECT w.origin, w.recent, count(*) AS n,
avg((SELECT count(*) FROM scene_performers sp WHERE sp.scene_id = w.scene_id)) AS perf,
avg(CASE WHEN x.duration_sec IS NULL THEN 1.0 ELSE 0.0 END) AS dur_null,
avg(CASE WHEN w.thumbnail_url IS NULL THEN 1.0 ELSE 0.0 END) AS thumb_null
FROM w JOIN scenes x ON x.id = w.scene_id
GROUP BY 1, 2
"""
)
).all()
stats: dict[str, dict[bool, Any]] = defaultdict(dict)
for r in rows:
stats[r.origin][bool(r.recent)] = r
out: list[dict[str, Any]] = []
for origin, parts in stats.items():
rec, base = parts.get(True), parts.get(False)
if rec is None or base is None or rec.n < _PARSER_MIN_RECENT or base.n < _PARSER_MIN_BASE:
continue
pr, pb = float(rec.perf or 0), float(base.perf or 0)
dr, db = float(rec.dur_null or 0), float(base.dur_null or 0)
tr, tb = float(rec.thumb_null or 0), float(base.thumb_null or 0)
probs: list[tuple[str, str]] = []
if pb >= 0 and pr >= 2.5 * max(pb, 0.4) and pr - pb >= 3:
probs.append(("perf_inflated", f"obsada rozdeta: {pr:.1f} performerow na scene (wczesniej {pb:.1f}), karty related?"))
if pb >= 0.8 and pr <= 0.25 * pb:
probs.append(("perf_lost", f"obsada zniknela: {pr:.2f} na scene (wczesniej {pb:.2f})"))
if dr >= 0.5 and db <= 0.25:
probs.append(("duration_lost", f"{dr*100:.0f}% scen bez dlugosci (wczesniej {db*100:.0f}%), apka je ukrywa"))
if tr >= 0.5 and tb <= 0.25:
probs.append(("thumb_lost", f"{tr*100:.0f}% zrodel bez miniatury (wczesniej {tb*100:.0f}%)"))
for metric, desc in probs:
out.append(
{
"origin": origin,
"metric": metric,
"recent_n": int(rec.n),
"base_n": int(base.n),
"level": "warning",
"message": f"parser-watchdog: {origin} {desc} (24 h: {int(rec.n)} scen, baza {int(base.n)})",
}
)
return out
def check_playback_health(session) -> list[dict[str, Any]]:
rows = session.execute(
text(
"""
SELECT origin,
count(*) FILTER (WHERE created_at > now() - interval '24 hours') AS att24,
avg(CASE WHEN status = 'success' THEN 1.0 ELSE 0.0 END)
FILTER (WHERE created_at > now() - interval '24 hours') AS ok24,
count(*) FILTER (WHERE created_at <= now() - interval '24 hours') AS attb,
avg(CASE WHEN status = 'success' THEN 1.0 ELSE 0.0 END)
FILTER (WHERE created_at <= now() - interval '24 hours') AS okb
FROM playback_events
WHERE created_at > now() - interval '8 days'
GROUP BY 1
"""
)
).all()
out: list[dict[str, Any]] = []
for r in rows:
att24 = int(r.att24 or 0)
if att24 < _PLAYBACK_MIN_ATTEMPTS or r.ok24 is None:
continue
ok24 = float(r.ok24)
okb = float(r.okb) if r.okb is not None else None
attb = int(r.attb or 0)
bucket = None
if ok24 < 0.3:
bucket, level = "lt30", "error"
elif ok24 < 0.6 and okb is not None and attb >= _PLAYBACK_MIN_BASE_ATTEMPTS and okb - ok24 >= 0.25:
bucket, level = "lt60", "warning"
if not bucket:
continue
base_txt = f", tydzien wczesniej {okb*100:.0f}%" if okb is not None else ""
out.append(
{
"origin": r.origin,
"bucket": bucket,
"ok24": round(ok24, 3),
"att24": att24,
"level": level,
"message": f"playback-watchdog: {r.origin} {ok24*100:.0f}% udanych z {att24} prob w 24 h{base_txt}",
}
)
return out
def _emit(items: list[dict[str, Any]], *, kind: str, key: str, sub: str) -> None:
try:
import sentry_sdk
except Exception: # pragma: no cover
return
for x in items:
try:
with sentry_sdk.push_scope() as scope:
scope.level = x["level"]
scope.set_tag("watchdog_kind", kind)
scope.set_tag("watchdog_origin", str(x.get(key)))
for k, v in x.items():
if k not in ("message", "level"):
scope.set_extra(k, v)
scope.fingerprint = [kind, str(x.get(key)), str(x.get(sub))]
sentry_sdk.capture_message(x["message"])
except Exception: # pragma: no cover
log.exception("quality-watchdog: Sentry capture failed")
def run_quality_watchdogs(*, to_sentry: bool = True) -> dict[str, Any]:
with session_scope() as s:
listing = check_listing_health(s)
parser = check_parser_quality(s)
playback = check_playback_health(s)
for group, name in ((listing, "listing"), (parser, "parser"), (playback, "playback")):
for x in group:
log.warning("quality-watchdog [%s]: %s", name, x["message"])
if to_sentry:
_emit(listing, kind="listing-watchdog", key="sitetag", sub="problem")
_emit(parser, kind="parser-watchdog", key="origin", sub="metric")
_emit(playback, kind="playback-watchdog", key="origin", sub="bucket")
summary = {"listing": len(listing), "parser": len(parser), "playback": len(playback)}
if not any(summary.values()):
log.info("quality-watchdog: bez uwag")
return {"summary": summary, "listing": listing, "parser": parser, "playback": playback}

View file

@ -35,6 +35,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-10-13b',
date: 'October 2026',
items: [
'Version 0.4.3 is out as a new APK (the app will offer to install it). Android TV: the app no longer crashes when it was opened from two places at once, for example the TV home screen and a shortcut. Crash reports from the native side now carry enough detail to be fixed.',
],
},
{ {
id: '2026-10-13a', id: '2026-10-13a',
date: 'October 2026', date: 'October 2026',

View file

@ -0,0 +1,176 @@
"""porntrex: dolicza długość scenom bez niej z listingu `/latest-updates/N/`.
Strona szczegółów porntrex nie podaje własnej długości (tylko długości filmów z paska
obok), więc backfill ze strony (`duration_backfill.py`) tu nie działa. Kafelek listingu
ją pokazuje (`class="durations"`), 85 kafelków na stronę, katalog ma ok. 8300 stron
(strona 15000 daje 404). Przebieg idzie po listingu od najnowszych i dla każdego kafelka
ustawia `scenes.duration_sec`, jeśli jest NULL. Nic nie nadpisuje.
Adresy: ścieżka search zapisuje `https://www.porntrex.com/video/<id>/<slug>`, browse
`https://porntrex.com/video/<id>/<slug>` (bez www), dlatego dopasowanie po obu wariantach.
Uruchomienie (długi przebieg, nie przez `exec`, patrz pamięć o restartach workera):
docker compose run -d --rm worker python3 scripts/porntrex_duration_walk.py --start 1
docker compose run --rm worker python3 scripts/porntrex_duration_walk.py --start 1 --end 20 --dry-run
Postęp w `--state-file` (domyślnie /tmp/porntrex_duration_walk.json), wznowienie od
zapisanej strony przy braku `--start`.
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from sqlalchemy import text
from app.connectors.direct_scrapers.porntrex_browse import _A_RE, _DUR_RE, _parse_duration
from app.db import session_scope
from app.extractors import browser_get
log = logging.getLogger("porntrex_duration_walk")
_LISTING = "https://www.porntrex.com/latest-updates/{page}/"
_ORIGIN = "tube:porntrexcom"
def parse_cards(html: str) -> dict[str, int]:
"""Kafelki listingu → {url bez www i bez końcowego '/': sekundy}."""
out: dict[str, int] = {}
anchors = list(_A_RE.finditer(html))
for idx, m in enumerate(anchors):
url = m.group("url").replace("://www.", "://").rstrip("/")
if url in out:
continue
end = anchors[idx + 1].start() if idx + 1 < len(anchors) else m.end() + 700
dm = _DUR_RE.search(html[m.start():end])
sec = _parse_duration(dm.group(1) if dm else None)
if sec:
out[url] = sec
return out
def variants(url: str) -> list[str]:
bare = url.replace("://www.", "://").rstrip("/")
www = bare.replace("https://", "https://www.", 1)
return [bare, bare + "/", www, www + "/"]
def apply(cards: dict[str, int], *, dry_run: bool) -> dict[str, int]:
"""Zwraca liczniki: matched (źródła w bazie), filled (ustawione), had (już miały)."""
counters = {"matched": 0, "filled": 0, "had": 0}
if not cards:
return counters
lookup: dict[str, int] = {}
for url, sec in cards.items():
for v in variants(url):
lookup[v] = sec
with session_scope() as s:
rows = s.execute(
text(
"""
SELECT ps.page_url, ps.id AS ps_id, sc.id AS scene_id, sc.duration_sec
FROM playback_sources ps
JOIN scenes sc ON sc.id = ps.scene_id
WHERE ps.origin = :origin AND ps.page_url = ANY(:urls)
"""
),
{"origin": _ORIGIN, "urls": list(lookup)},
).fetchall()
for page_url, ps_id, scene_id, have in rows:
counters["matched"] += 1
sec = lookup.get(page_url)
if not sec:
continue
if have:
counters["had"] += 1
continue
counters["filled"] += 1
if dry_run:
continue
s.execute(
text("UPDATE scenes SET duration_sec = :d WHERE id = :id AND duration_sec IS NULL"),
{"d": sec, "id": scene_id},
)
s.execute(
text(
"UPDATE playback_sources SET duration_sec = :d "
" WHERE id = :id AND duration_sec IS NULL"
),
{"d": sec, "id": ps_id},
)
return counters
def _fetch(page: int) -> tuple[int, str]:
res = browser_get(_LISTING.format(page=page), timeout=45)
status = int(getattr(res, "status_code", 200) or 200)
html = res.text if hasattr(res, "text") else str(res)
return status, html
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--start", type=int, default=None, help="pierwsza strona (domyślnie ze state-file albo 1)")
ap.add_argument("--end", type=int, default=20000, help="ostatnia strona (przebieg i tak staje na 404)")
ap.add_argument("--pace", type=float, default=1.0, help="odstęp między stronami w sekundach")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--state-file", default="/tmp/porntrex_duration_walk.json")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
start = args.start
if start is None:
try:
with open(args.state_file, encoding="utf-8") as f:
start = int(json.load(f).get("next_page", 1))
except (OSError, ValueError):
start = 1
total = {"pages": 0, "cards": 0, "matched": 0, "filled": 0, "had": 0}
empty_streak = 0
t0 = time.time()
for page in range(start, args.end + 1):
try:
status, html = _fetch(page)
except Exception as e:
log.warning("strona %d: błąd pobrania %s, czekam 30 s", page, e)
time.sleep(30)
try:
status, html = _fetch(page)
except Exception as e2:
log.error("strona %d: drugi błąd %s, przerywam", page, e2)
break
cards = parse_cards(html) if status < 400 else {}
if not cards:
empty_streak += 1
log.info("strona %d: http %s, brak kafelków (%d z rzędu)", page, status, empty_streak)
if empty_streak >= 3:
log.info("koniec listingu na stronie %d", page)
break
time.sleep(args.pace)
continue
empty_streak = 0
c = apply(cards, dry_run=args.dry_run)
total["pages"] += 1
total["cards"] += len(cards)
for k in ("matched", "filled", "had"):
total[k] += c[k]
log.info(
"strona %d: kafelków %d, w bazie %d, doliczone %d, miały %d%s",
page, len(cards), c["matched"], c["filled"], c["had"], " (dry-run)" if args.dry_run else "",
)
if not args.dry_run:
try:
with open(args.state_file, "w", encoding="utf-8") as f:
json.dump({"next_page": page + 1, "total": total, "ts": time.time()}, f)
except OSError:
pass
time.sleep(args.pace)
log.info("razem: %s w %.0f s", total, time.time() - t0)
print(json.dumps(total))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -95,7 +95,7 @@
</p> </p>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<a href="/goon-v0.4.2.apk" <a href="/goon-v0.4.3.apk"
class="px-6 py-4 rounded-xl bg-accent text-white font-bold hover:bg-accentDeep transition glow"> class="px-6 py-4 rounded-xl bg-accent text-white font-bold hover:bg-accentDeep transition glow">
Download APK Download APK
</a> </a>
@ -240,6 +240,16 @@
</p> </p>
<div class="space-y-6"> <div class="space-y-6">
<div class="bg-card border border-border rounded-2xl p-6">
<div class="font-mono text-xs text-accent mb-2">October 2026 &middot; 0.4.3</div>
<h3 class="text-lg font-bold mb-2">Android TV stays up</h3>
<p class="text-sm text-gray-400 leading-relaxed">
On TV boxes the app could crash when it was opened from two places at once,
for example the TV home screen and a shortcut. Fixed by running a single
activity. Native crash reports now carry debug symbols, so the remaining
rare crashes can be traced. Installs over 0.4.2 (same signing key).
</p>
</div>
<div class="bg-card border border-border rounded-2xl p-6"> <div class="bg-card border border-border rounded-2xl p-6">
<div class="font-mono text-xs text-accent mb-2">September 2026 &middot; 0.4.0</div> <div class="font-mono text-xs text-accent mb-2">September 2026 &middot; 0.4.0</div>
<h3 class="text-lg font-bold mb-2">Edge</h3> <h3 class="text-lg font-bold mb-2">Edge</h3>
@ -327,7 +337,7 @@
<li class="bg-card border border-border rounded-2xl p-5 flex gap-4"> <li class="bg-card border border-border rounded-2xl p-5 flex gap-4">
<span class="text-accent font-extrabold text-xl">1</span> <span class="text-accent font-extrabold text-xl">1</span>
<span class="text-sm text-gray-300 leading-relaxed"> <span class="text-sm text-gray-300 leading-relaxed">
<a href="/goon-v0.4.2.apk" class="text-accent font-bold hover:underline">Download the APK</a> <a href="/goon-v0.4.3.apk" class="text-accent font-bold hover:underline">Download the APK</a>
and open it. Allow "install from unknown sources" for your browser if Android asks. and open it. Allow "install from unknown sources" for your browser if Android asks.
</span> </span>
</li> </li>
@ -404,7 +414,7 @@
</p> </p>
</div> </div>
<div class="flex flex-col gap-1 text-right"> <div class="flex flex-col gap-1 text-right">
<a href="/goon-v0.4.2.apk" class="hover:text-accent transition">Download APK</a> <a href="/goon-v0.4.3.apk" class="hover:text-accent transition">Download APK</a>
<a href="https://git.goon-foss.org/goon-foss/goon" class="hover:text-accent transition">Source code</a> <a href="https://git.goon-foss.org/goon-foss/goon" class="hover:text-accent transition">Source code</a>
<a href="#donate" class="hover:text-accent transition">Donate</a> <a href="#donate" class="hover:text-accent transition">Donate</a>
</div> </div>