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:
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła.
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:
for k in ("image", "poster"):
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"):
return v
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.
paths = raw.get("paths")
if isinstance(paths, dict):
@ -142,7 +142,7 @@ def list_candidates(
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
# 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.
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:
"""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.
"""
from app.api.stream_proxy import get_bandwidth_stats
@ -308,7 +308,7 @@ def bandwidth_stats() -> BandwidthStats:
s_24h = get_bandwidth_stats(24)
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
settings = get_settings()
if settings.hetzner_api_token and settings.hetzner_server_id:

View file

@ -1,9 +1,9 @@
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
Endpointy:
GET /ui/ — lista pending (filter status)
GET /ui/candidate/{id} — side-by-side scen
POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject)
GET /ui/ lista pending (filter status)
GET /ui/candidate/{id} side-by-side scen
POST /ui/candidate/{id}/resolve, htmx form submit (action=merge_keep_left|merge_keep_right|reject)
zwraca fragment HTML z potwierdzeniem
"""
from __future__ import annotations
@ -197,9 +197,9 @@ def resolve_form(
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
# idempotentna na poziomie procesu — bezpiecznie wywoływać przy każdym startup.
# idempotentna na poziomie procesu, bezpiecznie wywoływać przy każdym startup.
import mimetypes
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
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,
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
Endpointy:
GET /blacklist — wszystkie 3 listy w jednym response
POST /blacklist/{kind}/{entity_id} — dodaj (idempotent)
DELETE /blacklist/{kind}/{entity_id} — usuń
GET /blacklist wszystkie 3 listy w jednym response
POST /blacklist/{kind}/{entity_id} dodaj (idempotent)
DELETE /blacklist/{kind}/{entity_id} usuń
`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,
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).
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
- duży scene-detail z thumbnail: ~600KB-1MB
"""
@ -92,7 +92,7 @@ def create_bug_report(
scene_id = payload.scene_id
elif session.get(Movie, payload.scene_id) is not None:
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(
id=uuid.uuid4(),
@ -211,7 +211,7 @@ def get_bug_report_screenshot(
bug_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> 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)
if br is None:
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`.
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`
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>/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
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.
"""
from __future__ import annotations
@ -35,7 +35,7 @@ def get_manifest(
expo_platform: str | None = Header(default=None, alias="expo-platform"),
) -> Response:
"""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
embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest
@ -87,7 +87,7 @@ def get_asset(
) -> Response:
"""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
`<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")
if not target.exists() or not target.is_file():
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
if target.suffix in (".js", ".bundle"):
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
można dodać dorzuceniem `user_id` query/header bez breaking change.
Endpointy (performers — `/favorites/...` zostawione żeby nie łamać starego mobile):
GET /favorites — lista ulubionych performerek
POST /favorites/{performer_id} — dodaj (idempotent)
DELETE /favorites/{performer_id} — usuń
POST /favorites/{performer_id}/seen — mark-as-seen (zeruje badge)
Endpointy (performers, `/favorites/...` zostawione żeby nie łamać starego mobile):
GET /favorites lista ulubionych performerek
POST /favorites/{performer_id} dodaj (idempotent)
DELETE /favorites/{performer_id} usuń
POST /favorites/{performer_id}/seen mark-as-seen (zeruje badge)
Endpointy (studios):
GET /favorites/studios — lista ulubionych studiów
POST /favorites/studios/{studio_id} — dodaj
DELETE /favorites/studios/{studio_id} — usuń
POST /favorites/studios/{studio_id}/seen — mark-as-seen
GET /favorites/studios lista ulubionych studiów
POST /favorites/studios/{studio_id} dodaj
DELETE /favorites/studios/{studio_id} usuń
POST /favorites/studios/{studio_id}/seen, mark-as-seen
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
- 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
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
{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="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
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
@ -198,7 +198,7 @@ class FavoriteOut(BaseModel):
class FavoriteListOut(BaseModel):
items: list[FavoriteOut]
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)
@ -216,7 +216,7 @@ def list_favorites(
return FavoriteListOut(items=[], total=0, new_total=0)
# 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.
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
new_counts = _merged_new_counts(session, device_id, kind="performer")
@ -274,7 +274,7 @@ def remove_favorite(
) -> None:
fav = session.get(FavoritePerformer, (device_id, performer_id))
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
session.delete(fav)
session.commit()
@ -416,7 +416,7 @@ def mark_studio_seen(
# ── Favorite movies ────────────────────────────────────────────────────────
# 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
# 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'`.
`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
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem
kasujemy resztki legacy).
@ -22,7 +22,7 @@ from app.db import get_session
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]] = [
("favorite_performers", "performer_id"),
("favorite_studios", "studio_id"),
@ -59,7 +59,7 @@ def import_from_device(
) -> ImportOut:
"""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
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
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)
)
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(
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
import uuid
@ -138,8 +138,8 @@ def list_movies(
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
# Movie playback origin policy — module-level (kiedyś było inline per-request
# definition, code-review #19 — perf hit + dorosły kod).
# Movie playback origin policy, module-level (kiedyś było inline per-request
# definition, code-review #19, perf hit + dorosły kod).
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
# random per origin).
_MOVIE_PREFERRED_ORIGINS = (
@ -150,9 +150,9 @@ _MOVIE_PREFERRED_ORIGINS = (
"streamporn",
"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
# 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.
_MOVIE_DROP_ORIGINS = frozenset({
"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
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`.
**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.
"""
from __future__ import annotations
@ -39,23 +39,23 @@ log = logging.getLogger(__name__)
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
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
#
# Verified time-bound:
# - 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
# - privatehost.com (pornhat CDN) — sign + exp_time, brak Referer requirement
# - sxyprn.com — signed path
# - eporner.com CDN — IP literal w path ale CDN go ignoruje
# - 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
# - privatehost.com (pornhat CDN), sign + exp_time, brak Referer requirement
# - sxyprn.com, signed path
# - eporner.com CDN, IP literal w path ale CDN go ignoruje
#
# NIE w allowlist (IP-bound, wymagają proxy):
# - premilkyway.com (latestpornvideo) — 403 cross-IP
# - tnmr.org (mypornerleak) — 403 cross-IP
# - porntrex.com/get_file — single-use token (410 po reuse)
# - freshporno.org/get_file — cv= signed token IP-bound
# - sn.porn-xp.com, porn00.org — force_proxy explicit
# - premilkyway.com (latestpornvideo), 403 cross-IP
# - tnmr.org (mypornerleak), 403 cross-IP
# - porntrex.com/get_file, single-use token (410 po reuse)
# - freshporno.org/get_file, cv= signed token IP-bound
# - sn.porn-xp.com, porn00.org, force_proxy explicit
_TIME_BOUND_CDN_RE = re.compile(
r"\b(?:"
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
@ -65,10 +65,10 @@ _TIME_BOUND_CDN_RE = re.compile(
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).
# 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(
r"\b(?:"
r"premilkyway\.com" # latestpornvideo
@ -82,17 +82,17 @@ _IP_BOUND_CDN_RE = re.compile(
class StreamLink(BaseModel):
"""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
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
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
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.
`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'.
"""
@ -122,7 +122,7 @@ def resolve_movie_playback(
playback_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> ResolveOut:
"""Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`.
"""Movies playback resolve, analog do `/scenes/{id}/playback/{pb}/resolve`.
Origin patterns:
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
@ -150,7 +150,7 @@ def resolve_movie_playback(
if pb.origin == "paradisehill":
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
# 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").
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
# nie ma sposobu przejść do następnego.
@ -162,7 +162,7 @@ def resolve_movie_playback(
parts = []
if 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).
links.append(
StreamLink(
@ -174,7 +174,7 @@ def resolve_movie_playback(
)
)
else:
# Fallback: brak videoList (np. login-only movie) — WebView na całość.
# Fallback: brak videoList (np. login-only movie), WebView na całość.
links = [
StreamLink(
stream_url=None,
@ -191,7 +191,7 @@ def resolve_movie_playback(
try:
stream = extract_stream_from_hoster(target, referer=referer)
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
# próbowałby zapisać jako .bin file; bug-report 2026-05-16
# "streamtape ściąga hurtowo pliki .bin").
@ -203,7 +203,7 @@ def resolve_movie_playback(
except Exception as e:
log.warning("movie hoster extract failed for %s: %s", target, e)
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).
log.info(
"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"
raw_meta: dict = {"origin": pb.origin, "host": target}
# 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`).
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
# 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
# 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łąź
@ -246,7 +246,7 @@ def resolve_movie_playback(
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:
links.append(
StreamLink(
@ -297,7 +297,7 @@ def mark_playback_dead(
request: Request,
session: Annotated[Session, Depends(get_session)],
) -> 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
przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
@ -362,7 +362,7 @@ def resolve_playback(
sitetag: str | None = None
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]
elif pb.origin.startswith("tube:"):
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)
raise HTTPException(status_code=410, detail=f"playback dead: {e}") from 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):
reason = f"tube page {e.status_code} {pb.page_url}"
pb.dead_at = datetime.now(UTC)
@ -397,7 +397,7 @@ def resolve_playback(
) from e
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`
# 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).
@ -410,9 +410,9 @@ def resolve_playback(
"extractor None for playback %s (origin=%s) — transient, not marking dead",
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.
# 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).
raise HTTPException(
status_code=503,
@ -446,7 +446,7 @@ DEFAULT_PLAYER_UA = (
def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
"""Wzbogaca StreamLink o:
- `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
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"))
# 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
# 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).
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
# CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
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/
# 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):
mobile_direct_ok = True
refetch_url = (link.raw or {}).get("refetch_url")
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.
manifest_producer = (link.raw or {}).get("manifest_producer")
token = make_token(
@ -498,15 +498,15 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
else:
ext = "mp4"
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
# 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
# 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).
# 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.
# 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.

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).
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
@ -22,12 +22,12 @@ router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[
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)
status: Literal["success", "error"]
scene_id: uuid.UUID | None = None
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).
error_detail: str | None = Field(default=None, max_length=512)
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
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
Endpointy:
GET /saved-searches — lista zapisów tego urządzenia (najnowsze pierwsze)
POST /saved-searches — dodaj {query} (idempotent na device+query)
DELETE /saved-searches/{id} — usuń
GET /saved-searches lista zapisów tego urządzenia (najnowsze pierwsze)
POST /saved-searches dodaj {query} (idempotent na device+query)
DELETE /saved-searches/{id} usuń
"""
from __future__ import annotations
@ -30,7 +30,7 @@ router = APIRouter(
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
@ -71,7 +71,7 @@ def add_saved_search(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SavedSearch:
# Idempotent — ten sam (device, query) zwraca istniejący wiersz.
# Idempotent, ten sam (device, query) zwraca istniejący wiersz.
existing = session.execute(
select(SavedSearch).where(
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:
GET /scene-favorites — lista ulubionych scen (pełen SceneOut)
POST /scene-favorites/{scene_id} — dodaj (idempotent)
DELETE /scene-favorites/{scene_id} — usuń
GET /scene-favorites lista ulubionych scen (pełen SceneOut)
POST /scene-favorites/{scene_id} dodaj (idempotent)
DELETE /scene-favorites/{scene_id} usuń
"""
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
import logging
@ -70,7 +70,7 @@ def _default_scene_count(session: Session) -> int:
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
# (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.
@ -89,11 +89,11 @@ def _jav_source_exists():
# 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
# 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).
# 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.
# 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_TTL = 300.0
@ -283,7 +283,7 @@ def list_scenes(
if len(tag_slug_list) > _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
# 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
# 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())
if origin:
# Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'.
# Substring match na origin, 'hqporner' złapie 'tube:hqpornercom'.
base = base.where(
exists(
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.
# 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).
if jav:
base = base.where(_jav_source_exists())
elif origin is None:
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).
for _bl_clause in blacklist_clauses(session, device_id):
base = base.where(_bl_clause)
@ -373,7 +373,7 @@ def list_scenes(
base = base.where(Scene.release_date >= cutoff)
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
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160.
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
# 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
# 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
# + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy.
total_capped = False
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
# 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
@ -436,7 +436,7 @@ def list_scenes(
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
)
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 = (
base.outerjoin(Studio, Studio.id == Scene.studio_id)
.order_by(
@ -451,10 +451,10 @@ def list_scenes(
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
# `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)
# 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.
@ -506,14 +506,14 @@ def similar_scenes(
device_id: Annotated[str, Depends(get_device_id)],
limit: int = Query(default=12, ge=1, le=30),
) -> 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
(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
_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
(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."""
scene = session.get(Scene, scene_id)
if scene is None:
@ -527,7 +527,7 @@ def similar_scenes(
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
).scalars().all()
# 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).
# 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.
@ -584,7 +584,7 @@ def similar_scenes(
.group_by(SceneTag.scene_id)
).all()
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.
if tag_ids and len(cand_ids) < limit * 4:
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:
"""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)."""
if not page_url:
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:
"""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`
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
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:
"""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
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:
return True
# 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ę
# 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.
# 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
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
if "tn.sextu.com" in url:
return 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:
# 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.
@ -693,14 +693,14 @@ def _is_rotting_thumb(url: str) -> bool:
def _needs_proxy(url: str) -> bool:
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
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."""
return url.startswith("http") and not url.startswith("/proxy/")
def _wrap_image_proxy(url: str, referer: str) -> str:
"""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."""
from app.api.stream_proxy import make_token
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
@ -717,7 +717,7 @@ def _build_scenes_out_batch(
) -> list[SceneOut]:
"""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`
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}).
tags_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.
pb_by_scene: dict = defaultdict(list)
if light:
@ -806,7 +806,7 @@ def _build_scenes_out_batch(
.order_by(PlaybackSource.origin.asc())
).all()
# 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
# ź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).
@ -847,7 +847,7 @@ def _build_scenes_out_batch(
if a_url and _needs_proxy(a_url):
a_url = _wrap_image_proxy(a_url, ref)
# 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(
PlaybackSourceOut(
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
# 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
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
# 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:
out = PlaybackSourceOut.model_validate(p)
# 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
# thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22.
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):
out.thumbnail_url = None
# 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.
if out.thumbnail_url and _needs_proxy(out.thumbnail_url):
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
@ -1090,7 +1090,7 @@ def remove_tag_from_scene(
) -> None:
"""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.
"""
rel = session.execute(
@ -1142,7 +1142,7 @@ def hide_scene(
) -> SceneHideOut:
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
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 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.
# 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
# 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
# w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently.
from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -1309,11 +1309,11 @@ def enrich_duration_from_tube(
scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> 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).
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).
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.
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).
"""
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).
`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)."""
from app.extractors._fetch import browser_get
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]
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(
scene_id=scene_id,
thumbnail_url=sources_with_thumb[0].thumbnail_url,

View file

@ -67,7 +67,7 @@ class SceneOut(BaseModel):
external_refs: list[ExternalRefOut] = []
playback_sources: list[PlaybackSourceOut] = []
# 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: datetime | None = None
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
@ -87,7 +87,7 @@ class SceneListOut(BaseModel):
page: int
per_page: int
# 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.
has_more: bool = False
# 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
(nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje,
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):
* **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).
* **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+.
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.
* **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,
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 = 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).
# Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie.
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.
_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.
_SCENES_PER_ENTITY = 120
@ -86,7 +86,7 @@ def _live_playback_exists():
def _not_blacklisted():
"""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 [
~exists(
@ -109,7 +109,7 @@ def _not_blacklisted():
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())
for cond in _not_blacklisted():
stmt = stmt.where(cond)
@ -120,7 +120,7 @@ def _performer_indexable_exists():
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
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 = (
select(1)
@ -180,7 +180,7 @@ def performer_page(
if performer is None:
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 = (
session.execute(
_indexable_scenes()
@ -283,7 +283,7 @@ def scene_page(
if scene is None:
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 = (
session.execute(
select(PlaybackSource)
@ -354,7 +354,7 @@ def landing(
request: Request,
session: Annotated[Session, Depends(get_session)],
) -> HTMLResponse:
"""Strona główna — crawl-entry. Najświeższe indeksowalne sceny + CTA."""
"""Strona główna, crawl-entry. Najświeższe indeksowalne sceny + CTA."""
scenes = (
session.execute(
_indexable_scenes()
@ -386,8 +386,8 @@ def landing(
@router.get("/get", response_class=HTMLResponse)
def get_app(request: Request) -> HTMLResponse:
"""Paid-traffic landing page — odchudzona pod konwersję instalacji APK.
noindex (patrz get.html) — nie ma konkurować w SERP z entity-stronami."""
"""Paid-traffic landing page, odchudzona pod konwersję instalacji APK.
noindex (patrz get.html), nie ma konkurować w SERP z entity-stronami."""
return templates.TemplateResponse(
request,
"seo/get.html",
@ -411,7 +411,7 @@ def page_2257(request: Request) -> HTMLResponse:
def gsc_site_verification(fname: str) -> PlainTextResponse:
"""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'.
Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest
jednosegmentowym *.html w rootcie).
@ -440,7 +440,7 @@ def _count(session: Session, stmt) -> int:
@router.get("/sitemap.xml")
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(
session,
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"
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.
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
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).
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
@ -42,7 +42,7 @@ class SourceRating(BaseModel):
richness: int
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
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
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
components: dict | None = None
@ -51,28 +51,28 @@ class SourceRating(BaseModel):
class SourceOut(BaseModel):
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)."""
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`)."""
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."""
scene_count: int
"""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."""
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ść."""
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)."""
@ -119,7 +119,7 @@ def list_sources(
) -> SourceListOut:
"""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(
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
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
poprawnie i serwuje content.
@ -41,8 +41,8 @@ router = APIRouter(prefix="/proxy", tags=["proxy"])
log = logging.getLogger(__name__)
# In-memory bandwidth counter — bytes-out per CDN domain per hour bucket.
# Restart api resetuje counter (akceptowalne — to operational metric, nie billing).
# In-memory bandwidth counter, bytes-out per CDN domain per hour bucket.
# Restart api resetuje counter (akceptowalne, to operational metric, nie billing).
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
from collections import defaultdict
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).
# 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
# 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
@ -170,10 +170,10 @@ def make_token(
`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.
`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.
`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
(→ 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
@ -190,7 +190,7 @@ def make_token(
if impersonate:
payload["i"] = 1
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.
payload["mp"] = producer
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:
"""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`
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
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.
HLS manifest ma:
- linie URI (segmenty .ts / sub-playlisty .m3u8) — relatywne lub absolute
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"` — też potrzebują rewrite
- linie URI (segmenty .ts / sub-playlisty .m3u8), relatywne lub absolute
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"`, też potrzebują rewrite
Każdy URL → token + /proxy/{token}/<basename>.<ext>.
"""
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:
"""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
bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin),
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
@ -310,7 +310,7 @@ def sign_url(
referer: str | None = Query(default=None),
) -> dict:
"""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}
@ -320,11 +320,11 @@ async def proxy_image(
_basename: str,
request: Request,
) -> 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
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)
target = payload["u"]
referer = payload["r"] or None
@ -334,18 +334,18 @@ async def proxy_image(
try:
r = await client.get(target, headers=headers)
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.
# 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)
return Response(content=b"", status_code=503, media_type="image/jpeg")
except Exception as 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
if r.status_code >= 400:
# Upstream 4xx/5xx dla thumba — degraded zamiast raise (placeholder w mobile).
# GOON-5 (Cloudflare 523) i GOON-D — bezsensowny noise w Sentry, lepiej
# Upstream 4xx/5xx dla thumba, degraded zamiast raise (placeholder w mobile).
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
# info log + 502 pass-through bez exception.
log.info("img proxy upstream %d for %s", r.status_code, target)
return Response(
@ -362,7 +362,7 @@ async def proxy_image(
# 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
# (< 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.
@ -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
# 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
# ź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>`.
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
_YOUPORN_POSTER_TTL = 1800
@ -417,7 +417,7 @@ async def youporn_thumb(video_id: str) -> Response:
)
if not m:
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.
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
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:
"""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).
UA + Accept wymagane — bez tego mixdrop zwraca minimalny body (bez packera).
UA + Accept wymagane, bez tego mixdrop zwraca minimalny body (bez packera).
"""
import re
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
(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.
Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound
hostery dalej idą pełnym `/proxy/`."""
@ -674,7 +674,7 @@ async def proxy_stream(
refetch_hoster = payload.get("rh")
# 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)
target = cached_target or original_target
@ -684,10 +684,10 @@ async def proxy_stream(
if 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
# 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).
if use_impersonate:
return await _curl_cffi_stream(
@ -707,7 +707,7 @@ async def proxy_stream(
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
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.
upstream = await client.send(
client.build_request(method, target, headers=headers),
@ -719,7 +719,7 @@ async def proxy_stream(
ups_headers = dict(upstream.headers)
await upstream.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
# cache trzymałby martwy CDN URL przez 30 min (TTL).
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):
# 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:
final_url = str(upstream.url)
if final_url != original_target:
@ -741,7 +741,7 @@ async def proxy_stream(
or "application/x-mpegurl" in ct
)
if is_m3u8:
# Manifest content — buffer fully, rewrite, return as m3u8.
# Manifest content, buffer fully, rewrite, return as m3u8.
body = await upstream.aread()
await upstream.aclose()
await client.aclose()
@ -769,7 +769,7 @@ async def proxy_stream(
yield chunk
except httpx.TransportError as e:
# 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.
# 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).
@ -792,7 +792,7 @@ async def proxy_stream(
await client.aclose()
raise
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.
await client.aclose()
log.info("proxy connect/timeout for %s: %s", target, e)
@ -818,10 +818,10 @@ def _upstream_error_response(
Rationale per status:
- **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.
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ł.
- **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")
if status == 429:
@ -845,7 +845,7 @@ def _upstream_error_response(
media_type="text/plain",
)
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.
log.info("proxy upstream %d for %s", status, target)
return Response(
@ -854,5 +854,5 @@ def _upstream_error_response(
headers={"Retry-After": "5"},
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}")

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:
- 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'")
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ą
# małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji.
_movie_live = exists().where(
@ -146,7 +146,7 @@ def list_tags(
]
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 +
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
base = select(Tag)
@ -236,13 +236,13 @@ def list_studios(
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
),
) -> 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"):
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
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(
and_(
MoviePlaybackSource.movie_id == Movie.id,
@ -280,7 +280,7 @@ def list_studios(
]
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.
base = select(Studio)
if q:
@ -340,13 +340,13 @@ class PerformerRescrapeOut(BaseModel):
# 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/
# 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
# 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
# bo INSERT ... ON CONFLICT DO NOTHING.
_TAG_RESCRAPE_THRESHOLD = 3
# Mainstream tubes priority dla tagów — bogate metadane.
# Mainstream tubes priority dla tagów, bogate metadane.
_TAG_PRIORITY = [
"xhamstercom", "porntrexcom", "epornercom", "youporncom",
"xvideoscom", "xnxxcom",
@ -360,7 +360,7 @@ def performer_top_tags(
limit: int = Query(default=20, ge=1, le=50),
) -> TagListOut:
"""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
pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request."""
live = exists().where(
@ -418,7 +418,7 @@ def rescrape_performer_scenes(
if perf is None:
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(
select(Scene.id)
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
@ -468,7 +468,7 @@ def rescrape_performer_scenes(
capped = False
cap_reason: str | None = None
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.
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)
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
# po niej miałoby PendingRollbackError.
sp = session.begin_nested()
@ -504,7 +504,7 @@ def rescrape_performer_scenes(
thumb = extract_thumbnail_url(r.text)
if thumb:
# 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).
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
# find()), więc 1 wystarczy.
@ -586,7 +586,7 @@ def refresh_performer(
performer_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> 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.
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",
)
# 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
# NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes).

View file

@ -1,8 +1,8 @@
"""Watch history + continue watching.
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
- 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ą
- 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ą
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
@ -55,7 +55,7 @@ def upsert_progress(
if session.get(Scene, scene_id) is None:
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 →
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -139,7 +139,7 @@ def upsert_movie_progress(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> 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")."""
if session.get(Movie, movie_id) is None:
raise HTTPException(status_code=404, detail="movie not found")

View file

@ -1,7 +1,7 @@
"""API key authentication.
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
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:
return # local/dev — wszystko otwarte
return # local/dev, wszystko otwarte
candidate: str | None = None
if x_api_key:

View file

@ -25,7 +25,7 @@ class Settings(BaseSettings):
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.
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
@ -48,12 +48,12 @@ class Settings(BaseSettings):
title_token_set_min: int = 88
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.
# 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")
# 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).
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(
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
# 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.
@ -92,31 +92,31 @@ class Settings(BaseSettings):
sched_browse_latest_max_pages: int = Field(
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).
# 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")
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")
# 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).
sched_bulk_dedup_hours: int = Field(
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ł).
# Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off.
sched_thumb_dedup_hours: int = Field(
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
# 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.
sched_title_dedup_hours: int = Field(
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
# 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.
@ -124,18 +124,18 @@ class Settings(BaseSettings):
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
)
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
# w ogóle — streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# w ogóle, streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
ingest_watchdog_movie_max_age_hours: int = Field(
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
)
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
# Śmieciowi performerzy, kategorie i studia podszywające się pod osoby („Creampie",
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
sched_junk_performers_hours: int = Field(
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
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
# 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(
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.
ingest_watchdog_max_age_hours: int = Field(
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
# 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.
ingest_watchdog_search_max_age_hours: int = Field(
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)
# 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.
@ -165,20 +165,20 @@ class Settings(BaseSettings):
tpdb_movie_enrich_batch: int = Field(
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
# 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.
sched_taxonomy_counts_hours: int = Field(
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).
# 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_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_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")
@ -188,14 +188,14 @@ class Settings(BaseSettings):
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
# ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli).
sched_source_stats_hours: int = Field(
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
# 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.

View file

@ -1,14 +1,14 @@
"""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
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).
## Jak dodać nowe movie site
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()`.
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
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]]:
"""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 (
MangopornConnector,
@ -43,10 +43,10 @@ def get_movie_connectors() -> list[tuple[str, type]]:
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
# 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
# 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.
# 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
@ -56,15 +56,15 @@ def get_movie_connectors() -> list[tuple[str, type]]:
("mangoporn", MangopornConnector),
("streampornvip", StreampornVipConnector),
("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.
("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
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
# gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed.
# gotowe moduły hosterów), vidara.to nieznana, leci jako zwykły embed.
("yourdailypornmovies", YourDailyPornMoviesConnector),
]

View file

@ -93,7 +93,7 @@ class RawScene(BaseModel):
(np. 'tpdb', 'stashdb')."""
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):
@ -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):
"""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
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")
@ -133,7 +133,7 @@ class RawMovieChapter(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
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).
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

View file

@ -1,25 +1,25 @@
"""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
`Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver
mergeuje idempotentnie po tym kluczu.
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 —
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,
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.
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,
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.
"""
# 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).
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.eporner import EpornerScraper
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.latestleaks import LatestLeaksScraper
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.xnxx import XnxxScraper
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
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
# Existing 4 (verified, in production)
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/...`
# 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.
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.
# 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
# i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio.
XVideosScraper,
@ -78,60 +78,60 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
YouPornScraper,
PornTrexScraper,
EpornerScraper,
# Aggregators (WordPress-like ?s= search; speculative — verify post-deploy)
# XMoviesForYouScraper — wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
# streamtape (DEAD_HOSTER_RE — malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
# Aggregators (WordPress-like ?s= search; speculative, verify post-deploy)
# XMoviesForYouScraper, wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
# 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.
# Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w
# 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
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
# SiskaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
# 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
# SiskaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
# 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
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
# 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
# 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
# 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).
# 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
# usunięte (~15k scen).
MyPornerLeakScraper,
# 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
# 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.
# 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
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
# Special
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
# (/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
# `/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
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
# `app/scheduler/browse_latest.py`.
#
# **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.**
# - 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.**
from app.connectors.direct_scrapers.freshporno import FreshpornoScraper # 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]] = [
PerverzijaScraper,
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/
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
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
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
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
# 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
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
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
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
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
# 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).
# 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.
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
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
# 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.
# 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,
# 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".
# 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
# 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
# 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.
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
# 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
# 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
# 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.
# 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%
# 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).
@ -224,105 +224,105 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
XnxxBrowseScraper,
FreshpornoScraper,
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"),
# thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
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>/`.
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
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
# 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.
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
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
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
# (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 +
# performer + date + duration nadrabiają.
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
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
# 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
# ('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`.
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
# ("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
# content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy.
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
FullmoviesScraper,
HDPornGGScraper,
# 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
# 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
# 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).
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.
# 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,
# 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
# 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
# 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
# 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).
# 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
# 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
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
HQPornerBrowseScraper,
# FourK69Scraper — USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
# JavflixScraper — JAV vertical (osobna sekcja). origin tube:javflix jest w
# FourK69Scraper, USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
# JavflixScraper, JAV vertical (osobna sekcja). origin tube:javflix jest w
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
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
# videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10.
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:
# data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10.
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
# 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.
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
# 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/
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
# Bez proxy: scraper no-op (pusty iterator).
SuperpornScraper,
# 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).
# 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).
]
__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ą
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.
Różnica vs `BaseSearchScraper`:
- **search**: tube wyszukuje sceny po performer name (dla performer-driven
backfill). Wymaga znanego performera.
- **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:
- 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:
"""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."""
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
(top-N) i deep_crawl (kursor). Zwraca:
None — transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
[] — pusty listing = koniec katalogu (caller: exhausted),
[...] — sceny z tej strony.
None , transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
[] pusty listing = koniec katalogu (caller: exhausted),
[...], sceny z tej strony.
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
(call API zamiast listing→detail). HTML browse subclasses dostarczają
@ -120,7 +120,7 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
break
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
# które chcą *oba* tryby mogą override'ować search() osobno.
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
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
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
img = Image.open(io.BytesIO(r.content))
# 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.
return str(imagehash.phash(img.convert("RGB")))
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.
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">`
→ `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:
attr = "property" if property else "name"
val = re.escape(property or name or "")
# double-quoted content (HTML standard) — preferred
# Pattern: <meta property="X" content="...inner..." > — inner allows apostrophes
# double-quoted content (HTML standard), preferred
# Pattern: <meta property="X" content="...inner..." > , inner allows apostrophes
_META_RE_CACHE[key] = re.compile(
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
`/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)
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
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
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
@ -109,7 +109,7 @@ def _extract_video_object(html: str) -> dict | None:
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:
super().__init__()
@ -123,9 +123,9 @@ class BasePlayTubeScraper(BaseBrowseScraper):
def _pick_studio(self, category_names: list[str]) -> str | 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
# 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
return f"{self.base_url}/sitemap.xml"
@ -156,7 +156,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
sm = browser_get(sm_url, timeout=self._timeout)
sm.raise_for_status()
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)
continue
for block in _URL_BLOCK_RE.findall(sm.text):
@ -227,7 +227,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
thumbnail_url = video.get("thumbnailUrl") or None
# 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] = []
category_names: list[str] = []
seen_perf: set[str] = set()
@ -275,7 +275,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
if 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.
playback_sources = [
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:
1. Build search URL z `_search_url_template` (formatowane query+page).
2. Fetch HTML curl_cffi.
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
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.
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
Subclass override:
- `sitetag: str` — np. "pornhubcom"
- `_search_url_template: str` — z `{query}` i `{page}` placeholderami
- `_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)
- `_token_filter_text(match) -> str` — co testować na query tokens (default: cała URL)
- `sitetag: str`, np. "pornhubcom"
- `_search_url_template: str`, z `{query}` i `{page}` placeholderami
- `_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)
- `_token_filter_text(match) -> str`, co testować na query tokens (default: cała URL)
"""
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
# (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.
_IMG_SRC_RE = re.compile(
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),
#: 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
#: 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.
#: 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.
_thumbnail_window: int = 800
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:
return m.group("url")
except IndexError:
@ -103,17 +103,17 @@ class BaseSearchScraper(BaseDirectTubeScraper):
def _format_query_for_url(self, query: str) -> str:
"""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())
def _fetch_scene_metadata(
self, scene_url: str
) -> 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).
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.
Returns: (studio, performers, tags). Każde może być None / pusta lista.
@ -153,7 +153,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
if scene_url.startswith("//"):
scene_url = "https:" + scene_url
elif scene_url.startswith("/"):
# Relative URL — prefix host z search URL.
# Relative URL, prefix host z search URL.
base = urllib.parse.urlparse(url)
scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
if scene_url in seen:
@ -165,11 +165,11 @@ class BaseSearchScraper(BaseDirectTubeScraper):
if slug_lower in self._nav_slug_blacklist:
continue
# 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 Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
# 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.
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
continue
@ -178,7 +178,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
# 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
# `<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_end = min(len(r.text), m.end() + self._thumbnail_window)
window_html = r.text[window_start:window_end]
@ -193,7 +193,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
# 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.
studio: RawStudio | None = None
extra_performers: list[RawPerformer] = []
@ -212,7 +212,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
description = meta[3]
# 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]
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
import abc
@ -13,7 +13,7 @@ class BaseDirectTubeScraper(abc.ABC):
żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
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.)."""
@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).
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`
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
`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
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
(BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co
@ -113,7 +113,7 @@ class EpornerApiScraper(BaseBrowseScraper):
)
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
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
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ł 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`).
"""
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
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
@ -9,7 +9,7 @@ URL patterns:
- Scene: `/videos/<slug>/`
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
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
@ -108,7 +108,7 @@ class FreshpornoScraper(BaseBrowseScraper):
# Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
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):
slug, name = m.group(1), m.group(2).strip()
if name.lower() in ("channels", ""):
@ -120,7 +120,7 @@ class FreshpornoScraper(BaseBrowseScraper):
)
break
# Performers — wszyscy `/models/<slug>/`
# Performers, wszyscy `/models/<slug>/`
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
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)
# 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] = []
if thumbnail_url:
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...">`.
# To data wrzucenia na freshporno, NIE oryginalna release_date studio — ale dla
# Release date, freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
# 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
# `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

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>/`,
`/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
@ -71,7 +71,7 @@ class FullmoviesScraper(BaseBrowseScraper):
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
# 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 - ...").
studio: RawStudio | None = None
if " - " in title:
@ -104,7 +104,7 @@ class FullmoviesScraper(BaseBrowseScraper):
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
# 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),
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
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
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
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
– 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.
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
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ć.
**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ą
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]+)"')
_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.
_INFO_RE = re.compile(
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>')
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
_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).
_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)
if pb:
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.
name = html_mod.unescape(name).strip().title()
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
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
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.
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
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
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
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).
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
@ -65,7 +65,7 @@ _UNIT_DAYS = {
def _submitted_to_date(detail_html: str):
"""`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)
if not m:
return None
@ -127,7 +127,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
return out
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] = []
tags: list[RawTag] = []
studio: RawStudio | None = None
@ -162,7 +162,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
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).
if not performers:
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,
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.
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
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)
- 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)
- 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
`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).
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
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 /
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)
_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>.+)$")
# `[Onlyfans] Some Title` / `[Darkkotv] …`
_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))
thumb = (data.get("thumbnail") or "").strip()
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
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
if vr.status_code != 200:
@ -145,7 +145,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
if not title:
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).
performers: list[RawPerformer] = []
seen_p: set[str] = set()
@ -186,7 +186,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
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] = []
seen_t: set[str] = set()
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:
`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,
`/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
factory, wyłącz; analogia do shyfap).
"""
@ -63,9 +63,9 @@ class HDPornGGScraper(BaseBrowseScraper):
title = meta_content(detail_html, property="og:title")
if not title:
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()
# 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.
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
# 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
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
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
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
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).
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.
"""
from __future__ import annotations
@ -55,7 +55,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
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
# 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ń
# 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
@ -92,7 +92,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
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.
performers=[RawPerformer(name=query.strip())],
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),
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>`.
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.
"""
from __future__ import annotations
@ -134,7 +134,7 @@ class HQPornerBrowseScraper(BaseBrowseScraper):
fingerprints.append(RawFingerprint(kind="phash", value=ph))
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}",
title=title,
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`.
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):
- 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")
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.
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
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
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ż
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&
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
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
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=
<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
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:
`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).
**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.
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
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.
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.
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
@ -61,7 +61,7 @@ _SCENE_HREF_RE = re.compile(
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
_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
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
_AMBIGUOUS_STUDIOS = {
@ -119,7 +119,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
def _load_catalog(self) -> list[str] | None:
"""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:
return self._catalog
urls: list[str] = []
@ -145,7 +145,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
def crawl_page(self, page: int) -> list[RawScene] | None:
"""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()
if catalog is None:
return None
@ -174,7 +174,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
scene = super()._parse_detail(scene_url, detail_html)
if scene is 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.
if not scene.performers:
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>`.
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
(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
`/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> ...">
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
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)
# 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")
# 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", ""}
@ -137,7 +137,7 @@ class LatestPornVideoScraper(BaseBrowseScraper):
)
# 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.
tags: list[RawTag] = []
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ą
`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
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
- `<a href title="...">` → URL sceny + czysty tytuł
- `<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
ś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.),
~40k+ scen po numeracji video id.
Sygnały per scena:
- 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
- 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ą
jeszcze linków modelek — fallback: flashvars `video_tags` (na świeżych scenach
(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
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
też szum ("deep throat", "natural tits", "hd porn").
- 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.
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
@ -111,7 +111,7 @@ class NepornScraper(BaseBrowseScraper):
description = (video.get("description") or "").strip() or None
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
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)
performers: list[RawPerformer] = []
@ -128,7 +128,7 @@ class NepornScraper(BaseBrowseScraper):
)
)
# 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()
tags_m = _VIDEO_TAGS_RE.search(detail_html)
for entry in (tags_m.group(1).split(",") if tags_m else []):
@ -160,7 +160,7 @@ class NepornScraper(BaseBrowseScraper):
seen_tag.add(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).
fingerprints: list[RawFingerprint] = []
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
(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).
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)
→ puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł
i tak ma nazwiska).

View file

@ -1,17 +1,17 @@
"""porn00.org — latest-vids browse scraper.
"""porn00.org, latest-vids browse scraper.
URL patterns:
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
- 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>/`
Sygnały dostępne:
- Title (listing card + h1 + og:title)
- Performer(s) (z sekcji "Pornstars:" na detail page — pojedynczy slug per link)
- Categories (z sekcji "Categories:" — `/category-name/<slug>/`)
- Performer(s) (z sekcji "Pornstars:" na detail page, pojedynczy slug per link)
- Categories (z sekcji "Categories:", `/category-name/<slug>/`)
- 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`)
BRAK:
@ -78,7 +78,7 @@ _CATEGORY_LINK_RE = re.compile(
)
# 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 `"`.
_VIDEO_URL_RE = re.compile(
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
@ -107,7 +107,7 @@ class Porn00Scraper(BaseBrowseScraper):
def __init__(self) -> None:
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.
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.
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.
performers: list[RawPerformer] = []
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
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
stream_url = vm.group(1)
elif (vm := _VIDEO_URL_RE.search(detail_html)):
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.
fingerprints: list[RawFingerprint] = []
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>`.
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:
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ą).
**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:
`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`
(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:
- 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)
- 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 /
zpi.cx ≈ 69% katalogu).
@ -96,7 +96,7 @@ class PornBusyScraper(BaseBrowseScraper):
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
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.
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
return _SITEMAP_INDEX
@ -131,7 +131,7 @@ class PornBusyScraper(BaseBrowseScraper):
if not loc:
continue
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/")):
continue
lm = _LASTMOD_RE.search(block)
@ -189,7 +189,7 @@ class PornBusyScraper(BaseBrowseScraper):
dsm = _DESC_RE.search(detail_html)
description = html.unescape(dsm.group(1)).strip() if dsm else None
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] = []
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ć
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
ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24
(ten sam wzorzec co perverzija).
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
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).

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
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 +
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.
URL patterns:
@ -21,7 +21,7 @@ URL patterns:
- Performer: `/pornstars-profile/<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`;
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
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"
# 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)
_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
)
# 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(
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)
)
# 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).
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
@ -245,7 +245,7 @@ class PornDoeScraper(BaseBrowseScraper):
if 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).
playback_sources = [
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
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,
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).
**Ś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.
- 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
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/`).
**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,
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.
"""
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=).
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

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
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).
@ -9,7 +9,7 @@ KVS listing tile:
<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ł>">
<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

View file

@ -1,9 +1,9 @@
"""pornxp.ph — latest-vids browse scraper.
"""pornxp.ph, latest-vids browse scraper.
URL patterns:
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
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.
- Detail: `/videos/<id_with_suffix>`.
- 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)
- Release year (regex `Released:` na detail page bodyText)
- 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"`)
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.
"""
from __future__ import annotations
@ -45,10 +45,10 @@ log = logging.getLogger(__name__)
_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 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(
r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
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_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.
_DETAIL_TAGS_BLOCK_RE = re.compile(
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)
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
# 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.
# 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_RE = re.compile(
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
re.IGNORECASE,
@ -99,11 +99,11 @@ def _classify_tag(name: str) -> str:
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
- 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`)
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.
"""
name = name.strip()
@ -136,7 +136,7 @@ class PornXPScraper(BaseBrowseScraper):
def __init__(self) -> None:
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">`
# ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde
# _extract_scene_urls override'uje).
@ -188,12 +188,12 @@ class PornXPScraper(BaseBrowseScraper):
return out
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, {})
data_id = meta.get("data_id")
if not data_id:
# 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)
data_id = id_match.group(1) if id_match else None
@ -207,8 +207,8 @@ class PornXPScraper(BaseBrowseScraper):
duration_sec = meta.get("duration_sec")
thumb = meta.get("thumb")
# Release year — `Released: 2016`. RawScene ma `release_date` (typu `date`),
# nie samo year — wpisujemy Jan 1 jako placeholder żeby resolver miał year
# Release year, `Released: 2016`. RawScene ma `release_date` (typu `date`),
# nie samo year, wpisujemy Jan 1 jako placeholder żeby resolver miał year
# signal (date proximity scoring tylko sprawdza year w composite).
release_date: date | None = None
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))
# 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:
return "https:" + u if u.startswith("//") else u
@ -264,7 +264,7 @@ class PornXPScraper(BaseBrowseScraper):
break
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
# studio+performer+year+title scoring.
fingerprints: list[RawFingerprint] = []
@ -275,7 +275,7 @@ class PornXPScraper(BaseBrowseScraper):
# Normalize page_url: pornxp homepage serwuje random URL suffix per request
# (`/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>`.
canonical_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:
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
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.
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/`.
**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.
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.
"""
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:
"""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}:')
if start < 0:
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
page'u (meta tags + body links): title, studio, performers, tags, duration,
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,
fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
@ -37,7 +37,7 @@ _PORNSTAR_LINK_RE = re.compile(
_TAG_LINK_RE = re.compile(
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.
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
@ -46,7 +46,7 @@ class ShyfapScraper(BaseBrowseScraper):
sitetag = "shyfapnet"
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)
if page <= 1:
return f"{_BASE}/videos_1/"
@ -108,7 +108,7 @@ class ShyfapScraper(BaseBrowseScraper):
if not embed_url and 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
m_studio = _STUDIO_LINK_RE.search(detail_html)
if m_studio:
@ -119,7 +119,7 @@ class ShyfapScraper(BaseBrowseScraper):
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] = []
seen_perf: set[str] = set()
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] = []
seen_tag: set[str] = set()
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)
)
# Playback source — embed_url (mobile WebView fallback). Stream extraction
# przez app/extractors/__init__.py wymaga osobnego registry entry — dla
# Playback source, embed_url (mobile WebView fallback). Stream extraction
# przez app/extractors/__init__.py wymaga osobnego registry entry, dla
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
playback_sources = [
RawPlaybackSource(
@ -161,7 +161,7 @@ class ShyfapScraper(BaseBrowseScraper):
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
# 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] = []
if thumbnail_url:
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
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
(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
nieskonfigurowane → scraper no-op (pusty iterator, log warning).
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
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).
Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa).
Sygnały (SSR HTML):
- 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>`
- 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).
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:
"""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):
new = html.unescape(text)
if new == text:
@ -95,7 +95,7 @@ class SuperpornScraper(BaseBrowseScraper):
def __init__(self) -> None:
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
if not self._proxy:
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
@ -106,7 +106,7 @@ class SuperpornScraper(BaseBrowseScraper):
return f"{_BASE}/?page={page}"
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.
if not self._proxy:
return []
@ -164,7 +164,7 @@ class SuperpornScraper(BaseBrowseScraper):
seen_tag.add(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] = []
if thumbnail_url:
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
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"
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
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.
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ą.
# 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
# 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
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
_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>/`
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>/).
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"`)
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
- 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] = []
seen_tag: set[str] = set()
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
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
@ -42,19 +42,19 @@ log = logging.getLogger(__name__)
_BASE = "https://sxyprn.com"
# 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(
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
re.IGNORECASE,
)
# 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(
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)
_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(
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
re.IGNORECASE,
@ -89,7 +89,7 @@ def _clean_title(raw: str) -> tuple[str, list[str]]:
class SxyPrnScraper(BaseSearchScraper):
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
def _performer_path(self, query: str) -> str:
@ -131,7 +131,7 @@ class SxyPrnScraper(BaseSearchScraper):
continue
title, tags = _clean_title(tm.group(1))
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.
if query_tokens and not all(tok in title_l for tok in query_tokens):
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`.
Ś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:
- **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 =
kolejny wycinek PER_PAGE.
- **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{}.
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
get_file HLS). RE 2026-07-10.
@ -62,7 +62,7 @@ def _parse_duration(value: str | None) -> int | None:
# --- bramka jakości tytułu ----------------------------------------------------------
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
# w apce nieodnajdywalne — nie mają ani tytułu, ani performera, po którym da się trafić.
# w apce nieodnajdywalne, nie mają ani tytułu, ani performera, po którym da się trafić.
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
# bo sprawdzała tylko jeden z czterech wzorców).
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
@ -74,7 +74,7 @@ def _has_cjk(text: str) -> bool:
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
wyszła przy pomiarze pierwszej wersji bramki — bez tego wyjątku poszłoby 708 z 708
wyszła przy pomiarze pierwszej wersji bramki, bez tego wyjątku poszłoby 708 z 708
tytułów CJK z próbki."""
return sum(1 for c in text if " " <= c <= "鿿" or "＀" <= c <= "￯") >= 3
@ -119,7 +119,7 @@ class VjavScraper(BaseBrowseScraper):
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
# działania, nie optymalizacją — bez klucza scraper się wyłącza.
# działania, nie optymalizacją, bez klucza scraper się wyłącza.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
@ -130,7 +130,7 @@ class VjavScraper(BaseBrowseScraper):
zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx —
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx,
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
try:
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
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
kończyło CAŁY run — źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
kończyło CAŁY run, źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`.
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
@ -268,7 +268,7 @@ class VjavScraper(BaseBrowseScraper):
out: list[RawScene] = []
for vid in chunk:
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.
res = browser_get(
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
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).
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>`
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).
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)
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):
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}"
_scene_url_re = re.compile(
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
`/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia
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.
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
ś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/.
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
@ -108,7 +108,7 @@ class XnxxBrowseScraper(BaseBrowseScraper):
if isinstance(thumbnail_url, list):
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(
external_id=f"{self.sitetag}:{scene_url}",
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)
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):
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}"
_scene_url_re = re.compile(
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):
# 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).
# 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
self._search_url_template = original.replace("{page}", str(page - 1))
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
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),
ż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
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
@ -29,7 +29,7 @@ _JSONLD_RE = re.compile(
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>...`.
# 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
# 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

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).

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"
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
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):
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
(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).
- **`<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.
- **`<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`
linki ale te miksują performerów/studio z tagami.
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
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.
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)
- Studio: `/channels/<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)
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 →
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
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"
# 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).
_SCENE_URL_RE = re.compile(
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"<[^>]+>")
# 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(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
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(
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
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."""
if not value:
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`.
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
try:
@ -265,7 +265,7 @@ class YesPornVipScraper(BaseBrowseScraper):
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
# 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.
tags: list[RawTag] = []
seen_tag: set[str] = set()
@ -283,17 +283,17 @@ class YesPornVipScraper(BaseBrowseScraper):
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
# 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] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
if 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
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
# 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…),
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
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
`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
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ł.
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).
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).
"""
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>`
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ść
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`,
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ł
ś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.
@ -96,7 +96,7 @@ class YouPornBrowseScraper(BaseBrowseScraper):
if isinstance(thumbnail_url, list):
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.
return RawScene(
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),
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
- <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
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
# (theme wyrendurował SEO-friendly URL bezpośrednio w grid)
# 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-]+/)"',
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".
_TITLE_RE = re.compile(
r'<h[1-6][^>]*\sitemprop="name"[^>]*>([^<]+)</h[1-6]>'
r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>',
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.
_YEAR_RE = re.compile(
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
# 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=...
# 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
# regexami i konsolidujemy w `_iter_player_options`.
_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
# doodstream/mixdrop/easyvidplayer). Capturujemy CAŁY opening tag w group(1)
# ż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).
_PLAYER_OPTION_DIV_RE = re.compile(
r'(<div[^>]*\bclass="Rtable1-cell"[^>]*>)\s*'
@ -135,7 +135,7 @@ _PLAYER_OPTION_DIV_RE = re.compile(
re.IGNORECASE | re.DOTALL,
)
_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
# instalacji bez schema. Trzeci fallback: og:image meta tag.
_POSTER_JSONLD_RE = re.compile(
@ -168,7 +168,7 @@ class DooplayConnector(BaseMovieConnector):
pass
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."""
if not url.startswith("http"):
url = self.base_url.rstrip("/") + url
@ -187,12 +187,12 @@ class DooplayConnector(BaseMovieConnector):
)
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
# 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
# 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
# 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`).
@ -335,7 +335,7 @@ def _parse_dooplay_detail(
poster_url = candidate
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
# resolverze (po performer_id).
performers = [
@ -360,14 +360,14 @@ def _parse_dooplay_detail(
# 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=
# 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
# 2026-05-16: "mangoporn przekierowuje do strony, reklama full screen").
playback_sources: list[RawPlaybackSource] = []
seen_hrefs: set[str] = set()
# 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
# przy ingest żeby nie zaśmiecać UI martwym contentem (bug-report 2026-05-18).
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)
# 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
# lookback po HTMLu, bo to mogło pickować poprzedni cell).
for m in _PLAYER_OPTION_DIV_RE.finditer(html):
@ -411,7 +411,7 @@ def _parse_dooplay_detail(
_emit_host_entry(href, source)
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
# niepotrzebnym ad-pageiem.
playback_sources.append(
@ -457,13 +457,13 @@ class StreampornVipConnector(DooplayConnector):
def _listing_path(self, page: int) -> str:
"""Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od
2026-07-11 (0 nowych przez 23 dni), ale strona żyła i konektor parsował
poprawnie — tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
poprawnie, tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dało
roczniki 2012-2015, strona główna wyłącznie 2026 (po 10-11 źródeł playbacku).
`?orderby=date` motyw ignoruje (odpowiedź identyczna z domyślną).
To ta sama klasa błędu co przy freshporno: właściwa strona, zły endpoint
listingu — patrz [[reference_kvs_root_rotates_use_latest_updates]].
listingu, patrz [[reference_kvs_root_rotates_use_latest_updates]].
Strony 1 i 2 współdzielą ~44 linki (sidebar/widgety), ale treść główna jest
stronicowana normalnie; dedup po URL w `fetch_movies` i tak je odsiewa.
@ -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"<[^>]+>")

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
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.
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).
"""
from __future__ import annotations
@ -151,7 +151,7 @@ class FreeoMovieConnector(BaseMovieConnector):
name = part.strip()
if not name or name.lower() == "xxx movies":
if name.lower() == "xxx movies":
break # dalej idzie obsada/studio — nie tagi
break # dalej idzie obsada/studio, nie tagi
continue
sl = slugify(name)
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:
- Age-gate: wymagany cookie `is18=1` (POST /is18/ zwraca 400 z curla, ale samo dorzucenie
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.
- Detail: `/<hex_id>/` — pełne meta + Video.js playlist (chaptery jako "Part 1/2/3").
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.
- Detail: `/<hex_id>/`, pełne meta + Video.js playlist (chaptery jako "Part 1/2/3").
Co ekstraktujemy:
- 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)
- 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
- 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).
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"
# 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).
_TITLE_RE = re.compile(
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>',
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"}]}, ...]
# 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").
@ -155,7 +155,7 @@ class ParadisehillConnector(BaseMovieConnector):
`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
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
page = 1
@ -179,14 +179,14 @@ class ParadisehillConnector(BaseMovieConnector):
if movie is None:
continue
# `since` filter — datePublished (= data uploadu na paradisehill) poniżej
# `since` filter, datePublished (= data uploadu na paradisehill) poniżej
# progu = stop crawla (listing chronologiczny).
#
# 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
# == since (midnight < since o dowolnej porze dnia) → uploady tego samego dnia
# 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
# (external_records upsert pomija niezmieniony hash).
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:
pass
# 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.
# 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.
release_year: int | None = None
m_yt = _YEAR_IN_TITLE_RE.search(title)
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()),
)
# 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.
# 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),
# 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,
@ -295,7 +295,7 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
# Paradisehill miesza dwa szablony per-page:
# v1: `itemprop="genre">Female Domination</span>`
# 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(
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,
url=page_url,
studio=studio,
performers=[], # Paradisehill rzadko ma cast linki — uzupełnimy przez mirrory.
performers=[], # Paradisehill rzadko ma cast linki, uzupełnimy przez mirrory.
tags=tags,
chapters=chapters,
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}}
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:
- 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.parent.id — kanoniczne UUID performerki w TPDB → external_id
- performer.parent.name / .extra.gender / .extra.birthday — kanoniczne metadane
- 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.parent.id, kanoniczne UUID performerki w TPDB → external_id
- performer.parent.name / .extra.gender / .extra.birthday, kanoniczne metadane
Format studia: scene.site = {id, name, slug, parent: {...}, network: {...}}
"""
@ -35,7 +35,7 @@ from tenacity import (
def _is_retryable_http_error(exc: BaseException) -> bool:
"""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
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]:
resp = client.get(path, params=params)
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)
resp.raise_for_status()
return resp.json()
@ -125,13 +125,13 @@ class TPDBConnector(BaseConnector):
) -> Iterator[RawScene]:
"""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,
/scenes?performer_id=<uuid> → 422.)
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
→ 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.
"""
try:
@ -157,11 +157,11 @@ class TPDBConnector(BaseConnector):
) -> Iterator[RawScene]:
"""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
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:
yield from self._paginate_scenes(
@ -215,7 +215,7 @@ class TPDBConnector(BaseConnector):
def fetch_movie(self, movie_uuid: str) -> dict[str, Any] | None:
"""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."""
with self._client() as client:
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
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")
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
`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
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
`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ą
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.
"""
from __future__ import annotations
@ -60,7 +60,7 @@ _CARD_RE = re.compile(
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)
_ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)</a>')
_TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)</a>')
@ -143,7 +143,7 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
if not title:
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] = []
seen_host: set[str] = set()
for m in _IFRAME_RE.finditer(detail):
@ -219,7 +219,7 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
performers=performers,
tags=tags,
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ł).
raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
)

View file

@ -1,11 +1,11 @@
"""TPDB movie enrichment + dedup.
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ę.
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:
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).
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
@ -168,7 +168,7 @@ def _best_match(
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:
movie.director = norm.director
if norm.release_year and not movie.release_year:
@ -243,7 +243,7 @@ def enrich_movie(
movie.studio_id = studio.id
# 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).
resolved: list[tuple[uuid.UUID, str | None]] = []
seen_perf_keys: set[str] = set()

View file

@ -4,16 +4,16 @@ Public API:
- `try_extract(sitetag, page_url) -> list[StreamSource] | None`
- `StreamSource` (dataclass)
- `HosterDead` (exception)
- `extract_stream_from_hoster(iframe_url, *, referer)` — generic packer-based hoster extract
- `fetch_tube_html(url)` — Chrome TLS fingerprint fetch (curl_cffi)
- `browser_get(url)` — low-level
- `extract_stream_from_hoster(iframe_url, *, referer)`, generic packer-based hoster extract
- `fetch_tube_html(url)`, Chrome TLS fingerprint fetch (curl_cffi)
- `browser_get(url)`, low-level
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 →
modułowy extractor. `try_extract()` to thin wrapper z exception handlingiem.
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
@ -63,14 +63,14 @@ log = logging.getLogger(__name__)
# (lub po Fazie 2 migracji: `tube:<sitetag>`).
#
# 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
# 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).
_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
# 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.
# Bug-report e8ddd8d4: WebView fallback (`_vps_blocked_fallback`) ładował
# 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,
"epornercom": eporner.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
# 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.
# 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),
# 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),
# zamrożone sceny/źródła skasowane z DB. Brak ekstraktorów → zero resolve.
"xvideoscom": _ytdlp.extract,
"xnxxcom": _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
# (`expires`+`md5`, NIE IP-bound) → mobile gra direct, zero VPS bandwidth.
"porntrexcom": porntrex.extract,
# fpoxxx — KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 →
# `videos3.fpo.xxx/remote_control.php?acctoken=<base64>` — zdekodowany acctoken
# fpoxxx, KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 →
# `videos3.fpo.xxx/remote_control.php?acctoken=<base64>`, zdekodowany acctoken
# zawiera WBITY IP serwera-resolvera → definitywnie IP-bound. WebView only.
"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).
# 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
@ -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
# (ads + klik-to-play + brak autoplay = dokładnie objaw z reportu 827a50a1).
"sxylandcom": _embed_iframe.extract,
# Aggregator tubes — generic embed-iframe → hoster unpacker
# Aggregator tubes, generic embed-iframe → hoster unpacker
"latestpornvideocom": latestpornvideo.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.
"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.
"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.
"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).
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
"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.
"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
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
"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
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
"fullporner": fullporner.extract,
# 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=`
# 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=`
# 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.
"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
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
"fullvideosporn": fullvideosporn.extract,
"siskavideo": _embed_iframe.extract,
"porn4dayspw": _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,
"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
# 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.
@ -158,24 +158,24 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
"xhamstercom": xhamster.extract,
# 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
# (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
# 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).
# Native, multi-quality, zero proxy/WebView. (zweryfikowane na emulatorze przed deploy)
"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
# (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
# testowałem finalny URL plain-curl-em → 403 → błędnie „IP-bound" → WebView.
# Teraz backend-resolve przez _kvs (curl_cffi chrome), native multi-quality,
# ZERO proxy (wcześniej force_proxy łamał no-proxy). Same mechanizm co freshporno.
"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.
"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/ +
# 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,
@ -183,60 +183,60 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# ad-heavy stronę a scrape łapał preroll-reklamę (bkcdn) zamiast wideo.
"yespornvip": yespornvip.extract,
# Direct-scraping tubes (mają też search scraper w connectors/direct_scrapers/)
# — używają identycznego embed-iframe pattern dla streamingu.
# hdporn92com — DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe,
# używają identycznego embed-iframe pattern dla streamingu.
# hdporn92com, DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe,
# JS hijackuje kliki na popunder. Wszystkie playback_sources mass-marked dead.
# 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.
# 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
# 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.
# 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
# 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
# segmentów. Zamiast WebView oddajemy m3u8 wprost.
"perverzijacom": perverzija.extract,
# Special: WebView-only (Yii2 session-bound player).
"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
# /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,
# 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, BRAKOWAŁO extractora (try_extract→None→"no stream";
# fullmovies.xxx + hdporn.gg, ta sama platforma (`<source>/get_file/8512/`).
# 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
# 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
# „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.)
"fullmoviesxxx": fullmovies.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:
# strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP, zweryfikowane),
# 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
# (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
# 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
# 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.
# javflix (JAV, WordPress) — przyciski serwerów to `<a class="myLink" href="<embed>">`
# "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>">`
# (streamtape/voe/doodstream/emturbovid). Generyczny _embed_iframe łapie je anchor-hoster
# patternem → type='hoster', telefon resolwuje (voe backend, dood/filemoon phone-side).
# Wrapper javflix.extract odsiewa placeholder players.mp4.
"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,
# 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,
# 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).
"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
# być phone-side: WebView ładuje stronę z residential IP telefonu, INJECTED_JS
# 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
- 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.
"""
extractor = _REGISTRY.get(sitetag)
@ -273,7 +273,7 @@ def supported_sitetags() -> tuple[str, ...]:
def is_vps_blocked_fallback(sitetag: str) -> bool:
"""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
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)."""

View file

@ -38,7 +38,7 @@ _DEFAULT_UA = (
@dataclass
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
text: str
@ -60,7 +60,7 @@ def browser_get(
) -> FetchResult:
"""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
ISP proxy). NIE dla streamów."""
if not _HAS_CURL_CFFI:
@ -78,7 +78,7 @@ def browser_get(
impersonate=impersonate,
allow_redirects=follow_redirects,
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))
@ -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.
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.
"""
import time as _time

View file

@ -9,10 +9,10 @@ from typing import Any
class StreamSource:
"""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`.
`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
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`.

View file

@ -1,17 +1,17 @@
"""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
detail page i jest dostępne w jednym z patternów:
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):
`<meta property="og:video:duration" content="PT16M32S">`.
3. **Schema.org VideoObject LD-JSON** (xvideos, xnxx, KVS-based):
`"duration": "PT00H07M10S"` w JSON-LD `<script type="application/ld+json">`.
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.
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
)
# 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(
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)?',

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.
Tu jest:
- `unpack_packer(js)` — dekoder P.A.C.K.E.R.
- `extract_stream_from_hoster(iframe_url, *, referer)` — fetch embed → unpack → m3u8/mp4
- `unpack_packer(js)`, dekoder P.A.C.K.E.R.
- `extract_stream_from_hoster(iframe_url, *, referer)`, fetch embed → unpack → m3u8/mp4
Te funkcje są używane przez:
1. Per-tube extractors (latestpornvideo, hqporner fallback) — page → embed iframe → tu
2. Movies playback (api/playback.py movies_router) — direct hoster URL → tu
1. Per-tube extractors (latestpornvideo, hqporner fallback), page → embed iframe → tu
2. Movies playback (api/playback.py movies_router), direct hoster URL → tu
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__)
# 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}
# ('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.
_PACKER_ARGS_RE = re.compile(
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
# 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.
_HOSTER_FALLBACK_URL_RE = re.compile(
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.
# 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.
_HOSTER_DEAD_PATTERNS = (
"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
# 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
# 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.
_KVS_MARKERS = ("kt_player(", "license_code")
# File hosters / known dead — rapidgator/nitroflare/frdl wymagają premium account
# (zwracają HTML z formularzem logowania zamiast video). Zwróć None bez fetch'u —
# File hosters / known dead, rapidgator/nitroflare/frdl wymagają premium account
# (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
# 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
# w naszej DB jest DMCA-dead ale ~5% żyje.
_FILE_HOSTER_RE = re.compile(
@ -169,8 +169,8 @@ def extract_stream_from_hoster(
return None
# 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=...`
# — generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme).
# `miixdrop` (double-i) to current canonical domain — wszystkie legacy
# generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme).
# `miixdrop` (double-i) to current canonical domain, wszystkie legacy
# `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
# embed src) trafiałyby do generic logic, gdzie regex `https?://...\.mp4`
@ -182,7 +182,7 @@ def extract_stream_from_hoster(
return sources[0].link
# Fall through to generic logic gdyby dedicated zwrócił None.
# 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
# HTML → generic logic zwracała None → WebView fallback. Trywialna podmiana endpointu.
_xtr = re.search(
@ -200,9 +200,9 @@ def extract_stream_from_hoster(
sources = streamtape.extract(iframe_url, timeout=timeout)
if sources:
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
# — 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.
from app.extractors.hosters import seekplayer_engine
if seekplayer_engine.matches(iframe_url):
@ -226,7 +226,7 @@ def extract_stream_from_hoster(
if sources:
return sources[0].link
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.
# Zwracamy None od razu → _embed_iframe Stage 2 → type='hoster' → mobile
# 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)
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
# 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
@ -305,7 +305,7 @@ def extract_stream_from_hoster(
if (url := _first_non_ad(_HOSTER_FALLBACK_URL_RE, html, 0)):
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),
# tylko gdy nasze własne metody zawiodły.
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 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.
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).
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.
formats = info.get("formats") or [info]
for fmt in formats:
@ -375,7 +375,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
url = fmt.get("url")
if _looks_like_video_url(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")
if _looks_like_video_url(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):
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
`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
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.
"""
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:
"""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
da się wyprodukować, żeby nie oddawać martwego źródła."""
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):
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:
`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,
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
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:
# 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).
# Brak headerów powodował że extract() na żywym mixdrop ID dostawał shell bez
# 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):
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.
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`, ...
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).
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.
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)
- upns (14287)
- player4me (41040)
@ -61,10 +61,10 @@ _IV = b"1234567890oiuytr"
#
# 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
# 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
# (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.
_HOST_RE = re.compile(
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()
# 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.
# CDN servuje cert na IP — fetch wymaga verify=False (stream_proxy.py ma już
# 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ż
# taką gałąź dla IP-host m3u8).
sources: list[StreamSource] = []
if source:

View file

@ -10,14 +10,14 @@ Pattern (verified 2026-05-15 z residential, live URL `/e/PZqBZp4OomF0Q61`):
'//streamtape.com/get_video' +
('<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=...` →
302 → `https://<cluster>.tapecontent.net/radosgw/<id>/<signed_path>/<title>.mp4`
(direct mp4, video/mp4 ~500MB, brak IP-bind).
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.
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__)
# 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.
# 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?`.
_ASSIGN_RE = re.compile(
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):
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
# (VPS variant gdzie decoy assignmenty produkują `.comb/get_video`).
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/...`
else:
url = combined
# Walidacja — odsiewa decoys (`streamtape.comb`, `streamtape.cob`).
# Walidacja, odsiewa decoys (`streamtape.comb`, `streamtape.cob`).
if (
"streamtape.com/get_video?" 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>'
Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.) —
Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.),
bierzemy LITERAL `Location` z window.location.href assignment.
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: `@$`, `^^`, `~@`, `%?`, `*~`, `!!`, `#&`.
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)
+ `fallback: [{file: ".mp4", type: "mp4", label: "720"}]`. URL signed
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.
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.
5. CDN host losowy ale wzorzec stały. Wymaga Referer = voe.sx (lub mirror) bo
token z `?node=` valida.
@ -72,7 +72,7 @@ def _decode_payload(payload: str) -> dict | None:
for sep in _MAGIC_SEPS:
s = s.replace(sep, "_")
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")
shifted = "".join(chr(ord(c) - 3) for c in b)
reversed_str = shifted[::-1]
@ -142,7 +142,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
if isinstance(fallback, dict):
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.
referer = "https://voe.sx/"
sources: list[StreamSource] = []

View file

@ -1,13 +1,13 @@
"""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
zawiera typowo 2-5 iframes:
- ad-iframes (willingcease.com, popads, discord, about:blank)
- 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)
- 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)
`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
# 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
# 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(
r"willingcease\.com"
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.
UWAGA: nie zwracamy auto-generated word-ngramów (KVS bywa generuje "Tania",
"Tania Amazon", "Amazon Shaft"... — bez wartości semantycznej). Dlatego pornditt
NIE jest tu obsłużone — KVS tagi to garbage.
"Tania Amazon", "Amazon Shaft"..., bez wartości semantycznej). Dlatego pornditt
NIE jest tu obsłużone, KVS tagi to garbage.
"""
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]:
"""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)
if not m:
@ -60,7 +60,7 @@ def extract_youporn(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(
r'<a[^>]+href="[^"]*?/tags/([a-z0-9-]+)/?"[^>]*>',
html, re.IGNORECASE,
@ -71,7 +71,7 @@ def extract_xvideos(html: str) -> list[str]:
def extract_xnxx(html: str) -> list[str]:
"""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).
"""
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]:
"""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:
`Watch porn video <Title>. Video duration is Xmin Ysec. Tags related to
this video: handjob, babe, for cash, blowjob, black hair.`

View file

@ -10,7 +10,7 @@ jednym z patternów:
`<meta name="twitter:image" content="...">`
3. **Schema.org VideoObject LD-JSON**:
`"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')`
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
# atrybucie onclick (`changeImage('//cdn/_main.jpg', 'cover_<id>')`) lub w preload
# 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.
_HQPORNER_THUMB_RE = re.compile(
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).
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
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
osobnych config'ów.
"""
@ -30,7 +30,7 @@ from app.extractors.hoster import extract_stream_from_hoster
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,
# .stream, .pro, .cloud, .live).
ANY_IFRAME_RE = re.compile(
@ -38,11 +38,11 @@ ANY_IFRAME_RE = re.compile(
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
# /embed-<id>(.html)? → xtapes.porn, niektóre stare 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(
r'/(?:e|embed|video/embed|v|watch|t|f|d|stream)/[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,
# 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.
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ą.
AD_DOMAIN_RE = re.compile(
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)
# 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,
# 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
# uniknąć false-positives z thumbnail/preview generation endpoints.
_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
# marker w path) ALE które NIE serwują direct stream:
# 1. File hosters (rapidgator/k2s/mediafire/...) — file storage, premium auth gate
# Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4` — to download URL,
# 1. File hosters (rapidgator/k2s/mediafire/...), file storage, premium auth gate
# Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4`, to download URL,
# 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.
# Bez filter Stage 0.5 zwracał tę URL jako type=mp4 i ExoPlayer dostawał HTML zamiast
# video → black screen / playback failed.
@ -106,11 +106,11 @@ _NOT_DIRECT_STREAM_RE = re.compile(
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
# 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> =`.
# 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.
_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*'
@ -118,9 +118,9 @@ _JS_SERVER_URL_RE = re.compile(
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.
# 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(
r'<a\s+[^>]*href=["\'](?P<url>https?://(?:'
r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|'
@ -131,10 +131,10 @@ _ANCHOR_HOSTER_RE = re.compile(
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>";`
# 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.
# Po match'u czyścimy `\/` → `/` w captured URL.
_JS_ESCAPED_IFRAME_SRC_RE = re.compile(
@ -151,18 +151,18 @@ _DATA_EMBED_RE = re.compile(
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).
#
# 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
# 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
# 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.
# 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).
# Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html`
# 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'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors
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
# za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22:
# POST /api/videos/<code>/embed/playback {"fingerprint":{}} → AES-256-GCM →
@ -188,12 +188,12 @@ DEAD_HOSTER_RE = re.compile(
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)
# 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
# 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.
CAPTCHA_HOSTER_RE = re.compile(
r'(?:'
@ -203,7 +203,7 @@ CAPTCHA_HOSTER_RE = re.compile(
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
# 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).
@ -215,14 +215,14 @@ _IP_BOUND_CDN_RE = re.compile(
r"premilkyway\.com" # latestpornvideo
r"|tnmr\.org" # mypornerleak (legacy CDN)
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
# → natywny player ładował HTML-player w nieskończoność (reports 06-24 "perverzija
# 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.
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=`
# — `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",
re.IGNORECASE,
)
@ -243,10 +243,10 @@ def _is_player_iframe(url: str) -> bool:
return False
if DEAD_HOSTER_RE.search(url):
return False
# Player path — `/e/`, `/embed/`, `/video/embed/`
# Player path, `/e/`, `/embed/`, `/video/embed/`
if PLAYER_PATH_RE.search(url):
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("/"))
if BARE_SLUG_PATH_RE.match(parsed.path) and not parsed.query:
return True
@ -279,13 +279,13 @@ def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSou
seen.add(url)
if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url):
continue
# Filter pseudo-direct URLs: file hosters (rapidgator/k2s — premium auth gate)
# i embed pages z .mp4 suffix (playmogo/dood /d/ — to HTML, nie video).
# Filter pseudo-direct URLs: file hosters (rapidgator/k2s, premium auth gate)
# i embed pages z .mp4 suffix (playmogo/dood /d/ to HTML, nie video).
if _NOT_DIRECT_STREAM_RE.search(url):
continue
# KVS false-positives (report c156e4b7/8db71220, watchporn przez _embed_iframe):
# 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.
low = url.lower()
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
# native players). Wymagamy quality marker w path (`<N>p.mp4`) żeby uniknąć
# 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)
if direct_sources:
log.info("embed_iframe: found %d direct stream URLs on %s",
@ -336,7 +336,7 @@ def extract(
return direct_sources
# 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)
# 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
@ -364,7 +364,7 @@ def extract(
# 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 = "..."`
# 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).
for m in _JS_SERVER_URL_RE.finditer(page_html):
candidate = m.group("url").strip()
@ -416,7 +416,7 @@ def extract(
iframe_urls.append(candidate)
# 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:\/\/...\" ...>"`.
# Po match'u czyścimy backslash escape: `\/` → `/`, `\\\"` → `"` (jeśli)
for m in _JS_ESCAPED_IFRAME_SRC_RE.finditer(page_html):
@ -435,17 +435,17 @@ def extract(
iframe_urls.append(candidate)
# 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
# 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ę.
iframe_urls.sort(key=_hoster_priority)
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
# 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:
log.info(
"embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None",
@ -454,7 +454,7 @@ def extract(
return None
# 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
# 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)
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)")]
@ -464,9 +464,9 @@ def extract(
# 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
# 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.
# 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.
if all(marker in page_html for marker in ("kt_player(", "license_code")):
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.
# Direct mp4/m3u8 idzie PIERWSZY w wyniku (ExoPlayer natywnie >> WebView), ale
# 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
# 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
# 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:
# 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)
continue
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
# release: premilkyway/tnmr.org bind token do requester IP.
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)"))
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.
# 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
# pierwszy, a drugi jako backup). Quality ustawiamy na nazwę hosta iframe'a żeby
# 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
# główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player.
sources = []

View file

@ -1,14 +1,14 @@
"""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>/
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
output kt_player).
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
samej sesji curl_cffi co fetch strony. Oddajemy finalny portable CDN url per jakość →
mobile gra direct natywnie, multi-quality, zero WebView/reklam/proxy.
@ -29,9 +29,9 @@ from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
# Ł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)
# - `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)
# W obu przypadkach finał to get_file → 302 → CDN (follow in-session).
_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
# 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
# wisiały po 60s = 180s → mobile "resolving w nieskończoność".
_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.
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:
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
# 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)
seen_dec: set[str] = set()
result: list[StreamSource] = []

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