style: myslniki em/en z komentarzy i docstringow (calosc repo)

1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-11 11:51:57 +02:00
parent 017927793a
commit a4486980a4
291 changed files with 1745 additions and 1745 deletions

View file

@ -30,7 +30,7 @@ router = APIRouter(
def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None: def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła. """Wyciąga thumbnail URL z external_records.raw dla danego źródła.
TPDB ma `image`/`poster`/`background.large`. StashDB raw nie zawiera image TPDB ma `image`/`poster`/`background.large`. StashDB raw nie zawiera image
(osobny query do StashDB potrzebny — tu zwracamy None).""" (osobny query do StashDB potrzebny, tu zwracamy None)."""
if kind == SourceKind.tpdb: if kind == SourceKind.tpdb:
for k in ("image", "poster"): for k in ("image", "poster"):
v = raw.get(k) v = raw.get(k)
@ -42,7 +42,7 @@ def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
if isinstance(v, str) and v.startswith("http"): if isinstance(v, str) and v.startswith("http"):
return v return v
elif kind == SourceKind.stashdb: elif kind == SourceKind.stashdb:
# StashDB scene response includes images via separate query — nie trzymamy # StashDB scene response includes images via separate query, nie trzymamy
# tego w raw obecnie. TODO: dorzucić mirror do `paths.screenshot` przy ingest. # tego w raw obecnie. TODO: dorzucić mirror do `paths.screenshot` przy ingest.
paths = raw.get("paths") paths = raw.get("paths")
if isinstance(paths, dict): if isinstance(paths, dict):
@ -142,7 +142,7 @@ def list_candidates(
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue # Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
# używa statycznego do listy + animowanego po hold-to-preview). Wybieramy najpierw # używa statycznego do listy + animowanego po hold-to-preview). Wybieramy najpierw
# napotkany niepusty URL — kolejność rzędów playback_sources nie jest gwarantowana, # napotkany niepusty URL, kolejność rzędów playback_sources nie jest gwarantowana,
# ale dla triage to wystarcza. # ale dla triage to wystarcza.
thumbs: dict[uuid.UUID, str] = {} thumbs: dict[uuid.UUID, str] = {}
animated_thumbs: dict[uuid.UUID, str] = {} animated_thumbs: dict[uuid.UUID, str] = {}
@ -292,7 +292,7 @@ def _fmt_bytes(b: int) -> str:
def bandwidth_stats() -> BandwidthStats: def bandwidth_stats() -> BandwidthStats:
"""Per-CDN VPS proxy bytes-out + Hetzner traffic stats. """Per-CDN VPS proxy bytes-out + Hetzner traffic stats.
Critical dla public release — pokazuje gdzie VPS bandwidth wycieka. Pozwala Critical dla public release, pokazuje gdzie VPS bandwidth wycieka. Pozwala
spotted Mixdrop / bandwidth-heavy CDN-y przed Hetzner overage charge. spotted Mixdrop / bandwidth-heavy CDN-y przed Hetzner overage charge.
""" """
from app.api.stream_proxy import get_bandwidth_stats from app.api.stream_proxy import get_bandwidth_stats
@ -308,7 +308,7 @@ def bandwidth_stats() -> BandwidthStats:
s_24h = get_bandwidth_stats(24) s_24h = get_bandwidth_stats(24)
s_7d = get_bandwidth_stats(168) s_7d = get_bandwidth_stats(168)
# Hetzner stats — load from cache file (written by check_hetzner_traffic.py cron). # Hetzner stats, load from cache file (written by check_hetzner_traffic.py cron).
hetzner_data = None hetzner_data = None
settings = get_settings() settings = get_settings()
if settings.hetzner_api_token and settings.hetzner_server_id: if settings.hetzner_api_token and settings.hetzner_server_id:

View file

@ -1,9 +1,9 @@
"""htmx + Jinja2 admin UI dla MergeCandidate triage. """htmx + Jinja2 admin UI dla MergeCandidate triage.
Endpointy: Endpointy:
GET /ui/ — lista pending (filter status) GET /ui/ lista pending (filter status)
GET /ui/candidate/{id} — side-by-side scen GET /ui/candidate/{id} side-by-side scen
POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject) POST /ui/candidate/{id}/resolve, htmx form submit (action=merge_keep_left|merge_keep_right|reject)
zwraca fragment HTML z potwierdzeniem zwraca fragment HTML z potwierdzeniem
""" """
from __future__ import annotations from __future__ import annotations
@ -197,9 +197,9 @@ def resolve_form(
def mount_static(app) -> None: # pragma: no cover - dev convenience def mount_static(app) -> None: # pragma: no cover - dev convenience
# APK MIME type — bez tego Android Browser nie traktuje pliku jako instalable APK # APK MIME type, bez tego Android Browser nie traktuje pliku jako instalable APK
# (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest # (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest
# idempotentna na poziomie procesu — bezpiecznie wywoływać przy każdym startup. # idempotentna na poziomie procesu, bezpiecznie wywoływać przy każdym startup.
import mimetypes import mimetypes
mimetypes.add_type("application/vnd.android.package-archive", ".apk") mimetypes.add_type("application/vnd.android.package-archive", ".apk")
if _STATIC_DIR.exists(): if _STATIC_DIR.exists():

View file

@ -1,12 +1,12 @@
"""Blacklists — globalnie ukryte performerki/studia/tagi. """Blacklists, globalnie ukryte performerki/studia/tagi.
Sceny które MAJĄ blacklisted entity wypadają z każdego /scenes (pełna lista, search, Sceny które MAJĄ blacklisted entity wypadają z każdego /scenes (pełna lista, search,
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`. performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
Endpointy: Endpointy:
GET /blacklist — wszystkie 3 listy w jednym response GET /blacklist wszystkie 3 listy w jednym response
POST /blacklist/{kind}/{entity_id} — dodaj (idempotent) POST /blacklist/{kind}/{entity_id} dodaj (idempotent)
DELETE /blacklist/{kind}/{entity_id} — usuń DELETE /blacklist/{kind}/{entity_id} usuń
`kind` ∈ {performer, studio, tag}. `kind` ∈ {performer, studio, tag}.
""" """

View file

@ -1,10 +1,10 @@
"""Bug reports — mobile FAB → POST /bug-reports → admin lista przez admin_html. """Bug reports, mobile FAB → POST /bug-reports → admin lista przez admin_html.
POST nie wymaga obecnego scene_id (user może raportować z FavoritesScreen, POST nie wymaga obecnego scene_id (user może raportować z FavoritesScreen,
SearchScreen itp.). Screenshot opcjonalny — niektóre ekrany nie warto kapturować. SearchScreen itp.). Screenshot opcjonalny, niektóre ekrany nie warto kapturować.
Limit body 1.5MB (FastAPI default jest hojny, ale dla rozsądku ograniczamy). Limit body 1.5MB (FastAPI default jest hojny, ale dla rozsądku ograniczamy).
Screenshot to PNG/JPEG z react-native-view-shot, base64 — typowe rozmiary: Screenshot to PNG/JPEG z react-native-view-shot, base64, typowe rozmiary:
- mały ekran scene-list: ~200-400KB - mały ekran scene-list: ~200-400KB
- duży scene-detail z thumbnail: ~600KB-1MB - duży scene-detail z thumbnail: ~600KB-1MB
""" """
@ -92,7 +92,7 @@ def create_bug_report(
scene_id = payload.scene_id scene_id = payload.scene_id
elif session.get(Movie, payload.scene_id) is not None: elif session.get(Movie, payload.scene_id) is not None:
movie_id = payload.scene_id movie_id = payload.scene_id
# else: ID nie istnieje już nigdzie (deleted) — drop oba na null # else: ID nie istnieje już nigdzie (deleted), drop oba na null
br = BugReport( br = BugReport(
id=uuid.uuid4(), id=uuid.uuid4(),
@ -211,7 +211,7 @@ def get_bug_report_screenshot(
bug_id: uuid.UUID, bug_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
) -> dict[str, str | None]: ) -> dict[str, str | None]:
"""Zwraca base64-encoded screenshot (jeśli jest) — admin UI go renderuje.""" """Zwraca base64-encoded screenshot (jeśli jest), admin UI go renderuje."""
br = session.get(BugReport, bug_id) br = session.get(BugReport, bug_id)
if br is None: if br is None:
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")

View file

@ -6,7 +6,7 @@ nowi użytkownicy widzieli/nadpisywali ulubione, watched-badge i blacklisty Jana
nagłówku `X-Device-Id`; backend scope'uje wszystkie tabele stanu po `device_id`. nagłówku `X-Device-Id`; backend scope'uje wszystkie tabele stanu po `device_id`.
Stare wiersze (sprzed migracji) mają `device_id = LEGACY_DEVICE`. Klient bez nagłówka Stare wiersze (sprzed migracji) mają `device_id = LEGACY_DEVICE`. Klient bez nagłówka
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE — czyli dostaje dawną (stara wersja apki przed OTA) trafia również na LEGACY_DEVICE, czyli dostaje dawną
współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy` współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy`
przepina LEGACY rows na konkretne device (Jan robi to raz po update). przepina LEGACY rows na konkretne device (Jan robi to raz po update).
""" """

View file

@ -8,9 +8,9 @@ Każdy update wgrany przez `scripts/publish_update.py` ląduje w
`app/static/expo-updates/<runtime>/<update_id>/`. Plik `app/static/expo-updates/<runtime>/<update_id>/`. Plik
`app/static/expo-updates/<runtime>/current.json` wskazuje aktywny update_id. `app/static/expo-updates/<runtime>/current.json` wskazuje aktywny update_id.
Endpointy SĄ PUBLICZNE (no auth) — Expo Updates SDK nie wstrzykuje X-API-Key. Endpointy SĄ PUBLICZNE (no auth), Expo Updates SDK nie wstrzykuje X-API-Key.
Bezpieczeństwo opiera się na TLS pinningu (mobile ufa tylko naszej self-signed Bezpieczeństwo opiera się na TLS pinningu (mobile ufa tylko naszej self-signed
cert SPKI z network_security_config) — ktoś bez tego pinu nie podstawi MITM cert SPKI z network_security_config), ktoś bez tego pinu nie podstawi MITM
manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key. manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key.
""" """
from __future__ import annotations from __future__ import annotations
@ -35,7 +35,7 @@ def get_manifest(
expo_platform: str | None = Header(default=None, alias="expo-platform"), expo_platform: str | None = Header(default=None, alias="expo-platform"),
) -> Response: ) -> Response:
"""Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0) """Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0)
+ platform (default android — i tak tylko Android wspieramy). + platform (default android, i tak tylko Android wspieramy).
204 No Content gdy nie ma update'u dla tego runtime'u → klient nadal odpala 204 No Content gdy nie ma update'u dla tego runtime'u → klient nadal odpala
embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest
@ -87,7 +87,7 @@ def get_asset(
) -> Response: ) -> Response:
"""Serwuje pojedynczy asset (JS bundle, image, font) z update directory. """Serwuje pojedynczy asset (JS bundle, image, font) z update directory.
`asset` to relative path względem `static/expo-updates/<runtime>/` — `asset` to relative path względem `static/expo-updates/<runtime>/`,
zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub
`<update_id>/assets/<hash>`. Path traversal blocked przez resolve+is_relative. `<update_id>/assets/<hash>`. Path traversal blocked przez resolve+is_relative.
""" """
@ -103,7 +103,7 @@ def get_asset(
raise HTTPException(status_code=400, detail="invalid asset path") raise HTTPException(status_code=400, detail="invalid asset path")
if not target.exists() or not target.is_file(): if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="asset not found") raise HTTPException(status_code=404, detail="asset not found")
# Content type — bundle to text/javascript, reszta autodetect przez FileResponse. # Content type, bundle to text/javascript, reszta autodetect przez FileResponse.
media_type = None media_type = None
if target.suffix in (".js", ".bundle"): if target.suffix in (".js", ".bundle"):
media_type = "application/javascript" media_type = "application/javascript"

View file

@ -1,19 +1,19 @@
"""Favorites — ulubione performerki + studia + liczenie nowych scen. """Favorites, ulubione performerki + studia + liczenie nowych scen.
Single-user (brak users), więc API zwraca/operuje na global zbiorze. Multi-user Single-user (brak users), więc API zwraca/operuje na global zbiorze. Multi-user
można dodać dorzuceniem `user_id` query/header bez breaking change. można dodać dorzuceniem `user_id` query/header bez breaking change.
Endpointy (performers — `/favorites/...` zostawione żeby nie łamać starego mobile): Endpointy (performers, `/favorites/...` zostawione żeby nie łamać starego mobile):
GET /favorites — lista ulubionych performerek GET /favorites lista ulubionych performerek
POST /favorites/{performer_id} — dodaj (idempotent) POST /favorites/{performer_id} dodaj (idempotent)
DELETE /favorites/{performer_id} — usuń DELETE /favorites/{performer_id} usuń
POST /favorites/{performer_id}/seen — mark-as-seen (zeruje badge) POST /favorites/{performer_id}/seen mark-as-seen (zeruje badge)
Endpointy (studios): Endpointy (studios):
GET /favorites/studios — lista ulubionych studiów GET /favorites/studios lista ulubionych studiów
POST /favorites/studios/{studio_id} — dodaj POST /favorites/studios/{studio_id} dodaj
DELETE /favorites/studios/{studio_id} — usuń DELETE /favorites/studios/{studio_id} usuń
POST /favorites/studios/{studio_id}/seen — mark-as-seen POST /favorites/studios/{studio_id}/seen, mark-as-seen
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at: "Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
- dla performerki: ScenePerformer.performer_id = X - dla performerki: ScenePerformer.performer_id = X
@ -61,7 +61,7 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict:
tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy
playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem
release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca
{group_id: n} — bez streamowania N*200 wierszy do Pythona. {group_id: n}, bez streamowania N*200 wierszy do Pythona.
kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers. kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers.
kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują
@ -134,7 +134,7 @@ def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
top-N ani odsiewu stub/backfill — te pojęcia dotyczą scen. top-N ani odsiewu stub/backfill, te pojęcia dotyczą scen.
""" """
from sqlalchemy import func from sqlalchemy import func
@ -198,7 +198,7 @@ class FavoriteOut(BaseModel):
class FavoriteListOut(BaseModel): class FavoriteListOut(BaseModel):
items: list[FavoriteOut] items: list[FavoriteOut]
total: int total: int
new_total: int # suma new_count po wszystkich — dla badge w toolbar new_total: int # suma new_count po wszystkich, dla badge w toolbar
@router.get("", response_model=FavoriteListOut) @router.get("", response_model=FavoriteListOut)
@ -216,7 +216,7 @@ def list_favorites(
return FavoriteListOut(items=[], total=0, new_total=0) return FavoriteListOut(items=[], total=0, new_total=0)
# scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez # scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym # _job_refresh_taxonomy_counts), ta sama definicja co przed (sceny z żywym
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019. # playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows} scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
new_counts = _merged_new_counts(session, device_id, kind="performer") new_counts = _merged_new_counts(session, device_id, kind="performer")
@ -274,7 +274,7 @@ def remove_favorite(
) -> None: ) -> None:
fav = session.get(FavoritePerformer, (device_id, performer_id)) fav = session.get(FavoritePerformer, (device_id, performer_id))
if fav is None: if fav is None:
# idempotent — brak ulubionego = nie ma nic do usunięcia, success # idempotent, brak ulubionego = nie ma nic do usunięcia, success
return return
session.delete(fav) session.delete(fav)
session.commit() session.commit()
@ -416,7 +416,7 @@ def mark_studio_seen(
# ── Favorite movies ──────────────────────────────────────────────────────── # ── Favorite movies ────────────────────────────────────────────────────────
# Movies nie mają child scenes per-favorite (jak performerki/studia), więc # Movies nie mają child scenes per-favorite (jak performerki/studia), więc
# `last_seen_at` nie jest tu używany do NEW count — tylko jako tracking ostatniego # `last_seen_at` nie jest tu używany do NEW count, tylko jako tracking ostatniego
# wglądu przez usera. Mobile używa NEW badge w liście /movies przez OSOBNY # wglądu przez usera. Mobile używa NEW badge w liście /movies przez OSOBNY
# globalny last_seen z AsyncStorage (client-side, brak backendowego state). # globalny last_seen z AsyncStorage (client-side, brak backendowego state).

View file

@ -2,7 +2,7 @@
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`. Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
`POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty) `POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty)
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki — kolejne na device wołającego. Robi to JEDEN raz właściciel instancji po update apki, kolejne
wywołania nic nie znajdą (legacy już puste). Jeśli na keep-device istnieje już wiersz wywołania nic nie znajdą (legacy już puste). Jeśli na keep-device istnieje już wiersz
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem
kasujemy resztki legacy). kasujemy resztki legacy).
@ -22,7 +22,7 @@ from app.db import get_session
router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)]) router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)])
# (tabela, kolumna-encji) — wszystkie tabele device-scoped (migracja 0022). # (tabela, kolumna-encji), wszystkie tabele device-scoped (migracja 0022).
_TABLES: list[tuple[str, str]] = [ _TABLES: list[tuple[str, str]] = [
("favorite_performers", "performer_id"), ("favorite_performers", "performer_id"),
("favorite_studios", "studio_id"), ("favorite_studios", "studio_id"),
@ -59,7 +59,7 @@ def import_from_device(
) -> ImportOut: ) -> ImportOut:
"""Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego """Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego
urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu
transferu — jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont). transferu, jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które
urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid().""" urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid()."""
@ -114,7 +114,7 @@ def adopt_legacy(
).bindparams(dev=device_id, legacy=LEGACY_DEVICE) ).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
) )
moved[table] = res.rowcount or 0 moved[table] = res.rowcount or 0
# Resztki legacy (encje które device już miał) — skasuj, żeby nie wisiały. # Resztki legacy (encje które device już miał), skasuj, żeby nie wisiały.
session.execute( session.execute(
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE) text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
) )

View file

@ -1,4 +1,4 @@
"""GET /movies — lista i szczegóły filmów.""" """GET /movies, lista i szczegóły filmów."""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
@ -138,8 +138,8 @@ def list_movies(
return MovieListOut(items=items, total=total, page=page, per_page=per_page) return MovieListOut(items=items, total=total, page=page, per_page=per_page)
# Movie playback origin policy — module-level (kiedyś było inline per-request # Movie playback origin policy, module-level (kiedyś było inline per-request
# definition, code-review #19 — perf hit + dorosły kod). # definition, code-review #19, perf hit + dorosły kod).
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample # Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
# random per origin). # random per origin).
_MOVIE_PREFERRED_ORIGINS = ( _MOVIE_PREFERRED_ORIGINS = (
@ -150,9 +150,9 @@ _MOVIE_PREFERRED_ORIGINS = (
"streamporn", "streamporn",
"pandamovies", "pandamovies",
) )
# File hosters które NIGDY nie dadzą się stream-extract bez premium account — # File hosters które NIGDY nie dadzą się stream-extract bez premium account,
# odfiltrowywane całkowicie (zaśmiecały listę watch options, bug-report # odfiltrowywane całkowicie (zaśmiecały listę watch options, bug-report
# 2026-05-15). Streamtape przywrócony 2026-05-15 — ma dedicated extractor, # 2026-05-15). Streamtape przywrócony 2026-05-15, ma dedicated extractor,
# ~5% URLów żyje. # ~5% URLów żyje.
_MOVIE_DROP_ORIGINS = frozenset({ _MOVIE_DROP_ORIGINS = frozenset({
"mangoporn:rapidgator", "mangoporn:rapidgator",

View file

@ -1,14 +1,14 @@
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve — rozwiązuje stream URL. """POST /scenes/{scene_id}/playback/{playback_id}/resolve, rozwiązuje stream URL.
Mobile apka woła ten endpoint na klik "Watch" — backend ekstraktuje świeży Mobile apka woła ten endpoint na klik "Watch", backend ekstraktuje świeży
stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez
Linking.openURL → Android player chooser (MX Player / VLC / browser). Linking.openURL → Android player chooser (MX Player / VLC / browser).
Stream URLs są podpisane/expire (zwykle ~kilka godzin) — nie cache'ujemy ich Stream URLs są podpisane/expire (zwykle ~kilka godzin), nie cache'ujemy ich
w DB, tylko resolve on-demand. Logika ekstrakcji per-tube w `app.extractors`. w DB, tylko resolve on-demand. Logika ekstrakcji per-tube w `app.extractors`.
**Dead-link detection**: gdy hoster embed page mówi "Video deleted/not found", **Dead-link detection**: gdy hoster embed page mówi "Video deleted/not found",
oznaczamy `PlaybackSource.dead_at = now()` — API dalej go nie listuje, mobile oznaczamy `PlaybackSource.dead_at = now()`, API dalej go nie listuje, mobile
nie pokaże martwego buttonu. nie pokaże martwego buttonu.
""" """
from __future__ import annotations from __future__ import annotations
@ -39,23 +39,23 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)]) router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)])
# CDN-domain allowlist dla mobile direct fetch — token IS time-bound (nie IP-bound), # CDN-domain allowlist dla mobile direct fetch, token IS time-bound (nie IP-bound),
# zweryfikowane cross-IP curl test 2026-05-18. Mobile ExoPlayer pobiera manifest+segments # zweryfikowane cross-IP curl test 2026-05-18. Mobile ExoPlayer pobiera manifest+segments
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc). # bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
# #
# Verified time-bound: # Verified time-bound:
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding) — signed token w path + exp_time # - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding), signed token w path + exp_time
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube) — validfrom+validto+hash # - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube), validfrom+validto+hash
# - privatehost.com (pornhat CDN) — sign + exp_time, brak Referer requirement # - privatehost.com (pornhat CDN), sign + exp_time, brak Referer requirement
# - sxyprn.com — signed path # - sxyprn.com, signed path
# - eporner.com CDN — IP literal w path ale CDN go ignoruje # - eporner.com CDN, IP literal w path ale CDN go ignoruje
# #
# NIE w allowlist (IP-bound, wymagają proxy): # NIE w allowlist (IP-bound, wymagają proxy):
# - premilkyway.com (latestpornvideo) — 403 cross-IP # - premilkyway.com (latestpornvideo), 403 cross-IP
# - tnmr.org (mypornerleak) — 403 cross-IP # - tnmr.org (mypornerleak), 403 cross-IP
# - porntrex.com/get_file — single-use token (410 po reuse) # - porntrex.com/get_file, single-use token (410 po reuse)
# - freshporno.org/get_file — cv= signed token IP-bound # - freshporno.org/get_file, cv= signed token IP-bound
# - sn.porn-xp.com, porn00.org — force_proxy explicit # - sn.porn-xp.com, porn00.org, force_proxy explicit
_TIME_BOUND_CDN_RE = re.compile( _TIME_BOUND_CDN_RE = re.compile(
r"\b(?:" r"\b(?:"
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
@ -65,10 +65,10 @@ _TIME_BOUND_CDN_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# IP-BOUND CDN signature — token bind do requester IP, cross-IP fetch = 403. # IP-BOUND CDN signature, token bind do requester IP, cross-IP fetch = 403.
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS). # Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak, # Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
# latestpornvideo) — wszystkie używają tego samego CDN pool. # latestpornvideo), wszystkie używają tego samego CDN pool.
_IP_BOUND_CDN_RE = re.compile( _IP_BOUND_CDN_RE = re.compile(
r"\b(?:" r"\b(?:"
r"premilkyway\.com" # latestpornvideo r"premilkyway\.com" # latestpornvideo
@ -82,17 +82,17 @@ _IP_BOUND_CDN_RE = re.compile(
class StreamLink(BaseModel): class StreamLink(BaseModel):
"""Pojedynczy variant stream URL (różne quality / kontener). """Pojedynczy variant stream URL (różne quality / kontener).
`stream_url` = URL do video file (mp4/m3u8/webm) — proxy-wrapped URL przez backend `stream_url` = URL do video file (mp4/m3u8/webm), proxy-wrapped URL przez backend
VPS (`/proxy/{token}/play.ext`). Bezpieczny fallback gdy CDN binduje URL do IP VPS (`/proxy/{token}/play.ext`). Bezpieczny fallback gdy CDN binduje URL do IP
extractora (np. fpo.xxx z kt_remote_ips cookie). Bandwidth idzie przez VPS. extractora (np. fpo.xxx z kt_remote_ips cookie). Bandwidth idzie przez VPS.
`direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z `direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`. poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`.
Mobile próbuje direct PIERWSZY — gdy CDN zwróci 403/410 (IP-bound), spada na Mobile próbuje direct PIERWSZY, gdy CDN zwróci 403/410 (IP-bound), spada na
`stream_url` (proxy). Daje 0 bandwidth na VPS-ie dla większości scen. `stream_url` (proxy). Daje 0 bandwidth na VPS-ie dla większości scen.
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn) — mobile `embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn), mobile
otwiera w WebView. Type: 'hoster'. otwiera w WebView. Type: 'hoster'.
""" """
@ -122,7 +122,7 @@ def resolve_movie_playback(
playback_id: uuid.UUID, playback_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
) -> ResolveOut: ) -> ResolveOut:
"""Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`. """Movies playback resolve, analog do `/scenes/{id}/playback/{pb}/resolve`.
Origin patterns: Origin patterns:
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc - 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
@ -150,7 +150,7 @@ def resolve_movie_playback(
if pb.origin == "paradisehill": if pb.origin == "paradisehill":
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts. # Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
# Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane # Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
# bez auth — 200 OK z plain User-Agent + Referer. # bez auth, 200 OK z plain User-Agent + Referer.
# Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N"). # Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla, # Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
# nie ma sposobu przejść do następnego. # nie ma sposobu przejść do następnego.
@ -162,7 +162,7 @@ def resolve_movie_playback(
parts = [] parts = []
if parts: if parts:
for url, label in parts: for url, label in parts:
# NIE proxifikujemy tutaj — outer `_proxify_link` poniżej (linia 247) opakuje # NIE proxifikujemy tutaj, outer `_proxify_link` poniżej (linia 247) opakuje
# wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL). # wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
links.append( links.append(
StreamLink( StreamLink(
@ -174,7 +174,7 @@ def resolve_movie_playback(
) )
) )
else: else:
# Fallback: brak videoList (np. login-only movie) — WebView na całość. # Fallback: brak videoList (np. login-only movie), WebView na całość.
links = [ links = [
StreamLink( StreamLink(
stream_url=None, stream_url=None,
@ -191,7 +191,7 @@ def resolve_movie_playback(
try: try:
stream = extract_stream_from_hoster(target, referer=referer) stream = extract_stream_from_hoster(target, referer=referer)
except HosterDead as e: except HosterDead as e:
# Hoster wprost mówi "video deleted" — oznacz dead, NIE proponuj # Hoster wprost mówi "video deleted", oznacz dead, NIE proponuj
# embed fallback (mobile ExoPlayer dostałby 404 HTML page i # embed fallback (mobile ExoPlayer dostałby 404 HTML page i
# próbowałby zapisać jako .bin file; bug-report 2026-05-16 # próbowałby zapisać jako .bin file; bug-report 2026-05-16
# "streamtape ściąga hurtowo pliki .bin"). # "streamtape ściąga hurtowo pliki .bin").
@ -203,7 +203,7 @@ def resolve_movie_playback(
except Exception as e: except Exception as e:
log.warning("movie hoster extract failed for %s: %s", target, e) log.warning("movie hoster extract failed for %s: %s", target, e)
if stream and _IP_BOUND_CDN_RE.search(stream): if stream and _IP_BOUND_CDN_RE.search(stream):
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.) — token bind do VPS IP, # IP-bound CDN (luluvid → cdn-tnmr.org, etc.), token bind do VPS IP,
# mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView). # mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView).
log.info( log.info(
"movie playback %s: stream URL IP-bound CDN — skip, WebView fallback", "movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
@ -225,11 +225,11 @@ def resolve_movie_playback(
type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4" type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
raw_meta: dict = {"origin": pb.origin, "host": target} raw_meta: dict = {"origin": pb.origin, "host": target}
# seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/ # seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/
# embedseek/upns — ~322k sources) zwraca master.m3u8 na raw-IP CDN # embedseek/upns, ~322k sources) zwraca master.m3u8 na raw-IP CDN
# (185.237.x/203.188.x/45.156.x `/v4/<tok>/<exp>/pp/<hash>/master.m3u8`). # (185.237.x/203.188.x/45.156.x `/v4/<tok>/<exp>/pp/<hash>/master.m3u8`).
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06): # Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
# manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest # manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
# VALID (verify=True OK — IP-SAN, nie self-signed jak głosił stary docstring). # VALID (verify=True OK, IP-SAN, nie self-signed jak głosił stary docstring).
# Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer # Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer
# gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink). # gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink).
# Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź # Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
@ -246,7 +246,7 @@ def resolve_movie_playback(
raw=raw_meta, raw=raw_meta,
) )
) )
# Zawsze dorzucamy embed jako fallback — mobile WebView może wyłapać URL z JS-a # Zawsze dorzucamy embed jako fallback, mobile WebView może wyłapać URL z JS-a
if pb.embed_url: if pb.embed_url:
links.append( links.append(
StreamLink( StreamLink(
@ -297,7 +297,7 @@ def mark_playback_dead(
request: Request, request: Request,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
) -> None: ) -> None:
"""User-triggered mark dead — long-press na playback chip w mobile. """User-triggered mark dead, long-press na playback chip w mobile.
Bug-report 2026-05-12 (dd17c709): "Eporner to nie temporary. Więc długie Bug-report 2026-05-12 (dd17c709): "Eporner to nie temporary. Więc długie
przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
@ -362,7 +362,7 @@ def resolve_playback(
sitetag: str | None = None sitetag: str | None = None
if pb.origin.startswith("pornapp:"): if pb.origin.startswith("pornapp:"):
# Legacy origin format — pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`. # Legacy origin format, pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
sitetag = pb.origin.split(":", 1)[1] sitetag = pb.origin.split(":", 1)[1]
elif pb.origin.startswith("tube:"): elif pb.origin.startswith("tube:"):
sitetag = pb.origin.split(":", 1)[1] sitetag = pb.origin.split(":", 1)[1]
@ -382,7 +382,7 @@ def resolve_playback(
log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e) log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
except TubePageError as e: except TubePageError as e:
# Tube page is gone (404/410) — mark dead, propagate as 410. Inne 5xx → 502. # Tube page is gone (404/410), mark dead, propagate as 410. Inne 5xx → 502.
if e.status_code in (404, 410): if e.status_code in (404, 410):
reason = f"tube page {e.status_code} {pb.page_url}" reason = f"tube page {e.status_code} {pb.page_url}"
pb.dead_at = datetime.now(UTC) pb.dead_at = datetime.now(UTC)
@ -397,7 +397,7 @@ def resolve_playback(
) from e ) from e
if not sources: if not sources:
# Extractor None — TRANSIENT failure (network glitch, tube chwilowy 503, # Extractor None, TRANSIENT failure (network glitch, tube chwilowy 503,
# ad-network response zmieniony, race condition). NIE oznaczamy `dead_at` # ad-network response zmieniony, race condition). NIE oznaczamy `dead_at`
# bo wcześniej powodowało false-positive permanent dead dla freshporno scen # bo wcześniej powodowało false-positive permanent dead dla freshporno scen
# które działały przy następnym attempt (bug-report 2026-05-12). # które działały przy następnym attempt (bug-report 2026-05-12).
@ -410,9 +410,9 @@ def resolve_playback(
"extractor None for playback %s (origin=%s) — transient, not marking dead", "extractor None for playback %s (origin=%s) — transient, not marking dead",
pb.id, pb.origin, pb.id, pb.origin,
) )
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film" — ten kod # 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film", ten kod
# jest dla permanent removal. 503 = transient, user może retry. # jest dla permanent removal. 503 = transient, user może retry.
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py) — # Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py),
# bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case). # bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case).
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
@ -446,7 +446,7 @@ DEFAULT_PLAYER_UA = (
def _proxify_link(link: StreamLink, referer: str) -> StreamLink: def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
"""Wzbogaca StreamLink o: """Wzbogaca StreamLink o:
- `stream_url`: proxy URL (fallback gdy direct fails) - `stream_url`: proxy URL (fallback gdy direct fails)
- `direct_url`: surowy CDN URL (preferowany — 0 VPS bandwidth) - `direct_url`: surowy CDN URL (preferowany, 0 VPS bandwidth)
- `headers`: Referer + User-Agent dla direct fetch - `headers`: Referer + User-Agent dla direct fetch
Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410. Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
""" """
@ -462,21 +462,21 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate")) use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
# force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów # force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów
# gdzie token IS bound do VPS IP (porn00 v-acctoken, pornxp sv.porn-xp.com # gdzie token IS bound do VPS IP (porn00 v-acctoken, pornxp sv.porn-xp.com
# signed path) — mobile direct ZAWSZE 403, więc nie ma sensu próbować. # signed path), mobile direct ZAWSZE 403, więc nie ma sensu próbować.
# Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy). # Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy).
force_proxy = bool(link.raw and link.raw.get("force_proxy")) force_proxy = bool(link.raw and link.raw.get("force_proxy"))
# mobile_direct_ok=True (extractor flag) → m3u8 może iść direct do mobile bo # mobile_direct_ok=True (extractor flag) → m3u8 może iść direct do mobile bo
# CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera # CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth. # manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok")) mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok"))
# Auto-detect time-bound CDN po domain — bez per-extractor flag setting. # Auto-detect time-bound CDN po domain, bez per-extractor flag setting.
# Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/ # Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/
# youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP. # youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP.
if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url): if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url):
mobile_direct_ok = True mobile_direct_ok = True
refetch_url = (link.raw or {}).get("refetch_url") refetch_url = (link.raw or {}).get("refetch_url")
refetch_hoster = (link.raw or {}).get("refetch_hoster") refetch_hoster = (link.raw or {}).get("refetch_hoster")
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL — `/proxy/hls` # manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL, `/proxy/hls`
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py. # zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
manifest_producer = (link.raw or {}).get("manifest_producer") manifest_producer = (link.raw or {}).get("manifest_producer")
token = make_token( token = make_token(
@ -498,15 +498,15 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
else: else:
ext = "mp4" ext = "mp4"
proxied = f"/proxy/{token}/play.{ext}" proxied = f"/proxy/{token}/play.{ext}"
# `direct_url`: surowy CDN URL — mobile próbuje go PIERWSZY (0 VPS bandwidth). # `direct_url`: surowy CDN URL, mobile próbuje go PIERWSZY (0 VPS bandwidth).
# ALE: dla type=m3u8/hls/mpd manifest URL musi być rewritowany żeby segmenty/keys # ALE: dla type=m3u8/hls/mpd manifest URL musi być rewritowany żeby segmenty/keys
# też leciały przez proxy (inne IP może też mieć rate limit/token issues), plus # też leciały przez proxy (inne IP może też mieć rate limit/token issues), plus
# ExoPlayer wybiera extractor po URL extension — `.mp4` w direct URL pornhat # ExoPlayer wybiera extractor po URL extension, `.mp4` w direct URL pornhat
# → Mp4Extractor → fail bo content to HLS. Dla m3u8/mpd zwracamy proxied JAKO # → Mp4Extractor → fail bo content to HLS. Dla m3u8/mpd zwracamy proxied JAKO
# direct (mobile używa go bezpośrednio, 1 hop przez VPS ale to jedyny sposób # direct (mobile używa go bezpośrednio, 1 hop przez VPS ale to jedyny sposób
# żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource). # żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource).
# Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez # Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez
# proxy — bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy # proxy, bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
# → extra round-trip + ExoPlayer "no extractors" przed retry. # → extra round-trip + ExoPlayer "no extractors" przed retry.
# mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token # mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token
# (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy. # (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.

View file

@ -1,8 +1,8 @@
"""Playback telemetry — POST /playback-events (fire-and-forget z apki). """Playback telemetry, POST /playback-events (fire-and-forget z apki).
Apka po każdej próbie odtwarzania woła to z origin sceny + wynikiem (success/error). Apka po każdej próbie odtwarzania woła to z origin sceny + wynikiem (success/error).
Sygnał zasila health/szybkość w rankingu źródeł (run_source_stats agreguje okno 7d). Sygnał zasila health/szybkość w rankingu źródeł (run_source_stats agreguje okno 7d).
Best-effort: apka NIE czeka i ignoruje błąd — telemetria nie może psuć playbacku. Best-effort: apka NIE czeka i ignoruje błąd, telemetria nie może psuć playbacku.
""" """
from __future__ import annotations from __future__ import annotations
@ -22,12 +22,12 @@ router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[
class PlaybackEventIn(BaseModel): class PlaybackEventIn(BaseModel):
# 'tube:<sitetag>' — zgodne z playback_sources.origin. Bez tego ping bezużyteczny. # 'tube:<sitetag>', zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
origin: str = Field(min_length=1, max_length=64) origin: str = Field(min_length=1, max_length=64)
status: Literal["success", "error"] status: Literal["success", "error"]
scene_id: uuid.UUID | None = None scene_id: uuid.UUID | None = None
error_kind: str | None = Field(default=None, max_length=64) error_kind: str | None = Field(default=None, max_length=64)
# Surowa treść błędu z native playera (ExoPlayer message) — diagnostyka realnych # Surowa treść błędu z native playera (ExoPlayer message), diagnostyka realnych
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error). # padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
error_detail: str | None = Field(default=None, max_length=512) error_detail: str | None = Field(default=None, max_length=512)
ttff_ms: int | None = Field(default=None, ge=0, le=600_000) ttff_ms: int | None = Field(default=None, ge=0, le=600_000)

View file

@ -1,12 +1,12 @@
"""Saved searches — zapisane słowa kluczowe per urządzenie. """Saved searches, zapisane słowa kluczowe per urządzenie.
User-report mobilism: sceny słabo opisane → user chce zapisywać często używane User-report mobilism: sceny słabo opisane → user chce zapisywać często używane
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu. zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
Endpointy: Endpointy:
GET /saved-searches — lista zapisów tego urządzenia (najnowsze pierwsze) GET /saved-searches lista zapisów tego urządzenia (najnowsze pierwsze)
POST /saved-searches — dodaj {query} (idempotent na device+query) POST /saved-searches dodaj {query} (idempotent na device+query)
DELETE /saved-searches/{id} — usuń DELETE /saved-searches/{id} usuń
""" """
from __future__ import annotations from __future__ import annotations
@ -30,7 +30,7 @@ router = APIRouter(
dependencies=[Depends(require_api_key)], dependencies=[Depends(require_api_key)],
) )
# Limit zapisów per urządzenie — to wygodny skrót, nie nieograniczona kolekcja. # Limit zapisów per urządzenie, to wygodny skrót, nie nieograniczona kolekcja.
MAX_PER_DEVICE = 50 MAX_PER_DEVICE = 50
@ -71,7 +71,7 @@ def add_saved_search(
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)], device_id: Annotated[str, Depends(get_device_id)],
) -> SavedSearch: ) -> SavedSearch:
# Idempotent — ten sam (device, query) zwraca istniejący wiersz. # Idempotent, ten sam (device, query) zwraca istniejący wiersz.
existing = session.execute( existing = session.execute(
select(SavedSearch).where( select(SavedSearch).where(
SavedSearch.device_id == device_id, SavedSearch.query == payload.query SavedSearch.device_id == device_id, SavedSearch.query == payload.query

View file

@ -1,9 +1,9 @@
"""Scene favorites — ulubione sceny (single-user, równolegle do /favorites/performers). """Scene favorites, ulubione sceny (single-user, równolegle do /favorites/performers).
Endpointy: Endpointy:
GET /scene-favorites — lista ulubionych scen (pełen SceneOut) GET /scene-favorites lista ulubionych scen (pełen SceneOut)
POST /scene-favorites/{scene_id} — dodaj (idempotent) POST /scene-favorites/{scene_id} dodaj (idempotent)
DELETE /scene-favorites/{scene_id} — usuń DELETE /scene-favorites/{scene_id} usuń
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -1,4 +1,4 @@
"""GET /scenes — lista i szczegóły scen z bazy kanonicznej.""" """GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@ -70,7 +70,7 @@ def _default_scene_count(session: Session) -> int:
return total return total
# JAV vertical — osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu # JAV vertical, osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace # (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem, # (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna. # więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
@ -89,11 +89,11 @@ def _jav_source_exists():
# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to # Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to
# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane # 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane
# per-row — przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te # per-row, przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
# puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu). # puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu).
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule # Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
# wracają. Patrz reference_scenes_list_perf / task #22. # wracają. Patrz reference_scenes_list_perf / task #22.
# Cache per device_id (blacklisty są teraz device-scoped — bug 2026-06-08). # Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {} _BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
_BLACKLIST_EMPTY_TTL = 300.0 _BLACKLIST_EMPTY_TTL = 300.0
@ -283,7 +283,7 @@ def list_scenes(
if len(tag_slug_list) > _MAX_TAGS: if len(tag_slug_list) > _MAX_TAGS:
raise HTTPException(status_code=422, detail=f"too many tag filters (max {_MAX_TAGS})") raise HTTPException(status_code=422, detail=f"too many tag filters (max {_MAX_TAGS})")
# AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny # AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny
# exists() — zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika. # exists(), zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
# #
# PERF (2026-06-07): resolvujemy slug→tag_id w aplikacji i filtrujemy po LITERALNYM # PERF (2026-06-07): resolvujemy slug→tag_id w aplikacji i filtrujemy po LITERALNYM
# tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze # tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze
@ -337,7 +337,7 @@ def list_scenes(
base = base.where(~live_playback_exists()) base = base.where(~live_playback_exists())
if origin: if origin:
# Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'. # Substring match na origin, 'hqporner' złapie 'tube:hqpornercom'.
base = base.where( base = base.where(
exists( exists(
select(1).where( select(1).where(
@ -350,14 +350,14 @@ def list_scenes(
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV. # JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes), # ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
# bramy nie stosujemy — jawnie chce ten origin, więc pokazujemy (regresja "No videos" # bramy nie stosujemy, jawnie chce ten origin, więc pokazujemy (regresja "No videos"
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None). # na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
if jav: if jav:
base = base.where(_jav_source_exists()) base = base.where(_jav_source_exists())
elif origin is None: elif origin is None:
base = base.where(~_jav_source_exists()) base = base.where(~_jav_source_exists())
# Blacklisty device (performer/studio/tag) — globalne wykluczenia, współdzielone z # Blacklisty device (performer/studio/tag), globalne wykluczenia, współdzielone z
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu). # licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
for _bl_clause in blacklist_clauses(session, device_id): for _bl_clause in blacklist_clauses(session, device_id):
base = base.where(_bl_clause) base = base.where(_bl_clause)
@ -373,7 +373,7 @@ def list_scenes(
base = base.where(Scene.release_date >= cutoff) base = base.where(Scene.release_date >= cutoff)
if min_quality_p is not None: if min_quality_p is not None:
# PlaybackSource.quality to wolny string — szukamy liczb w prefixie ('1080p', # PlaybackSource.quality to wolny string, szukamy liczb w prefixie ('1080p',
# '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy # '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160. # playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160.
from sqlalchemy import Integer, cast, or_ from sqlalchemy import Integer, cast, or_
@ -416,13 +416,13 @@ def list_scenes(
# dominującym kosztem (~4s na has_playback / min_duration / duży tag) i plan # dominującym kosztem (~4s na has_playback / min_duration / duży tag) i plan
# był NIESTABILNY (literal LIMIT + count-nad-PK pomogły w części przypadków, # był NIESTABILNY (literal LIMIT + count-nad-PK pomogły w części przypadków,
# ale planer i tak czasem skanuje cały zbiór zamiast urwać). Mobile paginuje # ale planer i tak czasem skanuje cały zbiór zamiast urwać). Mobile paginuje
# po `has_more` (per_page+1 fetch), NIE po `total` — `total` to tylko licznik # po `has_more` (per_page+1 fetch), NIE po `total`, `total` to tylko licznik
# "N+" w UI. Wyprowadzamy go z has_more PO fetchu (patrz niżej): dolna granica # "N+" w UI. Wyprowadzamy go z has_more PO fetchu (patrz niżej): dolna granica
# + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy. # + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy.
total_capped = False total_capped = False
total: int | None = _default_scene_count(session) if _is_pure_default else None total: int | None = _default_scene_count(session) if _is_pure_default else None
# Sort: KAŻDY tryb kończy się `Scene.id.desc()` — unikalny PK jako ostateczny # Sort: KAŻDY tryb kończy się `Scene.id.desc()`, unikalny PK jako ostateczny
# tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy # tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy
# ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek, # ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek,
# więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu # więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu
@ -436,7 +436,7 @@ def list_scenes(
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc() Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
) )
elif sort == "studio": elif sort == "studio":
# Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze. # Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze.
ordered = ( ordered = (
base.outerjoin(Studio, Studio.id == Scene.studio_id) base.outerjoin(Studio, Studio.id == Scene.studio_id)
.order_by( .order_by(
@ -451,10 +451,10 @@ def list_scenes(
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc() Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
) )
# Fetch per_page+1 — obecność (per_page+1)-szego wiersza = jest kolejna strona. # Fetch per_page+1, obecność (per_page+1)-szego wiersza = jest kolejna strona.
# To źródło prawdy dla paginacji (mobile getNextPageParam), niezależne od bounded # To źródło prawdy dla paginacji (mobile getNextPageParam), niezależne od bounded
# `total`. Nadmiarowy wiersz odcinamy przed serializacją. # `total`. Nadmiarowy wiersz odcinamy przed serializacją.
# LIMIT/OFFSET literalne (NIE bound-param) — patrz wyżej: sparametryzowany LIMIT # LIMIT/OFFSET literalne (NIE bound-param), patrz wyżej: sparametryzowany LIMIT
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy) # psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
# zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to # zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne. # walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
@ -506,14 +506,14 @@ def similar_scenes(
device_id: Annotated[str, Depends(get_device_id)], device_id: Annotated[str, Depends(get_device_id)],
limit: int = Query(default=12, ge=1, le=30), limit: int = Query(default=12, ge=1, le=30),
) -> SceneListOut: ) -> SceneListOut:
"""Sceny podobne do danej — ranking po WSPÓLNYCH performerach (mocny sygnał) i """Sceny podobne do danej, ranking po WSPÓLNYCH performerach (mocny sygnał) i
tagach (słabszy). Zwraca tylko grywalne sceny (żywy playback), zgodne z bramą JAV tagach (słabszy). Zwraca tylko grywalne sceny (żywy playback), zgodne z bramą JAV
(JAV↔JAV, nie-JAV↔nie-JAV), z pominięciem blacklist/stubów/samej siebie. (JAV↔JAV, nie-JAV↔nie-JAV), z pominięciem blacklist/stubów/samej siebie.
Wydajność: performerzy są selektywni, więc pula kandydatów z nich jest mała (cap Wydajność: performerzy są selektywni, więc pula kandydatów z nich jest mała (cap
_CAND_CAP). Tagi liczymy TYLKO w obrębie tej puli (tanio). Fill z tagów (bounded _CAND_CAP). Tagi liczymy TYLKO w obrębie tej puli (tanio). Fill z tagów (bounded
_TAG_FILL_CAP, HAVING >=2 wspólne) odpala się gdy scena ma mało/zero performerów _TAG_FILL_CAP, HAVING >=2 wspólne) odpala się gdy scena ma mało/zero performerów
(anonimowe tube sceny) — inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy (anonimowe tube sceny), inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id.""" wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id."""
scene = session.get(Scene, scene_id) scene = session.get(Scene, scene_id)
if scene is None: if scene is None:
@ -527,7 +527,7 @@ def similar_scenes(
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id) select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
).scalars().all() ).scalars().all()
# Odsiej ŚMIECIOWYCH performerów: kategorie/studia podszywające się pod performera # Odsiej ŚMIECIOWYCH performerów: kategorie/studia podszywające się pod performera
# (over-attribution z luźnego tube-searcha — np. "Big Tits", "Brazzers", "Ball Licking"). # (over-attribution z luźnego tube-searcha, np. "Big Tits", "Brazzers", "Ball Licking").
# Sygnał: slug pokrywa się z tagiem I performer NIE ma kanonicznego refa (tpdb/stashdb). # Sygnał: slug pokrywa się z tagiem I performer NIE ma kanonicznego refa (tpdb/stashdb).
# Prawdziwi performerzy z tym samym tagiem (tuby tagują sceny nazwiskiem) mają kanoniczny # Prawdziwi performerzy z tym samym tagiem (tuby tagują sceny nazwiskiem) mają kanoniczny
# ref → zostają. Bez tego similar klastrował się po "Ball Licking" zamiast po treści. # ref → zostają. Bez tego similar klastrował się po "Ball Licking" zamiast po treści.
@ -584,7 +584,7 @@ def similar_scenes(
.group_by(SceneTag.scene_id) .group_by(SceneTag.scene_id)
).all() ).all()
tag_shared = {r[0]: r[1] for r in rows} tag_shared = {r[0]: r[1] for r in rows}
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny) — inaczej pomijamy, # Fill z tagów gdy performerów mało/brak (anonimowe tube sceny), inaczej pomijamy,
# bo szeroki tag skanuje setki tysięcy scen. # bo szeroki tag skanuje setki tysięcy scen.
if tag_ids and len(cand_ids) < limit * 4: if tag_ids and len(cand_ids) < limit * 4:
rows = session.execute( rows = session.execute(
@ -642,7 +642,7 @@ _SXYPRN_POST_RE = re.compile(r"sxyprn\.com/post/([0-9a-f]{6,40})", re.IGNORECASE
def _sxyprn_thumb_url(page_url: str | None) -> str | None: def _sxyprn_thumb_url(page_url: str | None) -> str | None:
"""Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera """Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL — token żyje ~1h, (`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL, token żyje ~1h,
więc poster resolvujemy przy serwowaniu (bug 2026-06-10).""" więc poster resolvujemy przy serwowaniu (bug 2026-06-10)."""
if not page_url: if not page_url:
return None return None
@ -655,7 +655,7 @@ _YOUPORN_WATCH_RE = re.compile(r"youporn\.com/watch/(\d+)", re.IGNORECASE)
def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None: def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
"""Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny """Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny
on-demand endpoint `/proxy/youporn-thumb/<video_id>` — zapisany podpis wygasa i CDN on-demand endpoint `/proxy/youporn-thumb/<video_id>`, zapisany podpis wygasa i CDN
oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg` oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg`
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy).""" NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
if not page_url or not thumb or "hash=" not in thumb: if not page_url or not thumb or "hash=" not in thumb:
@ -667,7 +667,7 @@ def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
def _is_rotting_thumb(url: str) -> bool: def _is_rotting_thumb(url: str) -> bool:
"""sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po """sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po
~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze ~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze
slim-thumbnaila — używamy tylko gdy scena nie ma żadnej innej miniaturki.""" slim-thumbnaila, używamy tylko gdy scena nie ma żadnej innej miniaturki."""
if "trafficdeposit.com" in url: if "trafficdeposit.com" in url:
return True return True
# imgs.xiaoshenke.net (fullporner) było tu wpisane 2026-08-02, bo host oddawał 200 # imgs.xiaoshenke.net (fullporner) było tu wpisane 2026-08-02, bo host oddawał 200
@ -678,12 +678,12 @@ def _is_rotting_thumb(url: str) -> bool:
# rotting-thumb jest ZEROWANY, więc sceny mające tylko fullporner traciły miniaturę # rotting-thumb jest ZEROWANY, więc sceny mające tylko fullporner traciły miniaturę
# całkowicie („Jakiś problem z miniaturkami z fullporner"). Dotyczyło 53 tys. źródeł. # całkowicie („Jakiś problem z miniaturkami z fullporner"). Dotyczyło 53 tys. źródeł.
# Gdyby atrapy wróciły, filtruj po ROZMIARZE odpowiedzi w image-proxy, nie po hoście. # Gdyby atrapy wróciły, filtruj po ROZMIARZE odpowiedzi w image-proxy, nie po hoście.
# tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze # tn.sextu.com (fullvideosporn), CDN odrzuca ruch z IP datacenter (429), więc nasze
# image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy # image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła. # jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
if "tn.sextu.com" in url: if "tn.sextu.com" in url:
return True return True
# fastpic.org (galaxyporn i inne re-uploadery) — hosting zrywa połączenie zamiast # fastpic.org (galaxyporn i inne re-uploadery), hosting zrywa połączenie zamiast
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk: # oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony # scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn. # tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
@ -693,14 +693,14 @@ def _is_rotting_thumb(url: str) -> bool:
def _needs_proxy(url: str) -> bool: def _needs_proxy(url: str) -> bool:
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend. """Wszystkie thumbnaile z playback_sources są proxowane przez backend.
Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img, Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img,
inne sxyprn/eporner CDN-y) — expo-image nie wysyła Referera. inne sxyprn/eporner CDN-y), expo-image nie wysyła Referera.
Self-hosted lub backend-internal URL-e (zaczynające się od `/`) skipujemy.""" Self-hosted lub backend-internal URL-e (zaczynające się od `/`) skipujemy."""
return url.startswith("http") and not url.startswith("/proxy/") return url.startswith("http") and not url.startswith("/proxy/")
def _wrap_image_proxy(url: str, referer: str) -> str: def _wrap_image_proxy(url: str, referer: str) -> str:
"""Wraps a thumbnail URL through /proxy/img/{token}/img.jpg. Klient nie musi """Wraps a thumbnail URL through /proxy/img/{token}/img.jpg. Klient nie musi
znać sekretu Referer — backend wstawi sam. Long TTL (30d) bo thumby znać sekretu Referer, backend wstawi sam. Long TTL (30d) bo thumby
są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache.""" są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache."""
from app.api.stream_proxy import make_token from app.api.stream_proxy import make_token
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image # stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
@ -717,7 +717,7 @@ def _build_scenes_out_batch(
) -> list[SceneOut]: ) -> list[SceneOut]:
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N). """Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
Eliminuje N+1 z `_build_scene_out` w listach scen — `/scenes?per_page=24` szło Eliminuje N+1 z `_build_scene_out` w listach scen, `/scenes?per_page=24` szło
z ~9.6s do <500ms. Pojedyncza scena (`/scenes/{id}`) nadal używa `_build_scene_out` z ~9.6s do <500ms. Pojedyncza scena (`/scenes/{id}`) nadal używa `_build_scene_out`
bo overhead na batch nie ma sensu dla N=1. bo overhead na batch nie ma sensu dla N=1.
@ -760,7 +760,7 @@ def _build_scenes_out_batch(
) )
) )
# 3) Tags + 4) External refs — kafelek listy ich nie używa; w light mode pomijamy # 3) Tags + 4) External refs, kafelek listy ich nie używa; w light mode pomijamy
# (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}). # (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}).
tags_by_scene: dict = defaultdict(list) tags_by_scene: dict = defaultdict(list)
refs_by_scene: dict = defaultdict(list) refs_by_scene: dict = defaultdict(list)
@ -788,7 +788,7 @@ def _build_scenes_out_batch(
) )
) )
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę) — kafelek # 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę), kafelek
# czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url. # czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url.
pb_by_scene: dict = defaultdict(list) pb_by_scene: dict = defaultdict(list)
if light: if light:
@ -806,7 +806,7 @@ def _build_scenes_out_batch(
.order_by(PlaybackSource.origin.asc()) .order_by(PlaybackSource.origin.asc())
).all() ).all()
# Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). De-prioritize # Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). De-prioritize
# sxyprn/trafficdeposit thumbnaile — są podpisane czasowo i ROTUJĄ (asset 404 po # sxyprn/trafficdeposit thumbnaile, są podpisane czasowo i ROTUJĄ (asset 404 po
# ~tygodniach, nie da się re-signować; bug 2026-06-10). Wolimy miniaturkę z innego # ~tygodniach, nie da się re-signować; bug 2026-06-10). Wolimy miniaturkę z innego
# źródła gdy istnieje; sxyprn bierzemy tylko gdy nic innego nie ma (świeże jeszcze # źródła gdy istnieje; sxyprn bierzemy tylko gdy nic innego nie ma (świeże jeszcze
# działają, martwe → mobile pokazuje placeholder zamiast broken-image). # działają, martwe → mobile pokazuje placeholder zamiast broken-image).
@ -847,7 +847,7 @@ def _build_scenes_out_batch(
if a_url and _needs_proxy(a_url): if a_url and _needs_proxy(a_url):
a_url = _wrap_image_proxy(a_url, ref) a_url = _wrap_image_proxy(a_url, ref)
# id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek # id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek
# (SceneDetail re-fetchuje pełne źródła) — dummy sentinel. # (SceneDetail re-fetchuje pełne źródła), dummy sentinel.
pb_by_scene[sid].append( pb_by_scene[sid].append(
PlaybackSourceOut( PlaybackSourceOut(
id=uuid.UUID(int=0), origin="", page_url="", id=uuid.UUID(int=0), origin="", page_url="",
@ -999,7 +999,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
) )
# Collapse źródła dzielące ten sam origin (hoster). Zmergowana scena często agreguje # Collapse źródła dzielące ten sam origin (hoster). Zmergowana scena często agreguje
# kilka uploadów z JEDNEGO tube'a (re-enkody / wersje 4K: bug-report aa79a995 "2 linki, # kilka uploadów z JEDNEGO tube'a (re-enkody / wersje 4K: bug-report aa79a995 "2 linki,
# oba do porntrex" = ta sama scena std+4K) — w UI to nierozróżnialne linki do tego # oba do porntrex" = ta sama scena std+4K), w UI to nierozróżnialne linki do tego
# samego hostera (resolvują tym samym extractorem). Zostawiamy jeden najlepszy per # samego hostera (resolvują tym samym extractorem). Zostawiamy jeden najlepszy per
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość → # origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
# pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at). # pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at).
@ -1022,7 +1022,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
for p in playback_rows: for p in playback_rows:
out = PlaybackSourceOut.model_validate(p) out = PlaybackSourceOut.model_validate(p)
# sxyprn: podmień martwy trafficdeposit URL na żywy on-demand resolver # sxyprn: podmień martwy trafficdeposit URL na żywy on-demand resolver
# (/proxy/sxyprn-thumb/<id>) — TAK JAK lista (_build_scenes_out_batch). Bez tego # (/proxy/sxyprn-thumb/<id>), TAK JAK lista (_build_scenes_out_batch). Bez tego
# detal sceny pokazywał martwą miniaturę sxyprn mimo że lista działała, i "refresh # detal sceny pokazywał martwą miniaturę sxyprn mimo że lista działała, i "refresh
# thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22. # thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22.
sxy = _sxyprn_thumb_url(p.page_url) sxy = _sxyprn_thumb_url(p.page_url)
@ -1034,7 +1034,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url): elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url):
out.thumbnail_url = None out.thumbnail_url = None
# Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera # Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera
# (hqporner — fastporndelivery zwraca 403 bez Referer headera, expo-image # (hqporner, fastporndelivery zwraca 403 bez Referer headera, expo-image
# nie wysyła go domyślnie). Token ma 30-dniowy TTL bo thumby są stabilne. # nie wysyła go domyślnie). Token ma 30-dniowy TTL bo thumby są stabilne.
if out.thumbnail_url and _needs_proxy(out.thumbnail_url): if out.thumbnail_url and _needs_proxy(out.thumbnail_url):
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url) out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
@ -1090,7 +1090,7 @@ def remove_tag_from_scene(
) -> None: ) -> None:
"""Usuwa relację scene↔tag (np. user uznał że tag jest błędny dla tej sceny). """Usuwa relację scene↔tag (np. user uznał że tag jest błędny dla tej sceny).
Idempotent: brak relacji = success. Nie kasuje samego Tag-a — inne sceny mogą Idempotent: brak relacji = success. Nie kasuje samego Tag-a, inne sceny mogą
z niego korzystać. Sam tag zostaje w słowniku tagów. z niego korzystać. Sam tag zostaje w słowniku tagów.
""" """
rel = session.execute( rel = session.execute(
@ -1142,7 +1142,7 @@ def hide_scene(
) -> SceneHideOut: ) -> SceneHideOut:
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources """Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
jako dead → scena wypada z list (has_playback=false). Odwracalne w DB (dead_at). jako dead → scena wypada z list (has_playback=false). Odwracalne w DB (dead_at).
Nie kasujemy wiersza sceny — zachowujemy refs/dedup, tylko znika z UI.""" Nie kasujemy wiersza sceny, zachowujemy refs/dedup, tylko znika z UI."""
from datetime import UTC, datetime from datetime import UTC, datetime
from app.models.playback_source import PlaybackSource from app.models.playback_source import PlaybackSource
@ -1273,7 +1273,7 @@ def enrich_tags_from_tube(
# Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie. # Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie.
# Używamy PostgreSQL INSERT ... ON CONFLICT DO NOTHING zamiast ORM session.add() # Używamy PostgreSQL INSERT ... ON CONFLICT DO NOTHING zamiast ORM session.add()
# bo `resolve_tag` robi session.flush() w pętli, emitując pending SceneTag INSERT # bo `resolve_tag` robi session.flush() w pętli, emitując pending SceneTag INSERT
# z poprzednich iteracji — gdy 2 concurrent enrich-tags collide na tym samym # z poprzednich iteracji, gdy 2 concurrent enrich-tags collide na tym samym
# (scene_id, tag_id), drugi flush dostaje UniqueViolation (GOON-H, 4 events # (scene_id, tag_id), drugi flush dostaje UniqueViolation (GOON-H, 4 events
# w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently. # w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently.
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -1309,11 +1309,11 @@ def enrich_duration_from_tube(
scene_id: uuid.UUID, scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
) -> EnrichDurationOut: ) -> EnrichDurationOut:
"""Wyciąga duration z dowolnego tube playback_source — wszystkie znane tube'y """Wyciąga duration z dowolnego tube playback_source, wszystkie znane tube'y
udostępniają duration na detail page (og:video:duration lub LD-JSON ISO 8601). udostępniają duration na detail page (og:video:duration lub LD-JSON ISO 8601).
Mobile wywołuje to przy otwarciu SceneDetail gdy scene.duration_sec jest null Mobile wywołuje to przy otwarciu SceneDetail gdy scene.duration_sec jest null
AND ma tube source. Dla dedupu duration to najsilniejszy single signal — bez AND ma tube source. Dla dedupu duration to najsilniejszy single signal, bez
niego sceny z weak title-only score są capowane na 0.85 (review queue). niego sceny z weak title-only score są capowane na 0.85 (review queue).
Idempotent: zwraca aktualne duration_sec jeśli już ustawione. Idempotent: zwraca aktualne duration_sec jeśli już ustawione.
@ -1379,7 +1379,7 @@ def enrich_studio_from_tube(
"""Wyciąga studio (DVD/series) z pornhat scene page'a. """Wyciąga studio (DVD/series) z pornhat scene page'a.
Pornhat ma `class="info-video js-ajax-dvd" data-setup='{"title": "Adult Time", ...}'` Pornhat ma `class="info-video js-ajax-dvd" data-setup='{"title": "Adult Time", ...}'`
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern — na razie dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern, na razie
tylko pornhat (najczystsze studio metadata wśród free tubes). tylko pornhat (najczystsze studio metadata wśród free tubes).
""" """
import json as _json import json as _json
@ -1476,7 +1476,7 @@ def enrich_thumbnail_from_tube(
Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration). Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration).
`force=true` (przycisk "Refresh thumbnail" na SceneDetail, zgłoszenie d3376a71): `force=true` (przycisk "Refresh thumbnail" na SceneDetail, zgłoszenie d3376a71):
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a — dla zepsutych/stałych NADPISUJE istniejącą miniaturę świeżą ze strony tube'a, dla zepsutych/stałych
(rotting sxyprn/trafficdeposit, błędna grafika).""" (rotting sxyprn/trafficdeposit, błędna grafika)."""
from app.extractors._fetch import browser_get from app.extractors._fetch import browser_get
from app.extractors._models import TubePageError from app.extractors._models import TubePageError
@ -1497,7 +1497,7 @@ def enrich_thumbnail_from_tube(
sources_with_thumb = [s for s in sources if s.thumbnail_url] sources_with_thumb = [s for s in sources if s.thumbnail_url]
if sources_with_thumb and not force: if sources_with_thumb and not force:
# już mamy — idempotent return (force=true pomija, żeby odświeżyć). # już mamy, idempotent return (force=true pomija, żeby odświeżyć).
return EnrichThumbOut( return EnrichThumbOut(
scene_id=scene_id, scene_id=scene_id,
thumbnail_url=sources_with_thumb[0].thumbnail_url, thumbnail_url=sources_with_thumb[0].thumbnail_url,

View file

@ -67,7 +67,7 @@ class SceneOut(BaseModel):
external_refs: list[ExternalRefOut] = [] external_refs: list[ExternalRefOut] = []
playback_sources: list[PlaybackSourceOut] = [] playback_sources: list[PlaybackSourceOut] = []
# Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia # Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen — gdy # "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen, gdy
# `created_at > last_seen_at` (favorite) → badge. # `created_at > last_seen_at` (favorite) → badge.
created_at: datetime | None = None created_at: datetime | None = None
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej # True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
@ -87,7 +87,7 @@ class SceneListOut(BaseModel):
page: int page: int
per_page: int per_page: int
# has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe), # has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe),
# NIE z `total` — bo dla filtrowanych list `total` jest bounded ("1000+") żeby # NIE z `total`, bo dla filtrowanych list `total` jest bounded ("1000+") żeby
# uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total. # uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total.
has_more: bool = False has_more: bool = False
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+". # total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".

View file

@ -1,6 +1,6 @@
"""Publiczna, crawlowalna powierzchnia SEO — programmatic entity pages. """Publiczna, crawlowalna powierzchnia SEO, programmatic entity pages.
Jedyny publiczny router HTML poza /static i healthchecks — NIE wymaga api key, Jedyny publiczny router HTML poza /static i healthchecks, NIE wymaga api key,
bo Googlebot/użytkownik musi dotrzeć bez tokenu. Cel: łapać nawigacyjny long-tail bo Googlebot/użytkownik musi dotrzeć bez tokenu. Cel: łapać nawigacyjny long-tail
(nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje, (nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje,
a który realnie generuje organiczny ruch → pobranie apki. a który realnie generuje organiczny ruch → pobranie apki.
@ -8,14 +8,14 @@ a który realnie generuje organiczny ruch → pobranie apki.
Zasady (świadome, nie przypadkowe): Zasady (świadome, nie przypadkowe):
* **LINK-OUT only.** Strony renderują metadane i odsyłają do źródeł (`page_url`). * **LINK-OUT only.** Strony renderują metadane i odsyłają do źródeł (`page_url`).
NIE eksponujemy `stream_url`/`embed_url` — to zostaje value-add apki i trzyma NIE eksponujemy `stream_url`/`embed_url`, to zostaje value-add apki i trzyma
legalny profil "agregatora/wyszukiwarki" (dowozimy ruch tubom, nie re-streamujemy). legalny profil "agregatora/wyszukiwarki" (dowozimy ruch tubom, nie re-streamujemy).
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc * **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+. crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich. Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
* **Blacklist respektowany** — te same wykluczenia performer/studio/tag co w /scenes. * **Blacklist respektowany** te same wykluczenia performer/studio/tag co w /scenes.
Nie publikujemy SEO-stron dla zblacklistowanej treści. Nie publikujemy SEO-stron dla zblacklistowanej treści.
* **Anti-thin-page** — strona encji powstaje tylko gdy ma realną zawartość * **Anti-thin-page** strona encji powstaje tylko gdy ma realną zawartość
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404, (performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
nie pusty doorway (Google karze masowe cienkie strony). nie pusty doorway (Google karze masowe cienkie strony).
""" """
@ -49,7 +49,7 @@ from app.models.tag import Tag
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" _TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
# Analityka — wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony. # Analityka, wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony.
# Puste = tag się nie renderuje (zero third-party requestów, zachowanie bez zmian). # Puste = tag się nie renderuje (zero third-party requestów, zachowanie bez zmian).
# Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie. # Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie.
templates.env.globals["gtm_id"] = os.environ.get("GOON_GTM_ID", "") templates.env.globals["gtm_id"] = os.environ.get("GOON_GTM_ID", "")
@ -58,7 +58,7 @@ templates.env.globals["gsc_verify"] = os.environ.get("GOON_GSC_VERIFY", "")
# Limit URL-i na pojedynczy plik sitemap (spec: max 50k). Trzymamy z zapasem. # Limit URL-i na pojedynczy plik sitemap (spec: max 50k). Trzymamy z zapasem.
_SITEMAP_PAGE = 25_000 _SITEMAP_PAGE = 25_000
# Ile scen renderujemy na stronie encji (performer/studio) — pełna lista 1000+ scen # Ile scen renderujemy na stronie encji (performer/studio), pełna lista 1000+ scen
# to thin/slow; bierzemy najświeższe N, reszta i tak wpada przez sitemap scen. # to thin/slow; bierzemy najświeższe N, reszta i tak wpada przez sitemap scen.
_SCENES_PER_ENTITY = 120 _SCENES_PER_ENTITY = 120
@ -86,7 +86,7 @@ def _live_playback_exists():
def _not_blacklisted(): def _not_blacklisted():
"""Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag). """Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag).
Te same reguły co w GET /scenes — żeby SEO nie publikowało tego, co katalog ukrywa. Te same reguły co w GET /scenes, żeby SEO nie publikowało tego, co katalog ukrywa.
""" """
return [ return [
~exists( ~exists(
@ -109,7 +109,7 @@ def _not_blacklisted():
def _indexable_scenes(): def _indexable_scenes():
"""SELECT Scene żywych, nie-zblacklistowanych scen — baza pod listy i sitemap.""" """SELECT Scene żywych, nie-zblacklistowanych scen, baza pod listy i sitemap."""
stmt = select(Scene).where(_live_playback_exists()) stmt = select(Scene).where(_live_playback_exists())
for cond in _not_blacklisted(): for cond in _not_blacklisted():
stmt = stmt.where(cond) stmt = stmt.where(cond)
@ -120,7 +120,7 @@ def _performer_indexable_exists():
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę. """EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
`performer_page` — inaczej sitemap zgłaszałby URL-e dające 404. `performer_page`, inaczej sitemap zgłaszałby URL-e dające 404.
""" """
sub = ( sub = (
select(1) select(1)
@ -180,7 +180,7 @@ def performer_page(
if performer is None: if performer is None:
raise HTTPException(status_code=404, detail="performer not found") raise HTTPException(status_code=404, detail="performer not found")
# Sceny tej osoby — żywe, nie-blacklisted, najświeższe pierwsze. # Sceny tej osoby, żywe, nie-blacklisted, najświeższe pierwsze.
scenes = ( scenes = (
session.execute( session.execute(
_indexable_scenes() _indexable_scenes()
@ -283,7 +283,7 @@ def scene_page(
if scene is None: if scene is None:
raise HTTPException(status_code=404, detail="scene not found") raise HTTPException(status_code=404, detail="scene not found")
# Źródła — żywe, deduplikowane po origin (pokazujemy 1 link per tube). # Źródła, żywe, deduplikowane po origin (pokazujemy 1 link per tube).
sources_raw = ( sources_raw = (
session.execute( session.execute(
select(PlaybackSource) select(PlaybackSource)
@ -354,7 +354,7 @@ def landing(
request: Request, request: Request,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
) -> HTMLResponse: ) -> HTMLResponse:
"""Strona główna — crawl-entry. Najświeższe indeksowalne sceny + CTA.""" """Strona główna, crawl-entry. Najświeższe indeksowalne sceny + CTA."""
scenes = ( scenes = (
session.execute( session.execute(
_indexable_scenes() _indexable_scenes()
@ -386,8 +386,8 @@ def landing(
@router.get("/get", response_class=HTMLResponse) @router.get("/get", response_class=HTMLResponse)
def get_app(request: Request) -> HTMLResponse: def get_app(request: Request) -> HTMLResponse:
"""Paid-traffic landing page — odchudzona pod konwersję instalacji APK. """Paid-traffic landing page, odchudzona pod konwersję instalacji APK.
noindex (patrz get.html) — nie ma konkurować w SERP z entity-stronami.""" noindex (patrz get.html), nie ma konkurować w SERP z entity-stronami."""
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"seo/get.html", "seo/get.html",
@ -411,7 +411,7 @@ def page_2257(request: Request) -> HTMLResponse:
def gsc_site_verification(fname: str) -> PlainTextResponse: def gsc_site_verification(fname: str) -> PlainTextResponse:
"""Plik weryfikacyjny Google Search Console (metoda 'Plik HTML'). """Plik weryfikacyjny Google Search Console (metoda 'Plik HTML').
Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc' — bez '.html'). Google Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc', bez '.html'). Google
pobiera /<token>.html i oczekuje body 'google-site-verification: <token>.html'. pobiera /<token>.html i oczekuje body 'google-site-verification: <token>.html'.
Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest
jednosegmentowym *.html w rootcie). jednosegmentowym *.html w rootcie).
@ -440,7 +440,7 @@ def _count(session: Session, stmt) -> int:
@router.get("/sitemap.xml") @router.get("/sitemap.xml")
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response: def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
"""Sitemap index — listuje paginowane pod-sitemapy per typ encji.""" """Sitemap index, listuje paginowane pod-sitemapy per typ encji."""
n_perf = _count( n_perf = _count(
session, session,
select(Performer.id).where(_performer_indexable_exists()), select(Performer.id).where(_performer_indexable_exists()),

View file

@ -1,12 +1,12 @@
"""GET /sources — lista tube źródeł dla feature "Sites" (mobile top-level tab). """GET /sources, lista tube źródeł dla feature "Sites" (mobile top-level tab).
Bug-report 2026-05-24 (ea6f05f9, Scenes screen): user chce wybrać "pages" Bug-report 2026-05-24 (ea6f05f9, Scenes screen): user chce wybrać "pages"
obok Scenes i Movies — widzieć liście tube'ów i wchodzić w nie żeby zobaczyć obok Scenes i Movies, widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
najnowsze sceny z konkretnego źródła. najnowsze sceny z konkretnego źródła.
Endpoint enumeruje distinct `playback_sources.origin` z ŻYWYCH playback_sources Endpoint enumeruje distinct `playback_sources.origin` z ŻYWYCH playback_sources
(`dead_at IS NULL`), tylko origins zaczynające się od 'tube:' (kanoniczne źródła (`dead_at IS NULL`), tylko origins zaczynające się od 'tube:' (kanoniczne źródła
typu `canonical:tpdb_trailer` są pomijane — to nie są "scrapowane strony" w sensie typu `canonical:tpdb_trailer` są pomijane, to nie są "scrapowane strony" w sensie
intencji feature'a). intencji feature'a).
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze). Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
@ -42,7 +42,7 @@ class SourceRating(BaseModel):
richness: int richness: int
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość).""" """0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
health: int | None health: int | None
"""0-5: czy realnie gra (telemetria odtwarzania) — 0=offline. None gdy brak danych.""" """0-5: czy realnie gra (telemetria odtwarzania), 0=offline. None gdy brak danych."""
health_basis: str | None = None health_basis: str | None = None
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve).""" """'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
components: dict | None = None components: dict | None = None
@ -51,28 +51,28 @@ class SourceRating(BaseModel):
class SourceOut(BaseModel): class SourceOut(BaseModel):
origin: str origin: str
"""Raw origin string z DB — np. 'tube:hqpornercom'. Używany jako parametr """Raw origin string z DB, np. 'tube:hqpornercom'. Używany jako parametr
`origin=` filtra w GET /scenes (substring match).""" `origin=` filtra w GET /scenes (substring match)."""
sitetag: str sitetag: str
"""Origin bez prefiksu 'tube:' — np. 'hqpornercom'. Stabilne ID tube'a (zgodne """Origin bez prefiksu 'tube:', np. 'hqpornercom'. Stabilne ID tube'a (zgodne
z `BaseDirectTubeScraper.sitetag`).""" z `BaseDirectTubeScraper.sitetag`)."""
display_name: str display_name: str
"""Czytelna nazwa do UI — np. 'hqporner.com'. Wyprowadzona z sitetag przez """Czytelna nazwa do UI, np. 'hqporner.com'. Wyprowadzona z sitetag przez
`_sitetag_to_display`. Tylko presentation; logikę trzymamy na sitetag/origin.""" `_sitetag_to_display`. Tylko presentation; logikę trzymamy na sitetag/origin."""
scene_count: int scene_count: int
"""Liczba ŻYWYCH playback_sources (dead_at IS NULL) per origin. Approx scenes """Liczba ŻYWYCH playback_sources (dead_at IS NULL) per origin. Approx scenes
coverage — scena może mieć wiele sources tego samego origin (różne page_url), coverage, scena może mieć wiele sources tego samego origin (różne page_url),
więc trochę zawyża rzeczywistą scene-distinct count, ale dla orientacji OK.""" więc trochę zawyża rzeczywistą scene-distinct count, ale dla orientacji OK."""
last_scraped_at: datetime | None last_scraped_at: datetime | None
"""MAX(last_seen_at) — najświeższy scrape dla tego origin. Pozwala mobile pokazać """MAX(last_seen_at), najświeższy scrape dla tego origin. Pozwala mobile pokazać
'scrapowane Xh temu' i sortować świeżość.""" 'scrapowane Xh temu' i sortować świeżość."""
rating: SourceRating | None = None rating: SourceRating | None = None
"""Ocena 0-5★ (freshness/richness/health) z source_stats — None gdy jeszcze """Ocena 0-5★ (freshness/richness/health) z source_stats, None gdy jeszcze
nie policzona (job source-stats leci co kilka h).""" nie policzona (job source-stats leci co kilka h)."""
@ -119,7 +119,7 @@ def list_sources(
) -> SourceListOut: ) -> SourceListOut:
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources. """Zwraca listę tube źródeł z ŻYWYMI playback_sources.
Filter: `origin LIKE 'tube:%'` (drop canonical:* — TPDB trailery to inna semantyka). Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
""" """
rows = session.execute( rows = session.execute(
select( select(

View file

@ -1,8 +1,8 @@
"""Stream proxy — pomost VPS↔phone dla podpisanych URL-i CDN-ów. """Stream proxy, pomost VPS↔phone dla podpisanych URL-i CDN-ów.
Wiele hosterów (luluvids/medixiru/cdnvids/bigcdn) bindą podpisany URL do IP klienta Wiele hosterów (luluvids/medixiru/cdnvids/bigcdn) bindą podpisany URL do IP klienta
który fetchował embed page. Gdy backend ekstraktuje URL z VPS-a, signature który fetchował embed page. Gdy backend ekstraktuje URL z VPS-a, signature
weryfikuje VPS IP — telefon dostaje 403. Player na phonie kieruje requesty weryfikuje VPS IP, telefon dostaje 403. Player na phonie kieruje requesty
*przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature *przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature
poprawnie i serwuje content. poprawnie i serwuje content.
@ -41,8 +41,8 @@ router = APIRouter(prefix="/proxy", tags=["proxy"])
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# In-memory bandwidth counter — bytes-out per CDN domain per hour bucket. # In-memory bandwidth counter, bytes-out per CDN domain per hour bucket.
# Restart api resetuje counter (akceptowalne — to operational metric, nie billing). # Restart api resetuje counter (akceptowalne, to operational metric, nie billing).
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage. # Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
from collections import defaultdict from collections import defaultdict
from threading import Lock from threading import Lock
@ -90,7 +90,7 @@ TOKEN_TTL_SEC = 4 * 60 * 60 # 4h
# URL-level redirect cache: target_url -> (final_resolved_url, expires_ts). # URL-level redirect cache: target_url -> (final_resolved_url, expires_ts).
# Mobile ExoPlayer robi range-requesty per seek/preload — każdy hituje proxy z tym # Mobile ExoPlayer robi range-requesty per seek/preload, każdy hituje proxy z tym
# samym tokenem, proxy GET-uje target_url. Dla `porntrex.com/get_file/...` (a także # samym tokenem, proxy GET-uje target_url. Dla `porntrex.com/get_file/...` (a także
# fpoxxx, freshporno) URL jest **single-use**: pierwszy GET → 302 → CDN URL (time-bound), # fpoxxx, freshporno) URL jest **single-use**: pierwszy GET → 302 → CDN URL (time-bound),
# drugi GET → 410. Bez cache: drugi range = 410 → ExoPlayer fail → mobile fallback do # drugi GET → 410. Bez cache: drugi range = 410 → ExoPlayer fail → mobile fallback do
@ -170,10 +170,10 @@ def make_token(
`refresh`: URL embed page do refetch gdy `url` zwraca 4xx. Proxy odbierze `refresh`: URL embed page do refetch gdy `url` zwraca 4xx. Proxy odbierze
fresh stream URL z embed (np. mixdrop MDCore.wurl) gdy oryginalny token expired. fresh stream URL z embed (np. mixdrop MDCore.wurl) gdy oryginalny token expired.
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.) — proxy `refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.), proxy
dispatch do dedicated re-extract logic. dispatch do dedicated re-extract logic.
`impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot `impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot
detection — mxcontent, cloudflare-protected). detection, mxcontent, cloudflare-protected).
`stable_bucket_sec`: kwantyzuje czas bazowy expiry do okna N sekund, więc token `stable_bucket_sec`: kwantyzuje czas bazowy expiry do okna N sekund, więc token
(→ proxied URL) jest IDENTYCZNY dla wszystkich requestów w oknie. Bez tego `e` (→ proxied URL) jest IDENTYCZNY dla wszystkich requestów w oknie. Bez tego `e`
zawiera bieżący timestamp → URL inny przy każdym fetchu listy → expo-image cache zawiera bieżący timestamp → URL inny przy każdym fetchu listy → expo-image cache
@ -190,7 +190,7 @@ def make_token(
if impersonate: if impersonate:
payload["i"] = 1 payload["i"] = 1
if producer: if producer:
# Hoster oddający TREŚĆ manifestu (nie URL) — `/proxy/hls` zawoła producenta # Hoster oddający TREŚĆ manifestu (nie URL), `/proxy/hls` zawoła producenta
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py. # zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
payload["mp"] = producer payload["mp"] = producer
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
@ -220,7 +220,7 @@ def parse_token(token: str) -> dict:
def _ascii_safe_url(url: str) -> str: def _ascii_safe_url(url: str) -> str:
"""Encode non-ASCII chars w URL path/query, zachowując reserved chars dla URI. """Encode non-ASCII chars w URL path/query, zachowując reserved chars dla URI.
httpx wymaga ASCII headers — Referer z polskim/cyrillic/unicode (np. hqporner httpx wymaga ASCII headers, Referer z polskim/cyrillic/unicode (np. hqporner
`Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote` `Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote`
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
enkoduje znaki spoza ASCII.""" enkoduje znaki spoza ASCII."""
@ -255,8 +255,8 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
"""Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy. """Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy.
HLS manifest ma: HLS manifest ma:
- linie URI (segmenty .ts / sub-playlisty .m3u8) — relatywne lub absolute - linie URI (segmenty .ts / sub-playlisty .m3u8), relatywne lub absolute
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"` — też potrzebują rewrite - tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"`, też potrzebują rewrite
Każdy URL → token + /proxy/{token}/<basename>.<ext>. Każdy URL → token + /proxy/{token}/<basename>.<ext>.
""" """
out: list[str] = [] out: list[str] = []
@ -284,7 +284,7 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
def _absolutize_m3u8(content: str, base_url: str) -> str: def _absolutize_m3u8(content: str, base_url: str) -> str:
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN — """Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN,
NIE przez proxy. Dla direct-HLS passthrough (`/proxy/hls/...`): telefon ciągnie je NIE przez proxy. Dla direct-HLS passthrough (`/proxy/hls/...`): telefon ciągnie je
bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin), bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin),
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje).""" absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
@ -310,7 +310,7 @@ def sign_url(
referer: str | None = Query(default=None), referer: str | None = Query(default=None),
) -> dict: ) -> dict:
"""Pomocniczy endpoint dla mobile do uzyskania świeżego tokena (np. po expiry). """Pomocniczy endpoint dla mobile do uzyskania świeżego tokena (np. po expiry).
Normalnie /resolve zwraca już proxy URL — to fallback.""" Normalnie /resolve zwraca już proxy URL, to fallback."""
return {"token": make_token(url, referer), "expires_in": TOKEN_TTL_SEC} return {"token": make_token(url, referer), "expires_in": TOKEN_TTL_SEC}
@ -320,11 +320,11 @@ async def proxy_image(
_basename: str, _basename: str,
request: Request, request: Request,
) -> Response: ) -> Response:
"""Image proxy — używany dla thumbnaili z CDN-ów wymagających Referera """Image proxy, używany dla thumbnaili z CDN-ów wymagających Referera
(hqporner i inne porn-app sourcy). Mobile expo-image nie wysyła Referera (hqporner i inne porn-app sourcy). Mobile expo-image nie wysyła Referera
domyślnie, CDN zwraca 403. Backend dodaje Referer i streamuje obrazek. domyślnie, CDN zwraca 403. Backend dodaje Referer i streamuje obrazek.
Cache-Control: public,max-age=86400 — thumby są stabilne, klient może cachować.""" Cache-Control: public,max-age=86400, thumby są stabilne, klient może cachować."""
payload = parse_token(token) payload = parse_token(token)
target = payload["u"] target = payload["u"]
referer = payload["r"] or None referer = payload["r"] or None
@ -334,18 +334,18 @@ async def proxy_image(
try: try:
r = await client.get(target, headers=headers) r = await client.get(target, headers=headers)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e: except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
# CDN connect/timeout — transient (np. Cloudflare 523 origin unreachable # CDN connect/timeout, transient (np. Cloudflare 523 origin unreachable
# gdy upstream host jest off). Log INFO + 503, mobile renderuje placeholder. # gdy upstream host jest off). Log INFO + 503, mobile renderuje placeholder.
# Bez tego Sentry dostawał setki ERROR-ów (GOON-D/6) z każdym broken # Bez tego Sentry dostawał setki ERROR-ów (GOON-D/6) z każdym broken
# tube'em — spam-szumiło real-issues. # tube'em, spam-szumiło real-issues.
log.info("img proxy connect/timeout for %s: %s", target, e) log.info("img proxy connect/timeout for %s: %s", target, e)
return Response(content=b"", status_code=503, media_type="image/jpeg") return Response(content=b"", status_code=503, media_type="image/jpeg")
except Exception as e: except Exception as e:
log.warning("img proxy fetch failed for %s: %s", target, e) log.warning("img proxy fetch failed for %s: %s", target, e)
raise HTTPException(status_code=502, detail=f"img fetch failed: {e}") from e raise HTTPException(status_code=502, detail=f"img fetch failed: {e}") from e
if r.status_code >= 400: if r.status_code >= 400:
# Upstream 4xx/5xx dla thumba — degraded zamiast raise (placeholder w mobile). # Upstream 4xx/5xx dla thumba, degraded zamiast raise (placeholder w mobile).
# GOON-5 (Cloudflare 523) i GOON-D — bezsensowny noise w Sentry, lepiej # GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
# info log + 502 pass-through bez exception. # info log + 502 pass-through bez exception.
log.info("img proxy upstream %d for %s", r.status_code, target) log.info("img proxy upstream %d for %s", r.status_code, target)
return Response( return Response(
@ -362,7 +362,7 @@ async def proxy_image(
# sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token # sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować — resolvujemy bieżący # żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować, resolvujemy bieżący
# og:image ze strony /post/<id> przy serwowaniu. Cache resolved poster URL ~40min # og:image ze strony /post/<id> przy serwowaniu. Cache resolved poster URL ~40min
# (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/<id> → cache'uje bajty na # (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/<id> → cache'uje bajty na
# stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post. # stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post.
@ -376,10 +376,10 @@ _SXYPRN_PID_RE = re.compile(r"^[0-9a-f]{6,40}$")
# youporn on-demand thumbnail (bug-report bebf8c92 2026-07-25 "miniaturki z youporn # youporn on-demand thumbnail (bug-report bebf8c92 2026-07-25 "miniaturki z youporn
# nie działają"). youporn przeszedł dla części katalogu na imgproxy z PODPISANYM URL-em # nie działają"). youporn przeszedł dla części katalogu na imgproxy z PODPISANYM URL-em
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`) — taki URL # (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`), taki URL
# wygasa, więc zapisany w bazie po czasie zwraca 410 (audit 2026-07-26: 8666 z 62930 # wygasa, więc zapisany w bazie po czasie zwraca 410 (audit 2026-07-26: 8666 z 62930
# źródeł youporn). Reszta katalogu ma stare statyczne `.../original/N.jpg`, które działają # źródeł youporn). Reszta katalogu ma stare statyczne `.../original/N.jpg`, które działają
# bezterminowo — tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy # bezterminowo, tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
# świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/<video_id>`. # świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/<video_id>`.
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {} _YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
_YOUPORN_POSTER_TTL = 1800 _YOUPORN_POSTER_TTL = 1800
@ -417,7 +417,7 @@ async def youporn_thumb(video_id: str) -> Response:
) )
if not m: if not m:
return Response(content=b"", status_code=404, media_type="image/jpeg") return Response(content=b"", status_code=404, media_type="image/jpeg")
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&amp;`) — bez unescape # JSON-LD escapuje `\/`, a og:image ma encje HTML (`&amp;`), bez unescape
# podpis w query jest zepsuty i CDN oddaje 403. # podpis w query jest zepsuty i CDN oddaje 403.
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/")) poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
if poster.startswith("//"): if poster.startswith("//"):
@ -492,7 +492,7 @@ async def sxyprn_thumb(post_id: str) -> Response:
async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None: async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None:
"""Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl. """Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl.
Cookies persist w session, użytkowane potem do mp4 GET (same-session bind). Cookies persist w session, użytkowane potem do mp4 GET (same-session bind).
UA + Accept wymagane — bez tego mixdrop zwraca minimalny body (bez packera). UA + Accept wymagane, bez tego mixdrop zwraca minimalny body (bez packera).
""" """
import re import re
from yt_dlp.utils import decode_packed_codes from yt_dlp.utils import decode_packed_codes
@ -622,7 +622,7 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e
(variant/segmenty) zostają ABSOLUTNE na CDN → telefon ciągnie je DIRECT (phone IP, (variant/segmenty) zostają ABSOLUTNE na CDN → telefon ciągnie je DIRECT (phone IP,
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty — token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty,
zweryfikowane cross-IP 2026-06-11). Przez VPS leci tylko ~1KB manifestu, nie segmenty. zweryfikowane cross-IP 2026-06-11). Przez VPS leci tylko ~1KB manifestu, nie segmenty.
Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound
hostery dalej idą pełnym `/proxy/`.""" hostery dalej idą pełnym `/proxy/`."""
@ -674,7 +674,7 @@ async def proxy_stream(
refetch_hoster = payload.get("rh") refetch_hoster = payload.get("rh")
# Jeśli ten target był już wcześniej follow-redirect-ed, użyj cached final URL. # Jeśli ten target był już wcześniej follow-redirect-ed, użyj cached final URL.
# Powód: porntrex `get_file/` 410 po reuse — patrz `_REDIRECT_CACHE` docstring. # Powód: porntrex `get_file/` 410 po reuse, patrz `_REDIRECT_CACHE` docstring.
cached_target = _redirect_cache_get(original_target) cached_target = _redirect_cache_get(original_target)
target = cached_target or original_target target = cached_target or original_target
@ -684,10 +684,10 @@ async def proxy_stream(
if range_h: if range_h:
headers["Range"] = range_h headers["Range"] = range_h
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny — proxy zwraca pełne odpowiedzi method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny, proxy zwraca pełne odpowiedzi
# Hostery które wymagają Chrome JA3 fingerprint (mxcontent / cloudflare-protected # Hostery które wymagają Chrome JA3 fingerprint (mxcontent / cloudflare-protected
# CDNs) — od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip. # CDNs), od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
# Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py). # Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py).
if use_impersonate: if use_impersonate:
return await _curl_cffi_stream( return await _curl_cffi_stream(
@ -707,7 +707,7 @@ async def proxy_stream(
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True) client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
try: try:
# Sprobój streaming send PIERWSZY — sprawdź content-type po headers, # Sprobój streaming send PIERWSZY, sprawdź content-type po headers,
# potem decyzja: rewrite manifest vs stream binary. # potem decyzja: rewrite manifest vs stream binary.
upstream = await client.send( upstream = await client.send(
client.build_request(method, target, headers=headers), client.build_request(method, target, headers=headers),
@ -719,7 +719,7 @@ async def proxy_stream(
ups_headers = dict(upstream.headers) ups_headers = dict(upstream.headers)
await upstream.aclose() await upstream.aclose()
await client.aclose() await client.aclose()
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410) — # Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410),
# invaliduj cache i daj mobile retry przez fresh /resolve. Bez tego stale # invaliduj cache i daj mobile retry przez fresh /resolve. Bez tego stale
# cache trzymałby martwy CDN URL przez 30 min (TTL). # cache trzymałby martwy CDN URL przez 30 min (TTL).
if cached_target is not None and status in (401, 403, 404, 410): if cached_target is not None and status in (401, 403, 404, 410):
@ -728,7 +728,7 @@ async def proxy_stream(
# Pierwszy successful pass dla single-use targets (np. porntrex get_file): # Pierwszy successful pass dla single-use targets (np. porntrex get_file):
# cache resolved final URL (po follow_redirects). Następne range-requesty # cache resolved final URL (po follow_redirects). Następne range-requesty
# pójdą direct w CDN URL — get_file nie dostaje drugiego hita. # pójdą direct w CDN URL, get_file nie dostaje drugiego hita.
if cached_target is None: if cached_target is None:
final_url = str(upstream.url) final_url = str(upstream.url)
if final_url != original_target: if final_url != original_target:
@ -741,7 +741,7 @@ async def proxy_stream(
or "application/x-mpegurl" in ct or "application/x-mpegurl" in ct
) )
if is_m3u8: if is_m3u8:
# Manifest content — buffer fully, rewrite, return as m3u8. # Manifest content, buffer fully, rewrite, return as m3u8.
body = await upstream.aread() body = await upstream.aread()
await upstream.aclose() await upstream.aclose()
await client.aclose() await client.aclose()
@ -769,7 +769,7 @@ async def proxy_stream(
yield chunk yield chunk
except httpx.TransportError as e: except httpx.TransportError as e:
# Upstream CDN zerwał połączenie w trakcie streamu (peer closed before # Upstream CDN zerwał połączenie w trakcie streamu (peer closed before
# full body — RemoteProtocolError, ReadError, timeout). Klient dostaje # full body, RemoteProtocolError, ReadError, timeout). Klient dostaje
# uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji. # uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji.
# Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija # Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija
# outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11). # outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11).
@ -792,7 +792,7 @@ async def proxy_stream(
await client.aclose() await client.aclose()
raise raise
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e: except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
# CDN connect failure / timeout — transient, log na INFO (nie ERROR do Sentry). # CDN connect failure / timeout, transient, log na INFO (nie ERROR do Sentry).
# Zwracamy 503 zamiast 502 + Retry-After, mobile może retry-ować bez panic. # Zwracamy 503 zamiast 502 + Retry-After, mobile może retry-ować bez panic.
await client.aclose() await client.aclose()
log.info("proxy connect/timeout for %s: %s", target, e) log.info("proxy connect/timeout for %s: %s", target, e)
@ -818,10 +818,10 @@ def _upstream_error_response(
Rationale per status: Rationale per status:
- **429 Too Many Requests**: CDN rate-limit (np. fpo.xxx gdy proxy hammeruje - **429 Too Many Requests**: CDN rate-limit (np. fpo.xxx gdy proxy hammeruje
get_file/). Pass-through 429 + Retry-After żeby mobile zrobiło backoff. get_file/). Pass-through 429 + Retry-After żeby mobile zrobiło backoff.
Log INFO (nie ERROR) — to expected behavior CDN-a, nie nasz bug. Log INFO (nie ERROR), to expected behavior CDN-a, nie nasz bug.
- **404/410**: video deleted/expired token. Pass-through żeby player wiedział. - **404/410**: video deleted/expired token. Pass-through żeby player wiedział.
- **5xx upstream**: pochodzi z CDN-a, nie z naszego kodu. Log INFO. - **5xx upstream**: pochodzi z CDN-a, nie z naszego kodu. Log INFO.
- **inne 4xx**: 502 (i Sentry warn) — może być nasza wina (bad referer itp.). - **inne 4xx**: 502 (i Sentry warn), może być nasza wina (bad referer itp.).
""" """
retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After") retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After")
if status == 429: if status == 429:
@ -845,7 +845,7 @@ def _upstream_error_response(
media_type="text/plain", media_type="text/plain",
) )
if 500 <= status < 600: if 500 <= status < 600:
# CDN-side error (np. Cloudflare 523 — origin unreachable). Pass-through # CDN-side error (np. Cloudflare 523, origin unreachable). Pass-through
# 502 ale log INFO bo to nie nasza wina. # 502 ale log INFO bo to nie nasza wina.
log.info("proxy upstream %d for %s", status, target) log.info("proxy upstream %d for %s", status, target)
return Response( return Response(
@ -854,5 +854,5 @@ def _upstream_error_response(
headers={"Retry-After": "5"}, headers={"Retry-After": "5"},
media_type="text/plain", media_type="text/plain",
) )
# 4xx other (403 itp.) — raise żeby Sentry zarejestrował (może bug naszego kodu) # 4xx other (403 itp.), raise żeby Sentry zarejestrował (może bug naszego kodu)
raise HTTPException(status_code=502, detail=f"upstream {status}") raise HTTPException(status_code=502, detail=f"upstream {status}")

View file

@ -1,4 +1,4 @@
"""GET /tags, /performers, /studios — listy taxonomies do filtrów na mobile. """GET /tags, /performers, /studios, listy taxonomies do filtrów na mobile.
Każdy endpoint wspiera: Każdy endpoint wspiera:
- q: substring search po name_normalized (trgm fallback ilike) - q: substring search po name_normalized (trgm fallback ilike)
@ -107,7 +107,7 @@ def list_tags(
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'") raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
if for_movies: if for_movies:
# Movie tag count — zliczamy tylko Movies z ≥1 live MoviePlaybackSource. # Movie tag count, zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
# Tag-bez-żadnego-movie zwraca 0 (LEFT OUTER JOIN przez coalesce). Movies są # Tag-bez-żadnego-movie zwraca 0 (LEFT OUTER JOIN przez coalesce). Movies są
# małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji. # małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji.
_movie_live = exists().where( _movie_live = exists().where(
@ -146,7 +146,7 @@ def list_tags(
] ]
return TagListOut(items=items, total=total, page=page, per_page=per_page) return TagListOut(items=items, total=total, page=page, per_page=per_page)
# Scenes path — czyta zdenormalizowany Tag.scene_count (refresh w tle przez # Scenes path, czyta zdenormalizowany Tag.scene_count (refresh w tle przez
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags + # _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019. # EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
base = select(Tag) base = select(Tag)
@ -236,13 +236,13 @@ def list_studios(
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.", description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
), ),
) -> StudioListOut: ) -> StudioListOut:
from app.models.scene import Scene # lokalny import — Scene FK do Studio from app.models.scene import Scene # lokalny import, Scene FK do Studio
if order not in ("name", "scene_count", "popular"): if order not in ("name", "scene_count", "popular"):
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'") raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
if for_movies: if for_movies:
# Movies małe (~41k) — live aggregat OK. Scenes path niżej = denormalizacja. # Movies małe (~41k), live aggregat OK. Scenes path niżej = denormalizacja.
_movie_live = exists().where( _movie_live = exists().where(
and_( and_(
MoviePlaybackSource.movie_id == Movie.id, MoviePlaybackSource.movie_id == Movie.id,
@ -280,7 +280,7 @@ def list_studios(
] ]
return StudioListOut(items=items, total=total, page=page, per_page=per_page) return StudioListOut(items=items, total=total, page=page, per_page=per_page)
# Scenes path — czyta zdenormalizowany Studio.scene_count (refresh w tle). # Scenes path, czyta zdenormalizowany Studio.scene_count (refresh w tle).
# Wcześniej agregat 1.69M scenes + EXISTS playback per-request. Patrz migracja 0019. # Wcześniej agregat 1.69M scenes + EXISTS playback per-request. Patrz migracja 0019.
base = select(Studio) base = select(Studio)
if q: if q:
@ -340,13 +340,13 @@ class PerformerRescrapeOut(BaseModel):
# przy partial commits. 45s wall-clock + 50 scen max = ~12 fetches × 3s budgetowo. # przy partial commits. 45s wall-clock + 50 scen max = ~12 fetches × 3s budgetowo.
# Większe rescrape'y user może odpalać wielokrotnie (idempotent dzięki has_thumb/ # Większe rescrape'y user może odpalać wielokrotnie (idempotent dzięki has_thumb/
# tag_count check). # tag_count check).
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s — 5s margin na response build _RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s, 5s margin na response build
_RESCRAPE_MAX_SCENES = 50 _RESCRAPE_MAX_SCENES = 50
# Re-fetch tagów dla scen z < N tagami. Niektórzy performerzy mają legit 1-2 tagi # Re-fetch tagów dla scen z < N tagami. Niektórzy performerzy mają legit 1-2 tagi
# (niche), no harm w sprawdzeniu pierwszy raz; powtarzane wywołania są idempotent # (niche), no harm w sprawdzeniu pierwszy raz; powtarzane wywołania są idempotent
# bo INSERT ... ON CONFLICT DO NOTHING. # bo INSERT ... ON CONFLICT DO NOTHING.
_TAG_RESCRAPE_THRESHOLD = 3 _TAG_RESCRAPE_THRESHOLD = 3
# Mainstream tubes priority dla tagów — bogate metadane. # Mainstream tubes priority dla tagów, bogate metadane.
_TAG_PRIORITY = [ _TAG_PRIORITY = [
"xhamstercom", "porntrexcom", "epornercom", "youporncom", "xhamstercom", "porntrexcom", "epornercom", "youporncom",
"xvideoscom", "xnxxcom", "xvideoscom", "xnxxcom",
@ -360,7 +360,7 @@ def performer_top_tags(
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
) -> TagListOut: ) -> TagListOut:
"""Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips, """Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips,
bug-report 1a4bf258 — zastępują dev-only przycisk Re-scrape). Liczymy tylko po bug-report 1a4bf258, zastępują dev-only przycisk Re-scrape). Liczymy tylko po
scenach z żywym playbackiem (to co user realnie widzi na liście). scene_id index scenach z żywym playbackiem (to co user realnie widzi na liście). scene_id index
pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request.""" pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request."""
live = exists().where( live = exists().where(
@ -418,7 +418,7 @@ def rescrape_performer_scenes(
if perf is None: if perf is None:
raise HTTPException(status_code=404, detail="performer not found") raise HTTPException(status_code=404, detail="performer not found")
# 1) ID-only query — sceny ze ≥1 alive tube playback. # 1) ID-only query, sceny ze ≥1 alive tube playback.
scene_ids = session.execute( scene_ids = session.execute(
select(Scene.id) select(Scene.id)
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id) .join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
@ -468,7 +468,7 @@ def rescrape_performer_scenes(
capped = False capped = False
cap_reason: str | None = None cap_reason: str | None = None
started = _time.monotonic() started = _time.monotonic()
# Narrow exception set — łapiemy TYLKO oczekiwane network/parse failures. # Narrow exception set, łapiemy TYLKO oczekiwane network/parse failures.
# `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion. # `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion.
NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError) NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError)
@ -486,7 +486,7 @@ def rescrape_performer_scenes(
has_thumb = any(s.thumbnail_url for s in sources) has_thumb = any(s.thumbnail_url for s in sources)
existing_tag_count = tag_counts.get(scene_id, 0) existing_tag_count = tag_counts.get(scene_id, 0)
# SAVEPOINT — fail isolation. Pojedyncza scena z FK violation w SceneTag # SAVEPOINT, fail isolation. Pojedyncza scena z FK violation w SceneTag
# insert nie odpaliłby outer transaction; bez nested rollback całe N scen # insert nie odpaliłby outer transaction; bez nested rollback całe N scen
# po niej miałoby PendingRollbackError. # po niej miałoby PendingRollbackError.
sp = session.begin_nested() sp = session.begin_nested()
@ -504,7 +504,7 @@ def rescrape_performer_scenes(
thumb = extract_thumbnail_url(r.text) thumb = extract_thumbnail_url(r.text)
if thumb: if thumb:
# Update tylko źródła z którego pochodzi thumb (single playback). # Update tylko źródła z którego pochodzi thumb (single playback).
# Wcześniej apply'owalismy do wszystkich siblings — wrong-CDN # Wcześniej apply'owalismy do wszystkich siblings, wrong-CDN
# cross-attribution (np. xhamster thumb na porntrex entry). # cross-attribution (np. xhamster thumb na porntrex entry).
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile # `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
# find()), więc 1 wystarczy. # find()), więc 1 wystarczy.
@ -586,7 +586,7 @@ def refresh_performer(
performer_id: uuid.UUID, performer_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
) -> PerformerRefreshOut: ) -> PerformerRefreshOut:
"""On-demand search across all tubes dla pojedynczego performera. Synchronous — """On-demand search across all tubes dla pojedynczego performera. Synchronous,
blokujemy aż search skończy. Mobile pokazuje spinner. blokujemy aż search skończy. Mobile pokazuje spinner.
Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style
@ -606,7 +606,7 @@ def refresh_performer(
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit", detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
) )
# Lazy import — performer_driven ma ciężki connector tree # Lazy import, performer_driven ma ciężki connector tree
from app.scheduler.performer_driven import run_performer_driven from app.scheduler.performer_driven import run_performer_driven
# NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes). # NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes).

View file

@ -1,8 +1,8 @@
"""Watch history + continue watching. """Watch history + continue watching.
Single-user. Mobile pingu POST /scenes/{id}/progress przy: Single-user. Mobile pingu POST /scenes/{id}/progress przy:
- Klik Watch (position_sec=0) — wciąga scenę do recent watch - Klik Watch (position_sec=0), wciąga scenę do recent watch
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT) — z faktyczną pozycją - Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT), z faktyczną pozycją
Continue watching rail na home: GET /watch/recent?limit=10 → top scen po last_played_at, Continue watching rail na home: GET /watch/recent?limit=10 → top scen po last_played_at,
filtruje dead-finished (>=95% lub flag finished). Mobile pokazuje progress bar filtruje dead-finished (>=95% lub flag finished). Mobile pokazuje progress bar
@ -55,7 +55,7 @@ def upsert_progress(
if session.get(Scene, scene_id) is None: if session.get(Scene, scene_id) is None:
raise HTTPException(status_code=404, detail="scene not found") raise HTTPException(status_code=404, detail="scene not found")
# PG upsert — eliminuje race condition gdy mobile wysyła progress równolegle # PG upsert, eliminuje race condition gdy mobile wysyła progress równolegle
# (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add → # (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add →
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes. # commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -139,7 +139,7 @@ def upsert_movie_progress(
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)], device_id: Annotated[str, Depends(get_device_id)],
) -> MovieProgressOut: ) -> MovieProgressOut:
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26 — """Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26,
"przydałoby się oznaczenie filmów już obejrzanych").""" "przydałoby się oznaczenie filmów już obejrzanych")."""
if session.get(Movie, movie_id) is None: if session.get(Movie, movie_id) is None:
raise HTTPException(status_code=404, detail="movie not found") raise HTTPException(status_code=404, detail="movie not found")

View file

@ -1,7 +1,7 @@
"""API key authentication. """API key authentication.
Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer <key>`. Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer <key>`.
Gdy `settings.api_keys` jest puste — auth jest wyłączony (dev mode). Gdy `settings.api_keys` jest puste, auth jest wyłączony (dev mode).
Dodatkowo (anti-tamper): gdy `ALLOWED_APP_SIG_HASH` jest ustawione, każdy request Dodatkowo (anti-tamper): gdy `ALLOWED_APP_SIG_HASH` jest ustawione, każdy request
musi zawierać `X-App-Signature` z SHA256 (hex) signing certu APK. Mismatch → 403. musi zawierać `X-App-Signature` z SHA256 (hex) signing certu APK. Mismatch → 403.
@ -30,7 +30,7 @@ def require_api_key(
) )
if not settings.auth_enabled: if not settings.auth_enabled:
return # local/dev — wszystko otwarte return # local/dev, wszystko otwarte
candidate: str | None = None candidate: str | None = None
if x_api_key: if x_api_key:

View file

@ -25,7 +25,7 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL") log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
# Sentry observability — pusty DSN = init no-op (devel/local). Cloud free tier # Sentry observability, pusty DSN = init no-op (devel/local). Cloud free tier
# 5k errors/mies wystarczy dla 1-user app. # 5k errors/mies wystarczy dla 1-user app.
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN") sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT") sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
@ -48,12 +48,12 @@ class Settings(BaseSettings):
title_token_set_min: int = 88 title_token_set_min: int = 88
date_window_days: int = 7 date_window_days: int = 7
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source — # Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source,
# to permanentne orphany (free tubes nie hostują), ~56% ingestu TPDB/StashDB. # to permanentne orphany (free tubes nie hostują), ~56% ingestu TPDB/StashDB.
# False = wciągaj jak dawniej. Tube'y z clip-store studiem NIE są skipowane (mają playback). # False = wciągaj jak dawniej. Tube'y z clip-store studiem NIE są skipowane (mają playback).
skip_clip_store: bool = Field(default=True, validation_alias="GOON_SKIP_CLIP_STORE") skip_clip_store: bool = Field(default=True, validation_alias="GOON_SKIP_CLIP_STORE")
# Minimalny duration sceny z tube/scraper przy ingescie — <N s = trailer/teaser/preview. # Minimalny duration sceny z tube/scraper przy ingescie, <N s = trailer/teaser/preview.
# 0 = wyłączony. Nieznany duration nie jest wycinany. NIE dotyczy canonical (TPDB/StashDB). # 0 = wyłączony. Nieznany duration nie jest wycinany. NIE dotyczy canonical (TPDB/StashDB).
min_ingest_duration_sec: int = Field(default=180, validation_alias="GOON_MIN_INGEST_DURATION_SEC") min_ingest_duration_sec: int = Field(default=180, validation_alias="GOON_MIN_INGEST_DURATION_SEC")
@ -74,7 +74,7 @@ class Settings(BaseSettings):
sched_performer_continuous_refresh_days: int = Field( sched_performer_continuous_refresh_days: int = Field(
default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS" default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS"
) )
# Movie ingest — paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/ # Movie ingest, paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
# pandamovies). Każdy connector zapisuje swój `Source` i robi delta od ostatniego # pandamovies). Każdy connector zapisuje swój `Source` i robi delta od ostatniego
# successful run. Set to 0 to disable. Domyślnie 24h: movie sites rosną wolniej # successful run. Set to 0 to disable. Domyślnie 24h: movie sites rosną wolniej
# niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej. # niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej.
@ -92,31 +92,31 @@ class Settings(BaseSettings):
sched_browse_latest_max_pages: int = Field( sched_browse_latest_max_pages: int = Field(
default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES" default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES"
) )
# Deep-crawl (Faza 2a) — pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko # Deep-crawl (Faza 2a), pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
# top-N. Round-robin po tube'ach, wznawialny kursor (app/_state/deepcrawl_state.json). # top-N. Round-robin po tube'ach, wznawialny kursor (app/_state/deepcrawl_state.json).
# 0 = wyłączony. 60 stron/run × ~31 scen ≈ 1860 scen/run (~22 min, hard-timeout 1h). # 0 = wyłączony. 60 stron/run × ~31 scen ≈ 1860 scen/run (~22 min, hard-timeout 1h).
sched_deep_crawl_hours: int = Field(default=1, validation_alias="GOON_SCHED_DEEP_CRAWL_HOURS") sched_deep_crawl_hours: int = Field(default=1, validation_alias="GOON_SCHED_DEEP_CRAWL_HOURS")
deep_crawl_pages_per_run: int = Field(default=60, validation_alias="GOON_DEEP_CRAWL_PAGES_PER_RUN") deep_crawl_pages_per_run: int = Field(default=60, validation_alias="GOON_DEEP_CRAWL_PAGES_PER_RUN")
deepcrawl_state_path: str = Field(default="", validation_alias="GOON_DEEPCRAWL_STATE_PATH") deepcrawl_state_path: str = Field(default="", validation_alias="GOON_DEEPCRAWL_STATE_PATH")
# Bulk-dedup performers safety net — auto-merge duplikatów które resolver-time # Bulk-dedup performers safety net, auto-merge duplikatów które resolver-time
# scoring pominął. 12h cadence: leci 2x dziennie (po porannym browse-latest run). # scoring pominął. 12h cadence: leci 2x dziennie (po porannym browse-latest run).
sched_bulk_dedup_hours: int = Field( sched_bulk_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_BULK_DEDUP_HOURS" default=12, validation_alias="GOON_SCHED_BULK_DEDUP_HOURS"
) )
# Thumb-asset dedup — scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły, # Thumb-asset dedup, scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
# ten sam asset-id miniatury + długość). bulk_dedup tego nie łapie (brak phash/tytuł). # ten sam asset-id miniatury + długość). bulk_dedup tego nie łapie (brak phash/tytuł).
# Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off. # Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off.
sched_thumb_dedup_hours: int = Field( sched_thumb_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_THUMB_DEDUP_HOURS" default=12, validation_alias="GOON_SCHED_THUMB_DEDUP_HOURS"
) )
# Title+duration dedup — scala missing-merge dupy (ten sam performer + identyczny # Title+duration dedup, scala missing-merge dupy (ten sam performer + identyczny
# znormalizowany tytuł + długość co do sekundy), których bulk_dedup nie łapie (tube # znormalizowany tytuł + długość co do sekundy), których bulk_dedup nie łapie (tube
# re-scrape / cross-tube np. porn00 vs xnxx, reports 28fe8181/32df33b1). Odrastają # re-scrape / cross-tube np. porn00 vs xnxx, reports 28fe8181/32df33b1). Odrastają
# przy re-ingeście, stąd cyklicznie. 12h, playback-only (to co user widzi). 0 = off. # przy re-ingeście, stąd cyklicznie. 12h, playback-only (to co user widzi). 0 = off.
sched_title_dedup_hours: int = Field( sched_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS" default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
) )
# Blacklista zdegenerowanych phashy — wartości dzielonych przez ≥10 scen (zaślepka, # Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka,
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375 # czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć # scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off. # serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
@ -124,18 +124,18 @@ class Settings(BaseSettings):
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS" default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
) )
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem # Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
# w ogóle — streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy # w ogóle, streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl. # codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
ingest_watchdog_movie_max_age_hours: int = Field( ingest_watchdog_movie_max_age_hours: int = Field(
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS" default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
) )
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie", # Śmieciowi performerzy, kategorie i studia podszywające się pod osoby („Creampie",
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania. # „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off. # Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
sched_junk_performers_hours: int = Field( sched_junk_performers_hours: int = Field(
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS" default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
) )
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin # Ingest freshness watchdog, alert do Sentry gdy aktywny tube (origin
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie # tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie # pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
# widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence # widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence
@ -143,19 +143,19 @@ class Settings(BaseSettings):
sched_ingest_watchdog_hours: int = Field( sched_ingest_watchdog_hours: int = Field(
default=6, validation_alias="GOON_SCHED_INGEST_WATCHDOG_HOURS" default=6, validation_alias="GOON_SCHED_INGEST_WATCHDOG_HOURS"
) )
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS) — crawlowane raz dziennie z # Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS), crawlowane raz dziennie z
# listingu, więc 48h ciszy = anomalia. # listingu, więc 48h ciszy = anomalia.
ingest_watchdog_max_age_hours: int = Field( ingest_watchdog_max_age_hours: int = Field(
default=48, validation_alias="GOON_INGEST_WATCHDOG_MAX_AGE_HOURS" default=48, validation_alias="GOON_INGEST_WATCHDOG_MAX_AGE_HOURS"
) )
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS) — kadencja jest # Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS), kadencja jest
# nierówna (continuous queue ~30d refresh per performer, ingest orphan-heavy), więc # nierówna (continuous queue ~30d refresh per performer, ingest orphan-heavy), więc
# 48h dawałoby false-positivy. 7d (168h): healthy search-tuby obserwowane <6h świeżości # 48h dawałoby false-positivy. 7d (168h): healthy search-tuby obserwowane <6h świeżości
# (continuous tick hituje wszystkie tuby per performer), zamrożone ≥73h → ~28× margines. # (continuous tick hituje wszystkie tuby per performer), zamrożone ≥73h → ~28× margines.
ingest_watchdog_search_max_age_hours: int = Field( ingest_watchdog_search_max_age_hours: int = Field(
default=168, validation_alias="GOON_INGEST_WATCHDOG_SEARCH_MAX_AGE_HOURS" default=168, validation_alias="GOON_INGEST_WATCHDOG_SEARCH_MAX_AGE_HOURS"
) )
# TPDB movie enrichment — wzbogaca filmy obsadą + kategoriami (tagi) + studiem + # TPDB movie enrichment, wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
# reżyserem, plus kanoniczny TPDB UUID do dedupu mirrorów. paradisehill (primary) # reżyserem, plus kanoniczny TPDB UUID do dedupu mirrorów. paradisehill (primary)
# prawie nie ma obsady, więc to domyka największą lukę zakładki movies. Batch # prawie nie ma obsady, więc to domyka największą lukę zakładki movies. Batch
# najświeższych GRYWALNYCH filmów bez obsady/studia per run; 6h cadence. 0 = off. # najświeższych GRYWALNYCH filmów bez obsady/studia per run; 6h cadence. 0 = off.
@ -165,20 +165,20 @@ class Settings(BaseSettings):
tpdb_movie_enrich_batch: int = Field( tpdb_movie_enrich_batch: int = Field(
default=200, validation_alias="GOON_TPDB_MOVIE_ENRICH_BATCH" default=200, validation_alias="GOON_TPDB_MOVIE_ENRICH_BATCH"
) )
# Taxonomy scene_count refresh — przelicza denormalizowane liczniki scen na # Taxonomy scene_count refresh, przelicza denormalizowane liczniki scen na
# tags/performers/studios (hot-path /tags|/performers|/studios|/favorites czyta # tags/performers/studios (hot-path /tags|/performers|/studios|/favorites czyta
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence — # gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence,
# counts do tego stale, dla sortu "popular" + badge "(N)" bez znaczenia. 0 = off. # counts do tego stale, dla sortu "popular" + badge "(N)" bez znaczenia. 0 = off.
sched_taxonomy_counts_hours: int = Field( sched_taxonomy_counts_hours: int = Field(
default=3, validation_alias="GOON_SCHED_TAXONOMY_COUNTS_HOURS" default=3, validation_alias="GOON_SCHED_TAXONOMY_COUNTS_HOURS"
) )
# Hetzner Cloud bandwidth monitor — read-only API token (Security → API Tokens # Hetzner Cloud bandwidth monitor, read-only API token (Security → API Tokens
# w panelu Hetzner Cloud). Bez tokenu monitor wyłączony (warning w log). # w panelu Hetzner Cloud). Bez tokenu monitor wyłączony (warning w log).
# Free traffic per server: CX22=20TB, CPX21=20TB itd. Overage = €1/TB. # Free traffic per server: CX22=20TB, CPX21=20TB itd. Overage = €1/TB.
hetzner_api_token: str | None = Field(default=None, validation_alias="HETZNER_API_TOKEN") hetzner_api_token: str | None = Field(default=None, validation_alias="HETZNER_API_TOKEN")
hetzner_server_id: int | None = Field(default=None, validation_alias="HETZNER_SERVER_ID") hetzner_server_id: int | None = Field(default=None, validation_alias="HETZNER_SERVER_ID")
# Alert thresholds (% of included_traffic) — Sentry severity levels. # Alert thresholds (% of included_traffic), Sentry severity levels.
hetzner_alert_info_pct: int = Field(default=50, validation_alias="HETZNER_ALERT_INFO_PCT") hetzner_alert_info_pct: int = Field(default=50, validation_alias="HETZNER_ALERT_INFO_PCT")
hetzner_alert_warning_pct: int = Field(default=80, validation_alias="HETZNER_ALERT_WARNING_PCT") hetzner_alert_warning_pct: int = Field(default=80, validation_alias="HETZNER_ALERT_WARNING_PCT")
hetzner_alert_error_pct: int = Field(default=95, validation_alias="HETZNER_ALERT_ERROR_PCT") hetzner_alert_error_pct: int = Field(default=95, validation_alias="HETZNER_ALERT_ERROR_PCT")
@ -188,14 +188,14 @@ class Settings(BaseSettings):
default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS" default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS"
) )
# Source ranking (Sites screen) — przelicz source_stats (freshness/richness/health # Source ranking (Sites screen), przelicz source_stats (freshness/richness/health
# per origin). 0/None = wyłączone. Domyślnie 6h (richness to ciężki agregat po # per origin). 0/None = wyłączone. Domyślnie 6h (richness to ciężki agregat po
# ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli). # ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli).
sched_source_stats_hours: int = Field( sched_source_stats_hours: int = Field(
default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS" default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS"
) )
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB) — # Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB),
# używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare # używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare
# 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN # 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN
# IP-bound). Format env: `host:port:user:pass` (panel Bright Data). Pusty = brak. # IP-bound). Format env: `host:port:user:pass` (panel Bright Data). Pusty = brak.

View file

@ -1,14 +1,14 @@
"""Connector registry helpers. """Connector registry helpers.
Lazy factories — importy connectorów wykonują się dopiero w `get_movie_connectors()` Lazy factories, importy connectorów wykonują się dopiero w `get_movie_connectors()`
żeby uniknąć circular imports (modeles/db). Każdy entry: `(name, class)` w porządku żeby uniknąć circular imports (modeles/db). Każdy entry: `(name, class)` w porządku
ingestu (primary FIRST, mirrory potem — `resolve_movie` wtedy ma do czego dokleić ingestu (primary FIRST, mirrory potem, `resolve_movie` wtedy ma do czego dokleić
mirror playback sources). mirror playback sources).
## Jak dodać nowe movie site ## Jak dodać nowe movie site
1. Napisz subclass `DooplayConnector` w `app/connectors/dooplay.py` (jeśli site używa 1. Napisz subclass `DooplayConnector` w `app/connectors/dooplay.py` (jeśli site używa
dooplay/PsyPlay WP theme) — wystarczy `name` + `base_url`. Jeśli inny theme, dooplay/PsyPlay WP theme), wystarczy `name` + `base_url`. Jeśli inny theme,
napisz osobny connector implementujący `BaseMovieConnector.fetch_movies()`. napisz osobny connector implementujący `BaseMovieConnector.fetch_movies()`.
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej. 2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie 3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie
@ -31,7 +31,7 @@ from __future__ import annotations
def get_movie_connectors() -> list[tuple[str, type]]: def get_movie_connectors() -> list[tuple[str, type]]:
"""Zwraca listę (name, ConnectorCls) tuples w kolejności ingestu. """Zwraca listę (name, ConnectorCls) tuples w kolejności ingestu.
Lazy import — uniknięcie circular import bo connectory zaczepiają db/models. Lazy import, uniknięcie circular import bo connectory zaczepiają db/models.
""" """
from app.connectors.dooplay import ( from app.connectors.dooplay import (
MangopornConnector, MangopornConnector,
@ -43,10 +43,10 @@ def get_movie_connectors() -> list[tuple[str, type]]:
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do # Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content — # niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content,
# 72 nowych 2026-05-28; streamporn/pandamovies zwracają stale 0 new), na końcu # 72 nowych 2026-05-28; streamporn/pandamovies zwracają stale 0 new), na końcu
# streamporn + pandamovies. Powód reorderu (2026-05-30): gdy streamporn wiesza # streamporn + pandamovies. Powód reorderu (2026-05-30): gdy streamporn wiesza
# się intermittentnie, mangoporn musi zdążyć przed nim — patrz per-connector # się intermittentnie, mangoporn musi zdążyć przed nim, patrz per-connector
# timeout w _job_movie_ingest. # timeout w _job_movie_ingest.
# streamporn.nl padł (0 new od 2026-05-23) → zastąpiony żywym streamporn.vip # streamporn.nl padł (0 new od 2026-05-23) → zastąpiony żywym streamporn.vip
# (StreampornVipConnector, ocena 2026-07-02). Stara klasa .nl zostaje w dooplay.py # (StreampornVipConnector, ocena 2026-07-02). Stara klasa .nl zostaje w dooplay.py
@ -56,15 +56,15 @@ def get_movie_connectors() -> list[tuple[str, type]]:
("mangoporn", MangopornConnector), ("mangoporn", MangopornConnector),
("streampornvip", StreampornVipConnector), ("streampornvip", StreampornVipConnector),
("pandamovies", PandamoviesConnector), ("pandamovies", PandamoviesConnector),
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD, # freeomovie.to (bestia theme, nie dooplay), mirror: świeże filmy DVD,
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02. # title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
("freeomovie", FreeoMovieConnector), ("freeomovie", FreeoMovieConnector),
# yourdailypornmovies.ws (własny motyw WP, nie dooplay) — ocena 2026-07-28. # yourdailypornmovies.ws (własny motyw WP, nie dooplay), ocena 2026-07-28.
# SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna # SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na # pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003. # których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis, # Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają # id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
# gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed. # gotowe moduły hosterów), vidara.to nieznana, leci jako zwykły embed.
("yourdailypornmovies", YourDailyPornMoviesConnector), ("yourdailypornmovies", YourDailyPornMoviesConnector),
] ]

View file

@ -93,7 +93,7 @@ class RawScene(BaseModel):
(np. 'tpdb', 'stashdb').""" (np. 'tpdb', 'stashdb')."""
raw: dict[str, Any] = Field(default_factory=dict) raw: dict[str, Any] = Field(default_factory=dict)
"""Oryginalny payload z API — leci do external_records.raw.""" """Oryginalny payload z API, leci do external_records.raw."""
class BaseConnector(abc.ABC): class BaseConnector(abc.ABC):
@ -114,15 +114,15 @@ class BaseConnector(abc.ABC):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Movies — odrębny encja od scen, ale ten sam wzorzec connectorów # Movies, odrębny encja od scen, ale ten sam wzorzec connectorów
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class RawMovieChapter(BaseModel): class RawMovieChapter(BaseModel):
"""Pojedynczy rozdział filmu (movies czasem dzielą się na "Part 1/2/3" itp.). """Pojedynczy rozdział filmu (movies czasem dzielą się na "Part 1/2/3" itp.).
Identyfikatory chaptera nie są kanonizowane między źródłami — są lokalne dla movie, Identyfikatory chaptera nie są kanonizowane między źródłami, są lokalne dla movie,
indeksowane przez `chapter_index`. Może linkować do separate scene (jeśli ta scena indeksowane przez `chapter_index`. Może linkować do separate scene (jeśli ta scena
znana z TPDB/StashDB) — tym zajmuje się normalizator wyżej.""" znana z TPDB/StashDB), tym zajmuje się normalizator wyżej."""
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
@ -133,7 +133,7 @@ class RawMovieChapter(BaseModel):
class RawMovie(BaseModel): class RawMovie(BaseModel):
"""Surowy film z connectora — odpowiednik RawScene dla movies. """Surowy film z connectora, odpowiednik RawScene dla movies.
Performers / studio / tags reusable z RawPerformer / RawStudio / RawTag (te same Performers / studio / tags reusable z RawPerformer / RawStudio / RawTag (te same
typy w obu pipelinach). Playback sources to lista mirrorów odtwarzania (paradisehill typy w obu pipelinach). Playback sources to lista mirrorów odtwarzania (paradisehill
@ -170,7 +170,7 @@ class BaseMovieConnector(abc.ABC):
"""Connector dla source'a movies (paradisehill, psyplay, wp_movies). """Connector dla source'a movies (paradisehill, psyplay, wp_movies).
Symetrycznie do BaseConnector ale yielduje RawMovie. Każde źródło zna własną Symetrycznie do BaseConnector ale yielduje RawMovie. Każde źródło zna własną
paginację i format ID — konwerter wyżej (resolver) dba o dedup między źródłami. paginację i format ID, konwerter wyżej (resolver) dba o dedup między źródłami.
""" """
kind: SourceKind kind: SourceKind

View file

@ -1,25 +1,25 @@
"""Direct tube scrapers. """Direct tube scrapers.
Każdy scraper hit'uje tube bezpośrednio HTTPm — różne tube'y to różne rate limit Każdy scraper hit'uje tube bezpośrednio HTTPm, różne tube'y to różne rate limit
budgets, więc mogą iść równolegle. Wszystkie feedują sceny do tej samej budgets, więc mogą iść równolegle. Wszystkie feedują sceny do tej samej
`Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver `Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver
mergeuje idempotentnie po tym kluczu. mergeuje idempotentnie po tym kluczu.
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"` — myląca pozostałość po usuniętym Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"`, myląca pozostałość po usuniętym
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA — zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA,
to nasze własne direct-scrapery tubów). Przemianowana na `"tube-scraper"`; wiersz to nasze własne direct-scrapery tubów). Przemianowana na `"tube-scraper"`; wiersz
`sources` zaktualizowany w DB (UPDATE name) więc cała historia ingest_runs została. `sources` zaktualizowany w DB (UPDATE name) więc cała historia ingest_runs została.
Search-based ścieżka (per performer name); category browse'ng przez `categoriesUrl`. Search-based ścieżka (per performer name); category browse'ng przez `categoriesUrl`.
UWAGA — speculative scrapers: większość aggregator + special tubes (xmoviesforyou, UWAGA, speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak, watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
porndittcom, perverzija, fpoxxx, ...) ma URL templates + regex'y oparte na typowych porndittcom, perverzija, fpoxxx, ...) ma URL templates + regex'y oparte na typowych
WordPress conventions. Wymagają post-deploy verification — gdy któryś nie zwraca WordPress conventions. Wymagają post-deploy verification, gdy któryś nie zwraca
wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku. wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku.
""" """
# Umbrella Source.name dla wszystkich direct-scraperów (deep-crawl, browse-latest, # Umbrella Source.name dla wszystkich direct-scraperów (deep-crawl, browse-latest,
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące — nie ma zależności # performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące, nie ma zależności
# od zewnętrznego porn-app API). # od zewnętrznego porn-app API).
SCRAPER_SOURCE_NAME = "tube-scraper" SCRAPER_SOURCE_NAME = "tube-scraper"
@ -27,7 +27,7 @@ from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
from app.connectors.direct_scrapers.eporner import EpornerScraper from app.connectors.direct_scrapers.eporner import EpornerScraper
from app.connectors.direct_scrapers.fpoxxx import FpoxxxScraper from app.connectors.direct_scrapers.fpoxxx import FpoxxxScraper
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401 — kept for backref; disabled from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401, kept for backref; disabled
from app.connectors.direct_scrapers.hqporner import HQPornerScraper from app.connectors.direct_scrapers.hqporner import HQPornerScraper
from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper
from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper
@ -55,21 +55,21 @@ from app.connectors.direct_scrapers.xhamster import XHamsterScraper
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
from app.connectors.direct_scrapers.xnxx import XnxxScraper from app.connectors.direct_scrapers.xnxx import XnxxScraper
from app.connectors.direct_scrapers.xvideos import XVideosScraper from app.connectors.direct_scrapers.xvideos import XVideosScraper
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401 — kept for backref; delisted from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401, kept for backref; delisted
from app.connectors.direct_scrapers.youporn import YouPornScraper from app.connectors.direct_scrapers.youporn import YouPornScraper
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
# Existing 4 (verified, in production) # Existing 4 (verified, in production)
HQPornerScraper, HQPornerScraper,
# HDPorn92Scraper — wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe # HDPorn92Scraper, wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
# (tylko happyleafmotion ads), JS hijackuje wszystkie kliki → `go.rmishe.com/smartpop/...` # (tylko happyleafmotion ads), JS hijackuje wszystkie kliki → `go.rmishe.com/smartpop/...`
# popunder redirect. Mobile WebView page-as-hoster pokazuje ad redirect zamiast video. # popunder redirect. Mobile WebView page-as-hoster pokazuje ad redirect zamiast video.
# 33,598 playback_sources mass-marked dead, 27,374 solo-orphan scenes deleted. # 33,598 playback_sources mass-marked dead, 27,374 solo-orphan scenes deleted.
SxyLandScraper, SxyLandScraper,
# ZeroDayXXScraper (0dayxx) — USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan # ZeroDayXXScraper (0dayxx), USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
# factory (0.1% canonical), zastępujemy lepszymi źródłami. Dane/pliki/extractor skasowane. # factory (0.1% canonical), zastępujemy lepszymi źródłami. Dane/pliki/extractor skasowane.
# Mainstream (URL templates well-known) # Mainstream (URL templates well-known)
# PornHub + RedTube — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od # PornHub + RedTube, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
# 2026-05-12 (0.4% canonical match), zamrożone dane skasowane z DB, pliki scraperów # 2026-05-12 (0.4% canonical match), zamrożone dane skasowane z DB, pliki scraperów
# i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio. # i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio.
XVideosScraper, XVideosScraper,
@ -78,60 +78,60 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
YouPornScraper, YouPornScraper,
PornTrexScraper, PornTrexScraper,
EpornerScraper, EpornerScraper,
# Aggregators (WordPress-like ?s= search; speculative — verify post-deploy) # Aggregators (WordPress-like ?s= search; speculative, verify post-deploy)
# XMoviesForYouScraper — wyłączony 2026-05-12 (post audit fix). 100% scen serwuje # XMoviesForYouScraper, wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
# streamtape (DEAD_HOSTER_RE — malware drive-by .reg) + opcjonalnie playmogo/mixdrop. # streamtape (DEAD_HOSTER_RE, malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
# Mixdrop zrebrandował na m1xdrop.bz, yt-dlp out-of-date, packer/JS extract = fail. # Mixdrop zrebrandował na m1xdrop.bz, yt-dlp out-of-date, packer/JS extract = fail.
# Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w # Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w
# jadx). 1,321 solo-orphan scen. # jadx). 1,321 solo-orphan scen.
# WatchPornScraper — RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS. # WatchPornScraper, RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
# Site przebudowany na KVS (DoodStream-CAPTCHA z 2026-05-12 zniknął); browse # Site przebudowany na KVS (DoodStream-CAPTCHA z 2026-05-12 zniknął); browse
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py. # /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
# SiskaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20, # SiskaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
# bo search siski zepsuty site-side — `?s=` ignoruje query). Patrz siska.py. # bo search siski zepsuty site-side, `?s=` ignoruje query). Patrz siska.py.
# Porn4DaysScraper — wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape # Porn4DaysScraper, wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL = # only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346 # streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
# solo-orphan scen. # solo-orphan scen.
# PornDishScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24, # PornDishScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
# watchdog GOON-16: search `?s=` zamarzł 2026-05-07). WordPress → browse przez WP REST # watchdog GOON-16: search `?s=` zamarzł 2026-05-07). WordPress → browse przez WP REST
# API (/wp-json/wp/v2/posts) jak perverzija: tytuł/data/thumb/studio(category)/tagi. # API (/wp-json/wp/v2/posts) jak perverzija: tytuł/data/thumb/studio(category)/tagi.
# XxxFreeWatchScraper — wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan. # XxxFreeWatchScraper, wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
# Cloudflare 403 z VPS IP, mobile WebView teoretycznie działa ale 0/790 scen miało jakikolwiek # Cloudflare 403 z VPS IP, mobile WebView teoretycznie działa ale 0/790 scen miało jakikolwiek
# match do TPDB/StashDB. Pure orphan factory. Solo scenes deleted, scraper disabled. # match do TPDB/StashDB. Pure orphan factory. Solo scenes deleted, scraper disabled.
# LatestPornVideoScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22, # LatestPornVideoScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user 1da0375e: search-driven nie brał feedu "latest" → stary zestaw w apce). # user 1da0375e: search-driven nie brał feedu "latest" → stary zestaw w apce).
# LatestLeaksScraper — wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0% # LatestLeaksScraper, wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
# canonical match. Slug-concat tytuły, brak studio/duration/date signali. Solo orphany # canonical match. Slug-concat tytuły, brak studio/duration/date signali. Solo orphany
# usunięte (~15k scen). # usunięte (~15k scen).
MyPornerLeakScraper, MyPornerLeakScraper,
# Added 2026-05-12 (theporndude survey): jeden z 14 free tubes na liście który # Added 2026-05-12 (theporndude survey): jeden z 14 free tubes na liście który
# zwraca consistent search results. KVS engine, slug-aware scene URLs. Mostly # zwraca consistent search results. KVS engine, slug-aware scene URLs. Mostly
# orphan ingest (auto-screenshots, no canonical phash match — sprawdzone), ale # orphan ingest (auto-screenshots, no canonical phash match, sprawdzone), ale
# może łapać sceny popularnych performerów których jeszcze nie mamy w TPDB. # może łapać sceny popularnych performerów których jeszcze nie mamy w TPDB.
# PornHat (pornhatcom) + PornDitt (porndittcom) — USUNIĘTE CAŁKOWICIE 2026-06-22 # PornHat (pornhatcom) + PornDitt (porndittcom), USUNIĘTE CAŁKOWICIE 2026-06-22
# (user request). Orphan factories (0.2% / weak-signal canonical match), zastępujemy # (user request). Orphan factories (0.2% / weak-signal canonical match), zastępujemy
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane. # lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
# Special # Special
SxyPrnScraper, SxyPrnScraper,
# PerverzijaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22, # PerverzijaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user request). Search `?s=` → 429, homepage JS-renderowane; browse przez WP REST API # user request). Search `?s=` → 429, homepage JS-renderowane; browse przez WP REST API
# (/wp-json/wp/v2/posts) daje tytuł/datę/thumb/studio(category)/tagi. Playback embed-iframe. # (/wp-json/wp/v2/posts) daje tytuł/datę/thumb/studio(category)/tagi. Playback embed-iframe.
# FpoxxxScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22, # FpoxxxScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user request). fpo.xxx to KVS, nie WordPress → search `?s=` zwracał 0; browse z # user request). fpo.xxx to KVS, nie WordPress → search `?s=` zwracał 0; browse z
# `/new-<n>/` daje listing tile (tytuł/thumb/duration). Playback i tak phone-side (KVS). # `/new-<n>/` daje listing tile (tytuł/thumb/duration). Playback i tak phone-side (KVS).
] ]
# Browse-mode scrapers — iterują `latest-vids` listing zamiast search-by-performer. # Browse-mode scrapers, iterują `latest-vids` listing zamiast search-by-performer.
# Phash thumbnail fingerprint (waga 0.40 w composite scoring) auto-mergeuje do # Phash thumbnail fingerprint (waga 0.40 w composite scoring) auto-mergeuje do
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane # canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` + # raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
# `app/scheduler/browse_latest.py`. # `app/scheduler/browse_latest.py`.
# #
# **Pilot results (2026-05-12):** # **Pilot results (2026-05-12):**
# - ShyfapScraper: 0/23 match (0%) — robi własne thumbnails ≠ canonical # - ShyfapScraper: 0/23 match (0%), robi własne thumbnails ≠ canonical
# (phash Hamming 12-16). Plus rebranduje tytuły. **Wyłączony.** # (phash Hamming 12-16). Plus rebranduje tytuły. **Wyłączony.**
# - FreshpornoScraper: 39/59 match (66%) — hot-linkuje studio thumbnaile # - FreshpornoScraper: 39/59 match (66%), hot-linkuje studio thumbnaile
# (phash Hamming 0). Oryginalne tytuły + channels=studio 1:1. **Aktywny.** # (phash Hamming 0). Oryginalne tytuły + channels=studio 1:1. **Aktywny.**
from app.connectors.direct_scrapers.freshporno import FreshpornoScraper # noqa: E402 from app.connectors.direct_scrapers.freshporno import FreshpornoScraper # noqa: E402
from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402 from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402
@ -154,62 +154,62 @@ from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper
ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
PerverzijaScraper, PerverzijaScraper,
PornDishScraper, PornDishScraper,
# WatchPornScraper — re-enabled 2026-07-02 jako browse (site przebudowany na KVS, # WatchPornScraper, re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
# DoodStream-CAPTCHA zniknął). Browse /latest-updates/: og:title, JSON-LD duration/ # DoodStream-CAPTCHA zniknął). Browse /latest-updates/: og:title, JSON-LD duration/
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS # uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound). # get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
WatchPornScraper, WatchPornScraper,
# YoupervScraper — dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od # YoupervScraper, dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
# hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy # hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez # z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera). # tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
YoupervScraper, YoupervScraper,
# GalaxyPornScraper — dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w # GalaxyPornScraper, dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu # wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go # tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe # w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
# seekplayer → istniejący silnik, HLS przez /proxy/hls. # seekplayer → istniejący silnik, HLS przez /proxy/hls.
GalaxyPornScraper, GalaxyPornScraper,
# PornBusyScraper — dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/ # PornBusyScraper, dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage # uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first. # pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu. # Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
PornBusyScraper, PornBusyScraper,
# PornMikeScraper — dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD # PornMikeScraper, dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z # VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23% # refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar). # katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
# UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez # UWAGA: to klipy 5-12 min, nie pełne sceny, ~80% katalogu to jednak studia bez
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy. # żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
PornMikeScraper, PornMikeScraper,
# KPorner8Scraper — dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE # KPorner8Scraper, dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
# tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny # tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html` # dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać # są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka # tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb. # na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy. # Pilot: seen 62, new 25, updated 37, 60% dopina się do scen, które już mamy.
KPorner8Scraper, KPorner8Scraper,
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej # FullPornerScraper, dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured". # 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania. # Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO — za to # Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO, za to
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120 # data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość), # scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny # przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
# bez performera scaliły się 0/23. phash liczony celowo — to jedyny sygnał dla # bez performera scaliły się 0/23. phash liczony celowo, to jedyny sygnał dla
# bulk_dedup na te duplikaty, po tytule nie pójdzie. # bulk_dedup na te duplikaty, po tytule nie pójdzie.
FullPornerScraper, FullPornerScraper,
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma # SexuScraper (`sexu.py`), ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna # najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10 # długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min # minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i # w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie # tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie # to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
# performera. Scraper i extractor zostają w repo — gdyby serwis kiedyś zaczął wrzucać # performera. Scraper i extractor zostają w repo, gdyby serwis kiedyś zaczął wrzucać
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj. # pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI). # FullVideosPornScraper, dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75% # = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko # katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas). # sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
@ -224,105 +224,105 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
XnxxBrowseScraper, XnxxBrowseScraper,
FreshpornoScraper, FreshpornoScraper,
FpoxxxScraper, FpoxxxScraper,
# LatestPornVideoScraper — browse od 2026-06-22 (user 1da0375e: search-driven # LatestPornVideoScraper, browse od 2026-06-22 (user 1da0375e: search-driven
# nie brał feedu "latest"). Listing card: tytuł (z embedded "<Studio> YY MM DD"), # nie brał feedu "latest"). Listing card: tytuł (z embedded "<Studio> YY MM DD"),
# thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma # thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid # czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje. # iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
LatestPornVideoScraper, LatestPornVideoScraper,
# SiskaScraper — re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski # SiskaScraper, re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
# zepsuty site-side (`?s=` ignoruje query), więc latest-browse z `/page/<n>/`. # zepsuty site-side (`?s=` ignoruje query), więc latest-browse z `/page/<n>/`.
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/ # Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side. # kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
SiskaScraper, SiskaScraper,
# PornXPScraper — pilot 2026-05-17 (20 scen): studio 100%, performer 95%, # PornXPScraper, pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
# release_date 100%, duration 100%, stream_url 100%, phash 100%. Najlepsze # release_date 100%, duration 100%, stream_url 100%, phash 100%. Najlepsze
# sygnały spośród browse-mode scraperów. Stream direct mp4 (sv.porn-xp.com) # sygnały spośród browse-mode scraperów. Stream direct mp4 (sv.porn-xp.com)
# 360/720 quality. Release year z `Released: <year>` na detail. # 360/720 quality. Release year z `Released: <year>` na detail.
PornXPScraper, PornXPScraper,
# Porn00Scraper — pilot 2026-05-17 (16 scen): brak studio (0%) + brak release # Porn00Scraper, pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
# date (0%) ALE performer 100%, duration 100%, stream_url 100% (KVS video_alt_url # date (0%) ALE performer 100%, duration 100%, stream_url 100% (KVS video_alt_url
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title # 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować. # fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
Porn00Scraper, Porn00Scraper,
# PornDoeScraper — dołączony 2026-05-21 (theporndude audit). Każda scena ma # PornDoeScraper, dołączony 2026-05-21 (theporndude audit). Każda scena ma
# kompletny JSON-LD VideoObject: title + uploadDate + duration + named studio # kompletny JSON-LD VideoObject: title + uploadDate + duration + named studio
# (producer/publisher) + named performers (actor[]) + thumbnail. Najbogatsze # (producer/publisher) + named performers (actor[]) + thumbnail. Najbogatsze
# strukturalne metadane spośród browse scraperów — composite fuzzy match ma # strukturalne metadane spośród browse scraperów, composite fuzzy match ma
# komplet sygnałów. Phash hit-rate niski (własne crop-thumbnaile), studio + # komplet sygnałów. Phash hit-rate niski (własne crop-thumbnaile), studio +
# performer + date + duration nadrabiają. # performer + date + duration nadrabiają.
PornDoeScraper, PornDoeScraper,
# YesPornVipScraper — dołączony 2026-05-27 (user audit). JSON-LD VideoObject # YesPornVipScraper, dołączony 2026-05-27 (user audit). JSON-LD VideoObject
# + `<meta property="video:duration|release_date|tag">` per scena (Goon ma # + `<meta property="video:duration|release_date|tag">` per scena (Goon ma
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio + # duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`). # performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
# 941k organic monthly (SE Ranking, comparable z porndoe 731k / porntrex 790k). # 941k organic monthly (SE Ranking, comparable z porndoe 731k / porntrex 790k).
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen) — wysokie expected # Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen), wysokie expected
# canonical match dla studio scenes. Korekta: theporndude scorecard rank 26 # canonical match dla studio scenes. Korekta: theporndude scorecard rank 26
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** — pdude.link # ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** pdude.link
# redirect do porndudecams affiliate. Prawdziwa kanoniczna domena to TLD `.vip`. # redirect do porndudecams affiliate. Prawdziwa kanoniczna domena to TLD `.vip`.
YesPornVipScraper, YesPornVipScraper,
# FullmoviesScraper + HDPornGGScraper — dołączone 2026-06-01. KVS engine (sponsor_groups # FullmoviesScraper + HDPornGGScraper, dołączone 2026-06-01. KVS engine (sponsor_groups
# stack, `/videos/<slug>/` + `/latest-updates/`). Studio teraz z PREFIKSU tytułu # stack, `/videos/<slug>/` + `/latest-updates/`). Studio teraz z PREFIKSU tytułu
# ("Studio - Scene") — sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match # ("Studio - Scene"), sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
# zawsze Brazzers (mis-attribution, dlatego nigdy nie były włączone). Niosą paysite studio # zawsze Brazzers (mis-attribution, dlatego nigdy nie były włączone). Niosą paysite studio
# content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy. # content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy.
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest). # Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
FullmoviesScraper, FullmoviesScraper,
HDPornGGScraper, HDPornGGScraper,
# EpornerApiScraper — dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to # EpornerApiScraper, dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner — odrzucone), ALE eporner ma # JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner, odrzucone), ALE eporner ma
# publiczne JSON API (api/v2/video/search): 1 call = 100 filmów z title+length_sec+ # publiczne JSON API (api/v2/video/search): 1 call = 100 filmów z title+length_sec+
# keywords+added+thumb. ~100k filmów, deep-crawl przez crawl_page() (API, bez detail-fetch). # keywords+added+thumb. ~100k filmów, deep-crawl przez crawl_page() (API, bez detail-fetch).
EpornerApiScraper, EpornerApiScraper,
# XVideosBrowseScraper — dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate) # XVideosBrowseScraper, dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
# + page-parse /models/ (performerzy) + /tags/. Sample: median ~10.5min, 93% ≥3min. # + page-parse /models/ (performerzy) + /tags/. Sample: median ~10.5min, 93% ≥3min.
# Mega-katalog ~13M → deep_crawl._PAGE_CAP["xvideoscom"]=1800 (~50k najnowszych), nie # Mega-katalog ~13M → deep_crawl._PAGE_CAP["xvideoscom"]=1800 (~50k najnowszych), nie
# full-crawl. (youporn pominięty — JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.) # full-crawl. (youporn pominięty, JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
XVideosBrowseScraper, XVideosBrowseScraper,
# HQFapScraper — PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA # HQFapScraper, PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
# biblioteka CDN serwowała `/upload/videos/video_down.mp4` stub; PRZYWRÓCONY 2026-07-07 # biblioteka CDN serwowała `/upload/videos/video_down.mp4` stub; PRZYWRÓCONY 2026-07-07
# bo strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP), user request. # bo strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP), user request.
# HQFapScraper — ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła # HQFapScraper, ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
# Cloudflare z JS-challenge na /watch/: 403 z VPS, z residential IP przy pełnych # Cloudflare z JS-challenge na /watch/: 403 z VPS, z residential IP przy pełnych
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy — czyli blokada nie jest # nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy, czyli blokada nie jest
# po IP, tylko wymaga wykonania JS. WebView też nie przechodzi automatycznie (user # po IP, tylko wymaga wykonania JS. WebView też nie przechodzi automatycznie (user
# widzi challenge: zgłoszenia 60576a84 / 244131ba / 92e9133e). Telemetria: ZERO prób # widzi challenge: zgłoszenia 60576a84 / 244131ba / 92e9133e). Telemetria: ZERO prób
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS — # odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS,
# nieproporcjonalne, decyzja: usunąć (jak 4k69 i pierwsze usunięcie hqfap 2026-06-25). # nieproporcjonalne, decyzja: usunąć (jak 4k69 i pierwsze usunięcie hqfap 2026-06-25).
# HQPornerBrowseScraper — dołączony 2026-07-26. hqporner był TYLKO w # HQPornerBrowseScraper, dołączony 2026-07-26. hqporner był TYLKO w
# ALL_DIRECT_SCRAPERS (search po performerze), więc świeże sceny wchodziły wyłącznie # ALL_DIRECT_SCRAPERS (search po performerze), więc świeże sceny wchodziły wyłącznie
# przy trafieniu na znanego performera → 0 nowych scen w 7 dni (user-report). Browse # przy trafieniu na znanego performera → 0 nowych scen w 7 dni (user-report). Browse
# (listing / + ?p=N) daje ingest niezależny od stanu performerów. external_id ten sam # (listing / + ?p=N) daje ingest niezależny od stanu performerów. external_id ten sam
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę. # co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
HQPornerBrowseScraper, HQPornerBrowseScraper,
# FourK69Scraper — USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie. # FourK69Scraper, USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
# JavflixScraper — JAV vertical (osobna sekcja). origin tube:javflix jest w # JavflixScraper, JAV vertical (osobna sekcja). origin tube:javflix jest w
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV). # JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
JavflixScraper, JavflixScraper,
# VjavScraper — JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS). # VjavScraper, JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
# TXXX network: sitemap id-walk (newest=max id) + JSON metadata API. Stream # TXXX network: sitemap id-walk (newest=max id) + JSON metadata API. Stream
# videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10. # videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10.
VjavScraper, VjavScraper,
# SupjavScraper — JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS). # SupjavScraper, JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
# CF-blokuje VPS → browse homepage przez Bright Data proxy. Embed-aggregator: # CF-blokuje VPS → browse homepage przez Bright Data proxy. Embed-aggregator:
# data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10. # data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10.
SupjavScraper, SupjavScraper,
# NepornScraper — dołączony 2026-06-10 (user request). KVS engine (jak freshporno/ # NepornScraper, dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
# porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration # porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration
# meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa # meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa
# "- HardX Update - ..." — fuzzy match po tytule). Resolve server-side _kvs, # "- HardX Update - ...", fuzzy match po tytule). Resolve server-side _kvs,
# finalny remote_control.php portable cross-IP. # finalny remote_control.php portable cross-IP.
NepornScraper, NepornScraper,
# SuperpornScraper — dołączony 2026-06-10 (user request). superporn blokuje VPS IP # SuperpornScraper, dołączony 2026-06-10 (user request). superporn blokuje VPS IP
# twardym CF 403 (każda impersonacja TLS), więc ingest HTML idzie przez Bright Data # twardym CF 403 (każda impersonacja TLS), więc ingest HTML idzie przez Bright Data
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy — `_proxy` w # ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy, `_proxy` w
# _browse_base. JSON-LD (title+desc+uploadDate+thumb+duration) + chipy pornstar/ # _browse_base. JSON-LD (title+desc+uploadDate+thumb+duration) + chipy pornstar/
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback). # kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
# Bez proxy: scraper no-op (pusty iterator). # Bez proxy: scraper no-op (pusty iterator).
SuperpornScraper, SuperpornScraper,
# porntrex/hqporner/youporn — NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03). # porntrex/hqporner/youporn, NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
# ShyfapScraper — wyłączony 2026-05-12 (pilot fail, 0% match — orphan factory). # ShyfapScraper, wyłączony 2026-05-12 (pilot fail, 0% match, orphan factory).
] ]
__all__ = [ __all__ = [

View file

@ -1,15 +1,15 @@
"""BaseBrowseScraper — latest-vids browse mode (vs search-by-performer). """BaseBrowseScraper, latest-vids browse mode (vs search-by-performer).
Wzorzec: tube'y typu shyfap/freshporno/porn00/fullmovies/pornxp mają bogatą Wzorzec: tube'y typu shyfap/freshporno/porn00/fullmovies/pornxp mają bogatą
metadata (title, studio, performers, tags, duration, release_date, description) metadata (title, studio, performers, tags, duration, release_date, description)
na detail page'u — wystarczy do canonical fuzzy match w resolverze. Browse mode na detail page'u, wystarczy do canonical fuzzy match w resolverze. Browse mode
iteruje "latest" page (sorted by upload date) i fetchuje detail per scene. iteruje "latest" page (sorted by upload date) i fetchuje detail per scene.
Różnica vs `BaseSearchScraper`: Różnica vs `BaseSearchScraper`:
- **search**: tube wyszukuje sceny po performer name (dla performer-driven - **search**: tube wyszukuje sceny po performer name (dla performer-driven
backfill). Wymaga znanego performera. backfill). Wymaga znanego performera.
- **browse**: tube listuje newest scenes (latest-vids endpoint). Nie wymaga - **browse**: tube listuje newest scenes (latest-vids endpoint). Nie wymaga
żadnego query — chodzi o świeże sceny independent of performer state. żadnego query, chodzi o świeże sceny independent of performer state.
Browse jest komplementarny do search: Browse jest komplementarny do search:
- search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube) - search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube)
@ -67,15 +67,15 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
"""Parsuj scene detail HTML → RawScene z metadata. """Parsuj scene detail HTML → RawScene z metadata.
Zwraca None gdy scena niedostępna / parse fail — caller pominie ten URL, Zwraca None gdy scena niedostępna / parse fail, caller pominie ten URL,
nie aborti całe browse.""" nie aborti całe browse."""
def crawl_page(self, page: int) -> list[RawScene] | None: def crawl_page(self, page: int) -> list[RawScene] | None:
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest """Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
(top-N) i deep_crawl (kursor). Zwraca: (top-N) i deep_crawl (kursor). Zwraca:
None — transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted), None , transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
[] — pusty listing = koniec katalogu (caller: exhausted), [] pusty listing = koniec katalogu (caller: exhausted),
[...] — sceny z tej strony. [...], sceny z tej strony.
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
(call API zamiast listing→detail). HTML browse subclasses dostarczają (call API zamiast listing→detail). HTML browse subclasses dostarczają
@ -120,7 +120,7 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
break break
yield from scenes yield from scenes
# Stub `search()` — BaseDirectTubeScraper wymaga implementacji. Dla browse-only # Stub `search()`, BaseDirectTubeScraper wymaga implementacji. Dla browse-only
# tubes nie supportujemy performer-driven search; zwracamy pusty iterator. Tube'y # tubes nie supportujemy performer-driven search; zwracamy pusty iterator. Tube'y
# które chcą *oba* tryby mogą override'ować search() osobno. # które chcą *oba* tryby mogą override'ować search() osobno.
def search( def search(
@ -148,7 +148,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
Format pasuje do `SceneFingerprint.value` w DB (TPDB/StashDB importują ten sam Format pasuje do `SceneFingerprint.value` w DB (TPDB/StashDB importują ten sam
8x8 phash). Resolver Path 3 `find_by_phash_within` matchuje Hamming ≤5 (default). 8x8 phash). Resolver Path 3 `find_by_phash_within` matchuje Hamming ≤5 (default).
Wymaga lazy importu `imagehash`/`PIL` — żeby moduł browse_base importował się Wymaga lazy importu `imagehash`/`PIL`, żeby moduł browse_base importował się
nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver
spadnie do composite scoring, jak gdyby fingerprintu nie było). spadnie do composite scoring, jak gdyby fingerprintu nie było).
""" """
@ -169,7 +169,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
return None return None
img = Image.open(io.BytesIO(r.content)) img = Image.open(io.BytesIO(r.content))
# phash domyślnie hash_size=8 → 64-bit hash → 16 hex chars. Mode 'L' (greyscale) # phash domyślnie hash_size=8 → 64-bit hash → 16 hex chars. Mode 'L' (greyscale)
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame — # robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame,
# convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey. # convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey.
return str(imagehash.phash(img.convert("RGB"))) return str(imagehash.phash(img.convert("RGB")))
except Exception as e: except Exception as e:
@ -183,7 +183,7 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
Standardowy helper dla scraperów które używają OpenGraph / ya:ovs / itp. Standardowy helper dla scraperów które używają OpenGraph / ya:ovs / itp.
Cache compiled regex w module-scope dict (te same selectory powtarzają się). Cache compiled regex w module-scope dict (te same selectory powtarzają się).
NB: separate patterns dla `"..."` i `'...'` content quote — wcześniej jeden NB: separate patterns dla `"..."` i `'...'` content quote, wcześniej jeden
`[^"\']*` regex tnął title po wewnętrznym apostrofie (np. `<meta content="She's So Insatiable">` `[^"\']*` regex tnął title po wewnętrznym apostrofie (np. `<meta content="She's So Insatiable">`
→ `She`, bug-report 2026-05-20). Teraz matchujemy dokładnie ten sam quote co opening. → `She`, bug-report 2026-05-20). Teraz matchujemy dokładnie ten sam quote co opening.
""" """
@ -191,8 +191,8 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
if key not in _META_RE_CACHE: if key not in _META_RE_CACHE:
attr = "property" if property else "name" attr = "property" if property else "name"
val = re.escape(property or name or "") val = re.escape(property or name or "")
# double-quoted content (HTML standard) — preferred # double-quoted content (HTML standard), preferred
# Pattern: <meta property="X" content="...inner..." > — inner allows apostrophes # Pattern: <meta property="X" content="...inner..." > , inner allows apostrophes
_META_RE_CACHE[key] = re.compile( _META_RE_CACHE[key] = re.compile(
rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"' rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"'
rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'', rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'',

View file

@ -1,4 +1,4 @@
"""BasePlayTubeScraper — wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69). """BasePlayTubeScraper, wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
Platforma rozpoznawalna po: `/watch/<slug>_<id>.html`, sitemap index Platforma rozpoznawalna po: `/watch/<slug>_<id>.html`, sitemap index
`/sitemaps/videos/sitemap-N.xml` (z `<lastmod>`), JSON-LD VideoObject na detail `/sitemaps/videos/sitemap-N.xml` (z `<lastmod>`), JSON-LD VideoObject na detail
@ -9,9 +9,9 @@ Listing NIE paginuje się GET-em (PlayTube doładowuje AJAX-em `aj/load-more/`),
więc crawl_page buduje katalog z sitemapów (sort lastmod desc = newest first) więc crawl_page buduje katalog z sitemapów (sort lastmod desc = newest first)
i tnie na strony po `_PAGE_SIZE`. Działa dla browse_latest (pages 1-5) i i tnie na strony po `_PAGE_SIZE`. Działa dla browse_latest (pages 1-5) i
deep_crawl (kursor do końca katalogu). Minus: sitemap laguje ~dobę za deep_crawl (kursor do końca katalogu). Minus: sitemap laguje ~dobę za
najświeższymi uploadami — akceptowalne przy dziennym harmonogramie. najświeższymi uploadami, akceptowalne przy dziennym harmonogramie.
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()` — Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()`,
PlayTube nie ma strukturalnego pola studio na scenie, studio siedzi w PlayTube nie ma strukturalnego pola studio na scenie, studio siedzi w
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios). kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
@ -109,7 +109,7 @@ def _extract_video_object(html: str) -> dict | None:
class BasePlayTubeScraper(BaseBrowseScraper): class BasePlayTubeScraper(BaseBrowseScraper):
base_url: str # np. "https://hqfap.com" — subclass ustawia base_url: str # np. "https://hqfap.com", subclass ustawia
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -123,9 +123,9 @@ class BasePlayTubeScraper(BaseBrowseScraper):
def _pick_studio(self, category_names: list[str]) -> str | None: def _pick_studio(self, category_names: list[str]) -> str | None:
return None return None
# crawl_page override (jak EpornerApiScraper) — listing nie jest stronicowalny # crawl_page override (jak EpornerApiScraper), listing nie jest stronicowalny
# przez GET, źródłem paginacji jest sitemap. _listing_url/_extract_scene_urls # przez GET, źródłem paginacji jest sitemap. _listing_url/_extract_scene_urls
# nieużywane, ale abstrakcyjne — dostarczamy no-op implementacje. # nieużywane, ale abstrakcyjne, dostarczamy no-op implementacje.
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
return f"{self.base_url}/sitemap.xml" return f"{self.base_url}/sitemap.xml"
@ -156,7 +156,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
sm = browser_get(sm_url, timeout=self._timeout) sm = browser_get(sm_url, timeout=self._timeout)
sm.raise_for_status() sm.raise_for_status()
except Exception as e: except Exception as e:
# Brak jednego sitemapa ≠ fail całości — reszta katalogu wystarczy. # Brak jednego sitemapa ≠ fail całości, reszta katalogu wystarczy.
log.warning("%s: sitemap fetch failed %s: %s", self.sitetag, sm_url, e) log.warning("%s: sitemap fetch failed %s: %s", self.sitetag, sm_url, e)
continue continue
for block in _URL_BLOCK_RE.findall(sm.text): for block in _URL_BLOCK_RE.findall(sm.text):
@ -227,7 +227,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
thumbnail_url = video.get("thumbnailUrl") or None thumbnail_url = video.get("thumbnailUrl") or None
# Pille: pornstar → performer; category → studio (hook `_pick_studio`) # Pille: pornstar → performer; category → studio (hook `_pick_studio`)
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy — gwiazdki to nie dane. # albo tag. Ocenzurowane nazwy (`Te***`) pomijamy, gwiazdki to nie dane.
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
category_names: list[str] = [] category_names: list[str] = []
seen_perf: set[str] = set() seen_perf: set[str] = set()
@ -275,7 +275,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
if ph: if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph)) fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Stream: JSON-LD contentUrl wygasa (token time-bound) — NIE zapisujemy # Stream: JSON-LD contentUrl wygasa (token time-bound), NIE zapisujemy
# stream_url; extractor per-sitetag resolvuje świeży on-demand. # stream_url; extractor per-sitetag resolvuje świeży on-demand.
playback_sources = [ playback_sources = [
RawPlaybackSource( RawPlaybackSource(

View file

@ -1,20 +1,20 @@
"""BaseSearchScraper — shared search-page HTML scraping logika. """BaseSearchScraper, shared search-page HTML scraping logika.
Wzorzec stosowany przez wszystkie tube'y discovery scrapers: Wzorzec stosowany przez wszystkie tube'y discovery scrapers:
1. Build search URL z `_search_url_template` (formatowane query+page). 1. Build search URL z `_search_url_template` (formatowane query+page).
2. Fetch HTML curl_cffi. 2. Fetch HTML curl_cffi.
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL, 3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
opcjonalnie `slug` lub `id` jako tytuł source). opcjonalnie `slug` lub `id` jako tytuł source).
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query) — 4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query),
fuzzy search tube'ów często zwraca niezwiązane wyniki. fuzzy search tube'ów często zwraca niezwiązane wyniki.
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`. 5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
Subclass override: Subclass override:
- `sitetag: str` — np. "pornhubcom" - `sitetag: str`, np. "pornhubcom"
- `_search_url_template: str` — z `{query}` i `{page}` placeholderami - `_search_url_template: str`, z `{query}` i `{page}` placeholderami
- `_scene_url_re: re.Pattern[str]` — regex z named group `url` (scene URL) - `_scene_url_re: re.Pattern[str]`, regex z named group `url` (scene URL)
- `_title_from_match(match) -> str` — opcjonalny override (default: derive z URL slug) - `_title_from_match(match) -> str`, opcjonalny override (default: derive z URL slug)
- `_token_filter_text(match) -> str` — co testować na query tokens (default: cała URL) - `_token_filter_text(match) -> str`, co testować na query tokens (default: cała URL)
""" """
from __future__ import annotations from __future__ import annotations
@ -30,7 +30,7 @@ from app.extractors import browser_get
# Image src extraction: matches src, data-src, data-original, data-lazy-src, data-lazy # Image src extraction: matches src, data-src, data-original, data-lazy-src, data-lazy
# (lazy-load lib variants). Wymaga rozszerzenia obrazka żeby ograniczyć false positives # (lazy-load lib variants). Wymaga rozszerzenia obrazka żeby ograniczyć false positives
# (sprite icons, spinners) — JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają # (sprite icons, spinners), JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
# dla scene thumbnails. # dla scene thumbnails.
_IMG_SRC_RE = re.compile( _IMG_SRC_RE = re.compile(
r'<img[^>]+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']' r'<img[^>]+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']'
@ -53,7 +53,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
#: Regex matchujący scene URL w search HTML. Wymagana grupa `url` (full scene URL), #: Regex matchujący scene URL w search HTML. Wymagana grupa `url` (full scene URL),
#: opcjonalna grupa `slug` (do title derivation gdy dostępny w URL). #: opcjonalna grupa `slug` (do title derivation gdy dostępny w URL).
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder — subclass override _scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder, subclass override
#: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby #: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby
#: nie matchowały niezwiązanych slugów). #: nie matchowały niezwiązanych slugów).
@ -74,12 +74,12 @@ class BaseSearchScraper(BaseDirectTubeScraper):
}) })
#: Window (chars) wokół scene URL match, w którym szukamy `<img>` jako thumbnail. #: Window (chars) wokół scene URL match, w którym szukamy `<img>` jako thumbnail.
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>` — #: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>`,
#: ±800 chars łapie ten pattern niezawodnie. #: ±800 chars łapie ten pattern niezawodnie.
_thumbnail_window: int = 800 _thumbnail_window: int = 800
def _scene_url_from_match(self, m: re.Match[str]) -> str: def _scene_url_from_match(self, m: re.Match[str]) -> str:
"""Domyślnie group(1) — subclass override gdy regex używa named groups inaczej.""" """Domyślnie group(1), subclass override gdy regex używa named groups inaczej."""
try: try:
return m.group("url") return m.group("url")
except IndexError: except IndexError:
@ -103,17 +103,17 @@ class BaseSearchScraper(BaseDirectTubeScraper):
def _format_query_for_url(self, query: str) -> str: def _format_query_for_url(self, query: str) -> str:
"""Default: URL-encode (spaces → `+`). Subclass override gdy tube wymaga """Default: URL-encode (spaces → `+`). Subclass override gdy tube wymaga
innego formatu — np. KVS-style sites użyją slug (spaces → `-`). innego formatu, np. KVS-style sites użyją slug (spaces → `-`).
""" """
return urllib.parse.quote_plus(query.strip()) return urllib.parse.quote_plus(query.strip())
def _fetch_scene_metadata( def _fetch_scene_metadata(
self, scene_url: str self, scene_url: str
) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None: ) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None:
"""Optional hook — subclass może override żeby fetch'ować scene detail page """Optional hook, subclass może override żeby fetch'ować scene detail page
i wyciągnąć studio/performerów/tagi. Default zwraca None (skip detail fetch). i wyciągnąć studio/performerów/tagi. Default zwraca None (skip detail fetch).
Wywoływane PER SCENE w `search()` — dodaje +1 HTTP request per match. Subclass Wywoływane PER SCENE w `search()`, dodaje +1 HTTP request per match. Subclass
powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata. powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata.
Returns: (studio, performers, tags). Każde może być None / pusta lista. Returns: (studio, performers, tags). Każde może być None / pusta lista.
@ -153,7 +153,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
if scene_url.startswith("//"): if scene_url.startswith("//"):
scene_url = "https:" + scene_url scene_url = "https:" + scene_url
elif scene_url.startswith("/"): elif scene_url.startswith("/"):
# Relative URL — prefix host z search URL. # Relative URL, prefix host z search URL.
base = urllib.parse.urlparse(url) base = urllib.parse.urlparse(url)
scene_url = f"{base.scheme}://{base.netloc}{scene_url}" scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
if scene_url in seen: if scene_url in seen:
@ -165,11 +165,11 @@ class BaseSearchScraper(BaseDirectTubeScraper):
if slug_lower in self._nav_slug_blacklist: if slug_lower in self._nav_slug_blacklist:
continue continue
# Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()` # Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()`
# przepuszczał scenę gdy choć jeden token był w slug — dla performera # przepuszczał scenę gdy choć jeden token był w slug, dla performera
# "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug # "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug
# (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx", # (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
# bo `any("ava" in slug)` =True. User reports: scena "ava devine # bo `any("ava" in slug)` =True. User reports: scena "ava devine
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()` — slug musi # gangbanged..." miała Ava Koxxx w DB. Fix: `all()`, slug musi
# zawierać każdy ≥3-char token z imienia performera. # zawierać każdy ≥3-char token z imienia performera.
if query_tokens and not all(tok in slug_lower for tok in query_tokens): if query_tokens and not all(tok in slug_lower for tok in query_tokens):
continue continue
@ -178,7 +178,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
# Thumbnail: search ±N chars around scene_url match for nearest <img src=>. # Thumbnail: search ±N chars around scene_url match for nearest <img src=>.
# Większość tubes ma `<a href="<scene>"><img src="<thumb>"></a>` lub flat # Większość tubes ma `<a href="<scene>"><img src="<thumb>"></a>` lub flat
# `<img src=><a href=>` — window 800 obejmuje oba. # `<img src=><a href=>`, window 800 obejmuje oba.
window_start = max(0, m.start() - self._thumbnail_window) window_start = max(0, m.start() - self._thumbnail_window)
window_end = min(len(r.text), m.end() + self._thumbnail_window) window_end = min(len(r.text), m.end() + self._thumbnail_window)
window_html = r.text[window_start:window_end] window_html = r.text[window_start:window_end]
@ -193,7 +193,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}" thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
# Opcjonalny metadata fetch (studio/dodatkowi performerzy/tagi). Default # Opcjonalny metadata fetch (studio/dodatkowi performerzy/tagi). Default
# zwraca None — większość tube'ów ma tylko search HTML bez metadata. # zwraca None, większość tube'ów ma tylko search HTML bez metadata.
# PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach. # PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach.
studio: RawStudio | None = None studio: RawStudio | None = None
extra_performers: list[RawPerformer] = [] extra_performers: list[RawPerformer] = []
@ -212,7 +212,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
description = meta[3] description = meta[3]
# Performer z query zawsze obecny (driver scraping). Extra performers # Performer z query zawsze obecny (driver scraping). Extra performers
# z detail page dorzucamy — dedupe po slug/name w resolverze. # z detail page dorzucamy, dedupe po slug/name w resolverze.
all_performers = [RawPerformer(name=query.strip()), *extra_performers] all_performers = [RawPerformer(name=query.strip()), *extra_performers]
yield RawScene( yield RawScene(

View file

@ -1,4 +1,4 @@
"""BaseDirectTubeScraper — kontrakt dla bezpośrednich scraperów tube'ów.""" """BaseDirectTubeScraper, kontrakt dla bezpośrednich scraperów tube'ów."""
from __future__ import annotations from __future__ import annotations
import abc import abc
@ -13,7 +13,7 @@ class BaseDirectTubeScraper(abc.ABC):
żeby dziedziczyć logikę resolvera + idempotent merge per external_id.""" żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
sitetag: str sitetag: str
"""Stabilny ID tube'a — używany w external_id `f"{sitetag}:{url}"`. Zgodny """Stabilny ID tube'a, używany w external_id `f"{sitetag}:{url}"`. Zgodny
z porn-app sitetag (hqpornercom, sxylandcom, itp.).""" z porn-app sitetag (hqpornercom, sxylandcom, itp.)."""
@abc.abstractmethod @abc.abstractmethod

View file

@ -1,4 +1,4 @@
"""eporner.com — direct HTML scrape search results. """eporner.com, direct HTML scrape search results.
Search: `https://www.eporner.com/search/<q>/<page>/` (1-indexed pages). Search: `https://www.eporner.com/search/<q>/<page>/` (1-indexed pages).
Scene URL: `https://www.eporner.com/hd-porn/<id>/<slug>/` lub `/video-<id>/<slug>/`. Scene URL: `https://www.eporner.com/hd-porn/<id>/<slug>/` lub `/video-<id>/<slug>/`.

View file

@ -1,11 +1,11 @@
"""eporner.com — deep-crawl przez oficjalne JSON API (api/v2/video/search). """eporner.com, deep-crawl przez oficjalne JSON API (api/v2/video/search).
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów) — ALE eporner ma Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów), ALE eporner ma
publiczne API zwracające KOMPLETNĄ metadatę w jednym callu: `title`, `length_sec` publiczne API zwracające KOMPLETNĄ metadatę w jednym callu: `title`, `length_sec`
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów, (duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
`order=latest`, ~100/stronę → ~1000 szybkich calli (BEZ detail-fetch). To czyni `order=latest`, ~100/stronę → ~1000 szybkich calli (BEZ detail-fetch). To czyni
eporner idealnym SSR-bogatym źródłem deep-crawla (analiza 2026-06-03: porntrex/hqporner eporner idealnym SSR-bogatym źródłem deep-crawla (analiza 2026-06-03: porntrex/hqporner
odrzucone — KVS bez SSR duration; eporner-API je zastępuje). odrzucone, KVS bez SSR duration; eporner-API je zastępuje).
Override `crawl_page()` (API flow). HTML-owe _listing_url/_extract/_parse to stuby Override `crawl_page()` (API flow). HTML-owe _listing_url/_extract/_parse to stuby
(BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co (BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co
@ -113,7 +113,7 @@ class EpornerApiScraper(BaseBrowseScraper):
) )
return out return out
# HTML stuby — nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga. # HTML stuby, nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
def _listing_url(self, page: int) -> str: # pragma: no cover def _listing_url(self, page: int) -> str: # pragma: no cover
raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu") raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu")

View file

@ -1,6 +1,6 @@
"""fpo.xxx — latest-vids browse scraper (KVS engine). """fpo.xxx, latest-vids browse scraper (KVS engine).
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP — Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP,
search zwracał 0 (regex slug-URL nie pasował do `/video/<id>/`). Przerobione na search zwracał 0 (regex slug-URL nie pasował do `/video/<id>/`). Przerobione na
BROWSE (latest z `/new-<n>/`), 2026-06-22 (user request: ożywić zamrożone tuby). BROWSE (latest z `/new-<n>/`), 2026-06-22 (user request: ożywić zamrożone tuby).
@ -11,7 +11,7 @@ Listing tile (`/new-<n>/`):
→ tytuł, miniatura, duration, URL sceny. Performerów/tagów listing nie ma czysto → tytuł, miniatura, duration, URL sceny. Performerów/tagów listing nie ma czysto
(tytuł bywa JAV-code "Imai Kaho-RKI-602 ..."), więc puste → dorabia canonical-merge. (tytuł bywa JAV-code "Imai Kaho-RKI-602 ..."), więc puste → dorabia canonical-merge.
Playback: KVS (kt_player + license_code na detail page) — token IP-bound, resolve Playback: KVS (kt_player + license_code na detail page), token IP-bound, resolve
PO STRONIE TELEFONU (fpoxxxResolver.ts / WebView fallback, extractor `fpoxxx`). PO STRONIE TELEFONU (fpoxxxResolver.ts / WebView fallback, extractor `fpoxxx`).
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -1,4 +1,4 @@
"""freshporno.org — latest-vids browse scraper. """freshporno.org, latest-vids browse scraper.
Pilot #2 (po shyfap fail). Hipoteza: freshporno zachowuje oryginalne studio titles Pilot #2 (po shyfap fail). Hipoteza: freshporno zachowuje oryginalne studio titles
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match ("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
@ -9,7 +9,7 @@ URL patterns:
- Scene: `/videos/<slug>/` - Scene: `/videos/<slug>/`
Listing: świadomie `/latest-updates/` zamiast roota `/`. Root jest KVS-owym Listing: świadomie `/latest-updates/` zamiast roota `/`. Root jest KVS-owym
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request — serwer homepage z `cache-control: no-store` i świeżym PHPSESSID per-request, serwer
rotuje tam blok "featured" i na zimnej sesji potrafi podać stary zestaw zamiast rotuje tam blok "featured" i na zimnej sesji potrafi podać stary zestaw zamiast
najnowszych (zaobserwowane 2026-06-15: 3 dni browse-latest skipowało wszystko bo najnowszych (zaobserwowane 2026-06-15: 3 dni browse-latest skipowało wszystko bo
root podawał sceny z 20 maja; freshporno.org report). `/latest-updates/` to jawny root podawał sceny z 20 maja; freshporno.org report). `/latest-updates/` to jawny
@ -108,7 +108,7 @@ class FreshpornoScraper(BaseBrowseScraper):
# Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body # Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym) # (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
studio: RawStudio | None = None studio: RawStudio | None = None
# Skipnij nav linki ze stringiem "Channels" jako anchor text — bierzemy specific channel # Skipnij nav linki ze stringiem "Channels" jako anchor text, bierzemy specific channel
for m in _CHANNEL_LINK_RE.finditer(detail_html): for m in _CHANNEL_LINK_RE.finditer(detail_html):
slug, name = m.group(1), m.group(2).strip() slug, name = m.group(1), m.group(2).strip()
if name.lower() in ("channels", ""): if name.lower() in ("channels", ""):
@ -120,7 +120,7 @@ class FreshpornoScraper(BaseBrowseScraper):
) )
break break
# Performers — wszyscy `/models/<slug>/` # Performers, wszyscy `/models/<slug>/`
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
seen_perf: set[str] = set() seen_perf: set[str] = set()
for m in _MODEL_LINK_RE.finditer(detail_html): for m in _MODEL_LINK_RE.finditer(detail_html):
@ -154,7 +154,7 @@ class FreshpornoScraper(BaseBrowseScraper):
) )
# Phash z thumbnail. Wiemy że freshporno używa internal screenshots (preview.mp4.jpg) # Phash z thumbnail. Wiemy że freshporno używa internal screenshots (preview.mp4.jpg)
# więc to też może nie matchować canonical phashy — ale test pokaże. # więc to też może nie matchować canonical phashy, ale test pokaże.
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []
if thumbnail_url: if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/") ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
@ -170,8 +170,8 @@ class FreshpornoScraper(BaseBrowseScraper):
) )
] ]
# Release date — freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`. # Release date, freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
# To data wrzucenia na freshporno, NIE oryginalna release_date studio — ale dla # To data wrzucenia na freshporno, NIE oryginalna release_date studio, ale dla
# świeżych scen (uploaded niedługo po publikacji) różnica ≤ 3-7 dni, mieści się w # świeżych scen (uploaded niedługo po publikacji) różnica ≤ 3-7 dni, mieści się w
# `date_window_days=7` w resolverze. Bez tego pola scene NULL → match score 0 → # `date_window_days=7` w resolverze. Bez tego pola scene NULL → match score 0 →
# duplicate scene zamiast freshporno PS dodane do TPDB canonical (bug-report # duplicate scene zamiast freshporno PS dodane do TPDB canonical (bug-report

View file

@ -1,8 +1,8 @@
"""fullmovies.xxx — latest-vids browse scraper. """fullmovies.xxx, latest-vids browse scraper.
Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`, Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`,
`/networks/<slug>/`, `/models/<slug>/`, `/tags/<slug>/`. og:image to `img.fullmovies.xxx/...` `/networks/<slug>/`, `/models/<slug>/`, `/tags/<slug>/`. og:image to `img.fullmovies.xxx/...`
— **prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi. **prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
""" """
from __future__ import annotations from __future__ import annotations
@ -71,7 +71,7 @@ class FullmoviesScraper(BaseBrowseScraper):
# 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
# zwracał pierwszą z listy (Brazzers) dla każdej sceny — mis-attribution. Tytuł # zwracał pierwszą z listy (Brazzers) dla każdej sceny, mis-attribution. Tytuł
# po oczyszczeniu ma format "Studio - Opis" (np. "Fake Hostel - ..."). # po oczyszczeniu ma format "Studio - Opis" (np. "Fake Hostel - ...").
studio: RawStudio | None = None studio: RawStudio | None = None
if " - " in title: if " - " in title:
@ -104,7 +104,7 @@ class FullmoviesScraper(BaseBrowseScraper):
tags.append(RawTag(external_id=f"fullmoviesxxx:tag:{slug}", name=name, slug=slug)) tags.append(RawTag(external_id=f"fullmoviesxxx:tag:{slug}", name=name, slug=slug))
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do # Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
# canonical — auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio # canonical, auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
# thumbnaile). Matching trzyma się na title+performer+duration (seed: 92% tagged), # thumbnaile). Matching trzyma się na title+performer+duration (seed: 92% tagged),
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display). # więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []

View file

@ -1,4 +1,4 @@
"""fullporner.com — browse scraper. Dodany 2026-07-27. """fullporner.com, browse scraper. Dodany 2026-07-27.
Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s
na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu
@ -11,17 +11,17 @@ Listing jest ściśle chronologiczny, mimo że nagłówek mówi „Featured Vide
**Metadane są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest **Metadane są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu
studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
– Dylan Moore – Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp** - Dylan Moore - Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
(dokładna, nie względna) i długość co do sekundy. (dokładna, nie względna) i długość co do sekundy.
Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które
już mamy, przez `composite_auto` — composite radzi sobie mimo złych tytułów, bo waży już mamy, przez `composite_auto`, composite radzi sobie mimo złych tytułów, bo waży
performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po
odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash
miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać. miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać.
**Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23 **Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23
przypadków — bez performera composite nie ma na czym pracować, a studia tu nie ma przypadków, bez performera composite nie ma na czym pracować, a studia tu nie ma
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
tylko sceny z obsadą. tylko sceny z obsadą.
@ -55,7 +55,7 @@ _THUMB = "https://imgs.xiaoshenke.net/thumb/{}.jpg"
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"') _SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>") _TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
# `<span class="create">1785081937</span></div><div>36:33</div>` — data i długość # `<span class="create">1785081937</span></div><div>36:33</div>`, data i długość
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem. # siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
_INFO_RE = re.compile( _INFO_RE = re.compile(
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>' r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
@ -64,7 +64,7 @@ _PERF_BLOCK_RE = re.compile(r"<b>\s*Pornstars?:\s*</b>(.*?)</p>", re.IGNORECASE
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>') _PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL) _TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>') _TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
# `<iframe src="//xiaoshenke.net/video/4458723/14">` — id wspak, /14 = maska jakości. # `<iframe src="//xiaoshenke.net/video/4458723/14">`, id wspak, /14 = maska jakości.
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu). # Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"') _IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
@ -101,7 +101,7 @@ class FullPornerScraper(BaseBrowseScraper):
pb = _PERF_BLOCK_RE.search(detail_html) pb = _PERF_BLOCK_RE.search(detail_html)
if pb: if pb:
for name in _PERF_RE.findall(pb.group(1)): for name in _PERF_RE.findall(pb.group(1)):
# Strona pisze performerów z małej („melissa straton") — do wyświetlania # Strona pisze performerów z małej („melissa straton"), do wyświetlania
# podnosimy, dopasowanie i tak idzie po slugu. # podnosimy, dopasowanie i tak idzie po slugu.
name = html_mod.unescape(name).strip().title() name = html_mod.unescape(name).strip().title()
slug = slugify(name) slug = slugify(name)

View file

@ -1,4 +1,4 @@
"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ. """fullvideosporn.com (= sextu.com pod nową marką), browse scraper z BRAMKĄ NA OBSADĘ.
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140 To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`. pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
@ -6,15 +6,15 @@ pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez **BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany. Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem — wtedy sygnał jest dobry: 88-89% Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem, wtedy sygnał jest dobry: 88-89%
nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
~25-35% katalogu (~150-220k scen), czyli i tak dużo. ~25-35% katalogu (~150-220k scen), czyli i tak dużo.
Pozostałe pułapki, wszystkie obsłużone niżej: Pozostałe pułapki, wszystkie obsłużone niżej:
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** — te bywają - **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** te bywają
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom"). tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** — na całej stronie - **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** na całej stronie
jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles). jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc - sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie. parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
@ -65,7 +65,7 @@ _UNIT_DAYS = {
def _submitted_to_date(detail_html: str): def _submitted_to_date(detail_html: str):
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a """`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
to jest data uploadu na tube (nie premiery studia) — traktujemy jak inne tuby.""" to jest data uploadu na tube (nie premiery studia), traktujemy jak inne tuby."""
m = _SUBMITTED_RE.search(detail_html) m = _SUBMITTED_RE.search(detail_html)
if not m: if not m:
return None return None
@ -127,7 +127,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
return out return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
# Sekcje metadanych — każda w swoim <h3 class="item">. # Sekcje metadanych, każda w swoim <h3 class="item">.
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
tags: list[RawTag] = [] tags: list[RawTag] = []
studio: RawStudio | None = None studio: RawStudio | None = None
@ -162,7 +162,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl) RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
) )
# BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to # BRAMKA: bez obsady nie ingestujemy (patrz docstring, 2/3 katalogu to
# spunowane tytuły bez performera, które weszłyby jako puste orphany). # spunowane tytuły bez performera, które weszłyby jako puste orphany).
if not performers: if not performers:
return None return None

View file

@ -1,25 +1,25 @@
"""galaxyporn.net — browse scraper (WordPress retrotube). Dodany 2026-07-26. """galaxyporn.net, browse scraper (WordPress retrotube). Dodany 2026-07-26.
Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings, Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings,
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem — 92% performerów MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem, 92% performerów
z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny. z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny.
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0). Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
Metadane: Metadane:
- obsada: `<div id="video-actors">` — czysta, BEZ pollution (na całej stronie sceny - obsada: `<div id="video-actors">`, czysta, BEZ pollution (na całej stronie sceny
są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami) są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami)
- studio + data: prefiks tytułu `Studio YY MM DD Performer – Title` albo `[Studio]` - studio + data: prefiks tytułu `Studio YY MM DD Performer - Title` albo `[Studio]`
- tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio) - tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio)
- thumbnail: `data-main-thumb` / og:image - thumbnail: `data-main-thumb` / og:image
**Duration — uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani **Duration, uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje
`duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby `duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby
NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen). NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen).
Dlatego liczymy ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na Dlatego liczymy ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na
scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od
realnej (2226 s vs ~2276 s na próbce, ~2%) — akceptowalne, bo alternatywą jest brak. realnej (2226 s vs ~2276 s na próbce, ~2%), akceptowalne, bo alternatywą jest brak.
Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro / Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro /
sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący
@ -58,7 +58,7 @@ _TAGS_BLOCK_RE = re.compile(r'<div class="tags-list">(.*?)</div>', re.IGNORECASE
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE) _THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE) _IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch – Swapping Off The Pervy Maid` # `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch - Swapping Off The Pervy Maid`
_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>.+)$")
@ -105,7 +105,7 @@ def _resolve_duration(iframe_url: str, *, timeout: float, attempts: int = 3) ->
data = json.loads(_decrypt(r.text)) data = json.loads(_decrypt(r.text))
thumb = (data.get("thumbnail") or "").strip() thumb = (data.get("thumbnail") or "").strip()
if not thumb: if not thumb:
return None # payload OK, ale brak miniatur — retry nie pomoże return None # payload OK, ale brak miniatur, retry nie pomoże
vtt_url = host + thumb if thumb.startswith("/") else thumb vtt_url = host + thumb if thumb.startswith("/") else thumb
vr = browser_get(vtt_url, headers=headers, timeout=timeout) vr = browser_get(vtt_url, headers=headers, timeout=timeout)
if vr.status_code != 200: if vr.status_code != 200:
@ -145,7 +145,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
if not title: if not title:
return None return None
# Obsada — blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona # Obsada, blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
# ma dokładnie tyle linków /actor/ ile realnych aktorów). # ma dokładnie tyle linków /actor/ ile realnych aktorów).
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
seen_p: set[str] = set() seen_p: set[str] = set()
@ -186,7 +186,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
slug=slugify(studio_name), slug=slugify(studio_name),
) )
# Tagi — bez slugu studia (retrotube wrzuca studio także jako zwykły tag). # Tagi, bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
tags: list[RawTag] = [] tags: list[RawTag] = []
seen_t: set[str] = set() seen_t: set[str] = set()
studio_slug = slugify(studio_name) if studio_name else "" studio_slug = slugify(studio_name) if studio_name else ""

View file

@ -1,4 +1,4 @@
"""HDPorn92Scraper — direct HTML scrape hdporn92.com search. """HDPorn92Scraper, direct HTML scrape hdporn92.com search.
Search: `https://hdporn92.com/page/<n>/?s=<query>`. Scene URL format: Search: `https://hdporn92.com/page/<n>/?s=<query>`. Scene URL format:
`https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać `https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać

View file

@ -1,9 +1,9 @@
"""hdporn.gg — latest-vids browse scraper. """hdporn.gg, latest-vids browse scraper.
Engine podobny do freshporno: `/videos/<slug>/` URL, `/networks/<slug>/` = studio, Engine podobny do freshporno: `/videos/<slug>/` URL, `/networks/<slug>/` = studio,
`/models/<slug>/` = performer, `/tags/<slug>/` = tag. `/models/<slug>/` = performer, `/tags/<slug>/` = tag.
Quirk: og:image to internal CDN `img.hdporn.gg/...` — przed merging do prod Quirk: og:image to internal CDN `img.hdporn.gg/...`, przed merging do prod
sprawdzamy phash distance (gate-keeper: jeśli Hamming >5 dla >70% scen → orphan sprawdzamy phash distance (gate-keeper: jeśli Hamming >5 dla >70% scen → orphan
factory, wyłącz; analogia do shyfap). factory, wyłącz; analogia do shyfap).
""" """
@ -63,9 +63,9 @@ class HDPornGGScraper(BaseBrowseScraper):
title = meta_content(detail_html, property="og:title") title = meta_content(detail_html, property="og:title")
if not title: if not title:
return None return None
# og:title typowo zawiera ": Free HD Porn" suffix — usuń # og:title typowo zawiera ": Free HD Porn" suffix, usuń
title = re.sub(r":\s*Free HD Porn\s*$", "", title, flags=re.IGNORECASE).strip() title = re.sub(r":\s*Free HD Porn\s*$", "", title, flags=re.IGNORECASE).strip()
# I "Brazzers - " prefix często też w title — zostaw, bo studio name w title # I "Brazzers - " prefix często też w title, zostaw, bo studio name w title
# to silny sygnał dla fuzzy match. # to silny sygnał dla fuzzy match.
description = meta_content(detail_html, property="og:description") description = meta_content(detail_html, property="og:description")
@ -112,7 +112,7 @@ class HDPornGGScraper(BaseBrowseScraper):
) )
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do # Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
# canonical — auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile). # canonical, auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
# Matching trzyma się na title+performer+duration (seed: 92% tagged), więc download # Matching trzyma się na title+performer+duration (seed: 92% tagged), więc download
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display). # thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []

View file

@ -1,4 +1,4 @@
"""hqfap.com — latest-vids browse scraper (PlayTube CMS, patrz _playtube.py). """hqfap.com, latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
Dołączony 2026-06-10 (user request), usunięty 2026-06-25 (cała biblioteka CDN padła Dołączony 2026-06-10 (user request), usunięty 2026-06-25 (cała biblioteka CDN padła
na stub), przywrócony 2026-07-07 (strona wróciła na CDN vstor.top z realnymi plikami, na stub), przywrócony 2026-07-07 (strona wróciła na CDN vstor.top z realnymi plikami,

View file

@ -1,12 +1,12 @@
"""HQPornerScraper — direct HTML scrape hqporner search page. """HQPornerScraper, direct HTML scrape hqporner search page.
Search URL: `https://hqporner.com/?q=<query>&p=<page>`. Static HTML zwraca ~50 Search URL: `https://hqporner.com/?q=<query>&p=<page>`. Static HTML zwraca ~50
linków `/hdporn/<id>-<slug>.html` per strona. Tytuł deducimy ze slug'a (porn-app linków `/hdporn/<id>-<slug>.html` per strona. Tytuł deducimy ze slug'a (porn-app
data API zwraca dokładniejszy ale wymaga round-trip — dla MVP slug-derived OK, data API zwraca dokładniejszy ale wymaga round-trip, dla MVP slug-derived OK,
resolver i tak je sciagnie z TPDB merge). resolver i tak je sciagnie z TPDB merge).
Search fuzzy: hqporner zwraca "Lola Noir" gdy szukamy "Noir" itp. Dlatego Search fuzzy: hqporner zwraca "Lola Noir" gdy szukamy "Noir" itp. Dlatego
filtrujemy wyniki po tym czy slug zawiera query (lub jego token) — analogicznie filtrujemy wyniki po tym czy slug zawiera query (lub jego token), analogicznie
jak `fetch_scenes_for_search` w pornapp connectorze. jak `fetch_scenes_for_search` w pornapp connectorze.
""" """
from __future__ import annotations from __future__ import annotations
@ -55,7 +55,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być). # Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3} query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3}
# Człon <3 znaki ("Lady Vi", "Mia Li", "Ai Uehara") wypada z `query_tokens`, więc # Człon <3 znaki ("Lady Vi", "Mia Li", "Ai Uehara") wypada z `query_tokens`, więc
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'" — # zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'",
# czyli KAŻDA scena z tym słowem (audit 2026-07-28: Lady Vi miała 116 przypisań # czyli KAŻDA scena z tym słowem (audit 2026-07-28: Lady Vi miała 116 przypisań
# z tube, z czego 115 tylko przez „lady", realnie o niej 1). Gdy nazwa ma taki # z tube, z czego 115 tylko przez „lady", realnie o niej 1). Gdy nazwa ma taki
# człon, wymagamy dodatkowo CAŁEJ nazwy sklejonej bez separatorów (slug # człon, wymagamy dodatkowo CAŁEJ nazwy sklejonej bez separatorów (slug
@ -92,7 +92,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
page_url=scene_url, page_url=scene_url,
) )
], ],
# Wymuszamy hint performera = query — search per performer name znaczy # Wymuszamy hint performera = query, search per performer name znaczy
# że scena prawie na pewno o nim. Resolver dorobi ScenePerformer link. # że scena prawie na pewno o nim. Resolver dorobi ScenePerformer link.
performers=[RawPerformer(name=query.strip())], performers=[RawPerformer(name=query.strip())],
raw={ raw={

View file

@ -1,4 +1,4 @@
"""HQPornerBrowseScraper — latest-vids browse dla hqporner.com. """HQPornerBrowseScraper, latest-vids browse dla hqporner.com.
Dlaczego: hqporner był TYLKO w `ALL_DIRECT_SCRAPERS` (search po nazwisku performera), Dlaczego: hqporner był TYLKO w `ALL_DIRECT_SCRAPERS` (search po nazwisku performera),
więc nowe sceny wchodziły wyłącznie wtedy, gdy worker akurat szukał pasującego więc nowe sceny wchodziły wyłącznie wtedy, gdy worker akurat szukał pasującego
@ -17,7 +17,7 @@ Detail (`/hdporn/<id>-<slug>.html`) ma komplet metadanych:
`external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`. `external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`.
Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami. Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami.
Miniaturki fastporndelivery wymagają Referera (bez niego 403) — backend i tak owija je Miniaturki fastporndelivery wymagają Referera (bez niego 403), backend i tak owija je
w `/proxy/img/`, a phash liczymy z jawnym refererem. w `/proxy/img/`, a phash liczymy z jawnym refererem.
""" """
from __future__ import annotations from __future__ import annotations
@ -134,7 +134,7 @@ class HQPornerBrowseScraper(BaseBrowseScraper):
fingerprints.append(RawFingerprint(kind="phash", value=ph)) fingerprints.append(RawFingerprint(kind="phash", value=ph))
return RawScene( return RawScene(
# Ten sam format co HQPornerScraper.search — inaczej duplikat sceny. # Ten sam format co HQPornerScraper.search, inaczej duplikat sceny.
external_id=f"{self.sitetag}:{scene_url}", external_id=f"{self.sitetag}:{scene_url}",
title=title, title=title,
duration_sec=duration_sec, duration_sec=duration_sec,

View file

@ -1,7 +1,7 @@
"""javflix.cc — JAV browse scraper (WordPress, English-subbed JAV). """javflix.cc, JAV browse scraper (WordPress, English-subbed JAV).
Osobna pula JAV (kody typu BKD-368, tytuły azjatyckie), origin `tube:javflix`. Osobna pula JAV (kody typu BKD-368, tytuły azjatyckie), origin `tube:javflix`.
NIE deduplikuje się z zachodnim katalogiem — świadomie orphan vertical (sekcja JAV). NIE deduplikuje się z zachodnim katalogiem, świadomie orphan vertical (sekcja JAV).
Struktura (RE 2026-07-10): Struktura (RE 2026-07-10):
- listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/) - listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/)
@ -76,7 +76,7 @@ class JavflixScraper(BaseBrowseScraper):
up = _itemprop(detail_html, "uploadDate") up = _itemprop(detail_html, "uploadDate")
release_date = _parse_iso_date(up) if up else None release_date = _parse_iso_date(up) if up else None
# JAV code (BKD-368) ze sluga — kanoniczny identyfikator, dodajemy jako tag # JAV code (BKD-368) ze sluga, kanoniczny identyfikator, dodajemy jako tag
# (searchable) bo javflix nie ma osobnego pola kodu. # (searchable) bo javflix nie ma osobnego pola kodu.
slug = scene_url.rstrip("/").rsplit("/", 1)[-1] slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
cm = _CODE_RE.match(slug) cm = _CODE_RE.match(slug)

View file

@ -1,4 +1,4 @@
"""8kporner.com — browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27. """8kporner.com, browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane
pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny
@ -9,8 +9,8 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` → 403 również 1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` → 403 również
PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc
`_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos& `_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos&
page=latest` (20). To jednocześnie SUFIT — deep crawl jest niemożliwy. page=latest` (20). To jednocześnie SUFIT, deep crawl jest niemożliwy.
2. **Paginacja nie działa** — `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw 2. **Paginacja nie działa** `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`). co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`).
3. **Forma `/watch/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id= 3. **Forma `/watch/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id=
<id>` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny <id>` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny
@ -21,22 +21,22 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie
ma żadnego performera i weszłaby jako puste orphany. ma żadnego performera i weszłaby jako puste orphany.
Bramka się broni — z 117 performerów wciągniętych pilotem **113 (97%) ma ref Bramka się broni, z 117 performerów wciągniętych pilotem **113 (97%) ma ref
tpdb/stashdb**, najwyżej ze wszystkich źródeł dodanych w tej rundzie. Pilotowy ingest: tpdb/stashdb**, najwyżej ze wszystkich źródeł dodanych w tej rundzie. Pilotowy ingest:
`seen 62, new 25, updated 37, errors 0` — czyli 60% scen przypięło się do czegoś, co `seen 62, new 25, updated 37, errors 0`, czyli 60% scen przypięło się do czegoś, co
już mamy (to nie jest fabryka orphanów, tylko dodatkowe, lepsze jakościowo źródło). już mamy (to nie jest fabryka orphanów, tylko dodatkowe, lepsze jakościowo źródło).
**Studio.** Scena NIE linkuje studia osobno — na stronie sceny wszystko jest pillem **Studio.** Scena NIE linkuje studia osobno, na stronie sceny wszystko jest pillem
`/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami. `/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami.
Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi studiami. Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi studiami.
Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy
to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej
powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio. powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio.
Trafień jest mało (1 na 62 w pilocie) — większość scen po prostu nie ma pilla studia, Trafień jest mało (1 na 62 w pilocie), większość scen po prostu nie ma pilla studia,
ale kosztuje to jeden fetch na run, więc zostaje. ale kosztuje to jeden fetch na run, więc zostaje.
Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
canonical — dopasowanie musi opierać się na tytule, obsadzie i długości. canonical, dopasowanie musi opierać się na tytule, obsadzie i długości.
""" """
from __future__ import annotations from __future__ import annotations
@ -61,7 +61,7 @@ _SCENE_HREF_RE = re.compile(
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html") _SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
_STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE) _STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE)
# Pozycje z `/studios`, które są normalnymi gatunkami — jako studio dałyby fałszywkę # Pozycje z `/studios`, które są normalnymi gatunkami, jako studio dałyby fałszywkę
# na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki # na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe. # (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
_AMBIGUOUS_STUDIOS = { _AMBIGUOUS_STUDIOS = {
@ -119,7 +119,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
def _load_catalog(self) -> list[str] | None: def _load_catalog(self) -> list[str] | None:
"""Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz """Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz
docstring), więc to jedyne dostępne źródło URL-i — i zarazem sufit katalogu.""" docstring), więc to jedyne dostępne źródło URL-i, i zarazem sufit katalogu."""
if self._catalog is not None: if self._catalog is not None:
return self._catalog return self._catalog
urls: list[str] = [] urls: list[str] = []
@ -145,7 +145,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
def crawl_page(self, page: int) -> list[RawScene] | None: def crawl_page(self, page: int) -> list[RawScene] | None:
"""Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/` """Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/`
daje 403) i z pauzą — CF tnie przy szybszym tempie.""" daje 403) i z pauzą, CF tnie przy szybszym tempie."""
catalog = self._load_catalog() catalog = self._load_catalog()
if catalog is None: if catalog is None:
return None return None
@ -174,7 +174,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
scene = super()._parse_detail(scene_url, detail_html) scene = super()._parse_detail(scene_url, detail_html)
if scene is None: if scene is None:
return None return None
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą) — bez niej # BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą), bez niej
# większość katalogu wpadłaby jako puste orphany. # większość katalogu wpadłaby jako puste orphany.
if not scene.performers: if not scene.performers:
return None return None

View file

@ -1,4 +1,4 @@
"""latestleaks.co — direct HTML scrape. """latestleaks.co, direct HTML scrape.
Search: `https://latestleaks.co/page/<n>/?s=<q>`. Search: `https://latestleaks.co/page/<n>/?s=<q>`.
Scene URL: `https://latestleaks.co/<slug>/`. Scene URL: `https://latestleaks.co/<slug>/`.

View file

@ -1,4 +1,4 @@
"""latestpornvideo.com — latest-vids browse scraper. """latestpornvideo.com, latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem
(user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których (user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których
@ -6,7 +6,7 @@ akurat szukamy → feed strony "latest" nigdy nie wpada, w apce widać stary zes
a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z
`/page/<n>/`, page 1 = `/`), 2026-06-22. `/page/<n>/`, page 1 = `/`), 2026-06-22.
Listing card (zero detail-fetchy — detail page nie ma performerów ani duration): Listing card (zero detail-fetchy, detail page nie ma performerów ani duration):
<article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ..."> <article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ...">
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>"> <a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg" data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg"
@ -51,7 +51,7 @@ _CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")
_THUMB_NAME_RE = re.compile(r"/([A-Za-z0-9][A-Za-z0-9-]*?)-(\d{4})-(\d{2})-(\d{2})-", re.IGNORECASE) _THUMB_NAME_RE = re.compile(r"/([A-Za-z0-9][A-Za-z0-9-]*?)-(\d{4})-(\d{2})-(\d{2})-", re.IGNORECASE)
# Tytuł: `<Studio> YY MM DD <rest>` (np. "MySexMobile 20 10 23 Abella Danger"). # Tytuł: `<Studio> YY MM DD <rest>` (np. "MySexMobile 20 10 23 Abella Danger").
_TITLE_DATE_RE = re.compile(r"^(.*?)\s*\b(\d{2})\s+(\d{2})\s+(\d{2})\b") _TITLE_DATE_RE = re.compile(r"^(.*?)\s*\b(\d{2})\s+(\d{2})\s+(\d{2})\b")
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos" — to nie jest tag. # Karty homepage zawsze siedzą w kategorii "latest-porn-videos", to nie jest tag.
_CAT_SKIP = {"latest-porn-videos", "uncategorized", ""} _CAT_SKIP = {"latest-porn-videos", "uncategorized", ""}
@ -137,7 +137,7 @@ class LatestPornVideoScraper(BaseBrowseScraper):
) )
# Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos". # Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos".
# `tag-*` POMIJAMY — to mieszanka fragmentów imion performerów i gatunków, # `tag-*` POMIJAMY, to mieszanka fragmentów imion performerów i gatunków,
# bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum. # bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum.
tags: list[RawTag] = [] tags: list[RawTag] = []
seen_tag: set[str] = set() seen_tag: set[str] = set()

View file

@ -1,4 +1,4 @@
"""mypornerleak.com — performer-page listing scrape (search-based, performer-driven). """mypornerleak.com, performer-page listing scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 05-07): treść serwowana jest pod subdomeną 2026-06-16 fix (zamrożony od 05-07): treść serwowana jest pod subdomeną
`w8.mypornerleak.com` (load-balancer wN.), a stary regex szukał scen na gołym `w8.mypornerleak.com` (load-balancer wN.), a stary regex szukał scen na gołym
@ -6,7 +6,7 @@
listingu i sceny, więc fetchujemy `mypornerleak.com/actor/<slug>/` a linki scen listingu i sceny, więc fetchujemy `mypornerleak.com/actor/<slug>/` a linki scen
normalizujemy wN.→ kanoniczny (stabilny page_url/dedup). normalizujemy wN.→ kanoniczny (stabilny page_url/dedup).
Wszystko z LISTINGU (bez detail-fetcha) — karta `<article>` koduje metadane: Wszystko z LISTINGU (bez detail-fetcha), karta `<article>` koduje metadane:
- klasa `actors-<slug>` (multi) → performerzy; `category-<slug>` (multi) → tagi - klasa `actors-<slug>` (multi) → performerzy; `category-<slug>` (multi) → tagi
- `<a href title="...">` → URL sceny + czysty tytuł - `<a href title="...">` → URL sceny + czysty tytuł
- `<span class="duration">MM:SS|HH:MM:SS` - `<span class="duration">MM:SS|HH:MM:SS`

View file

@ -1,4 +1,4 @@
"""mypornerleak.com — latest BROWSE scraper via WordPress REST API, obok search scrapera. """mypornerleak.com, latest BROWSE scraper via WordPress REST API, obok search scrapera.
MyPornerLeakScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada MyPornerLeakScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada
świeżość wprost z WP REST (`/wp-json/wp/v2/posts?_embed=1`). W odróżnieniu od świeżość wprost z WP REST (`/wp-json/wp/v2/posts?_embed=1`). W odróżnieniu od

View file

@ -1,22 +1,22 @@
"""neporn.com — latest-vids browse scraper (KVS engine). """neporn.com, latest-vids browse scraper (KVS engine).
Dołączony 2026-06-10 (user request). Paysite re-upload (HardX, DAP itp.), Dołączony 2026-06-10 (user request). Paysite re-upload (HardX, DAP itp.),
~40k+ scen po numeracji video id. ~40k+ scen po numeracji video id.
Sygnały per scena: Sygnały per scena:
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl - JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl
(uwaga: bywa zmanglowany "https:https://..." — normalizujemy) (uwaga: bywa zmanglowany "https:https://...", normalizujemy)
- `<meta property="video:duration">` = sekundy wprost - `<meta property="video:duration">` = sekundy wprost
- Performerzy: `<a class="link" href="https://neporn.com/models/<slug>/">` - Performerzy: `<a class="link" href="https://neporn.com/models/<slug>/">`
(nazwa w spanie z ikoną — bierzemy slug i tytułujemy). Świeże uploady NIE mają (nazwa w spanie z ikoną, bierzemy slug i tytułujemy). Świeże uploady NIE mają
jeszcze linków modelek — fallback: flashvars `video_tags` (na świeżych scenach jeszcze linków modelek, fallback: flashvars `video_tags` (na świeżych scenach
to czyste nazwiska, np. "emily willis, gianna dior"); bierzemy wpisy 2-3 słowa to czyste nazwiska, np. "emily willis, gianna dior"); bierzemy wpisy 2-3 słowa
obecne w tytule i bez generycznych fraz (stoplist), bo starsze sceny mają tam obecne w tytule i bez generycznych fraz (stoplist), bo starsze sceny mają tam
też szum ("deep throat", "natural tits", "hd porn"). też szum ("deep throat", "natural tits", "hd porn").
- Kategorie: linki `/categories/<slug>/` w bloku info (tagi z `/tags/` to szum - Kategorie: linki `/categories/<slug>/` w bloku info (tagi z `/tags/` to szum
typu "hd porn", "2020" — pomijamy) typu "hd porn", "2020", pomijamy)
Studio: brak strukturalnego pola — tytuł bywa "- HardX Update - ..." ale format Studio: brak strukturalnego pola, tytuł bywa "- HardX Update - ..." ale format
niespójny, zostawiamy fuzzy matchowi po tytule. niespójny, zostawiamy fuzzy matchowi po tytule.
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona). Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
@ -111,7 +111,7 @@ class NepornScraper(BaseBrowseScraper):
description = (video.get("description") or "").strip() or None description = (video.get("description") or "").strip() or None
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
if thumbnail_url: if thumbnail_url:
# JSON-LD emituje "https:https://cdn..." — utnij zdublowany scheme. # JSON-LD emituje "https:https://cdn...", utnij zdublowany scheme.
thumbnail_url = re.sub(r"^https?:(?=https?://)", "", thumbnail_url) thumbnail_url = re.sub(r"^https?:(?=https?://)", "", thumbnail_url)
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
@ -128,7 +128,7 @@ class NepornScraper(BaseBrowseScraper):
) )
) )
# Fallback dla świeżych uploadów bez linków modelek: nazwiska z flashvars # Fallback dla świeżych uploadów bez linków modelek: nazwiska z flashvars
# video_tags — 2-3 słowa, obecne w tytule, bez generycznych fraz. # video_tags, 2-3 słowa, obecne w tytule, bez generycznych fraz.
title_cf = title.casefold() title_cf = title.casefold()
tags_m = _VIDEO_TAGS_RE.search(detail_html) tags_m = _VIDEO_TAGS_RE.search(detail_html)
for entry in (tags_m.group(1).split(",") if tags_m else []): for entry in (tags_m.group(1).split(",") if tags_m else []):
@ -160,7 +160,7 @@ class NepornScraper(BaseBrowseScraper):
seen_tag.add(slug) seen_tag.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))
# Phash z KVS screenshotów — niski hit-rate oczekiwany (jak porn00), # Phash z KVS screenshotów, niski hit-rate oczekiwany (jak porn00),
# graceful miss → composite scoring (performer+title+duration). # graceful miss → composite scoring (performer+title+duration).
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []
if thumbnail_url: if thumbnail_url:

View file

@ -1,4 +1,4 @@
"""perverzija.com — latest browse scraper via WordPress REST API. """perverzija.com, latest browse scraper via WordPress REST API.
Historia: dawniej search scraper (`?s=`), ale 2026-06 perverzija rate-limituje search Historia: dawniej search scraper (`?s=`), ale 2026-06 perverzija rate-limituje search
(429) a homepage jest JS-renderowane (brak linków postów w surowym HTML) → search (429) a homepage jest JS-renderowane (brak linków postów w surowym HTML) → search
@ -8,7 +8,7 @@ na stronę. VPS dociera (curl_cffi bypassuje JA3; 200 nie 403). Przerobione na b
2026-06-22 (user request). 2026-06-22 (user request).
Z REST `?_embed=1` bierzemy: tytuł, datę, miniaturę (featured_media), STUDIO Z REST `?_embed=1` bierzemy: tytuł, datę, miniaturę (featured_media), STUDIO
(taksonomia `category` — np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi (taksonomia `category`, np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
(`post_tag`). Performerów REST nie wystawia (custom taksonomia `stars` bez show_in_rest) (`post_tag`). Performerów REST nie wystawia (custom taksonomia `stars` bez show_in_rest)
→ puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł → puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł
i tak ma nazwiska). i tak ma nazwiska).

View file

@ -1,17 +1,17 @@
"""porn00.org — latest-vids browse scraper. """porn00.org, latest-vids browse scraper.
URL patterns: URL patterns:
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ... - Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
- Scene: `/video/<slug>/` - Scene: `/video/<slug>/`
- Performer: `/<slug>/` (np. `/august-skye/`) — w sekcji "Pornstars:" na detail - Performer: `/<slug>/` (np. `/august-skye/`), w sekcji "Pornstars:" na detail
- Categories: `/category-name/<slug>/` - Categories: `/category-name/<slug>/`
Sygnały dostępne: Sygnały dostępne:
- Title (listing card + h1 + og:title) - Title (listing card + h1 + og:title)
- Performer(s) (z sekcji "Pornstars:" na detail page — pojedynczy slug per link) - Performer(s) (z sekcji "Pornstars:" na detail page, pojedynczy slug per link)
- Categories (z sekcji "Categories:" — `/category-name/<slug>/`) - Categories (z sekcji "Categories:", `/category-name/<slug>/`)
- Duration (listing card `<div class="duration">MM:SS</div>`) - Duration (listing card `<div class="duration">MM:SS</div>`)
- Direct mp4 (KVS engine — `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`) - Direct mp4 (KVS engine, `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
- Thumbnail (own CDN `/contents/videos_screenshots/.../1.jpg`) - Thumbnail (own CDN `/contents/videos_screenshots/.../1.jpg`)
BRAK: BRAK:
@ -78,7 +78,7 @@ _CATEGORY_LINK_RE = re.compile(
) )
# Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`. # Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`.
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp. — bierzemy # URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp., bierzemy
# wszystko do najbliższego `'` lub `"`. # wszystko do najbliższego `'` lub `"`.
_VIDEO_URL_RE = re.compile( _VIDEO_URL_RE = re.compile(
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE, r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
@ -107,7 +107,7 @@ class Porn00Scraper(BaseBrowseScraper):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
# Cache listing card meta — duration + thumb + title. Detail page nie ma # Cache listing card meta, duration + thumb + title. Detail page nie ma
# tych pól w meta (brak og:duration), więc listing jest source of truth. # tych pól w meta (brak og:duration), więc listing jest source of truth.
self._listing_cache: dict[str, dict] = {} self._listing_cache: dict[str, dict] = {}
@ -145,7 +145,7 @@ class Porn00Scraper(BaseBrowseScraper):
# Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180. # Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180.
thumb = meta_content(detail_html, property="og:image") or meta.get("thumb") thumb = meta_content(detail_html, property="og:image") or meta.get("thumb")
# Performers — porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`, # Performers, porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
# `/category-name/`). Wszystkie linki tego pattern to performerzy. # `/category-name/`). Wszystkie linki tego pattern to performerzy.
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
seen_perf: set[str] = set() seen_perf: set[str] = set()
@ -177,14 +177,14 @@ class Porn00Scraper(BaseBrowseScraper):
) )
) )
# Direct mp4 z KVS flashvars — preferujemy 720p (video_alt_url) nad 360p (video_url). # Direct mp4 z KVS flashvars, preferujemy 720p (video_alt_url) nad 360p (video_url).
stream_url: str | None = None stream_url: str | None = None
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)): if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
stream_url = vm.group(1) stream_url = vm.group(1)
elif (vm := _VIDEO_URL_RE.search(detail_html)): elif (vm := _VIDEO_URL_RE.search(detail_html)):
stream_url = vm.group(1) stream_url = vm.group(1)
# Phash — porn00 robi własne screenshoty (`/contents/videos_screenshots/`), # Phash, porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
# więc canonical phash match raczej fail. Próbujemy mimo to. # więc canonical phash match raczej fail. Próbujemy mimo to.
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []
if thumb: if thumb:

View file

@ -1,4 +1,4 @@
"""porn4days.pw — direct HTML scrape. """porn4days.pw, direct HTML scrape.
Search: `https://porn4days.pw/page/<n>/?s=<q>`. Search: `https://porn4days.pw/page/<n>/?s=<q>`.
Scene URL: `https://porn4days.pw/<slug>/`. Scene URL: `https://porn4days.pw/<slug>/`.

View file

@ -1,22 +1,22 @@
"""pornbusy.com — browse scraper (WordPress + Rank Math). Dodany 2026-07-27. """pornbusy.com, browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
Ripy studyjne (~60%) + JAV z kodami (~30%). Ocena orphan-risk LOW z pomiarem: Ripy studyjne (~60%) + JAV z kodami (~30%). Ocena orphan-risk LOW z pomiarem:
80% scen z próbki 100 ma performera, który u nas MA już ref tpdb/stashdb, a 21% 80% scen z próbki 100 ma performera, który u nas MA już ref tpdb/stashdb, a 21%
tytułów mocno matchuje sceny które już mamy (czyli się scalą, nie zorphanują). tytułów mocno matchuje sceny które już mamy (czyli się scalą, nie zorphanują).
**Paginacja homepage jest ZEPSUTA** — `/page/2/` i `/page/3/` zwracają ten sam **Paginacja homepage jest ZEPSUTA** `/page/2/` i `/page/3/` zwracają ten sam
zestaw co strona 1 (zweryfikowane). Dlatego listing bierzemy z SITEMAPY: zestaw co strona 1 (zweryfikowane). Dlatego listing bierzemy z SITEMAPY:
`sitemap_index.xml` → 10× `post-sitemapN.xml` po ~500 URL-i, każdy z `<lastmod>`, `sitemap_index.xml` → 10× `post-sitemapN.xml` po ~500 URL-i, każdy z `<lastmod>`,
posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE` posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE`
(wzorzec jak `_playtube.BasePlayTubeScraper`). (wzorzec jak `_playtube.BasePlayTubeScraper`).
Metadane — każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z Metadane, każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
tytułu-slug: tytułu-slug:
- title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description - title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description
- obsada: `<div id="video-actors">` — CZYSTA, bez pollution (na stronie sceny jest - obsada: `<div id="video-actors">`, CZYSTA, bez pollution (na stronie sceny jest
dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related) dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related)
- kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`) - kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`)
- studio: **brak** — pornbusy nie ma pola studia ani prefiksu w tytule - studio: **brak** pornbusy nie ma pola studia ani prefiksu w tytule
Playback: `itemprop="embedURL"` → extractor `pornbusycom` (loadvid / seekplayer / Playback: `itemprop="embedURL"` → extractor `pornbusycom` (loadvid / seekplayer /
zpi.cx ≈ 69% katalogu). zpi.cx ≈ 69% katalogu).
@ -96,7 +96,7 @@ class PornBusyScraper(BaseBrowseScraper):
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się). # deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
self._catalog: list[str] | None = None self._catalog: list[str] | None = None
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta) — # Listing nie jest stronicowalny GET-em (homepage pagination zepsuta),
# paginację robi sitemap w `crawl_page`. Te dwie metody są abstrakcyjne w bazie. # paginację robi sitemap w `crawl_page`. Te dwie metody są abstrakcyjne w bazie.
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
return _SITEMAP_INDEX return _SITEMAP_INDEX
@ -131,7 +131,7 @@ class PornBusyScraper(BaseBrowseScraper):
if not loc: if not loc:
continue continue
url = loc.group(1) url = loc.group(1)
# Pomijamy strony taksonomii/nav — sceny to `/<slug>/` jednosegmentowe. # Pomijamy strony taksonomii/nav, sceny to `/<slug>/` jednosegmentowe.
if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")): if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")):
continue continue
lm = _LASTMOD_RE.search(block) lm = _LASTMOD_RE.search(block)
@ -189,7 +189,7 @@ class PornBusyScraper(BaseBrowseScraper):
dsm = _DESC_RE.search(detail_html) dsm = _DESC_RE.search(detail_html)
description = html.unescape(dsm.group(1)).strip() if dsm else None description = html.unescape(dsm.group(1)).strip() if dsm else None
if description and description.strip().lower() == title.strip().lower(): if description and description.strip().lower() == title.strip().lower():
description = None # opis bywa kopią tytułu — nie duplikujemy description = None # opis bywa kopią tytułu, nie duplikujemy
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
seen_p: set[str] = set() seen_p: set[str] = set()

View file

@ -1,13 +1,13 @@
"""porndish.com — latest browse scraper via WordPress REST API. """porndish.com, latest browse scraper via WordPress REST API.
Historia: dawniej search scraper (`?s=`), zamarzł 2026-05-07 (search przestał dawać Historia: dawniej search scraper (`?s=`), zamarzł 2026-05-07 (search przestał dawać
nowe sceny — 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS nowe sceny, 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
dociera, więc czysty kanał to REST API: `/wp-json/wp/v2/posts?_embed=1` daje dociera, więc czysty kanał to REST API: `/wp-json/wp/v2/posts?_embed=1` daje
ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24 ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24
(ten sam wzorzec co perverzija). (ten sam wzorzec co perverzija).
Z REST `_embed`: tytuł, data, miniatura (featured_media), STUDIO (taksonomia Z REST `_embed`: tytuł, data, miniatura (featured_media), STUDIO (taksonomia
`category` — np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag` — porndish `category`, np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag`, porndish
miesza w nich performerów z gatunkami, bierzemy jak jest; canonical-merge i tak miesza w nich performerów z gatunkami, bierzemy jak jest; canonical-merge i tak
dorabia performerów z TPDB/StashDB, a tytuł ma nazwiska). Performerów osobno nie dorabia performerów z TPDB/StashDB, a tytuł ma nazwiska). Performerów osobno nie
wyciągamy (post_tag ich nie rozdziela od gatunków bez listy known-performers). wyciągamy (post_tag ich nie rozdziela od gatunków bez listy known-performers).

View file

@ -1,4 +1,4 @@
"""porndoe.com — latest-vids browse scraper. """porndoe.com, latest-vids browse scraper.
Dołączony 2026-05-21 (theporndude audit). Jedyny verified high-value candidate Dołączony 2026-05-21 (theporndude audit). Jedyny verified high-value candidate
z 172 tube'ów na theporndude.com/top-porn-tube-sites + /full-porn-movies-sites. z 172 tube'ów na theporndude.com/top-porn-tube-sites + /full-porn-movies-sites.
@ -11,7 +11,7 @@ Czemu wart: każda scena ma kompletny **JSON-LD VideoObject** schema:
To wystarczy do composite fuzzy match w resolverze (studio + performer Jaccard + To wystarczy do composite fuzzy match w resolverze (studio + performer Jaccard +
date proximity + title token-set + duration). Phash hit-rate niski (porndoe robi date proximity + title token-set + duration). Phash hit-rate niski (porndoe robi
własne crop-thumbnaile 390x219, nie hot-linkuje studio art) — ale rich metadata własne crop-thumbnaile 390x219, nie hot-linkuje studio art), ale rich metadata
nadrabia, jak pornxp/porn00. nadrabia, jak pornxp/porn00.
URL patterns: URL patterns:
@ -21,7 +21,7 @@ URL patterns:
- Performer: `/pornstars-profile/<slug>` - Performer: `/pornstars-profile/<slug>`
- Tags/categories: `/categories/<slug>` - Tags/categories: `/categories/<slug>`
Playback: stream URL NIE jest inline w SSR HTML — player JS init dopiero po user Playback: stream URL NIE jest inline w SSR HTML, player JS init dopiero po user
"Play" click. Dajemy playback_source z page_url + origin `tube:porndoecom`; "Play" click. Dajemy playback_source z page_url + origin `tube:porndoecom`;
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z
@ -51,7 +51,7 @@ log = logging.getLogger(__name__)
_BASE = "https://porndoe.com" _BASE = "https://porndoe.com"
# Scene listing — `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum. # Scene listing, `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
_SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE) _SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE)
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE) _WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
@ -66,7 +66,7 @@ _TAG_LINK_RE = re.compile(
r'href="/category/\d+/([a-z0-9\-]+)"[^>]*>([^<]+)</a>', re.IGNORECASE r'href="/category/\d+/([a-z0-9\-]+)"[^>]*>([^<]+)</a>', re.IGNORECASE
) )
# ISO 8601 duration — porndoe emituje "PT8M0S" (czasem "T8M0S" bez P). # ISO 8601 duration, porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
_ISO_DUR_RE = re.compile( _ISO_DUR_RE = re.compile(
r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE
) )
@ -237,7 +237,7 @@ class PornDoeScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug) RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
) )
# Phash z thumbnail (porndoe robi własne crop-thumbnaile — niski hit-rate # Phash z thumbnail (porndoe robi własne crop-thumbnaile, niski hit-rate
# oczekiwany, ale graceful: brak match → resolver spada do composite scoring). # oczekiwany, ale graceful: brak match → resolver spada do composite scoring).
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []
if thumbnail_url: if thumbnail_url:
@ -245,7 +245,7 @@ class PornDoeScraper(BaseBrowseScraper):
if ph: if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph)) fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Playback — page_url do scena strony. Stream JS-rendered, więc extractor # Playback, page_url do scena strony. Stream JS-rendered, więc extractor
# `porndoecom` → `_vps_blocked_fallback.extract` (mobile WebView scrape). # `porndoecom` → `_vps_blocked_fallback.extract` (mobile WebView scrape).
playback_sources = [ playback_sources = [
RawPlaybackSource( RawPlaybackSource(

View file

@ -1,4 +1,4 @@
"""pornmike.com — browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27. """pornmike.com, browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia
u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan). u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan).
@ -6,7 +6,7 @@ Pomiar: 82% performerów ma u nas ref tpdb/stashdb, 19-20/20 kanałów znamy jak
Metadane w CAŁOŚCI z JSON-LD `@graph` → `ItemPage.mainEntity` (VideoObject): name, Metadane w CAŁOŚCI z JSON-LD `@graph` → `ItemPage.mainEntity` (VideoObject): name,
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
`og:`, zero itemprop — więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak `og:`, zero itemprop, więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
jest przepisany pod SEO, patrz niżej). jest przepisany pod SEO, patrz niżej).
**Świadome decyzje:** **Świadome decyzje:**
@ -21,7 +21,7 @@ jest przepisany pod SEO, patrz niżej).
zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości. zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości.
- Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2` - Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2`
jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok
sidebara (stąd 62 linki, realnie 52 sceny) — dedup po external_id go pochłania. sidebara (stąd 62 linki, realnie 52 sceny), dedup po external_id go pochłania.
- Ingest tylko EN (`/videos/`), pomijamy niemiecki mirror (`/de/filme/`). - Ingest tylko EN (`/videos/`), pomijamy niemiecki mirror (`/de/filme/`).
**Uwaga jakościowa:** to KLIPY 5-12 min (mediana ~487 s) wobec 1800-2400 s w tubach, **Uwaga jakościowa:** to KLIPY 5-12 min (mediana ~487 s) wobec 1800-2400 s w tubach,
@ -29,7 +29,7 @@ które przyjęliśmy. Dla ~20% katalogu (Tushy/Blacked Raw/Milfy/Anilos) będą
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas, obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
czyli realnie nowa podaż. czyli realnie nowa podaż.
Playback: `<source>` direct mp4 (twincdn) — extractor `pornmike`, bez tokenu, bez Playback: `<source>` direct mp4 (twincdn), extractor `pornmike`, bez tokenu, bez
Referera, przenośny cross-IP. Referera, przenośny cross-IP.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -1,9 +1,9 @@
"""PornTrex.com — direct HTML scrape search results. """PornTrex.com, direct HTML scrape search results.
Search: `https://www.porntrex.com/search/<q>/` (single page, brak ?page=). Search: `https://www.porntrex.com/search/<q>/` (single page, brak ?page=).
Scene URL: `https://www.porntrex.com/video/<id>/<slug>/` Scene URL: `https://www.porntrex.com/video/<id>/<slug>/`
Porntrex pagination niespójne między widokami — używamy `?from=<offset>` gdy page>1. Porntrex pagination niespójne między widokami, używamy `?from=<offset>` gdy page>1.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -1,6 +1,6 @@
"""porntrex.com — latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera. """porntrex.com, latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS — daje PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS, daje
pokrycie back-catalogu performerów. Ten browse dokłada gwarancję świeżości wprost pokrycie back-catalogu performerów. Ten browse dokłada gwarancję świeżości wprost
z feedu `/latest-updates/<n>/` (próg watchdog 48h zamiast 168h, nie zależy od kolejki z feedu `/latest-updates/<n>/` (próg watchdog 48h zamiast 168h, nie zależy od kolejki
performerów). Wzorzec jak xvideos (search + browse równolegle). performerów). Wzorzec jak xvideos (search + browse równolegle).
@ -9,7 +9,7 @@ KVS listing tile:
<div ... data-item-id="<id>"><a href="https://www.porntrex.com/video/<id>/<slug>"> <div ... data-item-id="<id>"><a href="https://www.porntrex.com/video/<id>/<slug>">
<img data-src="//ptx.cdntrex.com/contents/.../300x168/1.jpg" alt="<Tytuł>"> <img data-src="//ptx.cdntrex.com/contents/.../300x168/1.jpg" alt="<Tytuł>">
<div class="duration">MM:SS</div> <div class="duration">MM:SS</div>
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable) — bez zmian. Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable), bez zmian.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -1,9 +1,9 @@
"""pornxp.ph — latest-vids browse scraper. """pornxp.ph, latest-vids browse scraper.
URL patterns: URL patterns:
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination). - Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs
`/videos/94528971837`) — **`data-id` (np. `94528971`) jest stable** i tego `/videos/94528971837`), **`data-id` (np. `94528971`) jest stable** i tego
używamy dla external_id zamiast całego URL. używamy dla external_id zamiast całego URL.
- Detail: `/videos/<id_with_suffix>`. - Detail: `/videos/<id_with_suffix>`.
- Tags: `/tags/<URL-encoded-name>`. Trzy kategorie wnioskowane heurystyką - Tags: `/tags/<URL-encoded-name>`. Trzy kategorie wnioskowane heurystyką
@ -15,10 +15,10 @@ Rich signals (perfekt dla canonical match scoring):
- Performers (z tags w `<div class="tags">`, Capital + space + Capital) - Performers (z tags w `<div class="tags">`, Capital + space + Capital)
- Release year (regex `Released:` na detail page bodyText) - Release year (regex `Released:` na detail page bodyText)
- Duration (`<div class="item_dur">MM:SS</div>` listing card) - Duration (`<div class="item_dur">MM:SS</div>` listing card)
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`) — no hoster - Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`), no hoster
- Animated preview (`data-preview="//t.porn-xp.com/.../<id>.mp4"`) - Animated preview (`data-preview="//t.porn-xp.com/.../<id>.mp4"`)
Thumbnail: `<img class="item_img" src="/<id>.jpg">` — relatywny, pornxp's own CDN. Thumbnail: `<img class="item_img" src="/<id>.jpg">`, relatywny, pornxp's own CDN.
Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical. Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical.
""" """
from __future__ import annotations from __future__ import annotations
@ -45,10 +45,10 @@ log = logging.getLogger(__name__)
_BASE = "https://pornxp.ph" _BASE = "https://pornxp.ph"
# Listing card — DOTALL bo HTML cards są wieloliniowe. # Listing card, DOTALL bo HTML cards są wieloliniowe.
# Wariant 1 (eager): `<img class="item_img" src="/<id>.jpg">` # Wariant 1 (eager): `<img class="item_img" src="/<id>.jpg">`
# Wariant 2 (lazy): `<img class="item_img lazy" src="/images/fluid_spinner.svg" data-src="/<id>.jpg">` # Wariant 2 (lazy): `<img class="item_img lazy" src="/images/fluid_spinner.svg" data-src="/<id>.jpg">`
# Łapiemy obie warianty — w `_parse_listing_thumb` preferujemy `data-src` nad `src`. # Łapiemy obie warianty, w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
_LISTING_CARD_RE = re.compile( _LISTING_CARD_RE = re.compile(
r'<div class="item preview"\s+data-id="(?P<id>\d+)"' r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>' r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>'
@ -61,7 +61,7 @@ _LISTING_CARD_RE = re.compile(
_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE) _IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
_IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', re.IGNORECASE) _IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', re.IGNORECASE)
# Detail page — tags wrapper. Sometimes <div class="tags">, sometimes inline. # Detail page, tags wrapper. Sometimes <div class="tags">, sometimes inline.
# Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie. # Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie.
_DETAIL_TAGS_BLOCK_RE = re.compile( _DETAIL_TAGS_BLOCK_RE = re.compile(
r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL, r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL,
@ -71,8 +71,8 @@ _TAG_LINK_RE = re.compile(
) )
_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE) _RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE)
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE) _H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
# Direct mp4/m3u8 sources — preferujemy 720 nad 360. Format często protocol-relative: # Direct mp4/m3u8 sources, preferujemy 720 nad 360. Format często protocol-relative:
# `<source src="//sv.porn-xp.com/.../720.mp4">` — normalize do `https://...` w consumerze. # `<source src="//sv.porn-xp.com/.../720.mp4">`, normalize do `https://...` w consumerze.
_SOURCE_RE = re.compile( _SOURCE_RE = re.compile(
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"', r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
re.IGNORECASE, re.IGNORECASE,
@ -99,11 +99,11 @@ def _classify_tag(name: str) -> str:
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat - Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`) bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
- Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`) - Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`)
- Tag/category: pozostałe — lowercase single word LUB Cap single word - Tag/category: pozostałe, lowercase single word LUB Cap single word
(`oral`, `Lesbians`, `Incest`, `BBC`) (`oral`, `Lesbians`, `Incest`, `BBC`)
Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag. Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag.
To akceptowalne — composite score scoring tags ma niższą wagę niż studio match, To akceptowalne, composite score scoring tags ma niższą wagę niż studio match,
więc fallback z 1+ performer match wystarczy. więc fallback z 1+ performer match wystarczy.
""" """
name = name.strip() name = name.strip()
@ -136,7 +136,7 @@ class PornXPScraper(BaseBrowseScraper):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
# Cache listing card metadata per scene URL — populated w `_extract_scene_urls`, # Cache listing card metadata per scene URL, populated w `_extract_scene_urls`,
# consumed w `_parse_detail`. Detail page sam nie ma `<div class="item_dur">` # consumed w `_parse_detail`. Detail page sam nie ma `<div class="item_dur">`
# ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde # ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde
# _extract_scene_urls override'uje). # _extract_scene_urls override'uje).
@ -188,12 +188,12 @@ class PornXPScraper(BaseBrowseScraper):
return out return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
# Listing-card meta (preferowane — detail page nie ma duration/thumb) # Listing-card meta (preferowane, detail page nie ma duration/thumb)
meta = self._listing_cache.get(scene_url, {}) meta = self._listing_cache.get(scene_url, {})
data_id = meta.get("data_id") data_id = meta.get("data_id")
if not data_id: if not data_id:
# URL nie pasuje do listingu (random suffix mismatch po pagination redo). # URL nie pasuje do listingu (random suffix mismatch po pagination redo).
# Wyciągnij data-id z URL: /videos/<id>... — pierwsze 8-10 cyfr. # Wyciągnij data-id z URL: /videos/<id>..., pierwsze 8-10 cyfr.
id_match = re.search(r"/videos/(\d{6,12})", scene_url) id_match = re.search(r"/videos/(\d{6,12})", scene_url)
data_id = id_match.group(1) if id_match else None data_id = id_match.group(1) if id_match else None
@ -207,8 +207,8 @@ class PornXPScraper(BaseBrowseScraper):
duration_sec = meta.get("duration_sec") duration_sec = meta.get("duration_sec")
thumb = meta.get("thumb") thumb = meta.get("thumb")
# Release year — `Released: 2016`. RawScene ma `release_date` (typu `date`), # Release year, `Released: 2016`. RawScene ma `release_date` (typu `date`),
# nie samo year — wpisujemy Jan 1 jako placeholder żeby resolver miał year # nie samo year, wpisujemy Jan 1 jako placeholder żeby resolver miał year
# signal (date proximity scoring tylko sprawdza year w composite). # signal (date proximity scoring tylko sprawdza year w composite).
release_date: date | None = None release_date: date | None = None
if (m := _RELEASED_RE.search(detail_html)): if (m := _RELEASED_RE.search(detail_html)):
@ -251,7 +251,7 @@ class PornXPScraper(BaseBrowseScraper):
tags.append(RawTag(external_id=ext_id, name=display, slug=slug)) tags.append(RawTag(external_id=ext_id, name=display, slug=slug))
# Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`. # Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`.
# URL-e są protocol-relative — normalize do `https:`. Preferujemy 720 nad 360. # URL-e są protocol-relative, normalize do `https:`. Preferujemy 720 nad 360.
def _norm(u: str) -> str: def _norm(u: str) -> str:
return "https:" + u if u.startswith("//") else u return "https:" + u if u.startswith("//") else u
@ -264,7 +264,7 @@ class PornXPScraper(BaseBrowseScraper):
break break
stream_url = stream_url or all_sources[0] stream_url = stream_url or all_sources[0]
# Phash z thumbnail (pornxp własny CDN — expected niski match rate, ale # Phash z thumbnail (pornxp własny CDN, expected niski match rate, ale
# try). Reseter ścieżek do canonical odbędzie się głównie przez # try). Reseter ścieżek do canonical odbędzie się głównie przez
# studio+performer+year+title scoring. # studio+performer+year+title scoring.
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []
@ -275,7 +275,7 @@ class PornXPScraper(BaseBrowseScraper):
# Normalize page_url: pornxp homepage serwuje random URL suffix per request # Normalize page_url: pornxp homepage serwuje random URL suffix per request
# (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource # (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource
# unique key to `(origin, page_url)` — bez normalize generujemy 3x duplikaty # unique key to `(origin, page_url)`, bez normalize generujemy 3x duplikaty
# na każdym scrape run. Canonical URL = `/videos/<data_id>`. # na każdym scrape run. Canonical URL = `/videos/<data_id>`.
canonical_url = ( canonical_url = (
f"{_BASE}/videos/{data_id}" if data_id else scene_url f"{_BASE}/videos/{data_id}" if data_id else scene_url

View file

@ -1,4 +1,4 @@
"""sexu.com — browse scraper. Dodany 2026-07-27. """sexu.com, browse scraper. Dodany 2026-07-27.
Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen: Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen:
obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X, obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X,
@ -15,7 +15,7 @@ odrzuciłaby dobre dane.
1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych 1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych
tagów sceny tylko 5 (reszta to sidebar „popularne wyszukiwania"). Bez zawężenia tagów sceny tylko 5 (reszta to sidebar „popularne wyszukiwania"). Bez zawężenia
do bloku „Tags:" wpisalibyśmy każdej scenie te same 20 śmieci. do bloku „Tags:" wpisalibyśmy każdej scenie te same 20 śmieci.
2. **W bloku tagów siedzą też linki `/search?q=`** — to fraz-y pod SEO („pleases", 2. **W bloku tagów siedzą też linki `/search?q=`** to fraz-y pod SEO („pleases",
„hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`. „hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po **Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
@ -25,7 +25,7 @@ bo performer w tabeli studios to zanieczyszczenie, nie dana.
Data jest względna („7 hours ago", „yesterday"), więc liczymy ją wstecz od teraz. Data jest względna („7 hours ago", „yesterday"), więc liczymy ją wstecz od teraz.
To i tak data uploadu na tube, nie premiery studia. To i tak data uploadu na tube, nie premiery studia.
Playback: `POST /api/video-info` (extractor `sexu`) — wymaga Bright Data, bo Playback: `POST /api/video-info` (extractor `sexu`), wymaga Bright Data, bo
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy. Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
""" """
from __future__ import annotations from __future__ import annotations
@ -67,7 +67,7 @@ _BLOCK_END_RE = re.compile(r"_container|player-tags__title")
def _block(detail_html: str, label: str) -> str: def _block(detail_html: str, label: str) -> str:
"""Fragment HTML należący do bloku `<label>:` — patrz pułapka 1 w docstringu.""" """Fragment HTML należący do bloku `<label>:`, patrz pułapka 1 w docstringu."""
start = detail_html.find(f'player-tags__title">{label}:') start = detail_html.find(f'player-tags__title">{label}:')
if start < 0: if start < 0:
return "" return ""

View file

@ -1,10 +1,10 @@
"""shyfap.net — latest-vids browse scraper. """shyfap.net, latest-vids browse scraper.
Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail
page'u (meta tags + body links): title, studio, performers, tags, duration, page'u (meta tags + body links): title, studio, performers, tags, duration,
description, upload_date, embed_url. description, upload_date, embed_url.
Pierwszy pilot scrapera browse-mode (2026-05-12) — weryfikacja czy detail-page Pierwszy pilot scrapera browse-mode (2026-05-12), weryfikacja czy detail-page
metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00, metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00,
fullmovies, pornxp, freshporno, 4k69, hdporn.gg. fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
@ -37,7 +37,7 @@ _PORNSTAR_LINK_RE = re.compile(
_TAG_LINK_RE = re.compile( _TAG_LINK_RE = re.compile(
r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE
) )
# /video/<slug>_v<id>/ — id z URL używamy jako stable internal ID (np. w external_id), # /video/<slug>_v<id>/ id z URL używamy jako stable internal ID (np. w external_id),
# nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL. # nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL.
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE) _INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
@ -46,7 +46,7 @@ class ShyfapScraper(BaseBrowseScraper):
sitetag = "shyfapnet" sitetag = "shyfapnet"
def _listing_url(self, page: int) -> str: def _listing_url(self, page: int) -> str:
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk — sufiks `_1` # page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk, sufiks `_1`
# zawsze, dodatkowy `/N/` dla pagination) # zawsze, dodatkowy `/N/` dla pagination)
if page <= 1: if page <= 1:
return f"{_BASE}/videos_1/" return f"{_BASE}/videos_1/"
@ -108,7 +108,7 @@ class ShyfapScraper(BaseBrowseScraper):
if not embed_url and internal_id: if not embed_url and internal_id:
embed_url = f"{_BASE}/embed/{internal_id}" embed_url = f"{_BASE}/embed/{internal_id}"
# Studio — pierwszy `/studio/<slug>_s<id>/` link na stronie # Studio, pierwszy `/studio/<slug>_s<id>/` link na stronie
studio: RawStudio | None = None studio: RawStudio | None = None
m_studio = _STUDIO_LINK_RE.search(detail_html) m_studio = _STUDIO_LINK_RE.search(detail_html)
if m_studio: if m_studio:
@ -119,7 +119,7 @@ class ShyfapScraper(BaseBrowseScraper):
slug=slug, slug=slug,
) )
# Performers — wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena) # Performers, wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
performers: list[RawPerformer] = [] performers: list[RawPerformer] = []
seen_perf: set[str] = set() seen_perf: set[str] = set()
for m_p in _PORNSTAR_LINK_RE.finditer(detail_html): for m_p in _PORNSTAR_LINK_RE.finditer(detail_html):
@ -134,7 +134,7 @@ class ShyfapScraper(BaseBrowseScraper):
) )
) )
# Tags — wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena) # Tags, wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
tags: list[RawTag] = [] tags: list[RawTag] = []
seen_tag: set[str] = set() seen_tag: set[str] = set()
for m_t in _TAG_LINK_RE.finditer(detail_html): for m_t in _TAG_LINK_RE.finditer(detail_html):
@ -146,8 +146,8 @@ class ShyfapScraper(BaseBrowseScraper):
RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug) RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug)
) )
# Playback source — embed_url (mobile WebView fallback). Stream extraction # Playback source, embed_url (mobile WebView fallback). Stream extraction
# przez app/extractors/__init__.py wymaga osobnego registry entry — dla # przez app/extractors/__init__.py wymaga osobnego registry entry, dla
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up. # pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
playback_sources = [ playback_sources = [
RawPlaybackSource( RawPlaybackSource(
@ -161,7 +161,7 @@ class ShyfapScraper(BaseBrowseScraper):
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within, # Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
# Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny. # Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny.
# Niezależne od shyfap title-rebrandingu — bierze się z frame'u sceny. # Niezależne od shyfap title-rebrandingu, bierze się z frame'u sceny.
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []
if thumbnail_url: if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/") ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")

View file

@ -1,4 +1,4 @@
"""siska.video — latest-vids browse scraper. """siska.video, latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest

View file

@ -1,22 +1,22 @@
"""superporn.com — latest-vids browse scraper (przez Bright Data ISP proxy). """superporn.com, latest-vids browse scraper (przez Bright Data ISP proxy).
Dołączony 2026-06-10 (user request). superporn twardo blokuje VPS IP Cloudflarem Dołączony 2026-06-10 (user request). superporn twardo blokuje VPS IP Cloudflarem
(403 na KAŻDEJ impersonacji TLS — chrome/safari/firefox), więc ingest HTML idzie (403 na KAŻDEJ impersonacji TLS, chrome/safari/firefox), więc ingest HTML idzie
przez Bright Data ISP proxy (`settings.brightdata_proxy_url`). Gdy proxy przez Bright Data ISP proxy (`settings.brightdata_proxy_url`). Gdy proxy
nieskonfigurowane → scraper no-op (pusty iterator, log warning). nieskonfigurowane → scraper no-op (pusty iterator, log warning).
Proxy używamy TYLKO do scrape HTML. Playback NIE idzie przez proxy: `<source>` mp4 Proxy używamy TYLKO do scrape HTML. Playback NIE idzie przez proxy: `<source>` mp4
(cdnst*.superporn.com) ma token IP-bound do fetchera (403 cross-IP), więc resolve (cdnst*.superporn.com) ma token IP-bound do fetchera (403 cross-IP), więc resolve
musi nastąpić po stronie telefonu — extractor `superporncom` → `_vps_blocked_fallback` musi nastąpić po stronie telefonu, extractor `superporncom` → `_vps_blocked_fallback`
(mobile WebView ładuje stronę z residential IP telefonu, INJECTED_JS bierze video.src). (mobile WebView ładuje stronę z residential IP telefonu, INJECTED_JS bierze video.src).
Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa). Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa).
Sygnały (SSR HTML): Sygnały (SSR HTML):
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl, duration - JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl, duration
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer — pomijamy). ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer, pomijamy).
- Performerzy: chip `<a class="chip-link" href=".../pornstar/<slug>"><span>Name</span>` - Performerzy: chip `<a class="chip-link" href=".../pornstar/<slug>"><span>Name</span>`
- Kategorie: chip `<a class="chip-link" href="/<slug>"><span>Name</span>` (bez - Kategorie: chip `<a class="chip-link" href="/<slug>"><span>Name</span>` (bez
`/pornstar/`) — w bloku `#collapse-categories`. `/pornstar/`), w bloku `#collapse-categories`.
- Duration backup: `<video ... data-video-duration="2328">` (sekundy). - Duration backup: `<video ... data-video-duration="2328">` (sekundy).
Listing: `?page=N` (newest-first; sitemap lastmod jest z 2024, bezużyteczny). Listing: `?page=N` (newest-first; sitemap lastmod jest z 2024, bezużyteczny).
@ -69,7 +69,7 @@ _ISO_DUR_FULL_RE = re.compile(
def _clean(text: str) -> str: def _clean(text: str) -> str:
"""superporn dwukrotnie HTML-koduje encje w JSON-LD (`&amp;#39;` zamiast `'`). """superporn dwukrotnie HTML-koduje encje w JSON-LD (`&amp;#39;` zamiast `'`).
Unescape iteracyjnie (max 2) — bez over-decode dla zwykłych nazw.""" Unescape iteracyjnie (max 2), bez over-decode dla zwykłych nazw."""
for _ in range(2): for _ in range(2):
new = html.unescape(text) new = html.unescape(text)
if new == text: if new == text:
@ -95,7 +95,7 @@ class SuperpornScraper(BaseBrowseScraper):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
# Bright Data ISP proxy — bez niego superporn jest nieosiągalny z VPS. # Bright Data ISP proxy, bez niego superporn jest nieosiągalny z VPS.
self._proxy = get_settings().brightdata_proxy_url self._proxy = get_settings().brightdata_proxy_url
if not self._proxy: if not self._proxy:
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled") log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
@ -106,7 +106,7 @@ class SuperpornScraper(BaseBrowseScraper):
return f"{_BASE}/?page={page}" return f"{_BASE}/?page={page}"
def crawl_page(self, page: int): def crawl_page(self, page: int):
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403) — sygnalizuj # Bez proxy nie ma sensu uderzać (gwarantowany CF 403), sygnalizuj
# "exhausted" (pusta lista), żeby deep-crawl nie retry'ował w kółko. # "exhausted" (pusta lista), żeby deep-crawl nie retry'ował w kółko.
if not self._proxy: if not self._proxy:
return [] return []
@ -164,7 +164,7 @@ class SuperpornScraper(BaseBrowseScraper):
seen_tag.add(slug) seen_tag.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))
# Phash z thumbnaila — proxy NIE potrzebny (img*.superporn.com działa z VPS). # Phash z thumbnaila, proxy NIE potrzebny (img*.superporn.com działa z VPS).
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []
if thumbnail_url: if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/") ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")

View file

@ -1,4 +1,4 @@
"""supjav.com — JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP). """supjav.com, JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
Osobna pula JAV (sekcja JAV), origin `tube:supjav` w JAV_ORIGINS. supjav agreguje Osobna pula JAV (sekcja JAV), origin `tube:supjav` w JAV_ORIGINS. supjav agreguje
embedy (serwery TV/FST/ST/VOE), gdzie realny hoster jest schowany za `data-link` embedy (serwery TV/FST/ST/VOE), gdzie realny hoster jest schowany za `data-link`
@ -10,7 +10,7 @@ Browse (RE 2026-07-10):
- karty: `<div class="post"><a href="/<id>.html" title="<title>" rel="bookmark" - karty: `<div class="post"><a href="/<id>.html" title="<title>" rel="bookmark"
class="img"><img src=".../images/YYYY/MM/<CODE>.jpg!320x216.jpg" class="thumb">`. class="img"><img src=".../images/YYYY/MM/<CODE>.jpg!320x216.jpg" class="thumb">`.
- Metadane bierzemy Z KARTY (tytuł/thumb/URL/kod JAV/data z roku-miesiąca ścieżki - Metadane bierzemy Z KARTY (tytuł/thumb/URL/kod JAV/data z roku-miesiąca ścieżki
thumb), NIE z detalu — detal przez proxy jest flaky (CF przepuszcza ~40% IP), a thumb), NIE z detalu, detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
per-post fetch byłby wolny i lossy. Studio/tagi (poza kodem) dorabia enrich później. per-post fetch byłby wolny i lossy. Studio/tagi (poza kodem) dorabia enrich później.
supjav blokuje VPS IP twardym CF → listing idzie przez Bright Data ISP proxy z retry supjav blokuje VPS IP twardym CF → listing idzie przez Bright Data ISP proxy z retry
@ -36,7 +36,7 @@ _BASE = "https://supjav.com"
# atrybutu nie zawiera surowego "), więc tytuły z `>` (np. `<<3-Day Offer>>`) przechodzą. # atrybutu nie zawiera surowego "), więc tytuły z `>` (np. `<<3-Day Offer>>`) przechodzą.
# Link-obraz karty: <a href="URL" ... title="TITLE"><img src|data-original="THUMB">. # Link-obraz karty: <a href="URL" ... title="TITLE"><img src|data-original="THUMB">.
# supjav miesza kolejność atrybutów (href/rel/class="img"/title w dwóch wariantach), więc # supjav miesza kolejność atrybutów (href/rel/class="img"/title w dwóch wariantach), więc
# NIE wymagamy pozycji class="img" — wymóg `>\s*<img` i tak izoluje link-obraz od # NIE wymagamy pozycji class="img", wymóg `>\s*<img` i tak izoluje link-obraz od
# link-tekstu (ten drugi po `>` ma tekst, nie <img>). Thumb eager `src=` lub lazy # link-tekstu (ten drugi po `>` ma tekst, nie <img>). Thumb eager `src=` lub lazy
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original). # `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
_CARD_RE = re.compile( _CARD_RE = re.compile(

View file

@ -1,11 +1,11 @@
"""sxyland.com — performer-page scrape (search-based, performer-driven). """sxyland.com, performer-page scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 06-07): sxyland porzucił URL scen `/<numeric_id>/<slug>/` 2026-06-16 fix (zamrożony od 06-07): sxyland porzucił URL scen `/<numeric_id>/<slug>/`
na rzecz `/<slug>/`, więc stary regex (wymagał cyfry w ścieżce) dawał 0. WordPress `?s=` na rzecz `/<slug>/`, więc stary regex (wymagał cyfry w ścieżce) dawał 0. WordPress `?s=`
filtruje, ale miesza — czystsze są **strony performera** `/actor/<slug>/` filtruje, ale miesza, czystsze są **strony performera** `/actor/<slug>/`
(performer-driven query = nazwa performera → slugify → /actor/<slug>/). (performer-driven query = nazwa performera → slugify → /actor/<slug>/).
Bogate metadane (per-scene detail fetch — sxyland to WP tube, taksonomie na scenie): Bogate metadane (per-scene detail fetch, sxyland to WP tube, taksonomie na scenie):
- performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`) - performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`)
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...) - tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
- studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio - studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio
@ -181,7 +181,7 @@ class SxyLandScraper(BaseDirectTubeScraper):
) )
) )
# Tagi — TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu). # Tagi, TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
tags: list[RawTag] = [] tags: list[RawTag] = []
seen_tag: set[str] = set() seen_tag: set[str] = set()
block_m = _TAGS_BLOCK_RE.search(detail) block_m = _TAGS_BLOCK_RE.search(detail)

View file

@ -1,4 +1,4 @@
"""sxyprn.com — performer-page scrape (search-based, performer-driven). """sxyprn.com, performer-page scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 05-07): sxyprn NIE ma free-text searcha. Stary endpoint 2026-06-16 fix (zamrożony od 05-07): sxyprn NIE ma free-text searcha. Stary endpoint
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token `?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
@ -42,19 +42,19 @@ log = logging.getLogger(__name__)
_BASE = "https://sxyprn.com" _BASE = "https://sxyprn.com"
# js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera) # js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera)
# + aria-label (połamani performerzy — nieużywane). # + aria-label (połamani performerzy, nieużywane).
_CARD_ANCHOR_RE = re.compile( _CARD_ANCHOR_RE = re.compile(
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'", r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
re.IGNORECASE, re.IGNORECASE,
) )
# Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc # Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc
# NIE używamy `[^>]*` (łamie się na tym `>`) — leniwe `.*?` do wartości HH:MM:SS. # NIE używamy `[^>]*` (łamie się na tym `>`), leniwe `.*?` do wartości HH:MM:SS.
_DURATION_RE = re.compile( _DURATION_RE = re.compile(
r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL
) )
_SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE) _SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE)
_THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", re.IGNORECASE) _THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", re.IGNORECASE)
# post_time z tytułem — keyowane po ID, bo leży w post_control PO vid_container. # post_time z tytułem, keyowane po ID, bo leży w post_control PO vid_container.
_POSTTIME_RE = re.compile( _POSTTIME_RE = re.compile(
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'", r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
re.IGNORECASE, re.IGNORECASE,
@ -89,7 +89,7 @@ def _clean_title(raw: str) -> tuple[str, list[str]]:
class SxyPrnScraper(BaseSearchScraper): class SxyPrnScraper(BaseSearchScraper):
sitetag = "sxyprncom" sitetag = "sxyprncom"
# _search_url_template/_scene_url_re z bazy nieużywane — mamy custom search(). # _search_url_template/_scene_url_re z bazy nieużywane, mamy custom search().
_card_window = 2600 # od js-pop anchora do post_control z tytułem _card_window = 2600 # od js-pop anchora do post_control z tytułem
def _performer_path(self, query: str) -> str: def _performer_path(self, query: str) -> str:
@ -131,7 +131,7 @@ class SxyPrnScraper(BaseSearchScraper):
continue continue
title, tags = _clean_title(tm.group(1)) title, tags = _clean_title(tm.group(1))
title_l = title.lower() title_l = title.lower()
# Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule) — # Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule),
# chroni przed śmieciem na stronie i przed over-attribution. # chroni przed śmieciem na stronie i przed over-attribution.
if query_tokens and not all(tok in title_l for tok in query_tokens): if query_tokens and not all(tok in title_l for tok in query_tokens):
continue continue

View file

@ -1,15 +1,15 @@
"""vjav.com — JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API. """vjav.com, JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
Osobna pula JAV (tytuły azjatyckie + `title_jp`, studia typu JAVHD), origin `tube:vjav`. Osobna pula JAV (tytuły azjatyckie + `title_jp`, studia typu JAVHD), origin `tube:vjav`.
Świadomie orphan vertical (sekcja JAV) — NIE deduplikuje się z zachodnim katalogiem. Świadomie orphan vertical (sekcja JAV), NIE deduplikuje się z zachodnim katalogiem.
vjav to SPA (ktk_player + blob) — listing renderuje się JS-owo, więc HTML listingu vjav to SPA (ktk_player + blob), listing renderuje się JS-owo, więc HTML listingu
nie ma linków do scen. Zamiast tego: nie ma linków do scen. Zamiast tego:
- **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest - **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest
= najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N = = najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N =
kolejny wycinek PER_PAGE. kolejny wycinek PER_PAGE.
- **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json` - **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json`
(floor = id//1000*1000) — title (+title_jp), duration, post_date, channel.title (floor = id//1000*1000), title (+title_jp), duration, post_date, channel.title
(studio), models{} (performerki), categories{}+tags{}. (studio), models{} (performerki), categories{}+tags{}.
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php -> - **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
get_file HLS). RE 2026-07-10. get_file HLS). RE 2026-07-10.
@ -62,7 +62,7 @@ def _parse_duration(value: str | None) -> int | None:
# --- bramka jakości tytułu ---------------------------------------------------------- # --- bramka jakości tytułu ----------------------------------------------------------
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie # vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są # dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
# w apce nieodnajdywalne — nie mają ani tytułu, ani performera, po którym da się trafić. # w apce nieodnajdywalne, nie mają ani tytułu, ani performera, po którym da się trafić.
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona, # W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
# bo sprawdzała tylko jeden z czterech wzorców). # bo sprawdzała tylko jeden z czterech wzorców).
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_... _JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
@ -74,7 +74,7 @@ def _has_cjk(text: str) -> bool:
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest """Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
wyszła przy pomiarze pierwszej wersji bramki — bez tego wyjątku poszłoby 708 z 708 wyszła przy pomiarze pierwszej wersji bramki, bez tego wyjątku poszłoby 708 z 708
tytułów CJK z próbki.""" tytułów CJK z próbki."""
return sum(1 for c in text if " " <= c <= "鿿" or "＀" <= c <= "￯") >= 3 return sum(1 for c in text if " " <= c <= "鿿" or "＀" <= c <= "￯") >= 3
@ -119,7 +119,7 @@ class VjavScraper(BaseBrowseScraper):
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna, # vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy # /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem # 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
# działania, nie optymalizacją — bez klucza scraper się wyłącza. # działania, nie optymalizacją, bez klucza scraper się wyłącza.
self._proxy = get_settings().brightdata_proxy_url self._proxy = get_settings().brightdata_proxy_url
if not self._proxy: if not self._proxy:
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)") log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
@ -130,7 +130,7 @@ class VjavScraper(BaseBrowseScraper):
zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx — transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx,
i to właśnie przez to treść błędu 429 była parsowana jako XML.""" i to właśnie przez to treść błędu 429 była parsowana jako XML."""
try: try:
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy) res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
@ -238,7 +238,7 @@ class VjavScraper(BaseBrowseScraper):
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
kończyło CAŁY run — źródło dawało `seen: 0` mimo 39 dobrych scen na stronach kończyło CAŁY run, źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`. 2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`.
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze. Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
@ -268,7 +268,7 @@ class VjavScraper(BaseBrowseScraper):
out: list[RawScene] = [] out: list[RawScene] = []
for vid in chunk: for vid in chunk:
try: try:
# Proxy także tutaj — API metadanych siedzi na tym samym hoście, więc # Proxy także tutaj, API metadanych siedzi na tym samym hoście, więc
# obejmuje je ten sam 429 co sitemap. # obejmuje je ten sam 429 co sitemap.
res = browser_get( res = browser_get(
self._meta_url(vid), self._meta_url(vid),

View file

@ -1,7 +1,7 @@
"""watchporn.to — browse scraper (KVS engine). Re-enabled 2026-07-02. """watchporn.to, browse scraper (KVS engine). Re-enabled 2026-07-02.
Był search-scraperem (`?s=`), zamarzł, a potem site przebudowano na KVS z nowym Był search-scraperem (`?s=`), zamarzł, a potem site przebudowano na KVS z nowym
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął — teraz KVS layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął, teraz KVS
flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound). flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound).
Browse `/latest-updates/` → detail page: Browse `/latest-updates/` → detail page:

View file

@ -1,4 +1,4 @@
"""XHamster.com — direct HTML scrape search results. """XHamster.com, direct HTML scrape search results.
Search: `https://xhamster.com/search/<q>?page=<n>` Search: `https://xhamster.com/search/<q>?page=<n>`
Scene URL: `https://xhamster.com/videos/<slug>-<id>` Scene URL: `https://xhamster.com/videos/<slug>-<id>`

View file

@ -1,4 +1,4 @@
"""xmoviesforyou.com — direct HTML scrape. """xmoviesforyou.com, direct HTML scrape.
Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination). Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination).
Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment). Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment).

View file

@ -1,4 +1,4 @@
"""XNXX.com — direct HTML scrape search results. """XNXX.com, direct HTML scrape search results.
Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed) Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed)
Scene URL: `https://www.xnxx.com/video-<id>/<slug>` Scene URL: `https://www.xnxx.com/video-<id>/<slug>`
@ -12,7 +12,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
class XnxxScraper(BaseSearchScraper): class XnxxScraper(BaseSearchScraper):
sitetag = "xnxxcom" sitetag = "xnxxcom"
# `/<page-1>` — handle override in search() by replacing {page}. # `/<page-1>`, handle override in search() by replacing {page}.
_search_url_template = "https://www.xnxx.com/search/{query}/{page}" _search_url_template = "https://www.xnxx.com/search/{query}/{page}"
_scene_url_re = re.compile( _scene_url_re = re.compile(
r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"', r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"',

View file

@ -1,6 +1,6 @@
"""xnxx.com — BROWSE scraper (JSON-LD), obok search scrapera. """xnxx.com, BROWSE scraper (JSON-LD), obok search scrapera.
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail) — i TYLKO to Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail), i TYLKO to
bierzemy. Mimo wspólnego silnika z xvideos, xnxx detail NIE wystawia w SSR linków bierzemy. Mimo wspólnego silnika z xvideos, xnxx detail NIE wystawia w SSR linków
`/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia `/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia
canonical-merge + istniejący performer-search. XnxxScraper (search) zostaje; browse canonical-merge + istniejący performer-search. XnxxScraper (search) zostaje; browse
@ -8,13 +8,13 @@ dokłada sygnał świeżości. Tytuł z JSON-LD bywa HTML-encoded (`&comma;`/`&e
html.unescape. html.unescape.
Listing: xnxx NIE ma czystego SSR `/new/` (404), ale `/best/<YYYY-MM>/<page>` jest Listing: xnxx NIE ma czystego SSR `/new/` (404), ale `/best/<YYYY-MM>/<page>` jest
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc — pokrywa świeży SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc, pokrywa świeży
content (sortowanie best-of-month, nie ściśle chronologiczne, ale dla sygnału content (sortowanie best-of-month, nie ściśle chronologiczne, ale dla sygnału
świeżości wystarcza; ścisłą chronologię i tak daje performer-search). Homepage świeżości wystarcza; ścisłą chronologię i tak daje performer-search). Homepage
xnxx jest JS-renderowany (0 linków w surowym HTML), stąd /best/. xnxx jest JS-renderowany (0 linków w surowym HTML), stąd /best/.
Playback bez zmian (extractor `xnxxcom`). Phash pominięty (xnxx crop-thumbnaile, Playback bez zmian (extractor `xnxxcom`). Phash pominięty (xnxx crop-thumbnaile,
0% hit do canonical — jak xvideos). 0% hit do canonical, jak xvideos).
""" """
from __future__ import annotations from __future__ import annotations
@ -108,7 +108,7 @@ class XnxxBrowseScraper(BaseBrowseScraper):
if isinstance(thumbnail_url, list): if isinstance(thumbnail_url, list):
thumbnail_url = thumbnail_url[0] if thumbnail_url else None thumbnail_url = thumbnail_url[0] if thumbnail_url else None
# performers/tags puste — xnxx detail nie ma ich w SSR (patrz docstring). # performers/tags puste, xnxx detail nie ma ich w SSR (patrz docstring).
return RawScene( return RawScene(
external_id=f"{self.sitetag}:{scene_url}", external_id=f"{self.sitetag}:{scene_url}",
title=title, title=title,

View file

@ -1,4 +1,4 @@
"""XVideos.com — direct HTML scrape search results. """XVideos.com, direct HTML scrape search results.
Search: `https://www.xvideos.com/?k=<q>&p=<page-1>` (xvideos używa 0-indexed pages) Search: `https://www.xvideos.com/?k=<q>&p=<page-1>` (xvideos używa 0-indexed pages)
Scene URL: `https://www.xvideos.com/video<digits>/<slug>` Scene URL: `https://www.xvideos.com/video<digits>/<slug>`
@ -13,7 +13,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
class XVideosScraper(BaseSearchScraper): class XVideosScraper(BaseSearchScraper):
sitetag = "xvideoscom" sitetag = "xvideoscom"
# 0-indexed page — w base classie computed jako `page=N`, więc override _build_url. # 0-indexed page, w base classie computed jako `page=N`, więc override _build_url.
_search_url_template = "https://www.xvideos.com/?k={query}&p={page}" _search_url_template = "https://www.xvideos.com/?k={query}&p={page}"
_scene_url_re = re.compile( _scene_url_re = re.compile(
r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"', r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"',
@ -21,10 +21,10 @@ class XVideosScraper(BaseSearchScraper):
) )
def search(self, query, *, page=1, limit=None): def search(self, query, *, page=1, limit=None):
# XVideos używa 0-indexed pages — `page=1` w API → `&p=0` w URL. # XVideos używa 0-indexed pages, `page=1` w API → `&p=0` w URL.
# Override żeby base class fetch'nął zewnętrzny URL z (page-1). # Override żeby base class fetch'nął zewnętrzny URL z (page-1).
# Najprościej: dostosujmy URL w override przed wywołaniem super().search(). # Najprościej: dostosujmy URL w override przed wywołaniem super().search().
# Ale super() używa self._search_url_template — robimy clone z poprawionym page. # Ale super() używa self._search_url_template, robimy clone z poprawionym page.
original = self._search_url_template original = self._search_url_template
self._search_url_template = original.replace("{page}", str(page - 1)) self._search_url_template = original.replace("{page}", str(page - 1))
try: try:

View file

@ -1,14 +1,14 @@
"""xvideos.com — deep-crawl browse scraper (JSON-LD + page-parse). """xvideos.com, deep-crawl browse scraper (JSON-LD + page-parse).
xvideos SSR-uje JSON-LD VideoObject (duration, name, uploadDate) ORAZ na detail-stronie xvideos SSR-uje JSON-LD VideoObject (duration, name, uploadDate) ORAZ na detail-stronie
linki `/models/<slug>` (performerzy tej sceny) + `/tags/<slug>` (tagi). Sample 2026-06-03 linki `/models/<slug>` (performerzy tej sceny) + `/tags/<slug>` (tagi). Sample 2026-06-03
(15 scen): median ~10.5min, 93% ≥3min — dobry full-scene content (nie trailery). (15 scen): median ~10.5min, 93% ≥3min, dobry full-scene content (nie trailery).
Mega-katalog (~13M) → deep_crawl z per-tube page-cap (xvideoscom w deep_crawl._PAGE_CAP), Mega-katalog (~13M) → deep_crawl z per-tube page-cap (xvideoscom w deep_crawl._PAGE_CAP),
żeby nie monopolizował round-robin ani nie zalał bazy. Listing: /new/<page> (newest). żeby nie monopolizował round-robin ani nie zalał bazy. Listing: /new/<page> (newest).
Scene: /video.<hash>/<slug>. Playback: page_url + origin tube:xvideoscom (istniejący Scene: /video.<hash>/<slug>. Playback: page_url + origin tube:xvideoscom (istniejący
extractor `xvideoscom` resolvuje stream mobile-side). Phash pominięty (xvideos robi extractor `xvideoscom` resolvuje stream mobile-side). Phash pominięty (xvideos robi
własne crop-thumbnaile — 0% hit do canonical, jak fullmovies/hdporn). własne crop-thumbnaile, 0% hit do canonical, jak fullmovies/hdporn).
""" """
from __future__ import annotations from __future__ import annotations
@ -29,7 +29,7 @@ _JSONLD_RE = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
) )
# Model anchor: `<a href="/models/<slug>" ...><span ...></span><span class="name">Display Name</span>...`. # Model anchor: `<a href="/models/<slug>" ...><span ...></span><span class="name">Display Name</span>...`.
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora — # Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora,
# poprzedni wzorzec `>([^<]{2,60})</a>` wymagał tekstu zaraz po `>` i NIGDY nie pasował do # poprzedni wzorzec `>([^<]{2,60})</a>` wymagał tekstu zaraz po `>` i NIGDY nie pasował do
# obecnego markupu xvideos → wszystkie browse-scraped sceny xvideos lądowały z 0 performerami # obecnego markupu xvideos → wszystkie browse-scraped sceny xvideos lądowały z 0 performerami
# (bug-report 2026-06-07 "czemu nie ma aktorów, są na stronie"). `(?:(?!</a>).)*?` trzyma match # (bug-report 2026-06-07 "czemu nie ma aktorów, są na stronie"). `(?:(?!</a>).)*?` trzyma match

View file

@ -1,4 +1,4 @@
"""xxxfree.watch — direct HTML scrape. """xxxfree.watch, direct HTML scrape.
Domain: `xxxfree.watch` (sitetag `xxxfreewatch` is legacy from porn-app DEFAULT_SITETAGS). Domain: `xxxfree.watch` (sitetag `xxxfreewatch` is legacy from porn-app DEFAULT_SITETAGS).

View file

@ -1,25 +1,25 @@
"""yesporn.vip — latest-vids browse scraper. """yesporn.vip, latest-vids browse scraper.
Dołączony 2026-05-27. Identyfikowany przez user audit jako "scraper-of-paysites" Dołączony 2026-05-27. Identyfikowany przez user audit jako "scraper-of-paysites"
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude (DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
audit pomylił domeny: `yespornvip.com` (z theporndude rankingu) redirectuje przez audit pomylił domeny: `yespornvip.com` (z theporndude rankingu) redirectuje przez
pdude.link do `porndudecams.com` affiliate spam — kanoniczna domena ma TLD `.vip`. pdude.link do `porndudecams.com` affiliate spam, kanoniczna domena ma TLD `.vip`.
Czemu wart (parity z porndoe): Czemu wart (parity z porndoe):
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate - **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl (ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
(BunnyCDN: `yesnn.b-cdn.net/contents/videos_screenshots/...`). (BunnyCDN: `yesnn.b-cdn.net/contents/videos_screenshots/...`).
- **`<meta property="video:duration" content="2340">`** — durations już w sekundach - **`<meta property="video:duration" content="2340">`** durations już w sekundach
(fallback gdy ISO-duration parse fail). (fallback gdy ISO-duration parse fail).
- **`<meta property="video:release_date">`** — ISO 8601 z timezone, redundant z - **`<meta property="video:release_date">`** ISO 8601 z timezone, redundant z
JSON-LD uploadDate ale czystszy format. JSON-LD uploadDate ale czystszy format.
- **`<meta property="video:tag">`** (multiple) — kanoniczna lista tagów (np. - **`<meta property="video:tag">`** (multiple), kanoniczna lista tagów (np.
"Big Ass", "Threesome"). Główne źródło tagów; alternatywnie DOM ma `btn gold` "Big Ass", "Threesome"). Główne źródło tagów; alternatywnie DOM ma `btn gold`
linki ale te miksują performerów/studio z tagami. linki ale te miksują performerów/studio z tagami.
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">` - **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy, (studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
multiple). Slugi mają stable per-type salt (`*-i459s7` dla modeli, `*-7p72tp` multiple). Slugi mają stable per-type salt (`*-i459s7` dla modeli, `*-7p72tp`
dla channels) — zachowują się jak hash z site-version, ale stabilne przez dla channels), zachowują się jak hash z site-version, ale stabilne przez
sesje. sesje.
External_id strategia: `yespornvip:<numeric_video_id>` (`/video/69841/...` → `69841`). External_id strategia: `yespornvip:<numeric_video_id>` (`/video/69841/...` → `69841`).
@ -31,11 +31,11 @@ URL patterns:
- Scene: `/video/<id>/<slug>/` (id numeryczny, slug = title slug + 6-char salt) - Scene: `/video/<id>/<slug>/` (id numeryczny, slug = title slug + 6-char salt)
- Studio: `/channels/<slug>/` - Studio: `/channels/<slug>/`
- Performer: `/models/<slug>/` - Performer: `/models/<slug>/`
- Search: `/search/<query>/` (nie używane w browse-mode — można dorobić jako - Search: `/search/<query>/` (nie używane w browse-mode, można dorobić jako
osobny tryb dla performer-driven backfill jeśli będzie potrzeba) osobny tryb dla performer-driven backfill jeśli będzie potrzeba)
Playback: download endpoint `/view_video_download.php?id=<id>&format=<480|720|1080>` Playback: download endpoint `/view_video_download.php?id=<id>&format=<480|720|1080>`
z `data-attach-session="PHPSESSID"` — wymaga session cookie, więc nie direct mp4 z `data-attach-session="PHPSESSID"`, wymaga session cookie, więc nie direct mp4
z server-side. Plus jest `embedUrl: /embed/<id>` w JSON-LD. Extractor → z server-side. Plus jest `embedUrl: /embed/<id>` w JSON-LD. Extractor →
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy): `_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
mobile WebView fetcha embed z phone IP, INJECTED_JS scrape'uje `<video>.src`. mobile WebView fetcha embed z phone IP, INJECTED_JS scrape'uje `<video>.src`.
@ -66,7 +66,7 @@ log = logging.getLogger(__name__)
_BASE = "https://yesporn.vip" _BASE = "https://yesporn.vip"
# Listing — scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`. # Listing, scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
# Slug `<slug>` zawiera stable per-type salt (`*-npu57w` dla videos). # Slug `<slug>` zawiera stable per-type salt (`*-npu57w` dla videos).
_SCENE_URL_RE = re.compile( _SCENE_URL_RE = re.compile(
r'href="(https://yesporn\.vip/video/(\d+)/[a-z0-9\-]+/)"', r'href="(https://yesporn\.vip/video/(\d+)/[a-z0-9\-]+/)"',
@ -87,13 +87,13 @@ _PERFORMER_LINK_RE = re.compile(
) )
_HTML_TAG_RE = re.compile(r"<[^>]+>") _HTML_TAG_RE = re.compile(r"<[^>]+>")
# JSON-LD VideoObject — pełny blok między `<script type="application/ld+json">` tagami. # JSON-LD VideoObject, pełny blok między `<script type="application/ld+json">` tagami.
_JSONLD_RE = re.compile( _JSONLD_RE = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
# `<meta property="video:tag" content="Big Ass">` — multiple, jeden tag per meta. # `<meta property="video:tag" content="Big Ass">`, multiple, jeden tag per meta.
_META_TAG_RE = re.compile( _META_TAG_RE = re.compile(
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']', r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
re.IGNORECASE, re.IGNORECASE,
@ -122,7 +122,7 @@ def _parse_iso_date(value: str | None) -> date | None:
"""`2026-05-26T19:23:29Z` / `2026-05-26T19:23:29.EDT` → date.""" """`2026-05-26T19:23:29Z` / `2026-05-26T19:23:29.EDT` → date."""
if not value: if not value:
return None return None
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate — strip żeby # yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate, strip żeby
# `fromisoformat` nie crash'ował. video:release_date meta ma czysty `Z`. # `fromisoformat` nie crash'ował. video:release_date meta ma czysty `Z`.
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip()) cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
try: try:
@ -265,7 +265,7 @@ class YesPornVipScraper(BaseBrowseScraper):
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep # lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone # Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
# w 20-scene dry-run 2026-05-27. Trade-off: stracimy hipotetyczne legit # w 20-scene dry-run 2026-05-27. Trade-off: stracimy hipotetyczne legit
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował) — # lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował),
# akceptowalne bo tags mają wagę tylko 0.05 w composite scoring resolvera. # akceptowalne bo tags mają wagę tylko 0.05 w composite scoring resolvera.
tags: list[RawTag] = [] tags: list[RawTag] = []
seen_tag: set[str] = set() seen_tag: set[str] = set()
@ -283,17 +283,17 @@ class YesPornVipScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug) RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
) )
# Phash z thumbnailUrl — BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene. # Phash z thumbnailUrl, BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
# Hit-rate vs canonical TPDB/StashDB nieznany do pilot run; graceful: brak # Hit-rate vs canonical TPDB/StashDB nieznany do pilot run; graceful: brak
# phash → resolver spada do composite scoring (studio + performer + date + # phash → resolver spada do composite scoring (studio + performer + date +
# duration + title token-set) — wszystkie dostępne dzięki JSON-LD. # duration + title token-set), wszystkie dostępne dzięki JSON-LD.
fingerprints: list[RawFingerprint] = [] fingerprints: list[RawFingerprint] = []
if thumbnail_url: if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/") ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
if ph: if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph)) fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Playback — page_url do strony sceny. Direct mp4 (`view_video_download.php`) # Playback, page_url do strony sceny. Direct mp4 (`view_video_download.php`)
# wymaga PHPSESSID cookie (data-attach-session attribute), więc nie usable # wymaga PHPSESSID cookie (data-attach-session attribute), więc nie usable
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`: # server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
# mobile WebView z phone IP łapie session natively, INJECTED_JS scrape. # mobile WebView z phone IP łapie session natively, INJECTED_JS scrape.

View file

@ -1,4 +1,4 @@
"""youperv.com — browse scraper (DataLife Engine). Dodany 2026-07-26. """youperv.com, browse scraper (DataLife Engine). Dodany 2026-07-26.
Ripy paysite ze studiami (Brazzers Exxtra, Blacked, Evil Angel, Deeper, Private…), Ripy paysite ze studiami (Brazzers Exxtra, Blacked, Evil Angel, Deeper, Private…),
tytuły w formacie `Studio - Performer - Title` (71% próbki 132 tytułów), świeże tytuły w formacie `Studio - Performer - Title` (71% próbki 132 tytułów), świeże
@ -8,20 +8,20 @@ co do sekundy + duration = mocny sygnał do canonical match.
Listing: homepage (newest) + `/page/N/`, 19 scen/stronę, zero overlapu między Listing: homepage (newest) + `/page/N/`, 19 scen/stronę, zero overlapu między
stronami. Scene URL: `/<kategoria>/<id>-<slug>.html`. stronami. Scene URL: `/<kategoria>/<id>-<slug>.html`.
**KRYTYCZNE — scoping obsady**: performerzy MUSZĄ być czytani tylko z bloku **KRYTYCZNE, scoping obsady**: performerzy MUSZĄ być czytani tylko z bloku
`<div class="fmeta">` … `Related`. Na całej stronie jest 19-26 linków `<div class="fmeta">` … `Related`. Na całej stronie jest 19-26 linków
`xfsearch/pornstar/` (blok Related), w samym fmeta 1-2 realnych. Bez scopingu `xfsearch/pornstar/` (blok Related), w samym fmeta 1-2 realnych. Bez scopingu
powtórzylibyśmy błąd, przez który odrzuciliśmy xxxfiles (page-wide pollution powtórzylibyśmy błąd, przez który odrzuciliśmy xxxfiles (page-wide pollution
zaśmiecająca bazę performerów). zaśmiecająca bazę performerów).
Tytuł zostaje z prefiksem studia (jak hdporngg/porn00) — token_set_ratio i tak Tytuł zostaje z prefiksem studia (jak hdporngg/porn00), token_set_ratio i tak
złapie canonical, a prefiks niesie dodatkowy sygnał. złapie canonical, a prefiks niesie dodatkowy sygnał.
Playback: direct mp4 `<source>` na files.klubnichka-hd.com, BEZ tokena/expiry, ale Playback: direct mp4 `<source>` na files.klubnichka-hd.com, BEZ tokena/expiry, ale
CDN ma hotlink-guard na Referer (bez nagłówka 403, z nagłówkiem 206 cross-IP). CDN ma hotlink-guard na Referer (bez nagłówka 403, z nagłówkiem 206 cross-IP).
Rozwiązuje extractor `youpervcom` (VPS-side, mobile gra direct, zero WebView/proxy). Rozwiązuje extractor `youpervcom` (VPS-side, mobile gra direct, zero WebView/proxy).
Głębokość: deep-crawl capowany (`_PAGE_CAP` w deep_crawl.py) — strony ~2100+ to Głębokość: deep-crawl capowany (`_PAGE_CAP` w deep_crawl.py), strony ~2100+ to
stara amatorka bez performerów, z martwymi linkami (HTTP 500 na CDN). stara amatorka bez performerów, z martwymi linkami (HTTP 500 na CDN).
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -1,4 +1,4 @@
"""YouPorn.com — direct HTML scrape search results. """YouPorn.com, direct HTML scrape search results.
Search: `https://www.youporn.com/search/?query=<q>&page=<n>` Search: `https://www.youporn.com/search/?query=<q>&page=<n>`
Scene URL: `https://www.youporn.com/watch/<id>/<slug>/` Scene URL: `https://www.youporn.com/watch/<id>/<slug>/`

View file

@ -1,12 +1,12 @@
"""youporn.com — latest BROWSE scraper (JSON-LD + page-parse), obok search scrapera. """youporn.com, latest BROWSE scraper (JSON-LD + page-parse), obok search scrapera.
YouPornScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada świeżość YouPornScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada świeżość
wprost z `/browse/time/?page=<n>` (newest-first, SSR). Detail page ma JSON-LD wprost z `/browse/time/?page=<n>` (newest-first, SSR). Detail page ma JSON-LD
VideoObject (name/duration/uploadDate/thumbnail) — i TYLKO to bierzemy. VideoObject (name/duration/uploadDate/thumbnail), i TYLKO to bierzemy.
UWAGA: performerów/tagów z detail-strony NIE wyciągamy. JSON-LD nie ma pola `actor`, UWAGA: performerów/tagów z detail-strony NIE wyciągamy. JSON-LD nie ma pola `actor`,
a linki `/pornstar/` i `/category/` na stronie są zaśmiecone sidebarem (popularne a linki `/pornstar/` i `/category/` na stronie są zaśmiecone sidebarem (popularne
pornstars/related) bez czystego scene-scoped kontenera — naiwny regex podpinał te pornstars/related) bez czystego scene-scoped kontenera, naiwny regex podpinał te
same 2 pornstars do KAŻDEJ sceny (mass-misattribution). Browse to tylko sygnał same 2 pornstars do KAŻDEJ sceny (mass-misattribution). Browse to tylko sygnał
świeżości (próg watchdog 48h); performerów/tagi dorabia canonical-merge + istniejący świeżości (próg watchdog 48h); performerów/tagi dorabia canonical-merge + istniejący
search scraper (performer-driven). Listing SSR (/watch/<id>/); homepage JS-renderowany. search scraper (performer-driven). Listing SSR (/watch/<id>/); homepage JS-renderowany.
@ -96,7 +96,7 @@ class YouPornBrowseScraper(BaseBrowseScraper):
if isinstance(thumbnail_url, list): if isinstance(thumbnail_url, list):
thumbnail_url = thumbnail_url[0] if thumbnail_url else None thumbnail_url = thumbnail_url[0] if thumbnail_url else None
# performers/tags celowo puste — patrz docstring (sidebar pollution, brak # performers/tags celowo puste, patrz docstring (sidebar pollution, brak
# scene-scoped kontenera). Dorabia canonical-merge + search scraper. # scene-scoped kontenera). Dorabia canonical-merge + search scraper.
return RawScene( return RawScene(
external_id=f"{self.sitetag}:{scene_url}", external_id=f"{self.sitetag}:{scene_url}",

View file

@ -1,4 +1,4 @@
"""dooplay (a.k.a. PsyPlay) WordPress theme scraper — generic dla mangoporn/streamporn/pandamovies. """dooplay (a.k.a. PsyPlay) WordPress theme scraper, generic dla mangoporn/streamporn/pandamovies.
Te 3 strony to dokładnie ten sam template (theme=dooplay + PsyPlay player plugin), Te 3 strony to dokładnie ten sam template (theme=dooplay + PsyPlay player plugin),
więc parametryzujemy connector po `(base_url, source_name)` i odpalamy 3 instancje. więc parametryzujemy connector po `(base_url, source_name)` i odpalamy 3 instancje.
@ -15,7 +15,7 @@ Detail: `/movies/<slug>/` ma rich meta:
- <span class="dt_rating_vgs" itemprop="ratingValue">N</span> rating 0-10 - <span class="dt_rating_vgs" itemprop="ratingValue">N</span> rating 0-10
- <li ... data-fl-source="<embed_url>"><a href="<embed_link>">Host</a></li> player options - <li ... data-fl-source="<embed_url>"><a href="<embed_link>">Host</a></li> player options
Player ma multi-host options (DoodStream, LuluStream, RPMShare etc.) — każdy embed Player ma multi-host options (DoodStream, LuluStream, RPMShare etc.), każdy embed
URL idzie jako osobny `playback_source` z origin=`{site}:{host}` żeby później mobile URL idzie jako osobny `playback_source` z origin=`{site}:{host}` żeby później mobile
mógł wybrać czyim embedem chce odpalić scenę. mógł wybrać czyim embedem chce odpalić scenę.
""" """
@ -48,9 +48,9 @@ USER_AGENT = (
) )
# ---- selektory (theme-agnostic — działa dla dowolnego dooplay) ----------- # ---- selektory (theme-agnostic, działa dla dowolnego dooplay) -----------
# Listing item — dwa wzorce w zależności od witryny: # Listing item, dwa wzorce w zależności od witryny:
# 1. mangoporn: zwykłe <a href="https://site/movies/<slug>/"> bez klasy # 1. mangoporn: zwykłe <a href="https://site/movies/<slug>/"> bez klasy
# (theme wyrendurował SEO-friendly URL bezpośrednio w grid) # (theme wyrendurował SEO-friendly URL bezpośrednio w grid)
# 2. streamporn/pandamovies: <a class="ml-mask jt" href="<base>/<slug>/"> # 2. streamporn/pandamovies: <a class="ml-mask jt" href="<base>/<slug>/">
@ -62,14 +62,14 @@ _LIST_ITEM_RE = re.compile(
r'<a\s+href="(?P<url2>https?://[^"]+/movies/[a-z0-9-]+/)"', r'<a\s+href="(?P<url2>https?://[^"]+/movies/[a-z0-9-]+/)"',
re.IGNORECASE, re.IGNORECASE,
) )
# Tolerantny title — mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies # Tolerantny title, mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
# (raw PsyPlay theme) używają <h3 itemprop="name">. Łapiemy oba przez itemprop="name". # (raw PsyPlay theme) używają <h3 itemprop="name">. Łapiemy oba przez itemprop="name".
_TITLE_RE = re.compile( _TITLE_RE = re.compile(
r'<h[1-6][^>]*\sitemprop="name"[^>]*>([^<]+)</h[1-6]>' r'<h[1-6][^>]*\sitemprop="name"[^>]*>([^<]+)</h[1-6]>'
r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>', r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>',
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
# dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów — # dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów,
# różne thema dziedziczą podstawowy markup ale customizują URL słowniki. # różne thema dziedziczą podstawowy markup ale customizują URL słowniki.
_YEAR_RE = re.compile( _YEAR_RE = re.compile(
r'/(?:year|release-year)/(\d{4})/"\s*rel="tag"', re.IGNORECASE r'/(?:year|release-year)/(\d{4})/"\s*rel="tag"', re.IGNORECASE
@ -111,7 +111,7 @@ _GENRE_TAG_RE = re.compile(
# Player options: data-fl-source jest oryginalnym embed URL hostera, data-fl-url # Player options: data-fl-source jest oryginalnym embed URL hostera, data-fl-url
# to page URL u hostera. Stare theme (mangoporn): `<li class="hosts-buttons-wpx">`. # to page URL u hostera. Stare theme (mangoporn): `<li class="hosts-buttons-wpx">`.
# Nowe theme (pandamovies od ~2026-04): `<div class="Rtable1-cell" data-fl-url=... # Nowe theme (pandamovies od ~2026-04): `<div class="Rtable1-cell" data-fl-url=...
# data-fl-source=...>`. Trzeba też tolerować order-independent attrs — nowe theme # data-fl-source=...>`. Trzeba też tolerować order-independent attrs, nowe theme
# emituje url BEFORE source, stare odwrotnie. Łapiemy oba wzorce dwoma osobnymi # emituje url BEFORE source, stare odwrotnie. Łapiemy oba wzorce dwoma osobnymi
# regexami i konsolidujemy w `_iter_player_options`. # regexami i konsolidujemy w `_iter_player_options`.
_PLAYER_OPTION_RE = re.compile( _PLAYER_OPTION_RE = re.compile(
@ -126,7 +126,7 @@ _PLAYER_OPTION_RE = re.compile(
# Attrs są w kolejności url→source, source często pusty (`data-fl-source=""` dla # Attrs są w kolejności url→source, source często pusty (`data-fl-source=""` dla
# doodstream/mixdrop/easyvidplayer). Capturujemy CAŁY opening tag w group(1) # doodstream/mixdrop/easyvidplayer). Capturujemy CAŁY opening tag w group(1)
# żeby data-fl-source należał gwarantowanie do TEGO konkretnego div (wcześniejszy # żeby data-fl-source należał gwarantowanie do TEGO konkretnego div (wcześniejszy
# window-lookback 600 chars mógł pickować poprzedni cell — cross-attribution # window-lookback 600 chars mógł pickować poprzedni cell, cross-attribution
# doodstream→mixdrop entry, code-review #14). # doodstream→mixdrop entry, code-review #14).
_PLAYER_OPTION_DIV_RE = re.compile( _PLAYER_OPTION_DIV_RE = re.compile(
r'(<div[^>]*\bclass="Rtable1-cell"[^>]*>)\s*' r'(<div[^>]*\bclass="Rtable1-cell"[^>]*>)\s*'
@ -135,7 +135,7 @@ _PLAYER_OPTION_DIV_RE = re.compile(
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
_DATA_FL_SOURCE_RE = re.compile(r'data-fl-source="([^"]*)"', re.IGNORECASE) _DATA_FL_SOURCE_RE = re.compile(r'data-fl-source="([^"]*)"', re.IGNORECASE)
# Poster — JSON-LD `thumbnailUrl` jest najbardziej stabilny (każdy dooplay/PsyPlay # Poster, JSON-LD `thumbnailUrl` jest najbardziej stabilny (każdy dooplay/PsyPlay
# theme z SEO ma JSON-LD VideoObject schema). Fallback na class="poster" img dla starych # theme z SEO ma JSON-LD VideoObject schema). Fallback na class="poster" img dla starych
# instalacji bez schema. Trzeci fallback: og:image meta tag. # instalacji bez schema. Trzeci fallback: og:image meta tag.
_POSTER_JSONLD_RE = re.compile( _POSTER_JSONLD_RE = re.compile(
@ -168,7 +168,7 @@ class DooplayConnector(BaseMovieConnector):
pass pass
def _fetch(self, url: str) -> str: def _fetch(self, url: str) -> str:
"""browser_get z chrome120 impersonation — psyplay sites czasem blokują """browser_get z chrome120 impersonation, psyplay sites czasem blokują
czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to.""" czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to."""
if not url.startswith("http"): if not url.startswith("http"):
url = self.base_url.rstrip("/") + url url = self.base_url.rstrip("/") + url
@ -187,12 +187,12 @@ class DooplayConnector(BaseMovieConnector):
) )
return r.text return r.text
# Bezpiecznik — dooplay listing potrafi mieć tysiące stron (streamporn.nl ma # Bezpiecznik, dooplay listing potrafi mieć tysiące stron (streamporn.nl ma
# >2k filmów). Bez tego ingest wisi godzinami, jest killowany przy restartcie # >2k filmów). Bez tego ingest wisi godzinami, jest killowany przy restartcie
# schedulera, blokując kolejne connectory w queue (bug-report 2026-05-28: "od # schedulera, blokując kolejne connectory w queue (bug-report 2026-05-28: "od
# wczoraj nie ma nowych filmów" — streamporn wisiał od 5-24, blokował # wczoraj nie ma nowych filmów", streamporn wisiał od 5-24, blokował
# mangoporn + pandamovies). Listing jest sortowany po dacie uploadu (NIE # mangoporn + pandamovies). Listing jest sortowany po dacie uploadu (NIE
# release_date filmu — release może być z 2013 a upload z dziś), ale upload # release_date filmu, release może być z 2013 a upload z dziś), ale upload
# date nie jest w markupie, więc filtrowanie po `since` przez release_date # date nie jest w markupie, więc filtrowanie po `since` przez release_date
# nie działa. Pragmatyczny cap stron: 3 dla delta (≈150 nowych pozycji/dzień # nie działa. Pragmatyczny cap stron: 3 dla delta (≈150 nowych pozycji/dzień
# to znacznie powyżej realnego upload-rate), 50 dla full ingestu (`since=None`). # to znacznie powyżej realnego upload-rate), 50 dla full ingestu (`since=None`).
@ -335,7 +335,7 @@ def _parse_dooplay_detail(
poster_url = candidate poster_url = candidate
break break
# Performers — tylko sekcja "Pornstars" ma /pornstar/<slug>/ linki, dooplay # Performers, tylko sekcja "Pornstars" ma /pornstar/<slug>/ linki, dooplay
# filtruje cast w tej sekcji. Jaccard może łapać dubel ale dedup robimy w # filtruje cast w tej sekcji. Jaccard może łapać dubel ale dedup robimy w
# resolverze (po performer_id). # resolverze (po performer_id).
performers = [ performers = [
@ -360,14 +360,14 @@ def _parse_dooplay_detail(
# Playback sources: każdy host (Doodstream/Lulu/RPM/...) jako osobny entry. # Playback sources: każdy host (Doodstream/Lulu/RPM/...) jako osobny entry.
# Dedup po href żeby ten sam host nie wpadł 2x. Raw landing page (origin= # Dedup po href żeby ten sam host nie wpadł 2x. Raw landing page (origin=
# source_name, bez :host) appendujemy TYLKO gdy nie ma żadnych sub-hosters — # source_name, bez :host) appendujemy TYLKO gdy nie ma żadnych sub-hosters,
# inaczej myli usera (otwiera WebView z reklamami zamiast video; bug-report # inaczej myli usera (otwiera WebView z reklamami zamiast video; bug-report
# 2026-05-16: "mangoporn przekierowuje do strony, reklama full screen"). # 2026-05-16: "mangoporn przekierowuje do strony, reklama full screen").
playback_sources: list[RawPlaybackSource] = [] playback_sources: list[RawPlaybackSource] = []
seen_hrefs: set[str] = set() seen_hrefs: set[str] = set()
# Hostery file-download (non-streamable) + malware. Mobile player nie potrafi # Hostery file-download (non-streamable) + malware. Mobile player nie potrafi
# ich odtworzyć — rapidgator/nitroflare/frdl serwują .zip/.rar/.mp4 do download # ich odtworzyć, rapidgator/nitroflare/frdl serwują .zip/.rar/.mp4 do download
# (premium login required), streamtape ma malware drive-by .reg. Skipujemy # (premium login required), streamtape ma malware drive-by .reg. Skipujemy
# przy ingest żeby nie zaśmiecać UI martwym contentem (bug-report 2026-05-18). # przy ingest żeby nie zaśmiecać UI martwym contentem (bug-report 2026-05-18).
SKIP_HOSTERS = {"rapidgator", "nitroflare", "nitro", "frdl", "streamtape"} SKIP_HOSTERS = {"rapidgator", "nitroflare", "nitro", "frdl", "streamtape"}
@ -400,7 +400,7 @@ def _parse_dooplay_detail(
_emit_host_entry(m.group("href") or "", (m.group("source") or "").strip() or None) _emit_host_entry(m.group("href") or "", (m.group("source") or "").strip() or None)
# Nowy `<div class="Rtable1-cell">` markup (pandamovies od ~2026-04 + nowe # Nowy `<div class="Rtable1-cell">` markup (pandamovies od ~2026-04 + nowe
# streamporn instances). data-fl-source jest opcjonalny — capturujemy CAŁY # streamporn instances). data-fl-source jest opcjonalny, capturujemy CAŁY
# opening tag w group(1), data-fl-source extract z TEGO tagu (nie z window # opening tag w group(1), data-fl-source extract z TEGO tagu (nie z window
# lookback po HTMLu, bo to mogło pickować poprzedni cell). # lookback po HTMLu, bo to mogło pickować poprzedni cell).
for m in _PLAYER_OPTION_DIV_RE.finditer(html): for m in _PLAYER_OPTION_DIV_RE.finditer(html):
@ -411,7 +411,7 @@ def _parse_dooplay_detail(
_emit_host_entry(href, source) _emit_host_entry(href, source)
if not playback_sources: if not playback_sources:
# Brak sub-hosters znalezionych — fallback do landing page (mobile otworzy # Brak sub-hosters znalezionych, fallback do landing page (mobile otworzy
# w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest # w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest
# niepotrzebnym ad-pageiem. # niepotrzebnym ad-pageiem.
playback_sources.append( playback_sources.append(
@ -457,13 +457,13 @@ class StreampornVipConnector(DooplayConnector):
def _listing_path(self, page: int) -> str: def _listing_path(self, page: int) -> str:
"""Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od """Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od
2026-07-11 (0 nowych przez 23 dni), ale strona żyła i konektor parsował 2026-07-11 (0 nowych przez 23 dni), ale strona żyła i konektor parsował
poprawnie — tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i poprawnie, tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dało oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dało
roczniki 2012-2015, strona główna wyłącznie 2026 (po 10-11 źródeł playbacku). roczniki 2012-2015, strona główna wyłącznie 2026 (po 10-11 źródeł playbacku).
`?orderby=date` motyw ignoruje (odpowiedź identyczna z domyślną). `?orderby=date` motyw ignoruje (odpowiedź identyczna z domyślną).
To ta sama klasa błędu co przy freshporno: właściwa strona, zły endpoint To ta sama klasa błędu co przy freshporno: właściwa strona, zły endpoint
listingu — patrz [[reference_kvs_root_rotates_use_latest_updates]]. listingu, patrz [[reference_kvs_root_rotates_use_latest_updates]].
Strony 1 i 2 współdzielą ~44 linki (sidebar/widgety), ale treść główna jest Strony 1 i 2 współdzielą ~44 linki (sidebar/widgety), ale treść główna jest
stronicowana normalnie; dedup po URL w `fetch_movies` i tak je odsiewa. stronicowana normalnie; dedup po URL w `fetch_movies` i tak je odsiewa.
@ -482,7 +482,7 @@ class MangopornConnector(DooplayConnector):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers (zduplikowane z paradisehill.py — celowo, żeby connectory były niezależne) # Helpers (zduplikowane z paradisehill.py, celowo, żeby connectory były niezależne)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_TAG_RE = re.compile(r"<[^>]+>") _TAG_RE = re.compile(r"<[^>]+>")

View file

@ -1,4 +1,4 @@
"""freeomovie.to — movie source (WordPress "bestia" theme, NIE dooplay). """freeomovie.to, movie source (WordPress "bestia" theme, NIE dooplay).
Dodany 2026-07-02 (ocena). Pełnometrażowe filmy, świeże (auto-poster, dziś), tytuły Dodany 2026-07-02 (ocena). Pełnometrażowe filmy, świeże (auto-poster, dziś), tytuły
DVD → title-trigram mirror-attach do canonical (paradisehill/TPDB movies), dokładając DVD → title-trigram mirror-attach do canonical (paradisehill/TPDB movies), dokładając
@ -18,7 +18,7 @@ Struktura:
doczepia się do canonical po tytule, a TPDB enrichment dokłada obsadę/studio autorytatywnie. doczepia się do canonical po tytule, a TPDB enrichment dokłada obsadę/studio autorytatywnie.
Playback: TABS hostery → MoviePlaybackSource per host (voe/luluvid/vidhide resolvują się Playback: TABS hostery → MoviePlaybackSource per host (voe/luluvid/vidhide resolvują się
VPS-side; myvidplay = DoodStream clone, phone-side — wymaga myvidplay.com w DOOD_HOSTS). VPS-side; myvidplay = DoodStream clone, phone-side, wymaga myvidplay.com w DOOD_HOSTS).
Pomijamy streamtape (martwy malware) + mxdrop (flaky). Pomijamy streamtape (martwy malware) + mxdrop (flaky).
""" """
from __future__ import annotations from __future__ import annotations
@ -151,7 +151,7 @@ class FreeoMovieConnector(BaseMovieConnector):
name = part.strip() name = part.strip()
if not name or name.lower() == "xxx movies": if not name or name.lower() == "xxx movies":
if name.lower() == "xxx movies": if name.lower() == "xxx movies":
break # dalej idzie obsada/studio — nie tagi break # dalej idzie obsada/studio, nie tagi
continue continue
sl = slugify(name) sl = slugify(name)
if not sl or sl in seen_tag: if not sl or sl in seen_tag:

View file

@ -1,10 +1,10 @@
"""Paradisehill connector — primary source dla movies (full-length adult films). """Paradisehill connector, primary source dla movies (full-length adult films).
Site notes: Site notes:
- Age-gate: wymagany cookie `is18=1` (POST /is18/ zwraca 400 z curla, ale samo dorzucenie - Age-gate: wymagany cookie `is18=1` (POST /is18/ zwraca 400 z curla, ale samo dorzucenie
cookie do GET-a działa — site jest tolerancyjny). cookie do GET-a działa, site jest tolerancyjny).
- Listing: `/all/?sort=created_at&page=N` — paginacja po 28 filmów, mikro-data Schema.org Movie. - Listing: `/all/?sort=created_at&page=N`, paginacja po 28 filmów, mikro-data Schema.org Movie.
- Detail: `/<hex_id>/` — pełne meta + Video.js playlist (chaptery jako "Part 1/2/3"). - Detail: `/<hex_id>/`, pełne meta + Video.js playlist (chaptery jako "Part 1/2/3").
Co ekstraktujemy: Co ekstraktujemy:
- Schema.org microdata: name, description, director, datePublished (upload), image, thumbnailUrl - Schema.org microdata: name, description, director, datePublished (upload), image, thumbnailUrl
@ -12,7 +12,7 @@ Co ekstraktujemy:
- Genres: ze Schema.org `itemprop="genre"` (pierwszy = movie's main genre) - Genres: ze Schema.org `itemprop="genre"` (pierwszy = movie's main genre)
- Year: parsowany z description gdy obecny ("This 1999 film..."), bo `datePublished` to upload_date - Year: parsowany z description gdy obecny ("This 1999 film..."), bo `datePublished` to upload_date
- Chapters: liczba `<li>...Part N</li>` w playliście Video.js - Chapters: liczba `<li>...Part N</li>` w playliście Video.js
- Playback: na MVP `page_url` only — Video.js playlist URL jest dynamicznie ładowany przez JS - Playback: na MVP `page_url` only, Video.js playlist URL jest dynamicznie ładowany przez JS
i wymaga login session. Mobile może otworzyć page w WebView (degradacja lepsza niż brak). i wymaga login session. Mobile może otworzyć page w WebView (degradacja lepsza niż brak).
External_id: hex slug z URL-a (np. `259448f6b75ee` z `/259448f6b75ee/`). External_id: hex slug z URL-a (np. `259448f6b75ee` z `/259448f6b75ee/`).
@ -49,7 +49,7 @@ LISTING_PATH = "/all/" # ?sort=created_at&page=N
SOURCE_NAME = "paradisehill" SOURCE_NAME = "paradisehill"
# Microdata extraction — Schema.org tagi są stabilne i niezagubione przy lekkich # Microdata extraction, Schema.org tagi są stabilne i niezagubione przy lekkich
# zmianach themu (yii2 widget renderuje je inwariantnie). # zmianach themu (yii2 widget renderuje je inwariantnie).
_TITLE_RE = re.compile( _TITLE_RE = re.compile(
r'<h1\s+class="title-inside"\s+itemprop="name">([^<]+)</h1>', re.IGNORECASE r'<h1\s+class="title-inside"\s+itemprop="name">([^<]+)</h1>', re.IGNORECASE
@ -72,7 +72,7 @@ _CHAPTER_RE = re.compile(
r'<a\s+href="#"\s+class="js-list-item"\s+data-index="(\d+)">([^<]+)</a>', r'<a\s+href="#"\s+class="js-list-item"\s+data-index="(\d+)">([^<]+)</a>',
re.IGNORECASE, re.IGNORECASE,
) )
# videoList JS array w detail page — może mieć multiple parts (Video.js playlist): # videoList JS array w detail page, może mieć multiple parts (Video.js playlist):
# var videoList = [{"sources":[{"src":"...part1.mp4","type":"video/mp4"}]}, ...] # var videoList = [{"sources":[{"src":"...part1.mp4","type":"video/mp4"}]}, ...]
# Bez parsowania tego mobile WebView gra tylko pierwszy part, kolejne pomija. # Bez parsowania tego mobile WebView gra tylko pierwszy part, kolejne pomija.
# Bug-reports `c5693926`/`418270e4` 2026-05-21 ("ładuje tylko 1 z 4 części"). # Bug-reports `c5693926`/`418270e4` 2026-05-21 ("ładuje tylko 1 z 4 części").
@ -155,7 +155,7 @@ class ParadisehillConnector(BaseMovieConnector):
`since`: stop gdy datePublished < since. `limit`: stop po N filmach. `since`: stop gdy datePublished < since. `limit`: stop po N filmach.
Aktualnie 28 movies/page; site rośnie ~5/dzień, więc pełen crawl to ~tysiące Aktualnie 28 movies/page; site rośnie ~5/dzień, więc pełen crawl to ~tysiące
stron — w prod używamy `since` żeby zobaczyć tylko delta od poprzedniego runa. stron, w prod używamy `since` żeby zobaczyć tylko delta od poprzedniego runa.
""" """
seen = 0 seen = 0
page = 1 page = 1
@ -179,14 +179,14 @@ class ParadisehillConnector(BaseMovieConnector):
if movie is None: if movie is None:
continue continue
# `since` filter — datePublished (= data uploadu na paradisehill) poniżej # `since` filter, datePublished (= data uploadu na paradisehill) poniżej
# progu = stop crawla (listing chronologiczny). # progu = stop crawla (listing chronologiczny).
# #
# UWAGA: release_date to DATA (bez godziny). Wcześniej combine()→00:00 # UWAGA: release_date to DATA (bez godziny). Wcześniej combine()→00:00
# porównywane z TIMESTAMPEM `since` ucinało crawl na PIERWSZYM filmie z dnia # porównywane z TIMESTAMPEM `since` ucinało crawl na PIERWSZYM filmie z dnia
# == since (midnight < since o dowolnej porze dnia) → uploady tego samego dnia # == since (midnight < since o dowolnej porze dnia) → uploady tego samego dnia
# systematycznie ginęły, bo movie-ingest jest dzienny (seen=0-2/run mimo # systematycznie ginęły, bo movie-ingest jest dzienny (seen=0-2/run mimo
# świeżych filmów na froncie strony — bug-report 2026-06-01 "Movies stoją"). # świeżych filmów na froncie strony, bug-report 2026-06-01 "Movies stoją").
# Fix: porównuj po DACIE z 1-dniowym grace; ponowny fetch świeżych jest tani # Fix: porównuj po DACIE z 1-dniowym grace; ponowny fetch świeżych jest tani
# (external_records upsert pomija niezmieniony hash). # (external_records upsert pomija niezmieniony hash).
if since is not None and movie.release_date is not None: if since is not None and movie.release_date is not None:
@ -243,8 +243,8 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
except ValueError: except ValueError:
pass pass
# Year — najpierw z tytułu, potem z opisu. datePublished to upload date paradisehill # Year, najpierw z tytułu, potem z opisu. datePublished to upload date paradisehill
# (np. 2026-05) a nie production year (np. 1999) — useless dla year filtering. # (np. 2026-05) a nie production year (np. 1999), useless dla year filtering.
release_year: int | None = None release_year: int | None = None
m_yt = _YEAR_IN_TITLE_RE.search(title) m_yt = _YEAR_IN_TITLE_RE.search(title)
if m_yt: if m_yt:
@ -271,9 +271,9 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
name=_decode_html(m_studio.group(2).strip()), name=_decode_html(m_studio.group(2).strip()),
) )
# Genre — pierwszy itemprop="genre" w samym block-inside (nie w recommendations). # Genre, pierwszy itemprop="genre" w samym block-inside (nie w recommendations).
# Recommended films też mają itemprop="genre" więc match limity do block-inside. # Recommended films też mają itemprop="genre" więc match limity do block-inside.
# Wcześniejszy regex wymagał `</div></div><div class="similar"` — ale paradisehill # Wcześniejszy regex wymagał `</div></div><div class="similar"`, ale paradisehill
# czasami ma `</div></noindex>...<div class="similar"` (banner skin z 2026-05-19), # czasami ma `</div></noindex>...<div class="similar"` (banner skin z 2026-05-19),
# przez co block_match failował → fallback do html[:8000] → 0 tagów. Bug-report # przez co block_match failował → fallback do html[:8000] → 0 tagów. Bug-report
# `3c999b27` 2026-05-21 ("Brak kategorii"). Robust: szukaj similar jako stop boundary, # `3c999b27` 2026-05-21 ("Brak kategorii"). Robust: szukaj similar jako stop boundary,
@ -295,7 +295,7 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
# Paradisehill miesza dwa szablony per-page: # Paradisehill miesza dwa szablony per-page:
# v1: `itemprop="genre">Female Domination</span>` # v1: `itemprop="genre">Female Domination</span>`
# v2: `itemprop="genre"><a href="/category/...">All Sex</a></span>` (od 2026-05) # v2: `itemprop="genre"><a href="/category/...">All Sex</a></span>` (od 2026-05)
# Optional `<a>` wrapper między `itemprop` a tekstem — bez tego v2 dawał empty. # Optional `<a>` wrapper między `itemprop` a tekstem, bez tego v2 dawał empty.
for m_genre in re.finditer( for m_genre in re.finditer(
r'itemprop="genre"[^>]*>\s*(?:<a[^>]*>)?\s*([^<]+)', block, re.IGNORECASE, r'itemprop="genre"[^>]*>\s*(?:<a[^>]*>)?\s*([^<]+)', block, re.IGNORECASE,
): ):
@ -332,7 +332,7 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
backdrop_url=backdrop_url, backdrop_url=backdrop_url,
url=page_url, url=page_url,
studio=studio, studio=studio,
performers=[], # Paradisehill rzadko ma cast linki — uzupełnimy przez mirrory. performers=[], # Paradisehill rzadko ma cast linki, uzupełnimy przez mirrory.
tags=tags, tags=tags,
chapters=chapters, chapters=chapters,
playback_sources=playback_sources, playback_sources=playback_sources,

View file

@ -5,13 +5,13 @@ Lista scen: GET /scenes?per_page=200&page=N&date={YYYY-MM-DD} (delta filter)
Format: {data: [...], meta: {current_page, last_page, per_page, total}} Format: {data: [...], meta: {current_page, last_page, per_page, total}}
Sceny TPDB zwracają już rozwiniętych performerów (`performers[]`), studio (`site`) i tagi (`tags[]`). Sceny TPDB zwracają już rozwiniętych performerów (`performers[]`), studio (`site`) i tagi (`tags[]`).
W związku z tym pojedyncze GET /scenes wystarcza do MVP — nie musimy uderzać oddzielnie po performera. W związku z tym pojedyncze GET /scenes wystarcza do MVP, nie musimy uderzać oddzielnie po performera.
Format performera w scenie: Format performera w scenie:
- performer.id — ID przypisania performer↔scene (NIE używać do dedup) - performer.id ID przypisania performer↔scene (NIE używać do dedup)
- performer.name — imię w tej konkretnej scenie (może być alias, np. „Mia M.") - performer.name imię w tej konkretnej scenie (może być alias, np. „Mia M.")
- performer.parent.id — kanoniczne UUID performerki w TPDB → external_id - performer.parent.id, kanoniczne UUID performerki w TPDB → external_id
- performer.parent.name / .extra.gender / .extra.birthday — kanoniczne metadane - performer.parent.name / .extra.gender / .extra.birthday, kanoniczne metadane
Format studia: scene.site = {id, name, slug, parent: {...}, network: {...}} Format studia: scene.site = {id, name, slug, parent: {...}, network: {...}}
""" """
@ -35,7 +35,7 @@ from tenacity import (
def _is_retryable_http_error(exc: BaseException) -> bool: def _is_retryable_http_error(exc: BaseException) -> bool:
"""Retry transport errors + 5xx + 429; NIE retry 4xx (404/422 = permanent). """Retry transport errors + 5xx + 429; NIE retry 4xx (404/422 = permanent).
401/403 NIE są retryowalne tutaj — TPDB token expiry musiałby być 401/403 NIE są retryowalne tutaj, TPDB token expiry musiałby być
obsłużony jako auth refresh (TODO gdyby zaczęły się pojawiać). Aktualnie obsłużony jako auth refresh (TODO gdyby zaczęły się pojawiać). Aktualnie
expire'a się raz na rok, więc nie warto kombinować. expire'a się raz na rok, więc nie warto kombinować.
""" """
@ -100,7 +100,7 @@ class TPDBConnector(BaseConnector):
def _get(self, client: httpx.Client, path: str, params: dict[str, Any]) -> dict[str, Any]: def _get(self, client: httpx.Client, path: str, params: dict[str, Any]) -> dict[str, Any]:
resp = client.get(path, params=params) resp = client.get(path, params=params)
if resp.status_code == 429: if resp.status_code == 429:
# let tenacity retry — but raise something it knows # let tenacity retry, but raise something it knows
raise httpx.HTTPStatusError("rate limited", request=resp.request, response=resp) raise httpx.HTTPStatusError("rate limited", request=resp.request, response=resp)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
@ -125,13 +125,13 @@ class TPDBConnector(BaseConnector):
) -> Iterator[RawScene]: ) -> Iterator[RawScene]:
"""Pobiera wszystkie sceny TPDB dla performera o podanym kanonicznym ID. """Pobiera wszystkie sceny TPDB dla performera o podanym kanonicznym ID.
TPDB API: GET /performers/<id>/scenes — dedykowany endpoint. TPDB API: GET /performers/<id>/scenes, dedykowany endpoint.
(Inne warianty są broken: /scenes?performers[]=<uuid> zwraca zawsze total=0, (Inne warianty są broken: /scenes?performers[]=<uuid> zwraca zawsze total=0,
/scenes?performer_id=<uuid> → 422.) /scenes?performer_id=<uuid> → 422.)
404 = performer usunięty z TPDB (np. b959ccbb 2026-05-16 Sentry GOON-N). 404 = performer usunięty z TPDB (np. b959ccbb 2026-05-16 Sentry GOON-N).
Wcześniej leciało raise → exception bąbelek do scheduler.performer_driven Wcześniej leciało raise → exception bąbelek do scheduler.performer_driven
→ cały run failed. Teraz warn + yield empty — caller widzi 0 scen i → cały run failed. Teraz warn + yield empty, caller widzi 0 scen i
kontynuuje z następnym performer. kontynuuje z następnym performer.
""" """
try: try:
@ -157,11 +157,11 @@ class TPDBConnector(BaseConnector):
) -> Iterator[RawScene]: ) -> Iterator[RawScene]:
"""Pobiera wszystkie sceny TPDB dla site/studio o podanym ID. """Pobiera wszystkie sceny TPDB dla site/studio o podanym ID.
TPDB API: GET /sites/<id>/scenes — dedykowany endpoint analogiczny TPDB API: GET /sites/<id>/scenes, dedykowany endpoint analogiczny
do /performers/<id>/scenes. Bez paginacji limit zwraca total scenes do /performers/<id>/scenes. Bez paginacji limit zwraca total scenes
z meta.total (Brazzers=272, Naughty America=631 w czasie pisania). z meta.total (Brazzers=272, Naughty America=631 w czasie pisania).
404 = site usunięty z TPDB — analogicznie do fetch_scenes_for_performer. 404 = site usunięty z TPDB, analogicznie do fetch_scenes_for_performer.
""" """
try: try:
yield from self._paginate_scenes( yield from self._paginate_scenes(
@ -215,7 +215,7 @@ class TPDBConnector(BaseConnector):
def fetch_movie(self, movie_uuid: str) -> dict[str, Any] | None: def fetch_movie(self, movie_uuid: str) -> dict[str, Any] | None:
"""GET /movies/<uuid> → pojedynczy film (detail). None gdy 404/błąd. """GET /movies/<uuid> → pojedynczy film (detail). None gdy 404/błąd.
NB: list endpoint jest już hydrated, więc detail rzadko potrzebny — trzymamy NB: list endpoint jest już hydrated, więc detail rzadko potrzebny, trzymamy
dla spójności / gdyby TPDB kiedyś przeniósł część pól tylko do detalu.""" dla spójności / gdyby TPDB kiedyś przeniósł część pól tylko do detalu."""
with self._client() as client: with self._client() as client:
try: try:
@ -384,7 +384,7 @@ def _parse_movie(raw: dict[str, Any]) -> RawMovie | None:
director = ", ".join(d.get("name") for d in directors if isinstance(d, dict) and d.get("name")) or None director = ", ".join(d.get("name") for d in directors if isinstance(d, dict) and d.get("name")) or None
d = _parse_date(raw.get("date")) d = _parse_date(raw.get("date"))
# duration bywa 1s-placeholderem na movies — poniżej 60s traktujemy jak brak. # duration bywa 1s-placeholderem na movies, poniżej 60s traktujemy jak brak.
dur = raw.get("duration") dur = raw.get("duration")
duration_sec = int(dur) if dur and int(dur) > 60 else None duration_sec = int(dur) if dur and int(dur) > 60 else None

View file

@ -1,4 +1,4 @@
"""yourdailypornmovies.ws — konektor filmów. Dodany 2026-07-28. """yourdailypornmovies.ws, konektor filmów. Dodany 2026-07-28.
WordPress z własnym motywem (NIE dooplay), więc nie da się podpiąć pod WordPress z własnym motywem (NIE dooplay), więc nie da się podpiąć pod
`DooplayConnector` jak mangoporn/pandamovies/streamporn. `DooplayConnector` jak mangoporn/pandamovies/streamporn.
@ -12,7 +12,7 @@ okładki DVD z `imgs1cdn.adultempire.com`, nie re-hosty z pandanetwork.
Katalog: min. 8000 pozycji (strona 200 nadal oddaje wyniki, po 40 na stronę), sięga Katalog: min. 8000 pozycji (strona 200 nadal oddaje wyniki, po 40 na stronę), sięga
2003 roku, strona 1 ma bieżący rok. 2003 roku, strona 1 ma bieżący rok.
**Pułapka: `/tag/` w całym HTML to 2177 linków** — to chmura tagów w sidebarze, **Pułapka: `/tag/` w całym HTML to 2177 linków** to chmura tagów w sidebarze,
identyczny wzorzec jak przy sexu. Tagi (i obsada) MUSZĄ być czytane z bloków identyczny wzorzec jak przy sexu. Tagi (i obsada) MUSZĄ być czytane z bloków
`div.xmll`, inaczej każdy film dostanie te same dwa tysiące śmieci. `div.xmll`, inaczej każdy film dostanie te same dwa tysiące śmieci.
@ -23,7 +23,7 @@ Czego strona nie ma: długości filmu.
Playback: 3 hostery w iframe. `voe.sx` i `mxdrop.sx` zweryfikowane, że resolwują Playback: 3 hostery w iframe. `voe.sx` i `mxdrop.sx` zweryfikowane, że resolwują
istniejącymi modułami (`app/extractors/hosters/voe.py` → m3u8, `mixdrop.py` → mp4). istniejącymi modułami (`app/extractors/hosters/voe.py` → m3u8, `mixdrop.py` → mp4).
`vidara.to` jest nam nieznany — zapisujemy go jako zwykły embed, telefon otworzy go `vidara.to` jest nam nieznany, zapisujemy go jako zwykły embed, telefon otworzy go
w WebView, ale nie liczymy na niego. w WebView, ale nie liczymy na niego.
""" """
from __future__ import annotations from __future__ import annotations
@ -60,7 +60,7 @@ _CARD_RE = re.compile(
re.DOTALL, re.DOTALL,
) )
# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd — patrz pułapka w docstringu. # Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd, patrz pułapka w docstringu.
_XMLL_RE = re.compile(r'<div class="xmll">.*?</div>', re.DOTALL) _XMLL_RE = re.compile(r'<div class="xmll">.*?</div>', re.DOTALL)
_ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)</a>') _ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)</a>')
_TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)</a>') _TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)</a>')
@ -143,7 +143,7 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
if not title: if not title:
return None return None
# Playback najpierw — bez niego film jest bezużyteczny, nie tworzymy orphana. # Playback najpierw, bez niego film jest bezużyteczny, nie tworzymy orphana.
playback: list[RawPlaybackSource] = [] playback: list[RawPlaybackSource] = []
seen_host: set[str] = set() seen_host: set[str] = set()
for m in _IFRAME_RE.finditer(detail): for m in _IFRAME_RE.finditer(detail):
@ -219,7 +219,7 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
performers=performers, performers=performers,
tags=tags, tags=tags,
playback_sources=playback, playback_sources=playback,
# id produktu Adult Empire — mocniejsza kotwica do dedupu niż tytuł # id produktu Adult Empire, mocniejsza kotwica do dedupu niż tytuł
# (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał). # (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał).
raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")}, raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
) )

View file

@ -1,11 +1,11 @@
"""TPDB movie enrichment + dedup. """TPDB movie enrichment + dedup.
TPDB `/movies` jest naszym kanonicznym źródłem metadanych filmów: obsada, kategorie TPDB `/movies` jest naszym kanonicznym źródłem metadanych filmów: obsada, kategorie
(tagi), studio, reżyser, rok — plus stabilny UUID do dedupu. paradisehill (primary (tagi), studio, reżyser, rok, plus stabilny UUID do dedupu. paradisehill (primary
movie source) prawie nie ma obsady, więc TPDB wypełnia największą lukę. movie source) prawie nie ma obsady, więc TPDB wypełnia największą lukę.
Zasada: TPDB TYLKO wzbogaca ISTNIEJĄCE filmy (z paradisehill/dooplay) i dedupuje, Zasada: TPDB TYLKO wzbogaca ISTNIEJĄCE filmy (z paradisehill/dooplay) i dedupuje,
NIGDY nie tworzy nowych — TPDB nie ma playbacku, więc nowy film byłby niegrywalny. NIGDY nie tworzy nowych, TPDB nie ma playbacku, więc nowy film byłby niegrywalny.
Flow per film: Flow per film:
1. skip, jeśli film ma już TPDB movie ref (movie_external_refs jest tylko-movie, 1. skip, jeśli film ma już TPDB movie ref (movie_external_refs jest tylko-movie,
@ -19,7 +19,7 @@ Flow per film:
poster/rating (fill-only), przypnij MovieExternalRef(tpdb, uuid). poster/rating (fill-only), przypnij MovieExternalRef(tpdb, uuid).
Match jest zachowawczy (próg 0.90 + guard roku), bo TPDB search zwraca dużo (np. Match jest zachowawczy (próg 0.90 + guard roku), bo TPDB search zwraca dużo (np.
"Pirates" → gay Knightbreeders przed feature) — bez scoringu wzięlibyśmy zły film. "Pirates" → gay Knightbreeders przed feature), bez scoringu wzięlibyśmy zły film.
""" """
from __future__ import annotations from __future__ import annotations
@ -168,7 +168,7 @@ def _best_match(
def _fill_scalar_fields(movie: Movie, norm) -> None: def _fill_scalar_fields(movie: Movie, norm) -> None:
"""Fill-only — nie nadpisujemy pól ustawionych przez primary (paradisehill/mirror).""" """Fill-only, nie nadpisujemy pól ustawionych przez primary (paradisehill/mirror)."""
if norm.director and not movie.director: if norm.director and not movie.director:
movie.director = norm.director movie.director = norm.director
if norm.release_year and not movie.release_year: if norm.release_year and not movie.release_year:
@ -243,7 +243,7 @@ def enrich_movie(
movie.studio_id = studio.id movie.studio_id = studio.id
# Dedup wchodzących performerów po external_id (TPDB potrafi wylistować tego samego # Dedup wchodzących performerów po external_id (TPDB potrafi wylistować tego samego
# kanonicznego performera 2×: pod aliasem i kanonicznie) — bez tego resolve_performer # kanonicznego performera 2×: pod aliasem i kanonicznie), bez tego resolve_performer
# próbuje wstawić performer_external_refs 2× → UniqueViolation (jak w scene_resolver). # próbuje wstawić performer_external_refs 2× → UniqueViolation (jak w scene_resolver).
resolved: list[tuple[uuid.UUID, str | None]] = [] resolved: list[tuple[uuid.UUID, str | None]] = []
seen_perf_keys: set[str] = set() seen_perf_keys: set[str] = set()

View file

@ -4,16 +4,16 @@ Public API:
- `try_extract(sitetag, page_url) -> list[StreamSource] | None` - `try_extract(sitetag, page_url) -> list[StreamSource] | None`
- `StreamSource` (dataclass) - `StreamSource` (dataclass)
- `HosterDead` (exception) - `HosterDead` (exception)
- `extract_stream_from_hoster(iframe_url, *, referer)` — generic packer-based hoster extract - `extract_stream_from_hoster(iframe_url, *, referer)`, generic packer-based hoster extract
- `fetch_tube_html(url)` — Chrome TLS fingerprint fetch (curl_cffi) - `fetch_tube_html(url)`, Chrome TLS fingerprint fetch (curl_cffi)
- `browser_get(url)` — low-level - `browser_get(url)`, low-level
Architektura: każdy tube ma osobny moduł `app.extractors.tubes.<tube>` który eksportuje Architektura: każdy tube ma osobny moduł `app.extractors.tubes.<tube>` który eksportuje
`extract(page_url) -> list[StreamSource] | None`. Registry niżej mapuje sitetag → `extract(page_url) -> list[StreamSource] | None`. Registry niżej mapuje sitetag →
modułowy extractor. `try_extract()` to thin wrapper z exception handlingiem. modułowy extractor. `try_extract()` to thin wrapper z exception handlingiem.
Po removalu porn-app dependency, ten moduł jest jedynym mechanizmem rozwiązywania Po removalu porn-app dependency, ten moduł jest jedynym mechanizmem rozwiązywania
streamów — playback.py nie wpada już do porn-app /stream API. streamów, playback.py nie wpada już do porn-app /stream API.
""" """
from __future__ import annotations from __future__ import annotations
@ -63,14 +63,14 @@ log = logging.getLogger(__name__)
# (lub po Fazie 2 migracji: `tube:<sitetag>`). # (lub po Fazie 2 migracji: `tube:<sitetag>`).
# #
# Mainstream tubes (pornhub/xvideos/xnxx/xhamster/redtube/youporn/porntrex) używają # Mainstream tubes (pornhub/xvideos/xnxx/xhamster/redtube/youporn/porntrex) używają
# yt-dlp jako extractor — battle-tested, aktualizowane przez upstream przy zmianach # yt-dlp jako extractor, battle-tested, aktualizowane przez upstream przy zmianach
# HTML. Aggregator tubes (xmoviesforyou/watchporn/siska/...) używają generic # HTML. Aggregator tubes (xmoviesforyou/watchporn/siska/...) używają generic
# embed-iframe extractor (page → /e/<id> iframe → P.A.C.K.E.R. unpack). Custom kod # embed-iframe extractor (page → /e/<id> iframe → P.A.C.K.E.R. unpack). Custom kod
# tylko tam gdzie tube ma niestandardowy schemat (eporner XHR, sxyprn URL transform). # tylko tam gdzie tube ma niestandardowy schemat (eporner XHR, sxyprn URL transform).
_REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# hqporner — dedicated extractor zwraca multi-quality `<source>` mp4 URLs # hqporner, dedicated extractor zwraca multi-quality `<source>` mp4 URLs
# (bigcdn.cc / hqwo.cc / flyflv) z `force_proxy=True`. CDN URLs IP-bound do # (bigcdn.cc / hqwo.cc / flyflv) z `force_proxy=True`. CDN URLs IP-bound do
# VPS, więc playback.py routuje przez proxy — mobile dostaje quality picker # VPS, więc playback.py routuje przez proxy, mobile dostaje quality picker
# + natywny ExoPlayer, bez WebView. # + natywny ExoPlayer, bez WebView.
# Bug-report e8ddd8d4: WebView fallback (`_vps_blocked_fallback`) ładował # Bug-report e8ddd8d4: WebView fallback (`_vps_blocked_fallback`) ładował
# hqporner.com scene page w WebView, ale ta strona ma ad-iframes (adtng, # hqporner.com scene page w WebView, ale ta strona ma ad-iframes (adtng,
@ -81,25 +81,25 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
"hqpornercom": hqporner.extract, "hqpornercom": hqporner.extract,
"epornercom": eporner.extract, "epornercom": eporner.extract,
"sxyprncom": sxyprn.extract, "sxyprncom": sxyprn.extract,
# Mainstream tubes — yt-dlp # Mainstream tubes, yt-dlp
# NB: 2026-05-18 cross-IP test potwierdził że xvideos/xnxx/pornhub/youporn/redtube # NB: 2026-05-18 cross-IP test potwierdził że xvideos/xnxx/pornhub/youporn/redtube
# CDN URLs są **time-bound** (nie IP-bound) — mobile_direct_ok auto-detect w # CDN URLs są **time-bound** (nie IP-bound), mobile_direct_ok auto-detect w
# playback.py daje mobile direct fetch, zero VPS bandwidth. # playback.py daje mobile direct fetch, zero VPS bandwidth.
# pornhub + redtube — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Scrapery były # pornhub + redtube, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Scrapery były
# disabled od 2026-05-12 (0.4% canonical match — głównie skrócone amatorskie clipy), # disabled od 2026-05-12 (0.4% canonical match, głównie skrócone amatorskie clipy),
# zamrożone sceny/źródła skasowane z DB. Brak ekstraktorów → zero resolve. # zamrożone sceny/źródła skasowane z DB. Brak ekstraktorów → zero resolve.
"xvideoscom": _ytdlp.extract, "xvideoscom": _ytdlp.extract,
"xnxxcom": _ytdlp.extract, "xnxxcom": _ytdlp.extract,
"youporncom": _ytdlp.extract, "youporncom": _ytdlp.extract,
# porntrex KVS — 2026-05-22 VPS znów dociera (HTTP 200). Dedykowany extractor: # porntrex KVS, 2026-05-22 VPS znów dociera (HTTP 200). Dedykowany extractor:
# flashvars `video_url` → `get_file` 302 → CDN time-bound signed URL # flashvars `video_url` → `get_file` 302 → CDN time-bound signed URL
# (`expires`+`md5`, NIE IP-bound) → mobile gra direct, zero VPS bandwidth. # (`expires`+`md5`, NIE IP-bound) → mobile gra direct, zero VPS bandwidth.
"porntrexcom": porntrex.extract, "porntrexcom": porntrex.extract,
# fpoxxx — KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 → # fpoxxx, KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 →
# `videos3.fpo.xxx/remote_control.php?acctoken=<base64>` — zdekodowany acctoken # `videos3.fpo.xxx/remote_control.php?acctoken=<base64>`, zdekodowany acctoken
# zawiera WBITY IP serwera-resolvera → definitywnie IP-bound. WebView only. # zawiera WBITY IP serwera-resolvera → definitywnie IP-bound. WebView only.
"fpoxxx": _vps_blocked_fallback.extract, "fpoxxx": _vps_blocked_fallback.extract,
# sxyland — embeduje playmogo.com/e/<id> (= klon DoodStream: doodcdn.io + pass_md5 # sxyland, embeduje playmogo.com/e/<id> (= klon DoodStream: doodcdn.io + pass_md5
# + niewidzialny CF Turnstile; Chrome-DevTools verify 2026-06-08, bug-report 827a50a1). # + niewidzialny CF Turnstile; Chrome-DevTools verify 2026-06-08, bug-report 827a50a1).
# Strona sxyland NIE jest Turnstile-gated (VPS curl wyciąga iframe URL z HTML), więc # Strona sxyland NIE jest Turnstile-gated (VPS curl wyciąga iframe URL z HTML), więc
# _embed_iframe wyłuskuje embed playmogo i oddaje jako type='hoster' → mobile # _embed_iframe wyłuskuje embed playmogo i oddaje jako type='hoster' → mobile
@ -107,49 +107,49 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# mp4 → autoplay. Wcześniej _vps_blocked_fallback ładował CAŁĄ stronę sxyland w WebView # mp4 → autoplay. Wcześniej _vps_blocked_fallback ładował CAŁĄ stronę sxyland w WebView
# (ads + klik-to-play + brak autoplay = dokładnie objaw z reportu 827a50a1). # (ads + klik-to-play + brak autoplay = dokładnie objaw z reportu 827a50a1).
"sxylandcom": _embed_iframe.extract, "sxylandcom": _embed_iframe.extract,
# Aggregator tubes — generic embed-iframe → hoster unpacker # Aggregator tubes, generic embed-iframe → hoster unpacker
"latestpornvideocom": latestpornvideo.extract, "latestpornvideocom": latestpornvideo.extract,
"xmoviesforyoucom": _embed_iframe.extract, "xmoviesforyoucom": _embed_iframe.extract,
# watchporn — 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął). # watchporn, 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął).
# flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound. # flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound.
"watchporn": watchporn.extract, "watchporn": watchporn.extract,
# youperv — fluidplayer, zwykły <source> mp4 na files.klubnichka-hd.com. Bez tokena # youperv, fluidplayer, zwykły <source> mp4 na files.klubnichka-hd.com. Bez tokena
# i bez IP-bindingu; CDN pilnuje tylko Referera (cross-IP 206 z VPS) → mobile direct. # i bez IP-bindingu; CDN pilnuje tylko Referera (cross-IP 206 z VPS) → mobile direct.
"youpervcom": youperv.extract, "youpervcom": youperv.extract,
# galaxyporn — iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy, # galaxyporn, iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy,
# extractor tylko wyłuskuje iframe. HLS Referer+IP-bound → /proxy/hls. # extractor tylko wyłuskuje iframe. HLS Referer+IP-bound → /proxy/hls.
"galaxyporncom": galaxyporn.extract, "galaxyporncom": galaxyporn.extract,
# pornbusy — dispatch po embedURL: loadvid (POST-manifest przez /proxy/hls), # pornbusy, dispatch po embedURL: loadvid (POST-manifest przez /proxy/hls),
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik). # rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo). # upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
"pornbusycom": pornbusy.extract, "pornbusycom": pornbusy.extract,
# pornmike — gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry. # pornmike, gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry.
# Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView. # Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView.
"pornmike": pornmike.extract, "pornmike": pornmike.extract,
# 8kporner — JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!). # 8kporner, JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!).
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN # okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever". # dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
"8kpornercom": kporner8.extract, "8kpornercom": kporner8.extract,
# fullporner — iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości. # fullporner, iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez # Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi. # UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
"fullporner": fullporner.extract, "fullporner": fullporner.extract,
# sexu — POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS). # sexu, POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
# Bierzemy HLS `playerData.src`, NIE mp4 `sources` — te mają w tokenie `ip=` # Bierzemy HLS `playerData.src`, NIE mp4 `sources`, te mają w tokenie `ip=`
# requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi # requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi
# hls_needs_passthrough i manifest idzie przez /proxy/hls, segmenty direct. # hls_needs_passthrough i manifest idzie przez /proxy/hls, segmenty direct.
"sexu": sexu.extract, "sexu": sexu.extract,
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn. # fullvideosporn (= sextu), TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429 # Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy. # (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
"fullvideosporn": fullvideosporn.extract, "fullvideosporn": fullvideosporn.extract,
"siskavideo": _embed_iframe.extract, "siskavideo": _embed_iframe.extract,
"porn4dayspw": _embed_iframe.extract, "porn4dayspw": _embed_iframe.extract,
"porndishcom": _embed_iframe.extract, "porndishcom": _embed_iframe.extract,
# xxxfreewatch — DELISTED 2026-05-18. 790 solo-orphan scen, 0% match, CF-walled z VPS. # xxxfreewatch, DELISTED 2026-05-18. 790 solo-orphan scen, 0% match, CF-walled z VPS.
"latestleaksco": _embed_iframe.extract, "latestleaksco": _embed_iframe.extract,
"mypornerleakcom": _embed_iframe.extract, "mypornerleakcom": _embed_iframe.extract,
# xhamster — 2026-06-08 PRZEPIĘTE z _vps_blocked_fallback na natywny server-side HLS. # xhamster, 2026-06-08 PRZEPIĘTE z _vps_blocked_fallback na natywny server-side HLS.
# Re-test (DevTools + cross-IP): VPS pobiera scene page bez CF challenge, master m3u8 # Re-test (DevTools + cross-IP): VPS pobiera scene page bez CF challenge, master m3u8
# w SSR HTML, manifest+segmenty time-bound (portable, nie IP-bound). Mobile gra HLS # w SSR HTML, manifest+segmenty time-bound (portable, nie IP-bound). Mobile gra HLS
# direct, multi-quality, zero VPS proxy/WebView/reklam. Patrz tubes/xhamster.py. # direct, multi-quality, zero VPS proxy/WebView/reklam. Patrz tubes/xhamster.py.
@ -158,24 +158,24 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
"xhamstercom": xhamster.extract, "xhamstercom": xhamster.extract,
# Freshporno KVS (function/0 + license). 2026-06-04 DevTools + cross-IP re-test # Freshporno KVS (function/0 + license). 2026-06-04 DevTools + cross-IP re-test
# NAPRAWIA błąd z #20: finalny cdn4.freshporno.org/remote_control.php jest PORTABLE # NAPRAWIA błąd z #20: finalny cdn4.freshporno.org/remote_control.php jest PORTABLE
# (token time-bound nie IP-bound — VPS odtworzył token z residential → 206) ale # (token time-bound nie IP-bound, VPS odtworzył token z residential → 206) ale
# wymaga browser-TLS (curl_cffi chrome/ExoPlayer → 206; plain curl → 000). W #20 # wymaga browser-TLS (curl_cffi chrome/ExoPlayer → 206; plain curl → 000). W #20
# testowałem plain-curl-em poza sesją → 000 → błędnie „nieosiągalny" → WebView. # testowałem plain-curl-em poza sesją → 000 → błędnie „nieosiągalny" → WebView.
# Teraz backend-resolve jak yespornvip/pornditt (_kvs używa curl_cffi chrome). # Teraz backend-resolve jak yespornvip/pornditt (_kvs używa curl_cffi chrome).
# Native, multi-quality, zero proxy/WebView. (zweryfikowane na emulatorze przed deploy) # Native, multi-quality, zero proxy/WebView. (zweryfikowane na emulatorze przed deploy)
"freshpornoorg": freshporno.extract, "freshpornoorg": freshporno.extract,
# porn00 — KVS (plain get_file + license). 2026-06-04 DevTools + cross-IP re-test # porn00, KVS (plain get_file + license). 2026-06-04 DevTools + cross-IP re-test
# NAPRAWIA błąd z #20: finalny fe.porn00.org/...?token=&expires= jest PORTABLE # NAPRAWIA błąd z #20: finalny fe.porn00.org/...?token=&expires= jest PORTABLE
# (token time-bound nie IP-bound — Bright Data residential proxy z innego IP → 206) # (token time-bound nie IP-bound, Bright Data residential proxy z innego IP → 206)
# ale wymaga browser-TLS (curl_cffi chrome → 206; plain curl → 403). W #20 # ale wymaga browser-TLS (curl_cffi chrome → 206; plain curl → 403). W #20
# testowałem finalny URL plain-curl-em → 403 → błędnie „IP-bound" → WebView. # testowałem finalny URL plain-curl-em → 403 → błędnie „IP-bound" → WebView.
# Teraz backend-resolve przez _kvs (curl_cffi chrome), native multi-quality, # Teraz backend-resolve przez _kvs (curl_cffi chrome), native multi-quality,
# ZERO proxy (wcześniej force_proxy łamał no-proxy). Same mechanizm co freshporno. # ZERO proxy (wcześniej force_proxy łamał no-proxy). Same mechanizm co freshporno.
"porn00org": porn00.extract, "porn00org": porn00.extract,
# pornxp — `<source> //sr.porn-xp.com/<token>/.../720.mp4` (redirect → xpxp.eu). # pornxp, `<source> //sr.porn-xp.com/<token>/.../720.mp4` (redirect → xpxp.eu).
# 2026-06-01 (task #20): 403 cross-IP → token w path IP-bound. WebView only. # 2026-06-01 (task #20): 403 cross-IP → token w path IP-bound. WebView only.
"pornxpph": _vps_blocked_fallback.extract, "pornxpph": _vps_blocked_fallback.extract,
# yesporn.vip — KVS engine. VPS znów dociera (HTTP 200, odblokowane jak porntrex), # yesporn.vip, KVS engine. VPS znów dociera (HTTP 200, odblokowane jak porntrex),
# więc resolvujemy SERVER-SIDE: dekoduj flashvars `video_url`/alt/alt2 (function/0/ + # więc resolvujemy SERVER-SIDE: dekoduj flashvars `video_url`/alt/alt2 (function/0/ +
# license_code, algo kt_player) → follow get_file 302 → portable cdn5 url (time-bound, # license_code, algo kt_player) → follow get_file 302 → portable cdn5 url (time-bound,
# NIE IP/cookie-bound, zweryfikowane cross-IP 2026-05-31). Mobile gra direct natywnie, # NIE IP/cookie-bound, zweryfikowane cross-IP 2026-05-31). Mobile gra direct natywnie,
@ -183,60 +183,60 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# ad-heavy stronę a scrape łapał preroll-reklamę (bkcdn) zamiast wideo. # ad-heavy stronę a scrape łapał preroll-reklamę (bkcdn) zamiast wideo.
"yespornvip": yespornvip.extract, "yespornvip": yespornvip.extract,
# Direct-scraping tubes (mają też search scraper w connectors/direct_scrapers/) # Direct-scraping tubes (mają też search scraper w connectors/direct_scrapers/)
# — używają identycznego embed-iframe pattern dla streamingu. # używają identycznego embed-iframe pattern dla streamingu.
# hdporn92com — DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe, # hdporn92com, DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe,
# JS hijackuje kliki na popunder. Wszystkie playback_sources mass-marked dead. # JS hijackuje kliki na popunder. Wszystkie playback_sources mass-marked dead.
# 0dayxx + pornditt + pornhat — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request): orphan # 0dayxx + pornditt + pornhat, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request): orphan
# factories (0–0.2% canonical match), zastępujemy lepszymi źródłami. Dane skasowane. # factories (0-0.2% canonical match), zastępujemy lepszymi źródłami. Dane skasowane.
# CF-protected tube — curl_cffi w fetch_tube_html bypassa JA3, embed-iframe pattern. # CF-protected tube, curl_cffi w fetch_tube_html bypassa JA3, embed-iframe pattern.
# perverzija — natywny HLS (2026-07-28). Player xtremestream ma w HTML tylko # perverzija, natywny HLS (2026-07-28). Player xtremestream ma w HTML tylko
# preroll/midroll (grały w pętli, bug 40ec88cc/92e9133e), a wideo dociąga runtime: # preroll/midroll (grały w pętli, bug 40ec88cc/92e9133e), a wideo dociąga runtime:
# xs1.php?data=<md5>&q=<480|720> zwraca GOTOWY manifest HLS z pełnymi URL-ami # xs1.php?data=<md5>&q=<480|720> zwraca GOTOWY manifest HLS z pełnymi URL-ami
# segmentów. Zamiast WebView oddajemy m3u8 wprost. # segmentów. Zamiast WebView oddajemy m3u8 wprost.
"perverzijacom": perverzija.extract, "perverzijacom": perverzija.extract,
# Special: WebView-only (Yii2 session-bound player). # Special: WebView-only (Yii2 session-bound player).
"paradisehillcc": paradisehill.extract, "paradisehillcc": paradisehill.extract,
# PornDoe — dołączony 2026-05-21 (theporndude audit). Stream URL nie inline w # PornDoe, dołączony 2026-05-21 (theporndude audit). Stream URL nie inline w
# SSR HTML (player JS init po Play click), więc WebView fallback: mobile pobiera # SSR HTML (player JS init po Play click), więc WebView fallback: mobile pobiera
# /watch/<id> z phone IP, player JS dekoduje video.src, INJECTED_JS scrape. # /watch/<id> z phone IP, player JS dekoduje video.src, INJECTED_JS scrape.
# 0 VPS bandwidth — zgodne z pre-public bandwidth/anonimowość priorytet. # 0 VPS bandwidth, zgodne z pre-public bandwidth/anonimowość priorytet.
"porndoecom": _vps_blocked_fallback.extract, "porndoecom": _vps_blocked_fallback.extract,
# fullmovies.xxx + hdporn.gg — BRAKOWAŁO extractora (try_extract→None→"no stream"; # fullmovies.xxx + hdporn.gg, BRAKOWAŁO extractora (try_extract→None→"no stream";
# fullmovies.xxx + hdporn.gg — ta sama platforma (`<source>/get_file/8512/`). # fullmovies.xxx + hdporn.gg, ta sama platforma (`<source>/get_file/8512/`).
# 2026-06-04 (DevTools + cross-IP, naprawia „loading forever" + bug 19866e9e): # 2026-06-04 (DevTools + cross-IP, naprawia „loading forever" + bug 19866e9e):
# get_file binduje fpvcdn do IP FETCHERA + jest stateless + ważny ≥90s, więc # get_file binduje fpvcdn do IP FETCHERA + jest stateless + ważny ≥90s, więc
# oddajemy get_file NIEZRESOLWOWANY (mobile_direct) — telefon follow-uje 302 → # oddajemy get_file NIEZRESOLWOWANY (mobile_direct), telefon follow-uje 302 →
# fpvcdn z IP telefonu → gra. POMIJAMY 4K (time-out 30s na fpvcdn = przyczyna # fpvcdn z IP telefonu → gra. POMIJAMY 4K (time-out 30s na fpvcdn = przyczyna
# „loading forever"; 720/480p gra ~1s). Native, multi-quality, ZERO proxy/WebView. # „loading forever"; 720/480p gra ~1s). Native, multi-quality, ZERO proxy/WebView.
# (#19866e9e wcześniej źle: założyłem „get_file 403 IP-bound" testem plain-curl.) # (#19866e9e wcześniej źle: założyłem „get_file 403 IP-bound" testem plain-curl.)
"fullmoviesxxx": fullmovies.extract, "fullmoviesxxx": fullmovies.extract,
"hdporngg": hdporngg.extract, "hdporngg": hdporngg.extract,
# hqfap (PlayTube CMS) — JSON-LD contentUrl = direct mp4. USUNIĘTY 2026-06-25 (cała # hqfap (PlayTube CMS), JSON-LD contentUrl = direct mp4. USUNIĘTY 2026-06-25 (cała
# biblioteka serwowała `/upload/videos/video_down.mp4` stub), PRZYWRÓCONY 2026-07-07: # biblioteka serwowała `/upload/videos/video_down.mp4` stub), PRZYWRÓCONY 2026-07-07:
# strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP, zweryfikowane), # strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP, zweryfikowane),
# user request. 4k69 zostaje usunięty (nie sprawdzany ponownie). # user request. 4k69 zostaje usunięty (nie sprawdzany ponownie).
# 2026-07-28: hqfap wdrożył Cloudflare z JS-challenge na strony /watch/ — 403 zarówno # 2026-07-28: hqfap wdrożył Cloudflare z JS-challenge na strony /watch/ 403 zarówno
# z VPS, jak i z residential IP, także przy pełnym komplecie nagłówków przeglądarki # z VPS, jak i z residential IP, także przy pełnym komplecie nagłówków przeglądarki
# (strona główna dalej 200, więc blokada jest celowana w strony wideo). Phone-side # (strona główna dalej 200, więc blokada jest celowana w strony wideo). Phone-side
# `resolveHqfapPage` (zwykły fetch) nie ma jak przejść challenge, więc padał i user # `resolveHqfapPage` (zwykły fetch) nie ma jak przejść challenge, więc padał i user
# lądował na stronie zamiast na wideo (bug-report 92e9133e "Hqfap przekierowuje na # lądował na stronie zamiast na wideo (bug-report 92e9133e "Hqfap przekierowuje na
# stronę"; telemetria to PRZEMILCZAŁA — zero eventów od 27.07, bo błąd jest przed # stronę"; telemetria to PRZEMILCZAŁA, zero eventów od 27.07, bo błąd jest przed
# playerem). WebView (Chrome engine + JS + cookie) challenge przechodzi, więc # playerem). WebView (Chrome engine + JS + cookie) challenge przechodzi, więc
# przełączamy na tryb WebView; INJECTED_JS i tak wyłuska mp4 i wróci na native player. # przełączamy na tryb WebView; INJECTED_JS i tak wyłuska mp4 i wróci na native player.
# "hqfapcom" — ZDJĘTY 2026-08-02, patrz komentarz w direct_scrapers/__init__.py. # "hqfapcom", ZDJĘTY 2026-08-02, patrz komentarz w direct_scrapers/__init__.py.
# javflix (JAV, WordPress) — przyciski serwerów to `<a class="myLink" href="<embed>">` # javflix (JAV, WordPress), przyciski serwerów to `<a class="myLink" href="<embed>">`
# (streamtape/voe/doodstream/emturbovid). Generyczny _embed_iframe łapie je anchor-hoster # (streamtape/voe/doodstream/emturbovid). Generyczny _embed_iframe łapie je anchor-hoster
# patternem → type='hoster', telefon resolwuje (voe backend, dood/filemoon phone-side). # patternem → type='hoster', telefon resolwuje (voe backend, dood/filemoon phone-side).
# Wrapper javflix.extract odsiewa placeholder players.mp4. # Wrapper javflix.extract odsiewa placeholder players.mp4.
"javflix": javflix.extract, "javflix": javflix.extract,
# vjav (JAV, TXXX network) — videofile.php -> get_file HLS (patrz tubes/vjav.py). # vjav (JAV, TXXX network), videofile.php -> get_file HLS (patrz tubes/vjav.py).
"vjav": vjav.extract, "vjav": vjav.extract,
# supjav (JAV, embed-aggregator) — data-link reverse-hex -> lk1 -> hoster (type=hoster). # supjav (JAV, embed-aggregator), data-link reverse-hex -> lk1 -> hoster (type=hoster).
"supjav": supjav.extract, "supjav": supjav.extract,
# neporn — KVS function/0 + license (jak freshporno). Server-side _kvs resolve → # neporn, KVS function/0 + license (jak freshporno). Server-side _kvs resolve →
# data001.neporn.com/remote_control.php portable (cross-IP 206, 2026-06-10). # data001.neporn.com/remote_control.php portable (cross-IP 206, 2026-06-10).
"neporncom": neporn.extract, "neporncom": neporn.extract,
# superporn — `<source>` mp4 (cdnst*.superporn.com) token IP-bound do fetchera # superporn, `<source>` mp4 (cdnst*.superporn.com) token IP-bound do fetchera
# (403 cross-IP, test 2026-06-10), a sama strona CF-blocked z VPS. Resolve MUSI # (403 cross-IP, test 2026-06-10), a sama strona CF-blocked z VPS. Resolve MUSI
# być phone-side: WebView ładuje stronę z residential IP telefonu, INJECTED_JS # być phone-side: WebView ładuje stronę z residential IP telefonu, INJECTED_JS
# bierze video.src. Ingest HTML idzie osobno przez Bright Data proxy (scraper). # bierze video.src. Ingest HTML idzie osobno przez Bright Data proxy (scraper).
@ -251,7 +251,7 @@ def try_extract(sitetag: str, page_url: str) -> list[StreamSource] | None:
- brak extractora dla tego sitetag - brak extractora dla tego sitetag
- extractor zwrócił None / nie znalazł URL'a - extractor zwrócił None / nie znalazł URL'a
Raises HosterDead gdy embed page wprost mówi że video deleted/not found — Raises HosterDead gdy embed page wprost mówi że video deleted/not found,
caller (playback.py) łapie i oznacza playback_source.dead_at. caller (playback.py) łapie i oznacza playback_source.dead_at.
""" """
extractor = _REGISTRY.get(sitetag) extractor = _REGISTRY.get(sitetag)
@ -273,7 +273,7 @@ def supported_sitetags() -> tuple[str, ...]:
def is_vps_blocked_fallback(sitetag: str) -> bool: def is_vps_blocked_fallback(sitetag: str) -> bool:
"""True gdy sitetag resolvuje się TYLKO przez WebView fallback (IP-bound CDN / """True gdy sitetag resolvuje się TYLKO przez WebView fallback (IP-bound CDN /
ad-heavy / CAPTCHA — np. fpoxxx, pornxpph). Takie źródła dają gorszy ad-heavy / CAPTCHA, np. fpoxxx, pornxpph). Takie źródła dają gorszy
UX (reklamy, czarny ekran) niż natywny KVS/direct resolve, więc UI powinien je UX (reklamy, czarny ekran) niż natywny KVS/direct resolve, więc UI powinien je
rankować NIŻEJ gdy scena ma też natywne źródło (bug-report 2026-06-07: scena rankować NIŻEJ gdy scena ma też natywne źródło (bug-report 2026-06-07: scena
pokazywała fpoxxx-WebView przed działającym freshporno bo sort był alfabetyczny).""" pokazywała fpoxxx-WebView przed działającym freshporno bo sort był alfabetyczny)."""

View file

@ -38,7 +38,7 @@ _DEFAULT_UA = (
@dataclass @dataclass
class FetchResult: class FetchResult:
"""Mini response-like object — drop-in dla httpx.Response w naszych use case'ach.""" """Mini response-like object, drop-in dla httpx.Response w naszych use case'ach."""
status_code: int status_code: int
text: str text: str
@ -60,7 +60,7 @@ def browser_get(
) -> FetchResult: ) -> FetchResult:
"""GET z Chrome TLS fingerprint (curl_cffi). Spada do httpx gdy curl_cffi brak. """GET z Chrome TLS fingerprint (curl_cffi). Spada do httpx gdy curl_cffi brak.
`proxy` (http://user:pass@host:port) — routuje request przez proxy. Używane `proxy` (http://user:pass@host:port), routuje request przez proxy. Używane
tylko do ingestu HTML tubów blokujących VPS IP (np. superporn przez Bright Data tylko do ingestu HTML tubów blokujących VPS IP (np. superporn przez Bright Data
ISP proxy). NIE dla streamów.""" ISP proxy). NIE dla streamów."""
if not _HAS_CURL_CFFI: if not _HAS_CURL_CFFI:
@ -78,7 +78,7 @@ def browser_get(
impersonate=impersonate, impersonate=impersonate,
allow_redirects=follow_redirects, allow_redirects=follow_redirects,
proxies={"http": proxy, "https": proxy} if proxy else None, proxies={"http": proxy, "https": proxy} if proxy else None,
verify=not proxy, # Bright Data MITM CA — curl_cffi nie ma go w bundlu verify=not proxy, # Bright Data MITM CA, curl_cffi nie ma go w bundlu
) )
return FetchResult(status_code=r.status_code, text=r.text, url=str(r.url)) return FetchResult(status_code=r.status_code, text=r.text, url=str(r.url))
@ -90,7 +90,7 @@ def fetch_tube_html(url: str, *, timeout: float = 60.0, max_retries: int = 2) ->
(browser_get) żeby ominąć JA3 fingerprint blocks na CF-fronted tube'ach. (browser_get) żeby ominąć JA3 fingerprint blocks na CF-fronted tube'ach.
Retry: 5xx i empty body retry max_retries razy z exponential backoff (0.5s, 1s). Retry: 5xx i empty body retry max_retries razy z exponential backoff (0.5s, 1s).
Dla freshporno itp. które czasem zwracają 503/empty — bez retry user dostawał Dla freshporno itp. które czasem zwracają 503/empty, bez retry user dostawał
"extractor None" z transient hiccup. "extractor None" z transient hiccup.
""" """
import time as _time import time as _time

View file

@ -9,10 +9,10 @@ from typing import Any
class StreamSource: class StreamSource:
"""Pojedynczy resolved stream URL. """Pojedynczy resolved stream URL.
Mapuje na `StreamLink` w playback API (api/playback.py) — `link` → `stream_url`, Mapuje na `StreamLink` w playback API (api/playback.py), `link` → `stream_url`,
`quality` → `quality`, `type` → `type`. `quality` → `quality`, `type` → `type`.
`referer` — opcjonalny override Referera używanego przez stream_proxy. Niektóre `referer`, opcjonalny override Referera używanego przez stream_proxy. Niektóre
CDN-y (KVS-style watchporn.to, fpo.xxx itp.) zwracają 410/403 gdy Referer nie CDN-y (KVS-style watchporn.to, fpo.xxx itp.) zwracają 410/403 gdy Referer nie
pasuje do *embed page'a* (np. proxy używa `Referer: 0dayxx.com` ale CDN expectuje pasuje do *embed page'a* (np. proxy używa `Referer: 0dayxx.com` ale CDN expectuje
`Referer: watchporn.to`). Gdy None → caller (playback.py) używa `page_url`. `Referer: watchporn.to`). Gdy None → caller (playback.py) używa `page_url`.

View file

@ -1,17 +1,17 @@
"""Universal duration extractor for tube pages. """Universal duration extractor for tube pages.
Direct scrapery (xvideos, xnxx, youporn, porntrex, …) są search-only — pobierają Direct scrapery (xvideos, xnxx, youporn, porntrex, …) są search-only, pobierają
listing i wycioskują tylko URL + slug-as-title. Duration pojawia się dopiero na listing i wycioskują tylko URL + slug-as-title. Duration pojawia się dopiero na
detail page i jest dostępne w jednym z patternów: detail page i jest dostępne w jednym z patternów:
1. **OpenGraph numeric** (youporn, redtube, eporner): 1. **OpenGraph numeric** (youporn, redtube, eporner):
`<meta property="og:video:duration" content="992">` — sekundy. `<meta property="og:video:duration" content="992">`, sekundy.
2. **OpenGraph ISO 8601** (rzadkie): 2. **OpenGraph ISO 8601** (rzadkie):
`<meta property="og:video:duration" content="PT16M32S">`. `<meta property="og:video:duration" content="PT16M32S">`.
3. **Schema.org VideoObject LD-JSON** (xvideos, xnxx, KVS-based): 3. **Schema.org VideoObject LD-JSON** (xvideos, xnxx, KVS-based):
`"duration": "PT00H07M10S"` w JSON-LD `<script type="application/ld+json">`. `"duration": "PT00H07M10S"` w JSON-LD `<script type="application/ld+json">`.
4. **itemprop microdata** (sxyland, 0dayxx, niektóre WordPress): 4. **itemprop microdata** (sxyland, 0dayxx, niektóre WordPress):
`<meta itemprop="duration" content="P0DT0H21M13S">` — ISO 8601 z opcjonalnym `<meta itemprop="duration" content="P0DT0H21M13S">`, ISO 8601 z opcjonalnym
`P<days>D` prefix + opcjonalnym `T` blokiem HMS. `P<days>D` prefix + opcjonalnym `T` blokiem HMS.
Funkcja zwraca pierwszy znaleziony match jako int seconds, lub None. Funkcja zwraca pierwszy znaleziony match jako int seconds, lub None.
@ -29,7 +29,7 @@ _ITEMPROP_DURATION_RE = re.compile(
r'itemprop="duration"[^>]*content="([^"]+)"', re.IGNORECASE r'itemprop="duration"[^>]*content="([^"]+)"', re.IGNORECASE
) )
# Hqporner-style meta description: "Video duration is 6min 55sec" lub "1h 23min 5sec". # Hqporner-style meta description: "Video duration is 6min 55sec" lub "1h 23min 5sec".
# Generic — pasuje też do innych tube'ów które dorzucają w meta opis duration prozą. # Generic, pasuje też do innych tube'ów które dorzucają w meta opis duration prozą.
_META_DESC_DURATION_RE = re.compile( _META_DESC_DURATION_RE = re.compile(
r'(?:duration\s+is\s+|<meta\s+name="description"\s+content="[^"]*duration\s+is\s+)' r'(?:duration\s+is\s+|<meta\s+name="description"\s+content="[^"]*duration\s+is\s+)'
r'(?:(\d+)\s*h(?:our)?s?)?\s*(?:(\d+)\s*min)?\s*(?:(\d+)\s*sec)?', r'(?:(\d+)\s*h(?:our)?s?)?\s*(?:(\d+)\s*min)?\s*(?:(\d+)\s*sec)?',

View file

@ -5,12 +5,12 @@ Hostery embed-page'y stosują JWPlayer + P.A.C.K.E.R. obfuskację:
i chowają `sources: [{file: "https://...m3u8"}]` w packed JS. i chowają `sources: [{file: "https://...m3u8"}]` w packed JS.
Tu jest: Tu jest:
- `unpack_packer(js)` — dekoder P.A.C.K.E.R. - `unpack_packer(js)`, dekoder P.A.C.K.E.R.
- `extract_stream_from_hoster(iframe_url, *, referer)` — fetch embed → unpack → m3u8/mp4 - `extract_stream_from_hoster(iframe_url, *, referer)`, fetch embed → unpack → m3u8/mp4
Te funkcje są używane przez: Te funkcje są używane przez:
1. Per-tube extractors (latestpornvideo, hqporner fallback) — page → embed iframe → tu 1. Per-tube extractors (latestpornvideo, hqporner fallback), page → embed iframe → tu
2. Movies playback (api/playback.py movies_router) — direct hoster URL → tu 2. Movies playback (api/playback.py movies_router), direct hoster URL → tu
Nie ma już zależności od PornAppClient / porn-app API. Nie ma już zależności od PornAppClient / porn-app API.
""" """
@ -25,10 +25,10 @@ from app.extractors._models import HosterDead
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# P.A.C.K.E.R. javascript unpacker — odwraca obfuskację wzorca: # P.A.C.K.E.R. javascript unpacker, odwraca obfuskację wzorca:
# eval(function(p,a,c,k,e,d){while(c--)if(k[c])p=p.replace(...);return p} # eval(function(p,a,c,k,e,d){while(c--)if(k[c])p=p.replace(...);return p}
# ('PAYLOAD', BASE, COUNT, 'kw1|kw2|...'.split('|'), 0, {})) # ('PAYLOAD', BASE, COUNT, 'kw1|kw2|...'.split('|'), 0, {}))
# StreamWish, doodporn, mixdrop, filemoon — wszystkie używają tego packera do schowania # StreamWish, doodporn, mixdrop, filemoon, wszystkie używają tego packera do schowania
# `sources: [{file: "https://...m3u8"}]` w JWPlayer config. # `sources: [{file: "https://...m3u8"}]` w JWPlayer config.
_PACKER_ARGS_RE = re.compile( _PACKER_ARGS_RE = re.compile(
r"\}\s*\(\s*'((?:\\'|[^'])+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\'|[^'])*)'\s*\.split\('\|'\)", r"\}\s*\(\s*'((?:\\'|[^'])+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\'|[^'])*)'\s*\.split\('\|'\)",
@ -102,7 +102,7 @@ def _looks_like_ad(url: str) -> bool:
# Niektóre hostery (doodporn) chowają mp4/m3u8 w słowniku zmiennych i odwołują się do # Niektóre hostery (doodporn) chowają mp4/m3u8 w słowniku zmiennych i odwołują się do
# nich w `sources: [{file: links.hls2}]`. Wtedy regex powyżej nie złapie. Drugi pass # nich w `sources: [{file: links.hls2}]`. Wtedy regex powyżej nie złapie. Drugi pass
# bierze pierwszy `.m3u8|.mp4|.mpd` URL z całego unpacked HTML — heurystyka, ale # bierze pierwszy `.m3u8|.mp4|.mpd` URL z całego unpacked HTML, heurystyka, ale
# pierwszy taki URL to zwykle master playlist video. # pierwszy taki URL to zwykle master playlist video.
_HOSTER_FALLBACK_URL_RE = re.compile( _HOSTER_FALLBACK_URL_RE = re.compile(
r'https?://[^\s"\'<>]+\.(?:m3u8|mp4|mpd)(?:\?[^\s"\'<>]*)?', r'https?://[^\s"\'<>]+\.(?:m3u8|mp4|mpd)(?:\?[^\s"\'<>]*)?',
@ -111,7 +111,7 @@ _HOSTER_FALLBACK_URL_RE = re.compile(
# Sygnatury "video not found" / "deleted" które hostery wstawiają w HTML embed page. # Sygnatury "video not found" / "deleted" które hostery wstawiają w HTML embed page.
# Gdy widzimy te markery, to wiemy że link jest martwy — raise HosterDead, caller w # Gdy widzimy te markery, to wiemy że link jest martwy, raise HosterDead, caller w
# playback.py oznaczy playback_source.dead_at. # playback.py oznaczy playback_source.dead_at.
_HOSTER_DEAD_PATTERNS = ( _HOSTER_DEAD_PATTERNS = (
"Video not found", "Video not found",
@ -124,20 +124,20 @@ _HOSTER_DEAD_PATTERNS = (
) )
# KVS (Kernel Video Sharing) player markers — kt_player.js + license_code w HTML. # KVS (Kernel Video Sharing) player markers, kt_player.js + license_code w HTML.
# Używają go fpo.xxx, 0day.kim, hdporn92, sxyland, i wiele innych WordPress-based # Używają go fpo.xxx, 0day.kim, hdporn92, sxyland, i wiele innych WordPress-based
# tubes. KVS encryptuje URL `function/0/<encrypted>` license_code'em — regex fallback # tubes. KVS encryptuje URL `function/0/<encrypted>` license_code'em, regex fallback
# (`_HOSTER_FALLBACK_URL_RE`) złapie zamiast tego URL `event_reporting2` (tracking # (`_HOSTER_FALLBACK_URL_RE`) złapie zamiast tego URL `event_reporting2` (tracking
# pixel zwracający 1×1 GIF zamiast video). Jak widzimy markery KVS, idziemy od razu # pixel zwracający 1×1 GIF zamiast video). Jak widzimy markery KVS, idziemy od razu
# do yt-dlp którego generic extractor poprawnie deszyfruje URL. # do yt-dlp którego generic extractor poprawnie deszyfruje URL.
_KVS_MARKERS = ("kt_player(", "license_code") _KVS_MARKERS = ("kt_player(", "license_code")
# File hosters / known dead — rapidgator/nitroflare/frdl wymagają premium account # File hosters / known dead, rapidgator/nitroflare/frdl wymagają premium account
# (zwracają HTML z formularzem logowania zamiast video). Zwróć None bez fetch'u — # (zwracają HTML z formularzem logowania zamiast video). Zwróć None bez fetch'u,
# caller w movies playback dorzuci embed-only fallback i mobile i tak otworzy # caller w movies playback dorzuci embed-only fallback i mobile i tak otworzy
# WebView (gdzie user może zalogować się premium jeśli chce). # WebView (gdzie user może zalogować się premium jeśli chce).
# Streamtape USUNIĘTY z blacklistu 2026-05-15 — ma dedicated extractor (innerHTML # Streamtape USUNIĘTY z blacklistu 2026-05-15, ma dedicated extractor (innerHTML
# substring decode → /get_video → 302 → tapecontent.net mp4). Większość 12k URLów # substring decode → /get_video → 302 → tapecontent.net mp4). Większość 12k URLów
# w naszej DB jest DMCA-dead ale ~5% żyje. # w naszej DB jest DMCA-dead ale ~5% żyje.
_FILE_HOSTER_RE = re.compile( _FILE_HOSTER_RE = re.compile(
@ -169,8 +169,8 @@ def extract_stream_from_hoster(
return None return None
# Per-hoster dedicated extractors (specific URL shapes / decode patterns). # Per-hoster dedicated extractors (specific URL shapes / decode patterns).
# Mixdrop: P.A.C.K.E.R. → MDCore.wurl protocol-relative `//host/v2/<id>.mp4?s=...` # Mixdrop: P.A.C.K.E.R. → MDCore.wurl protocol-relative `//host/v2/<id>.mp4?s=...`
# — generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme). # generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme).
# `miixdrop` (double-i) to current canonical domain — wszystkie legacy # `miixdrop` (double-i) to current canonical domain, wszystkie legacy
# `mixdrop.{ag,sb,my,co,...}` + `m1xdrop.bz` 301-ują tam. Bez `miixdrop` # `mixdrop.{ag,sb,my,co,...}` + `m1xdrop.bz` 301-ują tam. Bez `miixdrop`
# w dispatch URLs already-on-new-domain (upstream tubes które zaktualizowały # w dispatch URLs already-on-new-domain (upstream tubes które zaktualizowały
# embed src) trafiałyby do generic logic, gdzie regex `https?://...\.mp4` # embed src) trafiałyby do generic logic, gdzie regex `https?://...\.mp4`
@ -182,7 +182,7 @@ def extract_stream_from_hoster(
return sources[0].link return sources[0].link
# Fall through to generic logic gdyby dedicated zwrócił None. # Fall through to generic logic gdyby dedicated zwrócił None.
# xtremestream (perverzija): videojs+hls.js player. Embed `/player/index.php?data=<HASH>` # xtremestream (perverzija): videojs+hls.js player. Embed `/player/index.php?data=<HASH>`
# — m3u8 master playlist serwowany przez `/player/xs1.php?data=<HASH>` (ten sam HASH, # m3u8 master playlist serwowany przez `/player/xs1.php?data=<HASH>` (ten sam HASH,
# inny endpoint; potwierdzone w inline JS `m3u8_loader_url`). Brak packera/file: w # inny endpoint; potwierdzone w inline JS `m3u8_loader_url`). Brak packera/file: w
# HTML → generic logic zwracała None → WebView fallback. Trywialna podmiana endpointu. # HTML → generic logic zwracała None → WebView fallback. Trywialna podmiana endpointu.
_xtr = re.search( _xtr = re.search(
@ -200,9 +200,9 @@ def extract_stream_from_hoster(
sources = streamtape.extract(iframe_url, timeout=timeout) sources = streamtape.extract(iframe_url, timeout=timeout)
if sources: if sources:
return sources[0].link return sources[0].link
return None # streamtape ma własną HosterDead obsługę — generic fallback by się sypał return None # streamtape ma własną HosterDead obsługę, generic fallback by się sypał
# Shared SPA+AES-CBC engine: embedseek/seekplayer/rpmplay/upns/player4me/easyvidplayer # Shared SPA+AES-CBC engine: embedseek/seekplayer/rpmplay/upns/player4me/easyvidplayer
# — wszystkie używają tego samego silnika (`/api/v1/video` z AES-CBC encrypted # wszystkie używają tego samego silnika (`/api/v1/video` z AES-CBC encrypted
# m3u8 source). Razem ~159k playback sources w DB. # m3u8 source). Razem ~159k playback sources w DB.
from app.extractors.hosters import seekplayer_engine from app.extractors.hosters import seekplayer_engine
if seekplayer_engine.matches(iframe_url): if seekplayer_engine.matches(iframe_url):
@ -226,7 +226,7 @@ def extract_stream_from_hoster(
if sources: if sources:
return sources[0].link return sources[0].link
return None return None
# filemoon "Byse" SPA — server-side resolve jest bezcelowy: stream URL z API # filemoon "Byse" SPA, server-side resolve jest bezcelowy: stream URL z API
# jest IP-bound do requestera, więc VPS dostałby URL działający tylko z VPS. # jest IP-bound do requestera, więc VPS dostałby URL działający tylko z VPS.
# Zwracamy None od razu → _embed_iframe Stage 2 → type='hoster' → mobile # Zwracamy None od razu → _embed_iframe Stage 2 → type='hoster' → mobile
# filemoonHoster.ts robi POST /playback + AES-256-GCM z IP użytkownika. # filemoonHoster.ts robi POST /playback + AES-256-GCM z IP użytkownika.
@ -238,7 +238,7 @@ def extract_stream_from_hoster(
): ):
log.debug("hoster %s: filemoon SPA → type=hoster (mobile-side resolve)", iframe_url) log.debug("hoster %s: filemoon SPA → type=hoster (mobile-side resolve)", iframe_url)
return None return None
# DoodStream + klony (playmogo/doply/myvidplay/dood.*/doodstream) — protokół pass_md5 # DoodStream + klony (playmogo/doply/myvidplay/dood.*/doodstream), protokół pass_md5
# + niewidzialny Cloudflare Turnstile wiązany z IP requestera. Server-side resolve # + niewidzialny Cloudflare Turnstile wiązany z IP requestera. Server-side resolve
# bezcelowy (VPS dostaje Turnstile gate / IP-bound token). Zwracamy None → caller # bezcelowy (VPS dostaje Turnstile gate / IP-bound token). Zwracamy None → caller
# oddaje embed jako type='hoster' → mobile doodstream.ts robi pass_md5 z IP usera # oddaje embed jako type='hoster' → mobile doodstream.ts robi pass_md5 z IP usera
@ -305,7 +305,7 @@ def extract_stream_from_hoster(
if (url := _first_non_ad(_HOSTER_FALLBACK_URL_RE, html, 0)): if (url := _first_non_ad(_HOSTER_FALLBACK_URL_RE, html, 0)):
return url return url
# 4) yt-dlp last resort — battle-tested extractory dla streamtape, dood, mixdrop, # 4) yt-dlp last resort, battle-tested extractory dla streamtape, dood, mixdrop,
# filemoon, voe, vidoza, etc. Nie używamy go domyślnie (slow + lots of HTTP), # filemoon, voe, vidoza, etc. Nie używamy go domyślnie (slow + lots of HTTP),
# tylko gdy nasze własne metody zawiodły. # tylko gdy nasze własne metody zawiodły.
ytdlp_url = _try_ytdlp_hoster(iframe_url, timeout=timeout) ytdlp_url = _try_ytdlp_hoster(iframe_url, timeout=timeout)
@ -324,7 +324,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
"""yt-dlp wrapper dla hosters których nasz P.A.C.K.E.R. unpacker nie ogarnął. """yt-dlp wrapper dla hosters których nasz P.A.C.K.E.R. unpacker nie ogarnął.
yt-dlp ma extractory dla popularnych hosterów (streamtape, dood, mixdrop, filemoon, yt-dlp ma extractory dla popularnych hosterów (streamtape, dood, mixdrop, filemoon,
voe, vidoza, streamwish, ...) — bezpośredni dostęp do `_extract_info`. Te extractory voe, vidoza, streamwish, ...), bezpośredni dostęp do `_extract_info`. Te extractory
robią multi-step AJAX / token rotation / regex unpacking dla każdego hostera. robią multi-step AJAX / token rotation / regex unpacking dla każdego hostera.
Catch-all exception handling: jeśli yt-dlp nie ma extractora dla tego hostera lub Catch-all exception handling: jeśli yt-dlp nie ma extractora dla tego hostera lub
@ -366,7 +366,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
# "fake video" lub błąd (zgłoszone 2026-05-10 #30c4d3cf perverzija). # "fake video" lub błąd (zgłoszone 2026-05-10 #30c4d3cf perverzija).
return any(ext in low for ext in (".m3u8", ".mp4", ".mpd", ".webm", ".ts")) return any(ext in low for ext in (".m3u8", ".mp4", ".mpd", ".webm", ".ts"))
# Best video format URL — yt-dlp już rankuje formats, pierwszy w `formats` zwykle jest # Best video format URL, yt-dlp już rankuje formats, pierwszy w `formats` zwykle jest
# najlepszy, albo `info["url"]` dla single-format extractorów. # najlepszy, albo `info["url"]` dla single-format extractorów.
formats = info.get("formats") or [info] formats = info.get("formats") or [info]
for fmt in formats: for fmt in formats:
@ -375,7 +375,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
url = fmt.get("url") url = fmt.get("url")
if _looks_like_video_url(url): if _looks_like_video_url(url):
return url return url
# Fallback: top-level URL — ale tylko gdy faktycznie wygląda na video. # Fallback: top-level URL, ale tylko gdy faktycznie wygląda na video.
top = info.get("url") top = info.get("url")
if _looks_like_video_url(top): if _looks_like_video_url(top):
return top return top

View file

@ -1,4 +1,4 @@
"""cdn.loadvid.com — HLS hoster oddający manifest przez POST (nie URL). """cdn.loadvid.com, HLS hoster oddający manifest przez POST (nie URL).
Protokół (reverse-engineer 2026-07-27, potwierdzony live): Protokół (reverse-engineer 2026-07-27, potwierdzony live):
1. GET /videos/play/<hash> → HTML z `<meta name="csrf-token">` + inline 1. GET /videos/play/<hash> → HTML z `<meta name="csrf-token">` + inline
@ -15,7 +15,7 @@ Rozwiązanie: `extract()` zwraca **embed URL** oznaczony `manifest_producer=load
`/proxy/hls/<token>/play.m3u8` rozpoznaje ten marker i zamiast GET-a woła `/proxy/hls/<token>/play.m3u8` rozpoznaje ten marker i zamiast GET-a woła
`fetch_manifest()`, po czym serwuje manifest telefonowi. Segmenty w manifeście są `fetch_manifest()`, po czym serwuje manifest telefonowi. Segmenty w manifeście są
ABSOLUTNE i w pełni przenośne (zweryfikowane: 206 `bytes 0-1023` BEZ jakichkolwiek ABSOLUTNE i w pełni przenośne (zweryfikowane: 206 `bytes 0-1023` BEZ jakichkolwiek
nagłówków, bez Referera, bez tokena — serwowane jako `image/png`, w środku TS), nagłówków, bez Referera, bez tokena, serwowane jako `image/png`, w środku TS),
więc telefon ciągnie je bezpośrednio z CDN. Przez VPS idzie tylko manifest. więc telefon ciągnie je bezpośrednio z CDN. Przez VPS idzie tylko manifest.
""" """
from __future__ import annotations from __future__ import annotations
@ -106,7 +106,7 @@ def fetch_manifest(embed_url: str, *, timeout: float = 30.0) -> str | None:
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
"""Zwraca embed URL z markerem producenta — manifest powstaje dopiero w """Zwraca embed URL z markerem producenta, manifest powstaje dopiero w
`/proxy/hls` (patrz docstring modułu). Weryfikujemy tu, że manifest realnie `/proxy/hls` (patrz docstring modułu). Weryfikujemy tu, że manifest realnie
da się wyprodukować, żeby nie oddawać martwego źródła.""" da się wyprodukować, żeby nie oddawać martwego źródła."""
if not matches(page_url): if not matches(page_url):

View file

@ -1,4 +1,4 @@
"""Mixdrop embed hoster — P.A.C.K.E.R. eval → MDCore.wurl direct mp4. """Mixdrop embed hoster, P.A.C.K.E.R. eval → MDCore.wurl direct mp4.
Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120): Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120):
1. Fetch `https://mixdrop.my/e/<id>` → 200 z 95KB body, redirect 301 do 1. Fetch `https://mixdrop.my/e/<id>` → 200 z 95KB body, redirect 301 do
@ -8,7 +8,7 @@ Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120):
3. yt-dlp's `decode_packed_codes()` rozkrywa do ~390 chars JavaScript: 3. yt-dlp's `decode_packed_codes()` rozkrywa do ~390 chars JavaScript:
`MDCore.wurl="//a-delivery22.mxcontent.net/v2/<id>.mp4?s=<sig>&e=<exp>&_t=<ts>"` `MDCore.wurl="//a-delivery22.mxcontent.net/v2/<id>.mp4?s=<sig>&e=<exp>&_t=<ts>"`
4. URL na `mxcontent.net` zwraca **direct mp4** (Content-Type: video/mp4, 4. URL na `mxcontent.net` zwraca **direct mp4** (Content-Type: video/mp4,
Content-Length: ~485MB) — działa z Hetzner VPS IP, brak token IP-bind. Content-Length: ~485MB), działa z Hetzner VPS IP, brak token IP-bind.
`s` to signed token (HMAC?), `e` to expiry timestamp (unix sec), `_t` to `s` to signed token (HMAC?), `e` to expiry timestamp (unix sec), `_t` to
issued timestamp. Token jest valid ~24h od `_t`. Refetching embed page po issued timestamp. Token jest valid ~24h od `_t`. Refetching embed page po
@ -42,7 +42,7 @@ _DEAD_RE = re.compile(
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
# UA + Accept są wymagane — bez nich mixdrop dla VALID video zwraca minimalny # UA + Accept są wymagane, bez nich mixdrop dla VALID video zwraca minimalny
# body bez P.A.C.K.E.R. (sam stream_proxy._refetch_mixdrop_url też tak robi). # body bez P.A.C.K.E.R. (sam stream_proxy._refetch_mixdrop_url też tak robi).
# Brak headerów powodował że extract() na żywym mixdrop ID dostawał shell bez # Brak headerów powodował że extract() na żywym mixdrop ID dostawał shell bez
# packera → no match → None → mobile dostawał czarny ekran. # packera → no match → None → mobile dostawał czarny ekran.

View file

@ -5,7 +5,7 @@ Wszyscy używają tego samego silnika (Vite-built React SPA + AES-CBC encrypted
Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI): Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI):
1. Embed URL = `https://<sub>.<host>.<tld>/#<hash_id>` — hash fragment to video ID. 1. Embed URL = `https://<sub>.<host>.<tld>/#<hash_id>`, hash fragment to video ID.
SPA shell `Loading...` body load'uje `/assets/index-<n>.js` bundle. SPA shell `Loading...` body load'uje `/assets/index-<n>.js` bundle.
2. JS fetcha `/api/v1/video?id=<hash_id>&w=<W>&h=<H>&r=` (W,H z window.screen). 2. JS fetcha `/api/v1/video?id=<hash_id>&w=<W>&h=<H>&r=` (W,H z window.screen).
@ -20,15 +20,15 @@ Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI):
- `title`, `poster`, `thumbnail`, ... - `title`, `poster`, `thumbnail`, ...
4. `source` URL jest signed z visitor IP. Z VPS fetch zwraca master.m3u8 z signed 4. `source` URL jest signed z visitor IP. Z VPS fetch zwraca master.m3u8 z signed
token tied to VPS IP — proxy fetcha segments z tym samym tokenem, działa. token tied to VPS IP, proxy fetcha segments z tym samym tokenem, działa.
CDN port 443 z `verify=False` (self-signed IP cert). CDN port 443 z `verify=False` (self-signed IP cert).
5. Wszystkie hostery share te same wartości KEY/IV. Wewnętrzna obfuskacja JS 5. Wszystkie hostery share te same wartości KEY/IV. Wewnętrzna obfuskacja JS
maskuje to lookupem `ue(773)`, `ue(686)` itp. — derived bytes są zawsze maskuje to lookupem `ue(773)`, `ue(686)` itp., derived bytes są zawsze
identyczne dla każdej domeny. identyczne dla każdej domeny.
Hostery covered (origin counts w DB, 2026-05-15): Hostery covered (origin counts w DB, 2026-05-15):
- embedseek (20271), seekplayer (20271) — mirror sites, dzielą hash_id - embedseek (20271), seekplayer (20271), mirror sites, dzielą hash_id
- rpmplay (15317) - rpmplay (15317)
- upns (14287) - upns (14287)
- player4me (41040) - player4me (41040)
@ -61,10 +61,10 @@ _IV = b"1234567890oiuytr"
# #
# 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`. # 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`.
# Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne # Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne
# domeny — bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania. # domeny, bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania.
# Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro # Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro
# (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy) # (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy)
# dekodują się tym samym silnikiem. Whitelist jest addytywna — istniejące hosty # dekodują się tym samym silnikiem. Whitelist jest addytywna, istniejące hosty
# matchują się dalej tak samo. # matchują się dalej tak samo.
_HOST_RE = re.compile( _HOST_RE = re.compile(
r"^(?:[a-z0-9]+\.)?" r"^(?:[a-z0-9]+\.)?"
@ -142,8 +142,8 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
cf = (data.get("cf") or "").strip() cf = (data.get("cf") or "").strip()
# Source: IP-bound m3u8 URL na CDN edge (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8`). # Source: IP-bound m3u8 URL na CDN edge (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8`).
# Token signed dla VPS IP — proxy poda segmenty z tego samego IP, OK. # Token signed dla VPS IP, proxy poda segmenty z tego samego IP, OK.
# CDN servuje cert na IP — fetch wymaga verify=False (stream_proxy.py ma już # CDN servuje cert na IP, fetch wymaga verify=False (stream_proxy.py ma już
# taką gałąź dla IP-host m3u8). # taką gałąź dla IP-host m3u8).
sources: list[StreamSource] = [] sources: list[StreamSource] = []
if source: if source:

View file

@ -10,14 +10,14 @@ Pattern (verified 2026-05-15 z residential, live URL `/e/PZqBZp4OomF0Q61`):
'//streamtape.com/get_video' + '//streamtape.com/get_video' +
('<junk>?id=<id>&expires=...&ip=...&token=...').substring(N).substring(M); ('<junk>?id=<id>&expires=...&ip=...&token=...').substring(N).substring(M);
Junk to 3-4 znaki przed `?` — substring(N).substring(M) je odcina. Junk to 3-4 znaki przed `?`, substring(N).substring(M) je odcina.
2. Po sklejeniu fetch `https://streamtape.com/get_video?id=...&token=...` → 2. Po sklejeniu fetch `https://streamtape.com/get_video?id=...&token=...` →
302 → `https://<cluster>.tapecontent.net/radosgw/<id>/<signed_path>/<title>.mp4` 302 → `https://<cluster>.tapecontent.net/radosgw/<id>/<signed_path>/<title>.mp4`
(direct mp4, video/mp4 ~500MB, brak IP-bind). (direct mp4, video/mp4 ~500MB, brak IP-bind).
3. Body czasem zwraca `Video not found! Maybe it got deleted by the creator!` 3. Body czasem zwraca `Video not found! Maybe it got deleted by the creator!`
— większość URLów w naszej DB (12k mass-DMCA'd 2026-05-15). Wtedy raise Tak jest dla większości URLów w naszej DB (12k mass-DMCA'd 2026-05-15). Wtedy raise
HosterDead, caller w playback.py oznaczy dead_at. HosterDead, caller w playback.py oznaczy dead_at.
Live URL coverage probed 2026-05-15: ~5% URLów żyje, reszta `Video not found`. Live URL coverage probed 2026-05-15: ~5% URLów żyje, reszta `Video not found`.
@ -33,10 +33,10 @@ from app.extractors._models import HosterDead, StreamSource
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Match: `getElementById('xlink').innerHTML = "<prefix>" + '' + ('<suffix>').substring(N).substring(M);` # Match: `getElementById('xlink').innerHTML = "<prefix>" + '' + ('<suffix>').substring(N).substring(M);`
# Streamtape generuje 4 assignmenty (ideoolink x2 + botlink + robotlink) — 2 są DECOYs # Streamtape generuje 4 assignmenty (ideoolink x2 + botlink + robotlink), 2 są DECOYs
# z połamanym hostname (`.comb`, `.cob`) i tylko botlink/robotlink dają prawdziwy URL. # z połamanym hostname (`.comb`, `.cob`) i tylko botlink/robotlink dają prawdziwy URL.
# Prefix może być fragmentem: `/streamtape.com`, `//streamtape.co`, `//streamtape.com/g` # Prefix może być fragmentem: `/streamtape.com`, `//streamtape.co`, `//streamtape.com/g`
# — `get_video` często jest split między prefix i suffix po slice'ach. Decyzja na # `get_video` często jest split między prefix i suffix po slice'ach. Decyzja na
# podstawie KOMBINOWANEGO output containing exact `streamtape.com/get_video?`. # podstawie KOMBINOWANEGO output containing exact `streamtape.com/get_video?`.
_ASSIGN_RE = re.compile( _ASSIGN_RE = re.compile(
r"document\.getElementById\(['\"](?P<elem>[a-z]+link)['\"]\)\.innerHTML\s*=\s*" r"document\.getElementById\(['\"](?P<elem>[a-z]+link)['\"]\)\.innerHTML\s*=\s*"
@ -74,7 +74,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
if _NOT_FOUND_RE.search(r.text): if _NOT_FOUND_RE.search(r.text):
raise HosterDead(f"streamtape {page_url}: Video not found") raise HosterDead(f"streamtape {page_url}: Video not found")
# Spróbuj wszystkie 4 assignmenty — pierwszy poprawny URL wygrywa. # Spróbuj wszystkie 4 assignmenty, pierwszy poprawny URL wygrywa.
# `get_video` może być w prefix (residential variant) lub split prefix+suffix # `get_video` może być w prefix (residential variant) lub split prefix+suffix
# (VPS variant gdzie decoy assignmenty produkują `.comb/get_video`). # (VPS variant gdzie decoy assignmenty produkują `.comb/get_video`).
final_url: str | None = None final_url: str | None = None
@ -91,7 +91,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
url = "https:/" + combined # `/streamtape.com/...` → `https://streamtape.com/...` url = "https:/" + combined # `/streamtape.com/...` → `https://streamtape.com/...`
else: else:
url = combined url = combined
# Walidacja — odsiewa decoys (`streamtape.comb`, `streamtape.cob`). # Walidacja, odsiewa decoys (`streamtape.comb`, `streamtape.cob`).
if ( if (
"streamtape.com/get_video?" in url "streamtape.com/get_video?" in url
and "id=" in url and "id=" in url

View file

@ -6,11 +6,11 @@ Pattern (verified 2026-05-15 z VPS Hetzner FI):
window.location.href = 'https://<random>.com/e/<id>' window.location.href = 'https://<random>.com/e/<id>'
Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.) — Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.),
bierzemy LITERAL `Location` z window.location.href assignment. bierzemy LITERAL `Location` z window.location.href assignment.
2. Mirror embed page 137KB zawiera `<script type="application/json">["DROH@$nJjm..."]</script>` 2. Mirror embed page 137KB zawiera `<script type="application/json">["DROH@$nJjm..."]</script>`
— pojedyncza zakodowana string z chunkami 4-char rozdzielonymi 7 distinct To pojedyncza zakodowana string z chunkami 4-char rozdzielonymi 7 distinct
2-char delimiterami: `@$`, `^^`, `~@`, `%?`, `*~`, `!!`, `#&`. 2-char delimiterami: `@$`, `^^`, `~@`, `%?`, `*~`, `!!`, `#&`.
3. Loader `/js/loader.bc4a6543429.js` (83KB obfuscator.io) wczytuje payload przez 3. Loader `/js/loader.bc4a6543429.js` (83KB obfuscator.io) wczytuje payload przez
@ -28,8 +28,8 @@ Pattern (verified 2026-05-15 z VPS Hetzner FI):
4. Decoded JSON ma `source` (HLS m3u8 na `cloudwindow-route.com` z signed token) 4. Decoded JSON ma `source` (HLS m3u8 na `cloudwindow-route.com` z signed token)
+ `fallback: [{file: ".mp4", type: "mp4", label: "720"}]`. URL signed + `fallback: [{file: ".mp4", type: "mp4", label: "720"}]`. URL signed
z `i=<visitor IP first 2 octets>` — z VPS dostajemy `i=46.62` (Hetzner). z `i=<visitor IP first 2 octets>`, z VPS dostajemy `i=46.62` (Hetzner).
Token IP-bound do tych pierwszych 2 oktetów (luźne) — proxy działa. Token IP-bound do tych pierwszych 2 oktetów (luźne), proxy działa.
5. CDN host losowy ale wzorzec stały. Wymaga Referer = voe.sx (lub mirror) bo 5. CDN host losowy ale wzorzec stały. Wymaga Referer = voe.sx (lub mirror) bo
token z `?node=` valida. token z `?node=` valida.
@ -72,7 +72,7 @@ def _decode_payload(payload: str) -> dict | None:
for sep in _MAGIC_SEPS: for sep in _MAGIC_SEPS:
s = s.replace(sep, "_") s = s.replace(sep, "_")
s = s.replace("_", "") s = s.replace("_", "")
# 1st atob — uses latin-1 to preserve all 256 byte values for shift step. # 1st atob, uses latin-1 to preserve all 256 byte values for shift step.
b = base64.b64decode(s + "=" * (-len(s) % 4)).decode("latin-1") b = base64.b64decode(s + "=" * (-len(s) % 4)).decode("latin-1")
shifted = "".join(chr(ord(c) - 3) for c in b) shifted = "".join(chr(ord(c) - 3) for c in b)
reversed_str = shifted[::-1] reversed_str = shifted[::-1]
@ -142,7 +142,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
if isinstance(fallback, dict): if isinstance(fallback, dict):
fallback = [fallback] fallback = [fallback]
# Voe CDN URL ma `i=<2-octet IP prefix>` — token loose-bound do IP range. # Voe CDN URL ma `i=<2-octet IP prefix>`, token loose-bound do IP range.
# Proxy z VPS dostaje signed dla `i=46.62`, fetch działa. # Proxy z VPS dostaje signed dla `i=46.62`, fetch działa.
referer = "https://voe.sx/" referer = "https://voe.sx/"
sources: list[StreamSource] = [] sources: list[StreamSource] = []

View file

@ -1,13 +1,13 @@
"""Wybór najlepszego iframe-hostera z page HTML tube'a typu wrapper. """Wybór najlepszego iframe-hostera z page HTML tube'a typu wrapper.
Tube'y typu siska/perverzija/latestpornvideo nie hostują player'a same — embedują Tube'y typu siska/perverzija/latestpornvideo nie hostują player'a same, embedują
zewnętrznych hosterów (luluvid, doodporn, mixdrop, streamtape, ...). Detail page zewnętrznych hosterów (luluvid, doodporn, mixdrop, streamtape, ...). Detail page
zawiera typowo 2-5 iframes: zawiera typowo 2-5 iframes:
- ad-iframes (willingcease.com, popads, discord, about:blank) - ad-iframes (willingcease.com, popads, discord, about:blank)
- dead hosters (streamtape malware, openload offline) - dead hosters (streamtape malware, openload offline)
- file-hosters (rapidgator, nitroflare — premium walled) - file-hosters (rapidgator, nitroflare, premium walled)
- "fake" hosters typu playmogo (niszowe forki, brak extractora) - "fake" hosters typu playmogo (niszowe forki, brak extractora)
- real KVS hosters (luluvid, doodporn, mixdrop, voe — `extract_stream_from_hoster` - real KVS hosters (luluvid, doodporn, mixdrop, voe, `extract_stream_from_hoster`
radzi sobie z nimi przez KVS markers + yt-dlp generic) radzi sobie z nimi przez KVS markers + yt-dlp generic)
`extract_best_iframe()` filtruje śmieci i preferuje hostery known-working z naszej `extract_best_iframe()` filtruje śmieci i preferuje hostery known-working z naszej
@ -21,9 +21,9 @@ import re
from app.extractors.tubes._embed_iframe import DEAD_HOSTER_RE from app.extractors.tubes._embed_iframe import DEAD_HOSTER_RE
# Ad / popup iframes — popunder networks + Discord widget + about:blank. # Ad / popup iframes, popunder networks + Discord widget + about:blank.
# Niektóre tube'y embedują kilka takich PRZED prawdziwym player'em żeby # Niektóre tube'y embedują kilka takich PRZED prawdziwym player'em żeby
# zmonetyzować ruch — naszego extractora trzeba przeskoczyć żeby trafić na video. # zmonetyzować ruch, naszego extractora trzeba przeskoczyć żeby trafić na video.
_AD_IFRAME_RE = re.compile( _AD_IFRAME_RE = re.compile(
r"willingcease\.com" r"willingcease\.com"
r"|popads|popunder|adsterra" r"|popads|popunder|adsterra"

View file

@ -8,8 +8,8 @@ Mapowanie sitetag → extractor jest w `EXTRACTORS` na końcu. `extract_tags(sit
to public entrypoint. to public entrypoint.
UWAGA: nie zwracamy auto-generated word-ngramów (KVS bywa generuje "Tania", UWAGA: nie zwracamy auto-generated word-ngramów (KVS bywa generuje "Tania",
"Tania Amazon", "Amazon Shaft"... — bez wartości semantycznej). Dlatego pornditt "Tania Amazon", "Amazon Shaft"..., bez wartości semantycznej). Dlatego pornditt
NIE jest tu obsłużone — KVS tagi to garbage. NIE jest tu obsłużone, KVS tagi to garbage.
""" """
from __future__ import annotations from __future__ import annotations
@ -40,7 +40,7 @@ def _dedupe(items: list[str], cap: int = 20) -> list[str]:
def extract_porntrex(html: str) -> list[str]: def extract_porntrex(html: str) -> list[str]:
"""porntrex: <span>Categories:</span><div class="items-holder js-categories"><a class="js-cat" href="/categories/<slug>/">NAME</a>... """porntrex: <span>Categories:</span><div class="items-holder js-categories"><a class="js-cat" href="/categories/<slug>/">NAME</a>...
`Tags:` block na porntrex zawiera auto-generated n-gramy ("Tania", "L H") — pomijamy. `Tags:` block na porntrex zawiera auto-generated n-gramy ("Tania", "L H"), pomijamy.
""" """
m = re.search(r'Categories:</span>\s*<div[^>]+>(.*?)</div>', html, re.DOTALL | re.IGNORECASE) m = re.search(r'Categories:</span>\s*<div[^>]+>(.*?)</div>', html, re.DOTALL | re.IGNORECASE)
if not m: if not m:
@ -60,7 +60,7 @@ def extract_youporn(html: str) -> list[str]:
def extract_xvideos(html: str) -> list[str]: def extract_xvideos(html: str) -> list[str]:
"""xvideos: <a href="/tags/<slug>/">name</a> (slug-style names — przerobimy na Title Case).""" """xvideos: <a href="/tags/<slug>/">name</a> (slug-style names, przerobimy na Title Case)."""
matches = re.findall( matches = re.findall(
r'<a[^>]+href="[^"]*?/tags/([a-z0-9-]+)/?"[^>]*>', r'<a[^>]+href="[^"]*?/tags/([a-z0-9-]+)/?"[^>]*>',
html, re.IGNORECASE, html, re.IGNORECASE,
@ -71,7 +71,7 @@ def extract_xvideos(html: str) -> list[str]:
def extract_xnxx(html: str) -> list[str]: def extract_xnxx(html: str) -> list[str]:
"""xnxx: tagi w JSON `"video_tags":["ass","deep-throat","rimming",...]` wewnątrz """xnxx: tagi w JSON `"video_tags":["ass","deep-throat","rimming",...]` wewnątrz
html5player config (NIE w `/tags/` URL-ach jak xvideos — to są różne systemy html5player config (NIE w `/tags/` URL-ach jak xvideos, to są różne systemy
pomimo tego że oba należą do WGCZ Holding). pomimo tego że oba należą do WGCZ Holding).
""" """
m = re.search(r'"video_tags"\s*:\s*\[([^\]]+)\]', html) m = re.search(r'"video_tags"\s*:\s*\[([^\]]+)\]', html)
@ -181,7 +181,7 @@ def extract_eporner(html: str) -> list[str]:
def extract_hqporner(html: str) -> list[str]: def extract_hqporner(html: str) -> list[str]:
"""hqporner: tagi siedzą w meta description po "Tags related to this video:". """hqporner: tagi siedzą w meta description po "Tags related to this video:".
Detail page jest "thin" (~26KB) bez tag links w body — wszystko jest tylko Detail page jest "thin" (~26KB) bez tag links w body, wszystko jest tylko
w `<meta name="description">` w formie zdania: w `<meta name="description">` w formie zdania:
`Watch porn video <Title>. Video duration is Xmin Ysec. Tags related to `Watch porn video <Title>. Video duration is Xmin Ysec. Tags related to
this video: handjob, babe, for cash, blowjob, black hair.` this video: handjob, babe, for cash, blowjob, black hair.`

View file

@ -10,7 +10,7 @@ jednym z patternów:
`<meta name="twitter:image" content="...">` `<meta name="twitter:image" content="...">`
3. **Schema.org VideoObject LD-JSON**: 3. **Schema.org VideoObject LD-JSON**:
`"thumbnailUrl": "https://..."` lub `"thumbnailUrl": ["url1", "url2"]` `"thumbnailUrl": "https://..."` lub `"thumbnailUrl": ["url1", "url2"]`
4. **html5player** (KVS-based — xnxx/xvideos): 4. **html5player** (KVS-based, xnxx/xvideos):
`html5player.setThumbUrl('https://thumb-cdn77.xnxx-cdn.com/.../t.jpg')` `html5player.setThumbUrl('https://thumb-cdn77.xnxx-cdn.com/.../t.jpg')`
Funkcja zwraca pierwszy znaleziony URL (string), lub None gdy żaden nie pasuje. Funkcja zwraca pierwszy znaleziony URL (string), lub None gdy żaden nie pasuje.
@ -34,7 +34,7 @@ _KVS_THUMB_RE = re.compile(
# hqporner: stare WordPress bez og:image / schema. Poster `_main.jpg` siedzi w # hqporner: stare WordPress bez og:image / schema. Poster `_main.jpg` siedzi w
# atrybucie onclick (`changeImage('//cdn/_main.jpg', 'cover_<id>')`) lub w preload # atrybucie onclick (`changeImage('//cdn/_main.jpg', 'cover_<id>')`) lub w preload
# array (`preload_<id> = ['//.../_1.jpg', ...]`). Wyciągamy `_main.jpg` z CDN-a # array (`preload_<id> = ['//.../_1.jpg', ...]`). Wyciągamy `_main.jpg` z CDN-a
# fastporndelivery.hqporner.com — to canonical poster, frames `_1..N.jpg` to # fastporndelivery.hqporner.com, to canonical poster, frames `_1..N.jpg` to
# hover animation. # hover animation.
_HQPORNER_THUMB_RE = re.compile( _HQPORNER_THUMB_RE = re.compile(
r"['\"](//[a-z0-9.\-]*hqporner[a-z0-9.\-]*/imgs/[^'\"]+_main\.jpg)['\"]", r"['\"](//[a-z0-9.\-]*hqporner[a-z0-9.\-]*/imgs/[^'\"]+_main\.jpg)['\"]",

View file

@ -11,9 +11,9 @@ porndittcom, fpoxxx, hdporn92, sxyland, 0dayxx, ...):
3. Filtruj iframe-y reklamowe (ad domains + `?key=`, `?idzone=` parametry). 3. Filtruj iframe-y reklamowe (ad domains + `?key=`, `?idzone=` parametry).
4. Spróbuj `extract_stream_from_hoster` (P.A.C.K.E.R. unpack JWPlayer) → m3u8/mp4. 4. Spróbuj `extract_stream_from_hoster` (P.A.C.K.E.R. unpack JWPlayer) → m3u8/mp4.
5. Jeśli nie wyciągnęło URL (CAPTCHA, multi-step JS, anti-bot), fallback do 5. Jeśli nie wyciągnęło URL (CAPTCHA, multi-step JS, anti-bot), fallback do
hoster type — mobile otworzy w WebView, hoster JS sam zrobi swoje. hoster type, mobile otworzy w WebView, hoster JS sam zrobi swoje.
`referer` dla hoster fetchu to `https://<page_host>/` — komputujemy automatycznie `referer` dla hoster fetchu to `https://<page_host>/`, komputujemy automatycznie
z `page_url`, więc rejestrujemy ten sam extractor pod wieloma sitetag-ami bez z `page_url`, więc rejestrujemy ten sam extractor pod wieloma sitetag-ami bez
osobnych config'ów. osobnych config'ów.
""" """
@ -30,7 +30,7 @@ from app.extractors.hoster import extract_stream_from_hoster
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Każdy iframe na stronie — szeroki match. Filtrowanie reklamowych zachodzi niżej. # Każdy iframe na stronie, szeroki match. Filtrowanie reklamowych zachodzi niżej.
# Generic TLD `[a-z]{2,8}` żeby pokryć egzotyczne hosting domains (.yt, .ws, .li, # Generic TLD `[a-z]{2,8}` żeby pokryć egzotyczne hosting domains (.yt, .ws, .li,
# .stream, .pro, .cloud, .live). # .stream, .pro, .cloud, .live).
ANY_IFRAME_RE = re.compile( ANY_IFRAME_RE = re.compile(
@ -38,11 +38,11 @@ ANY_IFRAME_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# Path patterns for "this is a player embed" — covers most hoster URL shapes: # Path patterns for "this is a player embed", covers most hoster URL shapes:
# /e/<id>, /embed/<id>, /video/embed/<id> → StreamWish, doodporn, dood.yt, luluvdo # /e/<id>, /embed/<id>, /video/embed/<id> → StreamWish, doodporn, dood.yt, luluvdo
# /embed-<id>(.html)? → xtapes.porn, niektóre stare hosty # /embed-<id>(.html)? → xtapes.porn, niektóre stare hosty
# /player/<id>(.php)?(?...)? → xtremestream.xyz, niektóre custom hosty # /player/<id>(.php)?(?...)? → xtremestream.xyz, niektóre custom hosty
# /v/<id>, /watch/<id> → emergency catch — niektóre tubes # /v/<id>, /watch/<id> → emergency catch, niektóre tubes
PLAYER_PATH_RE = re.compile( PLAYER_PATH_RE = re.compile(
r'/(?:e|embed|video/embed|v|watch|t|f|d|stream)/[a-zA-Z0-9_\-]+' r'/(?:e|embed|video/embed|v|watch|t|f|d|stream)/[a-zA-Z0-9_\-]+'
r'|/embed-[a-zA-Z0-9_\-]+' r'|/embed-[a-zA-Z0-9_\-]+'
@ -51,11 +51,11 @@ PLAYER_PATH_RE = re.compile(
) )
# Bare-slug player path (sdefx.cloud, niektóre playmogo): single path segment after host, # Bare-slug player path (sdefx.cloud, niektóre playmogo): single path segment after host,
# alphanumeric + dash. Żadnego `/folder/file` ani query stringa — to wykrycie hosterów # alphanumeric + dash. Żadnego `/folder/file` ani query stringa, to wykrycie hosterów
# które nie używają standardowego `/e/<id>` patternu. # które nie używają standardowego `/e/<id>` patternu.
BARE_SLUG_PATH_RE = re.compile(r'^/[a-zA-Z0-9_\-]{4,}/?$') BARE_SLUG_PATH_RE = re.compile(r'^/[a-zA-Z0-9_\-]{4,}/?$')
# Ad iframes — domeny reklam + parametry (idzone, key=) — odsiewamy nawet jeśli mają # Ad iframes, domeny reklam + parametry (idzone, key=), odsiewamy nawet jeśli mają
# inne path patterns. Lista ze stron które rzeczywiście aggregator tubes serwują. # inne path patterns. Lista ze stron które rzeczywiście aggregator tubes serwują.
AD_DOMAIN_RE = re.compile( AD_DOMAIN_RE = re.compile(
r'(?:willingcease|propellerads|popads|popcash|trafficstars|exoclick|adsterra|' r'(?:willingcease|propellerads|popads|popcash|trafficstars|exoclick|adsterra|'
@ -70,9 +70,9 @@ AD_DOMAIN_RE = re.compile(
) )
AD_QUERY_RE = re.compile(r'[?&](?:idzone|adkey|key|aff_id|adsrc)=', re.IGNORECASE) AD_QUERY_RE = re.compile(r'[?&](?:idzone|adkey|key|aff_id|adsrc)=', re.IGNORECASE)
# Direct stream URLs na page — niektóre aggregator tubes (porn4days, xmoviesforyou) # Direct stream URLs na page, niektóre aggregator tubes (porn4days, xmoviesforyou)
# wstawiają full mp4/m3u8 URL bezpośrednio w HTML/JS (download link, video.js source, # wstawiają full mp4/m3u8 URL bezpośrednio w HTML/JS (download link, video.js source,
# JWPlayer config). Skanujemy je przed iframe processing — direct URL > iframe wymagający # JWPlayer config). Skanujemy je przed iframe processing, direct URL > iframe wymagający
# WebView. Wymagamy że path zawiera quality marker (`<digits>p.mp4`) lub `.m3u8` żeby # WebView. Wymagamy że path zawiera quality marker (`<digits>p.mp4`) lub `.m3u8` żeby
# uniknąć false-positives z thumbnail/preview generation endpoints. # uniknąć false-positives z thumbnail/preview generation endpoints.
_DIRECT_MP4_RE = re.compile( _DIRECT_MP4_RE = re.compile(
@ -87,10 +87,10 @@ _DIRECT_M3U8_RE = re.compile(
# Hosty którym `_DIRECT_*_RE` regex matchuje (URL kończy się .mp4 z quality # Hosty którym `_DIRECT_*_RE` regex matchuje (URL kończy się .mp4 z quality
# marker w path) ALE które NIE serwują direct stream: # marker w path) ALE które NIE serwują direct stream:
# 1. File hosters (rapidgator/k2s/mediafire/...) — file storage, premium auth gate # 1. File hosters (rapidgator/k2s/mediafire/...), file storage, premium auth gate
# Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4` — to download URL, # Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4`, to download URL,
# mobile dostaje login page (HTML), nie video. # mobile dostaje login page (HTML), nie video.
# 2. Embed page'y z mp4-suffix path (playmogo/dood/filemoon /d/<id>/<filename>.mp4) — # 2. Embed page'y z mp4-suffix path (playmogo/dood/filemoon /d/<id>/<filename>.mp4),
# faktycznie HTML embed page, CAPTCHA-walled. # faktycznie HTML embed page, CAPTCHA-walled.
# Bez filter Stage 0.5 zwracał tę URL jako type=mp4 i ExoPlayer dostawał HTML zamiast # Bez filter Stage 0.5 zwracał tę URL jako type=mp4 i ExoPlayer dostawał HTML zamiast
# video → black screen / playback failed. # video → black screen / playback failed.
@ -106,11 +106,11 @@ _NOT_DIRECT_STREAM_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# JS-defined server URLs — niektóre aggregator tubes (porn4days) trzymają backup # JS-defined server URLs, niektóre aggregator tubes (porn4days) trzymają backup
# hosterów w JavaScript variables, a iframe renderuje tylko pierwszy. Reszta jest # hosterów w JavaScript variables, a iframe renderuje tylko pierwszy. Reszta jest
# dostępna przez clickable "Server 2"/"Server 3" buttons które JS-em swapują src # dostępna przez clickable "Server 2"/"Server 3" buttons które JS-em swapują src
# iframe'a. Pattern: `const SERVER<N>_URL = "https://..."` lub `var server<N> =`. # iframe'a. Pattern: `const SERVER<N>_URL = "https://..."` lub `var server<N> =`.
# Dla nas to są dodatkowe iframe URLs które trzeba zebrać jak inne — extract_stream_from_hoster # Dla nas to są dodatkowe iframe URLs które trzeba zebrać jak inne, extract_stream_from_hoster
# spróbuje je rozpakować, a fallback to type=hoster dla mobile WebView. # spróbuje je rozpakować, a fallback to type=hoster dla mobile WebView.
_JS_SERVER_URL_RE = re.compile( _JS_SERVER_URL_RE = re.compile(
r'(?:const|let|var)?\s*(?:SERVER\d+_URL|server\d+(?:_url)?|src\d+|stream_?\d+|video_?\d+)\s*=\s*' r'(?:const|let|var)?\s*(?:SERVER\d+_URL|server\d+(?:_url)?|src\d+|stream_?\d+|video_?\d+)\s*=\s*'
@ -118,9 +118,9 @@ _JS_SERVER_URL_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# Anchor-href hoster links — xmoviesforyou pattern: scene page nie ma <iframe> playera, # Anchor-href hoster links, xmoviesforyou pattern: scene page nie ma <iframe> playera,
# tylko serię `<a href="https://playmogo.com/d/...">MIXDROP</a>` download buttons. # tylko serię `<a href="https://playmogo.com/d/...">MIXDROP</a>` download buttons.
# Match na anchor wskazujący na typowe hoster domeny — daje fallback gdy brak iframe. # Match na anchor wskazujący na typowe hoster domeny, daje fallback gdy brak iframe.
_ANCHOR_HOSTER_RE = re.compile( _ANCHOR_HOSTER_RE = re.compile(
r'<a\s+[^>]*href=["\'](?P<url>https?://(?:' r'<a\s+[^>]*href=["\'](?P<url>https?://(?:'
r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|' r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|'
@ -131,10 +131,10 @@ _ANCHOR_HOSTER_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# Iframe src ukryty w escape'owanym JS string literal — porndish pattern: # Iframe src ukryty w escape'owanym JS string literal, porndish pattern:
# `const doodstreamContent = "<iframe ... src=\"https://playmogo.com/e/xyz\" ...></iframe>";` # `const doodstreamContent = "<iframe ... src=\"https://playmogo.com/e/xyz\" ...></iframe>";`
# Server-side widzimy tylko pusty `<div id="iframeHolder">` (iframe wstrzykuje się JS-em # Server-side widzimy tylko pusty `<div id="iframeHolder">` (iframe wstrzykuje się JS-em
# po kliknięciu "Video Player 1" button). Match na `src=\"<url>\"` w raw HTML source — # po kliknięciu "Video Player 1" button). Match na `src=\"<url>\"` w raw HTML source,
# `\"` to backslash-quote z JSON-style escape'owania, `\/` to backslash-slash. # `\"` to backslash-quote z JSON-style escape'owania, `\/` to backslash-slash.
# Po match'u czyścimy `\/` → `/` w captured URL. # Po match'u czyścimy `\/` → `/` w captured URL.
_JS_ESCAPED_IFRAME_SRC_RE = re.compile( _JS_ESCAPED_IFRAME_SRC_RE = re.compile(
@ -151,18 +151,18 @@ _DATA_EMBED_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# Blacklista hosterów — wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie: # Blacklista hosterów, wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie:
# #
# 1) Dead — w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran. # 1) Dead, w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran.
# xtapes.porn → 301 → camcaps.to → 403 (zmigrowane 2026-04, infrastruktura zniknęła). # xtapes.porn → 301 → camcaps.to → 403 (zmigrowane 2026-04, infrastruktura zniknęła).
# #
# 2) Malware — działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders # 2) Malware, działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders
# przez chain reklamowy. yt-dlp odmawia ich ekstrakcji (oznaczone jako piracy), więc # przez chain reklamowy. yt-dlp odmawia ich ekstrakcji (oznaczone jako piracy), więc
# nie ma mode'u "direct mp4 do ExoPlayera bez WebView" — jedyna opcja to WebView, # nie ma mode'u "direct mp4 do ExoPlayera bez WebView", jedyna opcja to WebView,
# a WebView pozwala JS-owi hostera ściągnąć user execu plik. Bezpieczniej wyciąć je # a WebView pozwala JS-owi hostera ściągnąć user execu plik. Bezpieczniej wyciąć je
# całkowicie i pokazać user'owi page_url aggregator tube'a (rzadziej malicious niż # całkowicie i pokazać user'owi page_url aggregator tube'a (rzadziej malicious niż
# sam hoster). User nadal może tam wybrać alt player jeśli istnieje. # sam hoster). User nadal może tam wybrać alt player jeśli istnieje.
# streamtape.com — notorious dla drive-by .reg downloads (zgłoszone 2026-05-07 # streamtape.com, notorious dla drive-by .reg downloads (zgłoszone 2026-05-07
# porn4days.pw → streamtape.com/e/<id> → popup + ściąganie .reg). # porn4days.pw → streamtape.com/e/<id> → popup + ściąganie .reg).
# Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html` # Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html`
# w path nie matchował, tylko prawdziwy hostname (`https://filemoon.to/...` lub # w path nie matchował, tylko prawdziwy hostname (`https://filemoon.to/...` lub
@ -177,7 +177,7 @@ DEAD_HOSTER_RE = re.compile(
r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware
r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors
r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019) r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019)
# filemoon — NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend" # filemoon, NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend"
# zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie # zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie
# za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22: # za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22:
# POST /api/videos/<code>/embed/playback {"fingerprint":{}} → AES-256-GCM → # POST /api/videos/<code>/embed/playback {"fingerprint":{}} → AES-256-GCM →
@ -188,12 +188,12 @@ DEAD_HOSTER_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# CAPTCHA-walled hosterzy — DoodStream variants serwują 5KB Cloudflare Turnstile # CAPTCHA-walled hosterzy, DoodStream variants serwują 5KB Cloudflare Turnstile
# challenge response na server-side requests z VPS IP. Na phone (T-Mobile/PLAY) # challenge response na server-side requests z VPS IP. Na phone (T-Mobile/PLAY)
# czasem przechodzi, mobile-side resolver (mobile/src/lib/doodstream.ts) ma szansę. # czasem przechodzi, mobile-side resolver (mobile/src/lib/doodstream.ts) ma szansę.
# ALE: gdy scene ma alt-hostera (np. luluvid, filemoon), tamten zazwyczaj nie ma # ALE: gdy scene ma alt-hostera (np. luluvid, filemoon), tamten zazwyczaj nie ma
# CAPTCHA gate → ExoPlayer odpala bezpośrednio. Sortujemy więc DoodStream NA KONIEC # CAPTCHA gate → ExoPlayer odpala bezpośrednio. Sortujemy więc DoodStream NA KONIEC
# listy — Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują # listy, Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują
# najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts. # najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts.
CAPTCHA_HOSTER_RE = re.compile( CAPTCHA_HOSTER_RE = re.compile(
r'(?:' r'(?:'
@ -203,7 +203,7 @@ CAPTCHA_HOSTER_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# IP-BOUND CDN URLs — stream URLs które bindują się do requester IP (VPS resolve # IP-BOUND CDN URLs, stream URLs które bindują się do requester IP (VPS resolve
# → mobile 403). Stage 1 server-side ekstrakta `extract_stream_from_hoster` zwraca # → mobile 403). Stage 1 server-side ekstrakta `extract_stream_from_hoster` zwraca
# tę URL, ale mobile direct nie pobierze. Lepiej dropować mp4/m3u8 wynik i upaść # tę URL, ale mobile direct nie pobierze. Lepiej dropować mp4/m3u8 wynik i upaść
# na hoster fallback (mobile WebView wyciągnie URL z phone IP/session). # na hoster fallback (mobile WebView wyciągnie URL z phone IP/session).
@ -215,14 +215,14 @@ _IP_BOUND_CDN_RE = re.compile(
r"premilkyway\.com" # latestpornvideo r"premilkyway\.com" # latestpornvideo
r"|tnmr\.org" # mypornerleak (legacy CDN) r"|tnmr\.org" # mypornerleak (legacy CDN)
r"|acek-cdn\.com" # mypornerleak (current CDN, shared KVS infra) r"|acek-cdn\.com" # mypornerleak (current CDN, shared KVS infra)
# xtremestream.xyz (perverzija) — `player/xs1.php?data=` to NIE direct mp4 tylko # xtremestream.xyz (perverzija), `player/xs1.php?data=` to NIE direct mp4 tylko
# IP-bound player endpoint (403 cross-IP z VPS). Stage 1 zwracał go jako type=mp4 # IP-bound player endpoint (403 cross-IP z VPS). Stage 1 zwracał go jako type=mp4
# → natywny player ładował HTML-player w nieskończoność (reports 06-24 "perverzija # → natywny player ładował HTML-player w nieskończoność (reports 06-24 "perverzija
# nie działa"/"loading w nieskończoność"). Skip → hoster fallback: WebView ładuje # nie działa"/"loading w nieskończoność"). Skip → hoster fallback: WebView ładuje
# index.php playera z residential IP telefonu, xs1.php gra w jego sesji. # index.php playera z residential IP telefonu, xs1.php gra w jego sesji.
r"|xtremestream\.[a-z]{2,8}" r"|xtremestream\.[a-z]{2,8}"
# URL signature shared across these CDNs: `/hls2/<XX>/<scene_id>/.../master.m3u8?t=<token>&s=<ts>&e=<exp>&srv=<srv>&asn=` # URL signature shared across these CDNs: `/hls2/<XX>/<scene_id>/.../master.m3u8?t=<token>&s=<ts>&e=<exp>&srv=<srv>&asn=`
# — `asn` query param = Autonomous System Number bind. Generic match jako safety net. # `asn` query param = Autonomous System Number bind. Generic match jako safety net.
r")\b", r")\b",
re.IGNORECASE, re.IGNORECASE,
) )
@ -243,10 +243,10 @@ def _is_player_iframe(url: str) -> bool:
return False return False
if DEAD_HOSTER_RE.search(url): if DEAD_HOSTER_RE.search(url):
return False return False
# Player path — `/e/`, `/embed/`, `/video/embed/` # Player path, `/e/`, `/embed/`, `/video/embed/`
if PLAYER_PATH_RE.search(url): if PLAYER_PATH_RE.search(url):
return True return True
# Bare slug — `<host>/<id>` (no folder, no query) — sdefx.cloud-style. # Bare slug, `<host>/<id>` (no folder, no query), sdefx.cloud-style.
parsed = urlparse(url if url.startswith("http") else "https:" + url.lstrip("/")) parsed = urlparse(url if url.startswith("http") else "https:" + url.lstrip("/"))
if BARE_SLUG_PATH_RE.match(parsed.path) and not parsed.query: if BARE_SLUG_PATH_RE.match(parsed.path) and not parsed.query:
return True return True
@ -279,13 +279,13 @@ def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSou
seen.add(url) seen.add(url)
if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url): if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url):
continue continue
# Filter pseudo-direct URLs: file hosters (rapidgator/k2s — premium auth gate) # Filter pseudo-direct URLs: file hosters (rapidgator/k2s, premium auth gate)
# i embed pages z .mp4 suffix (playmogo/dood /d/ — to HTML, nie video). # i embed pages z .mp4 suffix (playmogo/dood /d/ to HTML, nie video).
if _NOT_DIRECT_STREAM_RE.search(url): if _NOT_DIRECT_STREAM_RE.search(url):
continue continue
# KVS false-positives (report c156e4b7/8db71220, watchporn przez _embed_iframe): # KVS false-positives (report c156e4b7/8db71220, watchporn przez _embed_iframe):
# preview_/videos_screenshots to krótkie trailery (404), a surowy get_file/ bez # preview_/videos_screenshots to krótkie trailery (404), a surowy get_file/ bez
# podpisanego query-tokena daje 403 — kt_player dokłada token z license_code (patrz # podpisanego query-tokena daje 403, kt_player dokłada token z license_code (patrz
# natywny watchporn.extract). Żadne z nich to pełne wideo → pomijamy. # natywny watchporn.extract). Żadne z nich to pełne wideo → pomijamy.
low = url.lower() low = url.lower()
if "videos_screenshots/" in low or "/preview_" in low: if "videos_screenshots/" in low or "/preview_" in low:
@ -328,7 +328,7 @@ def extract(
# Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou # Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou
# native players). Wymagamy quality marker w path (`<N>p.mp4`) żeby uniknąć # native players). Wymagamy quality marker w path (`<N>p.mp4`) żeby uniknąć
# thumbnail-preview false-positives. Jeśli znaleziono → zwracamy, skipping iframe # thumbnail-preview false-positives. Jeśli znaleziono → zwracamy, skipping iframe
# processing — direct stream URL > WebView fallback. # processing, direct stream URL > WebView fallback.
direct_sources = _extract_direct_stream_urls(page_html, page_url) direct_sources = _extract_direct_stream_urls(page_html, page_url)
if direct_sources: if direct_sources:
log.info("embed_iframe: found %d direct stream URLs on %s", log.info("embed_iframe: found %d direct stream URLs on %s",
@ -336,7 +336,7 @@ def extract(
return direct_sources return direct_sources
# Znajdź WSZYSTKIE iframe-y które wyglądają na player. Wcześniej braliśmy tylko # Znajdź WSZYSTKIE iframe-y które wyglądają na player. Wcześniej braliśmy tylko
# pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie — # pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie,
# gdy pierwszy ma CF challenge / ad-heavy player (playmogo), drugi (luluvid) # gdy pierwszy ma CF challenge / ad-heavy player (playmogo), drugi (luluvid)
# może być cleaner. Dedupe po URL żeby nie dublować tego samego playera. # może być cleaner. Dedupe po URL żeby nie dublować tego samego playera.
# Trzymamy też raw_iframes (pre-filter) żeby odróżnić "page nie ma iframe-a" od # Trzymamy też raw_iframes (pre-filter) żeby odróżnić "page nie ma iframe-a" od
@ -364,7 +364,7 @@ def extract(
# JS-hidden backup servers (porn4days SERVER<N>_URL pattern). Niektóre tube'y # JS-hidden backup servers (porn4days SERVER<N>_URL pattern). Niektóre tube'y
# renderują tylko 1 iframe a backup hosterów trzymają w `const SERVER2_URL = "..."` # renderują tylko 1 iframe a backup hosterów trzymają w `const SERVER2_URL = "..."`
# JS variable + clickable "Server 2" button. Bez tego ekstraktor widzi tylko # JS variable + clickable "Server 2" button. Bez tego ekstraktor widzi tylko
# SERVER1 (najczęściej iceyfile/streamtape) — gdy ten 404 / malware-blocked, # SERVER1 (najczęściej iceyfile/streamtape), gdy ten 404 / malware-blocked,
# cały scrape ginie. Backupy mogą być na czystych hosterach (turbovidhls/veev.to). # cały scrape ginie. Backupy mogą być na czystych hosterach (turbovidhls/veev.to).
for m in _JS_SERVER_URL_RE.finditer(page_html): for m in _JS_SERVER_URL_RE.finditer(page_html):
candidate = m.group("url").strip() candidate = m.group("url").strip()
@ -416,7 +416,7 @@ def extract(
iframe_urls.append(candidate) iframe_urls.append(candidate)
# Escape'owane iframe src w JS string literals (porndish pattern). Iframe wstrzykuje # Escape'owane iframe src w JS string literals (porndish pattern). Iframe wstrzykuje
# się do DOM po kliknięciu user'a — server-side HTML widzimy tylko `<div id="iframeHolder">`, # się do DOM po kliknięciu user'a, server-side HTML widzimy tylko `<div id="iframeHolder">`,
# a iframe URL jest w `const doodstreamContent = "<iframe ... src=\"https:\/\/...\" ...>"`. # a iframe URL jest w `const doodstreamContent = "<iframe ... src=\"https:\/\/...\" ...>"`.
# Po match'u czyścimy backslash escape: `\/` → `/`, `\\\"` → `"` (jeśli) # Po match'u czyścimy backslash escape: `\/` → `/`, `\\\"` → `"` (jeśli)
for m in _JS_ESCAPED_IFRAME_SRC_RE.finditer(page_html): for m in _JS_ESCAPED_IFRAME_SRC_RE.finditer(page_html):
@ -435,17 +435,17 @@ def extract(
iframe_urls.append(candidate) iframe_urls.append(candidate)
# Stable-sort: CAPTCHA-walled hosty (DoodStream variants) NA KONIEC. Zachowujemy # Stable-sort: CAPTCHA-walled hosty (DoodStream variants) NA KONIEC. Zachowujemy
# DOM order wśród non-CAPTCHA — preferred player tube'a (zazwyczaj pierwszy) zostaje. # DOM order wśród non-CAPTCHA, preferred player tube'a (zazwyczaj pierwszy) zostaje.
# Powód: siska/xmoviesforyou wstawiają playmogo (dood) jako pierwszy iframe, luluvid # Powód: siska/xmoviesforyou wstawiają playmogo (dood) jako pierwszy iframe, luluvid
# jako drugi — bez sortu mobile bierze playmogo → CAPTCHA → czarny ekran. Z sortem # jako drugi, bez sortu mobile bierze playmogo → CAPTCHA → czarny ekran. Z sortem
# luluvid (lub inny clean hoster) ląduje na pozycji 1, ExoPlayer ma szansę. # luluvid (lub inny clean hoster) ląduje na pozycji 1, ExoPlayer ma szansę.
iframe_urls.sort(key=_hoster_priority) iframe_urls.sort(key=_hoster_priority)
if not iframe_urls: if not iframe_urls:
# Wszystkie iframe-y na stronie zostały odsiane (DEAD_HOSTER_RE — np. streamtape, # Wszystkie iframe-y na stronie zostały odsiane (DEAD_HOSTER_RE, np. streamtape,
# xtapes.porn). Page-as-hoster nie pomoże bo WebView na aggregator page i tak # xtapes.porn). Page-as-hoster nie pomoże bo WebView na aggregator page i tak
# załaduje ten sam (zablacklistowany) hoster. Lepiej zwrócić None żeby mobile # załaduje ten sam (zablacklistowany) hoster. Lepiej zwrócić None żeby mobile
# dostał 501 i pokazał error — user wybierze inną źródło, nie dostanie malware. # dostał 501 i pokazał error, user wybierze inną źródło, nie dostanie malware.
if raw_iframes_count > 0: if raw_iframes_count > 0:
log.info( log.info(
"embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None", "embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None",
@ -454,7 +454,7 @@ def extract(
return None return None
# Brak iframe-ów w ogóle (JS-only render np. mypornerleak's muliframe.js, # Brak iframe-ów w ogóle (JS-only render np. mypornerleak's muliframe.js,
# CloudFlare JS challenge na xxxfree.watch, login-walled tubes). Page-as-hoster # CloudFlare JS challenge na xxxfree.watch, login-walled tubes). Page-as-hoster
# ma sens — WebView przejdzie CF challenge, wyrenderuje JS, da user'owi player. # ma sens, WebView przejdzie CF challenge, wyrenderuje JS, da user'owi player.
log.info("embed_iframe: no iframes in %s — page-as-hoster fallback", page_url) log.info("embed_iframe: no iframes in %s — page-as-hoster fallback", page_url)
page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page" page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
return [StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")] return [StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")]
@ -464,9 +464,9 @@ def extract(
# Stage 0: yt-dlp generic na PAGE URL (nie iframe). Niektóre tube'y (pornditt, # Stage 0: yt-dlp generic na PAGE URL (nie iframe). Niektóre tube'y (pornditt,
# latestleaks) mają KVS player config (`kt_player(`, `license_code:`) wprost na # latestleaks) mają KVS player config (`kt_player(`, `license_code:`) wprost na
# scene page'u — embed iframe to czysta CSS shell. yt-dlp generic potrafi # scene page'u, embed iframe to czysta CSS shell. yt-dlp generic potrafi
# zdeszyfrować KVS URL z page'a, więc wolimy to niż WebView fallback. # zdeszyfrować KVS URL z page'a, więc wolimy to niż WebView fallback.
# Referer = pełny page URL (nie host root) — KVS get_file/ URL jest signed # Referer = pełny page URL (nie host root), KVS get_file/ URL jest signed
# względem konkretnej page'a, host root daje 410. # względem konkretnej page'a, host root daje 410.
if all(marker in page_html for marker in ("kt_player(", "license_code")): if all(marker in page_html for marker in ("kt_player(", "license_code")):
from app.extractors.hoster import _try_ytdlp_hoster from app.extractors.hoster import _try_ytdlp_hoster
@ -477,8 +477,8 @@ def extract(
# Stage 1: spróbuj wyekstraktować direct video URL z każdego iframe'a po kolei. # Stage 1: spróbuj wyekstraktować direct video URL z każdego iframe'a po kolei.
# Direct mp4/m3u8 idzie PIERWSZY w wyniku (ExoPlayer natywnie >> WebView), ale # Direct mp4/m3u8 idzie PIERWSZY w wyniku (ExoPlayer natywnie >> WebView), ale
# NIE pomijamy reszty iframe — dodajemy je jako hoster fallback. Powód: niektóre # NIE pomijamy reszty iframe, dodajemy je jako hoster fallback. Powód: niektóre
# CDN-y file storage (vidnest.live, iceyfile) blokują Hetzner ASN — Stage 1 wyciąga # CDN-y file storage (vidnest.live, iceyfile) blokują Hetzner ASN, Stage 1 wyciąga
# URL z embed page, ale ani VPS proxy ani mobile go nie pobierze (No route to host # URL z embed page, ale ani VPS proxy ani mobile go nie pobierze (No route to host
# albo 403). Bez iframe fallbacku mobile dostaje 503 i utyka. Z fallbackiem chain: # albo 403). Bez iframe fallbacku mobile dostaje 503 i utyka. Z fallbackiem chain:
# direct → proxy → iframe1 (WebView) → iframe2 (WebView) → page (WebView). # direct → proxy → iframe1 (WebView) → iframe2 (WebView) → page (WebView).
@ -491,7 +491,7 @@ def extract(
log.warning("embed_iframe: extract failed for %s: %s", iframe_url, e) log.warning("embed_iframe: extract failed for %s: %s", iframe_url, e)
continue continue
if stream_url: if stream_url:
# IP-bound CDN check — skip Stage 1 result, force fallback na hoster # IP-bound CDN check, skip Stage 1 result, force fallback na hoster
# (mobile WebView pobiera embed page z phone IP). Critical dla public # (mobile WebView pobiera embed page z phone IP). Critical dla public
# release: premilkyway/tnmr.org bind token do requester IP. # release: premilkyway/tnmr.org bind token do requester IP.
if _IP_BOUND_CDN_RE.search(stream_url): if _IP_BOUND_CDN_RE.search(stream_url):
@ -516,13 +516,13 @@ def extract(
sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")) sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)"))
return sources return sources
# Stage 2: wszystkie iframe-y zwróć jako hoster sources — mobile dostaje listę # Stage 2: wszystkie iframe-y zwróć jako hoster sources, mobile dostaje listę
# alternatyw, użytkownik może switchować gdy pierwszy ma overlay/CF challenge. # alternatyw, użytkownik może switchować gdy pierwszy ma overlay/CF challenge.
# Pierwszy w liście ma być najczystszy — ale bez wiedzy a priori które są ad-heavy # Pierwszy w liście ma być najczystszy, ale bez wiedzy a priori które są ad-heavy
# zostawiamy kolejność DOM (zwykle authoring tube wstawia preferred player jako # zostawiamy kolejność DOM (zwykle authoring tube wstawia preferred player jako
# pierwszy, a drugi jako backup). Quality ustawiamy na nazwę hosta iframe'a żeby # pierwszy, a drugi jako backup). Quality ustawiamy na nazwę hosta iframe'a żeby
# PlaybackQualityModal pokazał użytkownikowi rozróżnialne opcje (np. "playmogo" # PlaybackQualityModal pokazał użytkownikowi rozróżnialne opcje (np. "playmogo"
# vs "luluvid"). Na końcu page_url jako safety-net — gdy wszystkie iframe'y są # vs "luluvid"). Na końcu page_url jako safety-net, gdy wszystkie iframe'y są
# martwe (np. sxyland → xtapes.porn 301→camcaps.to 403), mobile WebView otworzy # martwe (np. sxyland → xtapes.porn 301→camcaps.to 403), mobile WebView otworzy
# główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player. # główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player.
sources = [] sources = []

View file

@ -1,14 +1,14 @@
"""Współdzielony resolver dla KVS (kt_player) tube'ów z `function/0/` + `license_code`. """Współdzielony resolver dla KVS (kt_player) tube'ów z `function/0/` + `license_code`.
Wzorzec (yespornvip, pornditt, ... — wszystkie KVS na tym samym silniku): Wzorzec (yespornvip, pornditt, ..., wszystkie KVS na tym samym silniku):
flashvars: `video_url` / `video_alt_url` / `video_alt_url2` = `function/0/https://<host>/ flashvars: `video_url` / `video_alt_url` / `video_alt_url2` = `function/0/https://<host>/
get_file/<srv>/<HASH>/.../<id>.mp4/` (480/720/1080p) + `license_code: '$...'`. get_file/<srv>/<HASH>/.../<id>.mp4/` (480/720/1080p) + `license_code: '$...'`.
kt_player dekoduje HASH (permutacja pierwszych 32 znaków algorytmem license_code — kt_player dekoduje HASH (permutacja pierwszych 32 znaków algorytmem license_code,
algo zgodny z yt-dlp KVS `_kvs_get_real_url`, zweryfikowany 2026-05-31 że odtwarza algo zgodny z yt-dlp KVS `_kvs_get_real_url`, zweryfikowany 2026-05-31 że odtwarza
output kt_player). output kt_player).
Zdekodowany `get_file` 302-redirectuje do CDN (cdn5.yesporn.vip / twa.tgprn.com / ...) Zdekodowany `get_file` 302-redirectuje do CDN (cdn5.yesporn.vip / twa.tgprn.com / ...)
który serwuje wideo (206). Finalny URL jest **time-bound signed, NIE IP/cookie-bound** — który serwuje wideo (206). Finalny URL jest **time-bound signed, NIE IP/cookie-bound**,
portable cross-IP. get_file token jest session-bound, więc 302 MUSI być rozwiązany w tej portable cross-IP. get_file token jest session-bound, więc 302 MUSI być rozwiązany w tej
samej sesji curl_cffi co fetch strony. Oddajemy finalny portable CDN url per jakość → samej sesji curl_cffi co fetch strony. Oddajemy finalny portable CDN url per jakość →
mobile gra direct natywnie, multi-quality, zero WebView/reklam/proxy. mobile gra direct natywnie, multi-quality, zero WebView/reklam/proxy.
@ -29,9 +29,9 @@ from app.extractors._models import StreamSource
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Łapie OBA formaty flashvars: # Łapie OBA formaty flashvars:
# - `function/0/https://.../get_file/<HASH>/...` (yespornvip/pornditt/freshporno — # - `function/0/https://.../get_file/<HASH>/...` (yespornvip/pornditt/freshporno,
# zakodowany, dekodowany przez real_url) # zakodowany, dekodowany przez real_url)
# - `https://.../get_file/...` (porn00 — już zdekodowany plain get_file; real_url # - `https://.../get_file/...` (porn00, już zdekodowany plain get_file; real_url
# przepuszcza go bez zmian) # przepuszcza go bez zmian)
# W obu przypadkach finał to get_file → 302 → CDN (follow in-session). # W obu przypadkach finał to get_file → 302 → CDN (follow in-session).
_URL_RE = re.compile( _URL_RE = re.compile(
@ -49,7 +49,7 @@ _HASH_LENGTH = 32
# get_file 302-follow timeout. Zdrowy CDN odpowiada <1s; gdy wideo zostało usunięte # get_file 302-follow timeout. Zdrowy CDN odpowiada <1s; gdy wideo zostało usunięte
# z CDN (strona istnieje, ale get_file stalluje) request wisi do timeoutu. Trzymamy # z CDN (strona istnieje, ale get_file stalluje) request wisi do timeoutu. Trzymamy
# go NISKO i osobno od page-fetch timeoutu — bug 6ec1960e 2026-06-02: yesporn.vip # go NISKO i osobno od page-fetch timeoutu, bug 6ec1960e 2026-06-02: yesporn.vip
# przeszedł na cdn4/remote_control.php i dla martwych scen wszystkie 3 jakości # przeszedł na cdn4/remote_control.php i dla martwych scen wszystkie 3 jakości
# wisiały po 60s = 180s → mobile "resolving w nieskończoność". # wisiały po 60s = 180s → mobile "resolving w nieskończoność".
_GETFILE_TIMEOUT = 10.0 _GETFILE_TIMEOUT = 10.0
@ -117,7 +117,7 @@ def resolve_kvs(page_url: str, *, base_url: str, timeout: float = 60.0) -> list[
"""Fetch KVS page → decode function/0 get_file (per jakość) → follow 302 → portable CDN. """Fetch KVS page → decode function/0 get_file (per jakość) → follow 302 → portable CDN.
Zwraca StreamSource'y posortowane malejąco po jakości, lub None gdy nic nie wyszło. Zwraca StreamSource'y posortowane malejąco po jakości, lub None gdy nic nie wyszło.
base_url: scheme+host hosta (np. 'https://yesporn.vip') — do Referera i logów. base_url: scheme+host hosta (np. 'https://yesporn.vip'), do Referera i logów.
""" """
if not _HAS_CURL_CFFI: if not _HAS_CURL_CFFI:
log.info("kvs: curl_cffi unavailable — cannot resolve %s", page_url) log.info("kvs: curl_cffi unavailable — cannot resolve %s", page_url)
@ -150,7 +150,7 @@ def resolve_kvs(page_url: str, *, base_url: str, timeout: float = 60.0) -> list[
# get_file timeout NISKI (osobny od page-fetch) + early-break: gdy 2 pierwsze # get_file timeout NISKI (osobny od page-fetch) + early-break: gdy 2 pierwsze
# jakości nie rozwiążą się (przy 0 dotychczasowych wyników) scena jest martwa na # jakości nie rozwiążą się (przy 0 dotychczasowych wyników) scena jest martwa na
# CDN — nie ma sensu czekać na trzecią (oszczędza kolejne _GETFILE_TIMEOUT). # CDN, nie ma sensu czekać na trzecią (oszczędza kolejne _GETFILE_TIMEOUT).
gf_timeout = min(timeout, _GETFILE_TIMEOUT) gf_timeout = min(timeout, _GETFILE_TIMEOUT)
seen_dec: set[str] = set() seen_dec: set[str] = set()
result: list[StreamSource] = [] result: list[StreamSource] = []

Some files were not shown because too many files have changed in this diff Show more