Compare commits
No commits in common. "main" and "v0.4.3" have entirely different histories.
22 changed files with 46 additions and 1023 deletions
|
|
@ -1,41 +0,0 @@
|
||||||
"""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")
|
|
||||||
|
|
@ -267,12 +267,6 @@ 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)
|
||||||
|
|
@ -791,32 +785,6 @@ 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:
|
||||||
|
|
@ -903,10 +871,7 @@ 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.
|
||||||
# `_basename` to `play.m3u8` nadane w `_proxify_link` z typu zrodla, wiec manifest bez
|
path_suggests_m3u8 = path_lower.endswith(".m3u8")
|
||||||
# `.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:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ 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,
|
||||||
|
|
@ -29,7 +28,6 @@ _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
|
||||||
)
|
)
|
||||||
|
|
@ -70,14 +68,6 @@ 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
|
||||||
|
|
@ -132,7 +122,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,6 @@ _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})\]")
|
||||||
|
|
||||||
|
|
@ -181,11 +178,6 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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 (
|
||||||
|
|
@ -33,7 +32,6 @@ _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
|
||||||
)
|
)
|
||||||
|
|
@ -77,14 +75,6 @@ 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
|
||||||
|
|
@ -140,7 +130,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,6 @@ _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.
|
||||||
|
|
@ -116,21 +112,17 @@ 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,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ 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"
|
||||||
|
|
@ -31,11 +30,6 @@ _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:
|
||||||
|
|
@ -102,24 +96,11 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -32,21 +32,6 @@ _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+)/")
|
||||||
|
|
@ -89,18 +74,9 @@ 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()
|
||||||
if new_theme:
|
for m in _MODEL_RE.finditer(detail_html):
|
||||||
# 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:
|
||||||
|
|
@ -110,45 +86,26 @@ 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):
|
||||||
def _add_tag(slug: str, raw_name: str) -> None:
|
slug = m.group(1)
|
||||||
name = html.unescape(raw_name).strip().lstrip("#").strip()
|
name = html.unescape(m.group(2)).strip()
|
||||||
if slug in seen_t or not name:
|
if slug in seen_t or not name:
|
||||||
return
|
continue
|
||||||
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:
|
cm = _CAT_RE.search(detail_html)
|
||||||
sm = _SITE_RE.search(detail_html)
|
if cm:
|
||||||
if sm:
|
cname = html.unescape(cm.group(2)).strip()
|
||||||
pm = _PUBLISHER_RE.search(detail_html)
|
if cname:
|
||||||
cname = html.unescape(pm.group(1)).strip() if pm else sm.group(1).replace("-", " ").title()
|
studio = RawStudio(
|
||||||
if cname:
|
external_id=f"{self.sitetag}:studio:{slugify(cname)}", name=cname, slug=slugify(cname)
|
||||||
studio = RawStudio(
|
)
|
||||||
external_id=f"{self.sitetag}:studio:{slugify(cname)}", name=cname, slug=slugify(cname)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cm = _CAT_RE.search(detail_html)
|
|
||||||
if cm:
|
|
||||||
cname = html.unescape(cm.group(2)).strip()
|
|
||||||
if cname:
|
|
||||||
studio = RawStudio(
|
|
||||||
external_id=f"{self.sitetag}:studio:{slugify(cname)}", name=cname, slug=slugify(cname)
|
|
||||||
)
|
|
||||||
|
|
||||||
return RawScene(
|
return RawScene(
|
||||||
external_id=f"{self.sitetag}:{scene_url}",
|
external_id=f"{self.sitetag}:{scene_url}",
|
||||||
|
|
|
||||||
|
|
@ -141,29 +141,12 @@ 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 celowo tu nie ma, patrz `app/models/duration_backfill_queue.py`.
|
#: porntrex i mypornerleak 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
"""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"
|
|
||||||
|
|
@ -68,16 +68,11 @@ 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"{player_url}&q={q}",
|
link=f"https://{host}/player/xs1.php?data={vid}&q={q}",
|
||||||
quality=f"{q}p",
|
quality=f"{q}p",
|
||||||
type="m3u8",
|
type="m3u8",
|
||||||
referer=player_url,
|
referer=player_url,
|
||||||
|
|
@ -85,7 +80,11 @@ 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,
|
||||||
"manifest_producer": "xtremestream",
|
# Segmenty NIE są IP-bound i nie potrzebują Referera: segment pobrany
|
||||||
|
# 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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.3",
|
"version": "0.4.2",
|
||||||
"runtime_version": "1.4",
|
"runtime_version": "1.4",
|
||||||
"apk_build": 17,
|
"apk_build": 16,
|
||||||
"apk_url": apk_url,
|
"apk_url": apk_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -45,7 +44,6 @@ from app.models.tag_vertical_count import TagVerticalCount
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"BrowseRun",
|
|
||||||
"BlacklistedPerformer",
|
"BlacklistedPerformer",
|
||||||
"BlacklistedStudio",
|
"BlacklistedStudio",
|
||||||
"BlacklistedTag",
|
"BlacklistedTag",
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -21,37 +21,12 @@ 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
|
||||||
|
|
@ -87,11 +62,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -74,89 +74,44 @@ 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, gone). Sieć i parsowanie, bez dotykania bazy.
|
"""(scene_id, duration|None). 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, False)
|
return (scene_id, None)
|
||||||
return (scene_id, extract_duration_for_origin(origin, r.text, page_url), False)
|
return (scene_id, extract_duration_for_origin(origin, r.text, page_url))
|
||||||
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, False)
|
return (scene_id, None)
|
||||||
|
|
||||||
|
|
||||||
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 = _take_batch(s, batch=batch)
|
rows = s.execute(
|
||||||
|
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, "gone": 0}
|
return {"taken": 0, "filled": 0, "missed": 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, _gone in results if dur]
|
filled = [(sid, dur) for sid, dur in results if dur]
|
||||||
gone = [sid for sid, dur, is_gone in results if not dur and is_gone]
|
missed = [sid for sid, dur in results if not dur]
|
||||||
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:
|
||||||
|
|
@ -175,17 +130,6 @@ 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(
|
||||||
|
|
@ -201,9 +145,4 @@ def run_duration_backfill(*, batch: int = _DEFAULT_BATCH) -> dict[str, int]:
|
||||||
{"ids": missed, "max_attempts": _MAX_ATTEMPTS},
|
{"ids": missed, "max_attempts": _MAX_ATTEMPTS},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {"taken": len(rows), "filled": len(filled), "missed": len(missed)}
|
||||||
"taken": len(rows),
|
|
||||||
"filled": len(filled),
|
|
||||||
"missed": len(missed),
|
|
||||||
"gone": len(gone),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -61,59 +61,12 @@ 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, reczny
|
# odcinki raz w tygodniu, obie listy w calosci znane w bazie). Prog w godzinach.
|
||||||
# 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(
|
||||||
*,
|
*,
|
||||||
|
|
@ -145,7 +98,6 @@ 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)]
|
||||||
|
|
@ -180,10 +132,6 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -455,15 +455,6 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
"""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}
|
|
||||||
|
|
@ -35,13 +35,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
"""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())
|
|
||||||
|
|
@ -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.3.apk"
|
<a href="/goon-v0.4.2.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,16 +240,6 @@
|
||||||
</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 · 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 · 0.4.0</div>
|
<div class="font-mono text-xs text-accent mb-2">September 2026 · 0.4.0</div>
|
||||||
<h3 class="text-lg font-bold mb-2">Edge</h3>
|
<h3 class="text-lg font-bold mb-2">Edge</h3>
|
||||||
|
|
@ -337,7 +327,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.3.apk" class="text-accent font-bold hover:underline">Download the APK</a>
|
<a href="/goon-v0.4.2.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>
|
||||||
|
|
@ -414,7 +404,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.3.apk" class="hover:text-accent transition">Download APK</a>
|
<a href="/goon-v0.4.2.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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue