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:
parent
017927793a
commit
a4486980a4
291 changed files with 1745 additions and 1745 deletions
|
|
@ -30,7 +30,7 @@ router = APIRouter(
|
||||||
def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
|
def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
|
||||||
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła.
|
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła.
|
||||||
TPDB ma `image`/`poster`/`background.large`. StashDB raw nie zawiera image
|
TPDB ma `image`/`poster`/`background.large`. StashDB raw nie zawiera image
|
||||||
(osobny query do StashDB potrzebny — tu zwracamy None)."""
|
(osobny query do StashDB potrzebny, tu zwracamy None)."""
|
||||||
if kind == SourceKind.tpdb:
|
if kind == SourceKind.tpdb:
|
||||||
for k in ("image", "poster"):
|
for k in ("image", "poster"):
|
||||||
v = raw.get(k)
|
v = raw.get(k)
|
||||||
|
|
@ -42,7 +42,7 @@ def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
|
||||||
if isinstance(v, str) and v.startswith("http"):
|
if isinstance(v, str) and v.startswith("http"):
|
||||||
return v
|
return v
|
||||||
elif kind == SourceKind.stashdb:
|
elif kind == SourceKind.stashdb:
|
||||||
# StashDB scene response includes images via separate query — nie trzymamy
|
# StashDB scene response includes images via separate query, nie trzymamy
|
||||||
# tego w raw obecnie. TODO: dorzucić mirror do `paths.screenshot` przy ingest.
|
# tego w raw obecnie. TODO: dorzucić mirror do `paths.screenshot` przy ingest.
|
||||||
paths = raw.get("paths")
|
paths = raw.get("paths")
|
||||||
if isinstance(paths, dict):
|
if isinstance(paths, dict):
|
||||||
|
|
@ -142,7 +142,7 @@ def list_candidates(
|
||||||
|
|
||||||
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
|
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
|
||||||
# używa statycznego do listy + animowanego po hold-to-preview). Wybieramy najpierw
|
# używa statycznego do listy + animowanego po hold-to-preview). Wybieramy najpierw
|
||||||
# napotkany niepusty URL — kolejność rzędów playback_sources nie jest gwarantowana,
|
# napotkany niepusty URL, kolejność rzędów playback_sources nie jest gwarantowana,
|
||||||
# ale dla triage to wystarcza.
|
# ale dla triage to wystarcza.
|
||||||
thumbs: dict[uuid.UUID, str] = {}
|
thumbs: dict[uuid.UUID, str] = {}
|
||||||
animated_thumbs: dict[uuid.UUID, str] = {}
|
animated_thumbs: dict[uuid.UUID, str] = {}
|
||||||
|
|
@ -292,7 +292,7 @@ def _fmt_bytes(b: int) -> str:
|
||||||
def bandwidth_stats() -> BandwidthStats:
|
def bandwidth_stats() -> BandwidthStats:
|
||||||
"""Per-CDN VPS proxy bytes-out + Hetzner traffic stats.
|
"""Per-CDN VPS proxy bytes-out + Hetzner traffic stats.
|
||||||
|
|
||||||
Critical dla public release — pokazuje gdzie VPS bandwidth wycieka. Pozwala
|
Critical dla public release, pokazuje gdzie VPS bandwidth wycieka. Pozwala
|
||||||
spotted Mixdrop / bandwidth-heavy CDN-y przed Hetzner overage charge.
|
spotted Mixdrop / bandwidth-heavy CDN-y przed Hetzner overage charge.
|
||||||
"""
|
"""
|
||||||
from app.api.stream_proxy import get_bandwidth_stats
|
from app.api.stream_proxy import get_bandwidth_stats
|
||||||
|
|
@ -308,7 +308,7 @@ def bandwidth_stats() -> BandwidthStats:
|
||||||
s_24h = get_bandwidth_stats(24)
|
s_24h = get_bandwidth_stats(24)
|
||||||
s_7d = get_bandwidth_stats(168)
|
s_7d = get_bandwidth_stats(168)
|
||||||
|
|
||||||
# Hetzner stats — load from cache file (written by check_hetzner_traffic.py cron).
|
# Hetzner stats, load from cache file (written by check_hetzner_traffic.py cron).
|
||||||
hetzner_data = None
|
hetzner_data = None
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.hetzner_api_token and settings.hetzner_server_id:
|
if settings.hetzner_api_token and settings.hetzner_server_id:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
|
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
|
||||||
|
|
||||||
Endpointy:
|
Endpointy:
|
||||||
GET /ui/ — lista pending (filter status)
|
GET /ui/ lista pending (filter status)
|
||||||
GET /ui/candidate/{id} — side-by-side scen
|
GET /ui/candidate/{id} side-by-side scen
|
||||||
POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject)
|
POST /ui/candidate/{id}/resolve, htmx form submit (action=merge_keep_left|merge_keep_right|reject)
|
||||||
zwraca fragment HTML z potwierdzeniem
|
zwraca fragment HTML z potwierdzeniem
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -197,9 +197,9 @@ def resolve_form(
|
||||||
|
|
||||||
|
|
||||||
def mount_static(app) -> None: # pragma: no cover - dev convenience
|
def mount_static(app) -> None: # pragma: no cover - dev convenience
|
||||||
# APK MIME type — bez tego Android Browser nie traktuje pliku jako instalable APK
|
# APK MIME type, bez tego Android Browser nie traktuje pliku jako instalable APK
|
||||||
# (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest
|
# (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest
|
||||||
# idempotentna na poziomie procesu — bezpiecznie wywoływać przy każdym startup.
|
# idempotentna na poziomie procesu, bezpiecznie wywoływać przy każdym startup.
|
||||||
import mimetypes
|
import mimetypes
|
||||||
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
|
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
|
||||||
if _STATIC_DIR.exists():
|
if _STATIC_DIR.exists():
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"""Blacklists — globalnie ukryte performerki/studia/tagi.
|
"""Blacklists, globalnie ukryte performerki/studia/tagi.
|
||||||
|
|
||||||
Sceny które MAJĄ blacklisted entity wypadają z każdego /scenes (pełna lista, search,
|
Sceny które MAJĄ blacklisted entity wypadają z każdego /scenes (pełna lista, search,
|
||||||
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
|
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
|
||||||
|
|
||||||
Endpointy:
|
Endpointy:
|
||||||
GET /blacklist — wszystkie 3 listy w jednym response
|
GET /blacklist wszystkie 3 listy w jednym response
|
||||||
POST /blacklist/{kind}/{entity_id} — dodaj (idempotent)
|
POST /blacklist/{kind}/{entity_id} dodaj (idempotent)
|
||||||
DELETE /blacklist/{kind}/{entity_id} — usuń
|
DELETE /blacklist/{kind}/{entity_id} usuń
|
||||||
|
|
||||||
`kind` ∈ {performer, studio, tag}.
|
`kind` ∈ {performer, studio, tag}.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"""Bug reports — mobile FAB → POST /bug-reports → admin lista przez admin_html.
|
"""Bug reports, mobile FAB → POST /bug-reports → admin lista przez admin_html.
|
||||||
|
|
||||||
POST nie wymaga obecnego scene_id (user może raportować z FavoritesScreen,
|
POST nie wymaga obecnego scene_id (user może raportować z FavoritesScreen,
|
||||||
SearchScreen itp.). Screenshot opcjonalny — niektóre ekrany nie warto kapturować.
|
SearchScreen itp.). Screenshot opcjonalny, niektóre ekrany nie warto kapturować.
|
||||||
|
|
||||||
Limit body 1.5MB (FastAPI default jest hojny, ale dla rozsądku ograniczamy).
|
Limit body 1.5MB (FastAPI default jest hojny, ale dla rozsądku ograniczamy).
|
||||||
Screenshot to PNG/JPEG z react-native-view-shot, base64 — typowe rozmiary:
|
Screenshot to PNG/JPEG z react-native-view-shot, base64, typowe rozmiary:
|
||||||
- mały ekran scene-list: ~200-400KB
|
- mały ekran scene-list: ~200-400KB
|
||||||
- duży scene-detail z thumbnail: ~600KB-1MB
|
- duży scene-detail z thumbnail: ~600KB-1MB
|
||||||
"""
|
"""
|
||||||
|
|
@ -92,7 +92,7 @@ def create_bug_report(
|
||||||
scene_id = payload.scene_id
|
scene_id = payload.scene_id
|
||||||
elif session.get(Movie, payload.scene_id) is not None:
|
elif session.get(Movie, payload.scene_id) is not None:
|
||||||
movie_id = payload.scene_id
|
movie_id = payload.scene_id
|
||||||
# else: ID nie istnieje już nigdzie (deleted) — drop oba na null
|
# else: ID nie istnieje już nigdzie (deleted), drop oba na null
|
||||||
|
|
||||||
br = BugReport(
|
br = BugReport(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
|
@ -211,7 +211,7 @@ def get_bug_report_screenshot(
|
||||||
bug_id: uuid.UUID,
|
bug_id: uuid.UUID,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> dict[str, str | None]:
|
) -> dict[str, str | None]:
|
||||||
"""Zwraca base64-encoded screenshot (jeśli jest) — admin UI go renderuje."""
|
"""Zwraca base64-encoded screenshot (jeśli jest), admin UI go renderuje."""
|
||||||
br = session.get(BugReport, bug_id)
|
br = session.get(BugReport, bug_id)
|
||||||
if br is None:
|
if br is None:
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ nowi użytkownicy widzieli/nadpisywali ulubione, watched-badge i blacklisty Jana
|
||||||
nagłówku `X-Device-Id`; backend scope'uje wszystkie tabele stanu po `device_id`.
|
nagłówku `X-Device-Id`; backend scope'uje wszystkie tabele stanu po `device_id`.
|
||||||
|
|
||||||
Stare wiersze (sprzed migracji) mają `device_id = LEGACY_DEVICE`. Klient bez nagłówka
|
Stare wiersze (sprzed migracji) mają `device_id = LEGACY_DEVICE`. Klient bez nagłówka
|
||||||
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE — czyli dostaje dawną
|
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE, czyli dostaje dawną
|
||||||
współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy`
|
współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy`
|
||||||
przepina LEGACY rows na konkretne device (Jan robi to raz po update).
|
przepina LEGACY rows na konkretne device (Jan robi to raz po update).
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ Każdy update wgrany przez `scripts/publish_update.py` ląduje w
|
||||||
`app/static/expo-updates/<runtime>/<update_id>/`. Plik
|
`app/static/expo-updates/<runtime>/<update_id>/`. Plik
|
||||||
`app/static/expo-updates/<runtime>/current.json` wskazuje aktywny update_id.
|
`app/static/expo-updates/<runtime>/current.json` wskazuje aktywny update_id.
|
||||||
|
|
||||||
Endpointy SĄ PUBLICZNE (no auth) — Expo Updates SDK nie wstrzykuje X-API-Key.
|
Endpointy SĄ PUBLICZNE (no auth), Expo Updates SDK nie wstrzykuje X-API-Key.
|
||||||
Bezpieczeństwo opiera się na TLS pinningu (mobile ufa tylko naszej self-signed
|
Bezpieczeństwo opiera się na TLS pinningu (mobile ufa tylko naszej self-signed
|
||||||
cert SPKI z network_security_config) — ktoś bez tego pinu nie podstawi MITM
|
cert SPKI z network_security_config), ktoś bez tego pinu nie podstawi MITM
|
||||||
manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key.
|
manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -35,7 +35,7 @@ def get_manifest(
|
||||||
expo_platform: str | None = Header(default=None, alias="expo-platform"),
|
expo_platform: str | None = Header(default=None, alias="expo-platform"),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0)
|
"""Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0)
|
||||||
+ platform (default android — i tak tylko Android wspieramy).
|
+ platform (default android, i tak tylko Android wspieramy).
|
||||||
|
|
||||||
204 No Content gdy nie ma update'u dla tego runtime'u → klient nadal odpala
|
204 No Content gdy nie ma update'u dla tego runtime'u → klient nadal odpala
|
||||||
embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest
|
embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest
|
||||||
|
|
@ -87,7 +87,7 @@ def get_asset(
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Serwuje pojedynczy asset (JS bundle, image, font) z update directory.
|
"""Serwuje pojedynczy asset (JS bundle, image, font) z update directory.
|
||||||
|
|
||||||
`asset` to relative path względem `static/expo-updates/<runtime>/` —
|
`asset` to relative path względem `static/expo-updates/<runtime>/`,
|
||||||
zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub
|
zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub
|
||||||
`<update_id>/assets/<hash>`. Path traversal blocked przez resolve+is_relative.
|
`<update_id>/assets/<hash>`. Path traversal blocked przez resolve+is_relative.
|
||||||
"""
|
"""
|
||||||
|
|
@ -103,7 +103,7 @@ def get_asset(
|
||||||
raise HTTPException(status_code=400, detail="invalid asset path")
|
raise HTTPException(status_code=400, detail="invalid asset path")
|
||||||
if not target.exists() or not target.is_file():
|
if not target.exists() or not target.is_file():
|
||||||
raise HTTPException(status_code=404, detail="asset not found")
|
raise HTTPException(status_code=404, detail="asset not found")
|
||||||
# Content type — bundle to text/javascript, reszta autodetect przez FileResponse.
|
# Content type, bundle to text/javascript, reszta autodetect przez FileResponse.
|
||||||
media_type = None
|
media_type = None
|
||||||
if target.suffix in (".js", ".bundle"):
|
if target.suffix in (".js", ".bundle"):
|
||||||
media_type = "application/javascript"
|
media_type = "application/javascript"
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
"""Favorites — ulubione performerki + studia + liczenie nowych scen.
|
"""Favorites, ulubione performerki + studia + liczenie nowych scen.
|
||||||
|
|
||||||
Single-user (brak users), więc API zwraca/operuje na global zbiorze. Multi-user
|
Single-user (brak users), więc API zwraca/operuje na global zbiorze. Multi-user
|
||||||
można dodać dorzuceniem `user_id` query/header bez breaking change.
|
można dodać dorzuceniem `user_id` query/header bez breaking change.
|
||||||
|
|
||||||
Endpointy (performers — `/favorites/...` zostawione żeby nie łamać starego mobile):
|
Endpointy (performers, `/favorites/...` zostawione żeby nie łamać starego mobile):
|
||||||
GET /favorites — lista ulubionych performerek
|
GET /favorites lista ulubionych performerek
|
||||||
POST /favorites/{performer_id} — dodaj (idempotent)
|
POST /favorites/{performer_id} dodaj (idempotent)
|
||||||
DELETE /favorites/{performer_id} — usuń
|
DELETE /favorites/{performer_id} usuń
|
||||||
POST /favorites/{performer_id}/seen — mark-as-seen (zeruje badge)
|
POST /favorites/{performer_id}/seen mark-as-seen (zeruje badge)
|
||||||
|
|
||||||
Endpointy (studios):
|
Endpointy (studios):
|
||||||
GET /favorites/studios — lista ulubionych studiów
|
GET /favorites/studios lista ulubionych studiów
|
||||||
POST /favorites/studios/{studio_id} — dodaj
|
POST /favorites/studios/{studio_id} dodaj
|
||||||
DELETE /favorites/studios/{studio_id} — usuń
|
DELETE /favorites/studios/{studio_id} usuń
|
||||||
POST /favorites/studios/{studio_id}/seen — mark-as-seen
|
POST /favorites/studios/{studio_id}/seen, mark-as-seen
|
||||||
|
|
||||||
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
|
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
|
||||||
- dla performerki: ScenePerformer.performer_id = X
|
- dla performerki: ScenePerformer.performer_id = X
|
||||||
|
|
@ -61,7 +61,7 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
||||||
tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy
|
tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy
|
||||||
playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem
|
playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem
|
||||||
release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca
|
release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca
|
||||||
{group_id: n} — bez streamowania N*200 wierszy do Pythona.
|
{group_id: n}, bez streamowania N*200 wierszy do Pythona.
|
||||||
|
|
||||||
kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers.
|
kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers.
|
||||||
kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują
|
kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują
|
||||||
|
|
@ -134,7 +134,7 @@ def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
||||||
|
|
||||||
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
|
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
|
||||||
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
|
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
|
||||||
top-N ani odsiewu stub/backfill — te pojęcia dotyczą scen.
|
top-N ani odsiewu stub/backfill, te pojęcia dotyczą scen.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
|
@ -198,7 +198,7 @@ class FavoriteOut(BaseModel):
|
||||||
class FavoriteListOut(BaseModel):
|
class FavoriteListOut(BaseModel):
|
||||||
items: list[FavoriteOut]
|
items: list[FavoriteOut]
|
||||||
total: int
|
total: int
|
||||||
new_total: int # suma new_count po wszystkich — dla badge w toolbar
|
new_total: int # suma new_count po wszystkich, dla badge w toolbar
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=FavoriteListOut)
|
@router.get("", response_model=FavoriteListOut)
|
||||||
|
|
@ -216,7 +216,7 @@ def list_favorites(
|
||||||
return FavoriteListOut(items=[], total=0, new_total=0)
|
return FavoriteListOut(items=[], total=0, new_total=0)
|
||||||
|
|
||||||
# scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez
|
# scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez
|
||||||
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
|
# _job_refresh_taxonomy_counts), ta sama definicja co przed (sceny z żywym
|
||||||
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
||||||
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
||||||
new_counts = _merged_new_counts(session, device_id, kind="performer")
|
new_counts = _merged_new_counts(session, device_id, kind="performer")
|
||||||
|
|
@ -274,7 +274,7 @@ def remove_favorite(
|
||||||
) -> None:
|
) -> None:
|
||||||
fav = session.get(FavoritePerformer, (device_id, performer_id))
|
fav = session.get(FavoritePerformer, (device_id, performer_id))
|
||||||
if fav is None:
|
if fav is None:
|
||||||
# idempotent — brak ulubionego = nie ma nic do usunięcia, success
|
# idempotent, brak ulubionego = nie ma nic do usunięcia, success
|
||||||
return
|
return
|
||||||
session.delete(fav)
|
session.delete(fav)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -416,7 +416,7 @@ def mark_studio_seen(
|
||||||
|
|
||||||
# ── Favorite movies ────────────────────────────────────────────────────────
|
# ── Favorite movies ────────────────────────────────────────────────────────
|
||||||
# Movies nie mają child scenes per-favorite (jak performerki/studia), więc
|
# Movies nie mają child scenes per-favorite (jak performerki/studia), więc
|
||||||
# `last_seen_at` nie jest tu używany do NEW count — tylko jako tracking ostatniego
|
# `last_seen_at` nie jest tu używany do NEW count, tylko jako tracking ostatniego
|
||||||
# wglądu przez usera. Mobile używa NEW badge w liście /movies przez OSOBNY
|
# wglądu przez usera. Mobile używa NEW badge w liście /movies przez OSOBNY
|
||||||
# globalny last_seen z AsyncStorage (client-side, brak backendowego state).
|
# globalny last_seen z AsyncStorage (client-side, brak backendowego state).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
|
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
|
||||||
`POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty)
|
`POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty)
|
||||||
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki — kolejne
|
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki, kolejne
|
||||||
wywołania nic nie znajdą (legacy już puste). Jeśli na keep-device istnieje już wiersz
|
wywołania nic nie znajdą (legacy już puste). Jeśli na keep-device istnieje już wiersz
|
||||||
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem
|
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem
|
||||||
kasujemy resztki legacy).
|
kasujemy resztki legacy).
|
||||||
|
|
@ -22,7 +22,7 @@ from app.db import get_session
|
||||||
|
|
||||||
router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)])
|
router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)])
|
||||||
|
|
||||||
# (tabela, kolumna-encji) — wszystkie tabele device-scoped (migracja 0022).
|
# (tabela, kolumna-encji), wszystkie tabele device-scoped (migracja 0022).
|
||||||
_TABLES: list[tuple[str, str]] = [
|
_TABLES: list[tuple[str, str]] = [
|
||||||
("favorite_performers", "performer_id"),
|
("favorite_performers", "performer_id"),
|
||||||
("favorite_studios", "studio_id"),
|
("favorite_studios", "studio_id"),
|
||||||
|
|
@ -59,7 +59,7 @@ def import_from_device(
|
||||||
) -> ImportOut:
|
) -> ImportOut:
|
||||||
"""Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego
|
"""Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego
|
||||||
urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu
|
urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu
|
||||||
transferu — jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
|
transferu, jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
|
||||||
|
|
||||||
KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które
|
KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które
|
||||||
urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid()."""
|
urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid()."""
|
||||||
|
|
@ -114,7 +114,7 @@ def adopt_legacy(
|
||||||
).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
|
).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
|
||||||
)
|
)
|
||||||
moved[table] = res.rowcount or 0
|
moved[table] = res.rowcount or 0
|
||||||
# Resztki legacy (encje które device już miał) — skasuj, żeby nie wisiały.
|
# Resztki legacy (encje które device już miał), skasuj, żeby nie wisiały.
|
||||||
session.execute(
|
session.execute(
|
||||||
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
|
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""GET /movies — lista i szczegóły filmów."""
|
"""GET /movies, lista i szczegóły filmów."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -138,8 +138,8 @@ def list_movies(
|
||||||
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
|
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
|
|
||||||
# Movie playback origin policy — module-level (kiedyś było inline per-request
|
# Movie playback origin policy, module-level (kiedyś było inline per-request
|
||||||
# definition, code-review #19 — perf hit + dorosły kod).
|
# definition, code-review #19, perf hit + dorosły kod).
|
||||||
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
|
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
|
||||||
# random per origin).
|
# random per origin).
|
||||||
_MOVIE_PREFERRED_ORIGINS = (
|
_MOVIE_PREFERRED_ORIGINS = (
|
||||||
|
|
@ -150,9 +150,9 @@ _MOVIE_PREFERRED_ORIGINS = (
|
||||||
"streamporn",
|
"streamporn",
|
||||||
"pandamovies",
|
"pandamovies",
|
||||||
)
|
)
|
||||||
# File hosters które NIGDY nie dadzą się stream-extract bez premium account —
|
# File hosters które NIGDY nie dadzą się stream-extract bez premium account,
|
||||||
# odfiltrowywane całkowicie (zaśmiecały listę watch options, bug-report
|
# odfiltrowywane całkowicie (zaśmiecały listę watch options, bug-report
|
||||||
# 2026-05-15). Streamtape przywrócony 2026-05-15 — ma dedicated extractor,
|
# 2026-05-15). Streamtape przywrócony 2026-05-15, ma dedicated extractor,
|
||||||
# ~5% URLów żyje.
|
# ~5% URLów żyje.
|
||||||
_MOVIE_DROP_ORIGINS = frozenset({
|
_MOVIE_DROP_ORIGINS = frozenset({
|
||||||
"mangoporn:rapidgator",
|
"mangoporn:rapidgator",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve — rozwiązuje stream URL.
|
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve, rozwiązuje stream URL.
|
||||||
|
|
||||||
Mobile apka woła ten endpoint na klik "Watch" — backend ekstraktuje świeży
|
Mobile apka woła ten endpoint na klik "Watch", backend ekstraktuje świeży
|
||||||
stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez
|
stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez
|
||||||
Linking.openURL → Android player chooser (MX Player / VLC / browser).
|
Linking.openURL → Android player chooser (MX Player / VLC / browser).
|
||||||
|
|
||||||
Stream URLs są podpisane/expire (zwykle ~kilka godzin) — nie cache'ujemy ich
|
Stream URLs są podpisane/expire (zwykle ~kilka godzin), nie cache'ujemy ich
|
||||||
w DB, tylko resolve on-demand. Logika ekstrakcji per-tube w `app.extractors`.
|
w DB, tylko resolve on-demand. Logika ekstrakcji per-tube w `app.extractors`.
|
||||||
|
|
||||||
**Dead-link detection**: gdy hoster embed page mówi "Video deleted/not found",
|
**Dead-link detection**: gdy hoster embed page mówi "Video deleted/not found",
|
||||||
oznaczamy `PlaybackSource.dead_at = now()` — API dalej go nie listuje, mobile
|
oznaczamy `PlaybackSource.dead_at = now()`, API dalej go nie listuje, mobile
|
||||||
nie pokaże martwego buttonu.
|
nie pokaże martwego buttonu.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -39,23 +39,23 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)])
|
router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)])
|
||||||
|
|
||||||
# CDN-domain allowlist dla mobile direct fetch — token IS time-bound (nie IP-bound),
|
# CDN-domain allowlist dla mobile direct fetch, token IS time-bound (nie IP-bound),
|
||||||
# zweryfikowane cross-IP curl test 2026-05-18. Mobile ExoPlayer pobiera manifest+segments
|
# zweryfikowane cross-IP curl test 2026-05-18. Mobile ExoPlayer pobiera manifest+segments
|
||||||
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
|
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
|
||||||
#
|
#
|
||||||
# Verified time-bound:
|
# Verified time-bound:
|
||||||
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding) — signed token w path + exp_time
|
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding), signed token w path + exp_time
|
||||||
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube) — validfrom+validto+hash
|
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube), validfrom+validto+hash
|
||||||
# - privatehost.com (pornhat CDN) — sign + exp_time, brak Referer requirement
|
# - privatehost.com (pornhat CDN), sign + exp_time, brak Referer requirement
|
||||||
# - sxyprn.com — signed path
|
# - sxyprn.com, signed path
|
||||||
# - eporner.com CDN — IP literal w path ale CDN go ignoruje
|
# - eporner.com CDN, IP literal w path ale CDN go ignoruje
|
||||||
#
|
#
|
||||||
# NIE w allowlist (IP-bound, wymagają proxy):
|
# NIE w allowlist (IP-bound, wymagają proxy):
|
||||||
# - premilkyway.com (latestpornvideo) — 403 cross-IP
|
# - premilkyway.com (latestpornvideo), 403 cross-IP
|
||||||
# - tnmr.org (mypornerleak) — 403 cross-IP
|
# - tnmr.org (mypornerleak), 403 cross-IP
|
||||||
# - porntrex.com/get_file — single-use token (410 po reuse)
|
# - porntrex.com/get_file, single-use token (410 po reuse)
|
||||||
# - freshporno.org/get_file — cv= signed token IP-bound
|
# - freshporno.org/get_file, cv= signed token IP-bound
|
||||||
# - sn.porn-xp.com, porn00.org — force_proxy explicit
|
# - sn.porn-xp.com, porn00.org, force_proxy explicit
|
||||||
_TIME_BOUND_CDN_RE = re.compile(
|
_TIME_BOUND_CDN_RE = re.compile(
|
||||||
r"\b(?:"
|
r"\b(?:"
|
||||||
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
|
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
|
||||||
|
|
@ -65,10 +65,10 @@ _TIME_BOUND_CDN_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# IP-BOUND CDN signature — token bind do requester IP, cross-IP fetch = 403.
|
# IP-BOUND CDN signature, token bind do requester IP, cross-IP fetch = 403.
|
||||||
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
|
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
|
||||||
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
|
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
|
||||||
# latestpornvideo) — wszystkie używają tego samego CDN pool.
|
# latestpornvideo), wszystkie używają tego samego CDN pool.
|
||||||
_IP_BOUND_CDN_RE = re.compile(
|
_IP_BOUND_CDN_RE = re.compile(
|
||||||
r"\b(?:"
|
r"\b(?:"
|
||||||
r"premilkyway\.com" # latestpornvideo
|
r"premilkyway\.com" # latestpornvideo
|
||||||
|
|
@ -82,17 +82,17 @@ _IP_BOUND_CDN_RE = re.compile(
|
||||||
class StreamLink(BaseModel):
|
class StreamLink(BaseModel):
|
||||||
"""Pojedynczy variant stream URL (różne quality / kontener).
|
"""Pojedynczy variant stream URL (różne quality / kontener).
|
||||||
|
|
||||||
`stream_url` = URL do video file (mp4/m3u8/webm) — proxy-wrapped URL przez backend
|
`stream_url` = URL do video file (mp4/m3u8/webm), proxy-wrapped URL przez backend
|
||||||
VPS (`/proxy/{token}/play.ext`). Bezpieczny fallback gdy CDN binduje URL do IP
|
VPS (`/proxy/{token}/play.ext`). Bezpieczny fallback gdy CDN binduje URL do IP
|
||||||
extractora (np. fpo.xxx z kt_remote_ips cookie). Bandwidth idzie przez VPS.
|
extractora (np. fpo.xxx z kt_remote_ips cookie). Bandwidth idzie przez VPS.
|
||||||
|
|
||||||
`direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z
|
`direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z
|
||||||
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
|
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
|
||||||
poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`.
|
poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`.
|
||||||
Mobile próbuje direct PIERWSZY — gdy CDN zwróci 403/410 (IP-bound), spada na
|
Mobile próbuje direct PIERWSZY, gdy CDN zwróci 403/410 (IP-bound), spada na
|
||||||
`stream_url` (proxy). Daje 0 bandwidth na VPS-ie dla większości scen.
|
`stream_url` (proxy). Daje 0 bandwidth na VPS-ie dla większości scen.
|
||||||
|
|
||||||
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn) — mobile
|
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn), mobile
|
||||||
otwiera w WebView. Type: 'hoster'.
|
otwiera w WebView. Type: 'hoster'.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ def resolve_movie_playback(
|
||||||
playback_id: uuid.UUID,
|
playback_id: uuid.UUID,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> ResolveOut:
|
) -> ResolveOut:
|
||||||
"""Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`.
|
"""Movies playback resolve, analog do `/scenes/{id}/playback/{pb}/resolve`.
|
||||||
|
|
||||||
Origin patterns:
|
Origin patterns:
|
||||||
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
|
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
|
||||||
|
|
@ -150,7 +150,7 @@ def resolve_movie_playback(
|
||||||
if pb.origin == "paradisehill":
|
if pb.origin == "paradisehill":
|
||||||
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
|
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
|
||||||
# Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
|
# Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
|
||||||
# bez auth — 200 OK z plain User-Agent + Referer.
|
# bez auth, 200 OK z plain User-Agent + Referer.
|
||||||
# Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
|
# Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
|
||||||
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
|
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
|
||||||
# nie ma sposobu przejść do następnego.
|
# nie ma sposobu przejść do następnego.
|
||||||
|
|
@ -162,7 +162,7 @@ def resolve_movie_playback(
|
||||||
parts = []
|
parts = []
|
||||||
if parts:
|
if parts:
|
||||||
for url, label in parts:
|
for url, label in parts:
|
||||||
# NIE proxifikujemy tutaj — outer `_proxify_link` poniżej (linia 247) opakuje
|
# NIE proxifikujemy tutaj, outer `_proxify_link` poniżej (linia 247) opakuje
|
||||||
# wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
|
# wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
|
||||||
links.append(
|
links.append(
|
||||||
StreamLink(
|
StreamLink(
|
||||||
|
|
@ -174,7 +174,7 @@ def resolve_movie_playback(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: brak videoList (np. login-only movie) — WebView na całość.
|
# Fallback: brak videoList (np. login-only movie), WebView na całość.
|
||||||
links = [
|
links = [
|
||||||
StreamLink(
|
StreamLink(
|
||||||
stream_url=None,
|
stream_url=None,
|
||||||
|
|
@ -191,7 +191,7 @@ def resolve_movie_playback(
|
||||||
try:
|
try:
|
||||||
stream = extract_stream_from_hoster(target, referer=referer)
|
stream = extract_stream_from_hoster(target, referer=referer)
|
||||||
except HosterDead as e:
|
except HosterDead as e:
|
||||||
# Hoster wprost mówi "video deleted" — oznacz dead, NIE proponuj
|
# Hoster wprost mówi "video deleted", oznacz dead, NIE proponuj
|
||||||
# embed fallback (mobile ExoPlayer dostałby 404 HTML page i
|
# embed fallback (mobile ExoPlayer dostałby 404 HTML page i
|
||||||
# próbowałby zapisać jako .bin file; bug-report 2026-05-16
|
# próbowałby zapisać jako .bin file; bug-report 2026-05-16
|
||||||
# "streamtape ściąga hurtowo pliki .bin").
|
# "streamtape ściąga hurtowo pliki .bin").
|
||||||
|
|
@ -203,7 +203,7 @@ def resolve_movie_playback(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("movie hoster extract failed for %s: %s", target, e)
|
log.warning("movie hoster extract failed for %s: %s", target, e)
|
||||||
if stream and _IP_BOUND_CDN_RE.search(stream):
|
if stream and _IP_BOUND_CDN_RE.search(stream):
|
||||||
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.) — token bind do VPS IP,
|
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.), token bind do VPS IP,
|
||||||
# mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView).
|
# mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView).
|
||||||
log.info(
|
log.info(
|
||||||
"movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
|
"movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
|
||||||
|
|
@ -225,11 +225,11 @@ def resolve_movie_playback(
|
||||||
type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
|
type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
|
||||||
raw_meta: dict = {"origin": pb.origin, "host": target}
|
raw_meta: dict = {"origin": pb.origin, "host": target}
|
||||||
# seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/
|
# seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/
|
||||||
# embedseek/upns — ~322k sources) zwraca master.m3u8 na raw-IP CDN
|
# embedseek/upns, ~322k sources) zwraca master.m3u8 na raw-IP CDN
|
||||||
# (185.237.x/203.188.x/45.156.x `/v4/<tok>/<exp>/pp/<hash>/master.m3u8`).
|
# (185.237.x/203.188.x/45.156.x `/v4/<tok>/<exp>/pp/<hash>/master.m3u8`).
|
||||||
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
|
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
|
||||||
# manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
|
# manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
|
||||||
# VALID (verify=True OK — IP-SAN, nie self-signed jak głosił stary docstring).
|
# VALID (verify=True OK, IP-SAN, nie self-signed jak głosił stary docstring).
|
||||||
# Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer
|
# Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer
|
||||||
# gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink).
|
# gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink).
|
||||||
# Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
|
# Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
|
||||||
|
|
@ -246,7 +246,7 @@ def resolve_movie_playback(
|
||||||
raw=raw_meta,
|
raw=raw_meta,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Zawsze dorzucamy embed jako fallback — mobile WebView może wyłapać URL z JS-a
|
# Zawsze dorzucamy embed jako fallback, mobile WebView może wyłapać URL z JS-a
|
||||||
if pb.embed_url:
|
if pb.embed_url:
|
||||||
links.append(
|
links.append(
|
||||||
StreamLink(
|
StreamLink(
|
||||||
|
|
@ -297,7 +297,7 @@ def mark_playback_dead(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""User-triggered mark dead — long-press na playback chip w mobile.
|
"""User-triggered mark dead, long-press na playback chip w mobile.
|
||||||
|
|
||||||
Bug-report 2026-05-12 (dd17c709): "Eporner to nie temporary. Więc długie
|
Bug-report 2026-05-12 (dd17c709): "Eporner to nie temporary. Więc długie
|
||||||
przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
|
przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
|
||||||
|
|
@ -362,7 +362,7 @@ def resolve_playback(
|
||||||
|
|
||||||
sitetag: str | None = None
|
sitetag: str | None = None
|
||||||
if pb.origin.startswith("pornapp:"):
|
if pb.origin.startswith("pornapp:"):
|
||||||
# Legacy origin format — pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
|
# Legacy origin format, pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
|
||||||
sitetag = pb.origin.split(":", 1)[1]
|
sitetag = pb.origin.split(":", 1)[1]
|
||||||
elif pb.origin.startswith("tube:"):
|
elif pb.origin.startswith("tube:"):
|
||||||
sitetag = pb.origin.split(":", 1)[1]
|
sitetag = pb.origin.split(":", 1)[1]
|
||||||
|
|
@ -382,7 +382,7 @@ def resolve_playback(
|
||||||
log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
|
log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
|
||||||
raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
|
raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
|
||||||
except TubePageError as e:
|
except TubePageError as e:
|
||||||
# Tube page is gone (404/410) — mark dead, propagate as 410. Inne 5xx → 502.
|
# Tube page is gone (404/410), mark dead, propagate as 410. Inne 5xx → 502.
|
||||||
if e.status_code in (404, 410):
|
if e.status_code in (404, 410):
|
||||||
reason = f"tube page {e.status_code} {pb.page_url}"
|
reason = f"tube page {e.status_code} {pb.page_url}"
|
||||||
pb.dead_at = datetime.now(UTC)
|
pb.dead_at = datetime.now(UTC)
|
||||||
|
|
@ -397,7 +397,7 @@ def resolve_playback(
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
if not sources:
|
if not sources:
|
||||||
# Extractor None — TRANSIENT failure (network glitch, tube chwilowy 503,
|
# Extractor None, TRANSIENT failure (network glitch, tube chwilowy 503,
|
||||||
# ad-network response zmieniony, race condition). NIE oznaczamy `dead_at`
|
# ad-network response zmieniony, race condition). NIE oznaczamy `dead_at`
|
||||||
# bo wcześniej powodowało false-positive permanent dead dla freshporno scen
|
# bo wcześniej powodowało false-positive permanent dead dla freshporno scen
|
||||||
# które działały przy następnym attempt (bug-report 2026-05-12).
|
# które działały przy następnym attempt (bug-report 2026-05-12).
|
||||||
|
|
@ -410,9 +410,9 @@ def resolve_playback(
|
||||||
"extractor None for playback %s (origin=%s) — transient, not marking dead",
|
"extractor None for playback %s (origin=%s) — transient, not marking dead",
|
||||||
pb.id, pb.origin,
|
pb.id, pb.origin,
|
||||||
)
|
)
|
||||||
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film" — ten kod
|
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film", ten kod
|
||||||
# jest dla permanent removal. 503 = transient, user może retry.
|
# jest dla permanent removal. 503 = transient, user może retry.
|
||||||
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py) —
|
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py),
|
||||||
# bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case).
|
# bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case).
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
|
|
@ -446,7 +446,7 @@ DEFAULT_PLAYER_UA = (
|
||||||
def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
||||||
"""Wzbogaca StreamLink o:
|
"""Wzbogaca StreamLink o:
|
||||||
- `stream_url`: proxy URL (fallback gdy direct fails)
|
- `stream_url`: proxy URL (fallback gdy direct fails)
|
||||||
- `direct_url`: surowy CDN URL (preferowany — 0 VPS bandwidth)
|
- `direct_url`: surowy CDN URL (preferowany, 0 VPS bandwidth)
|
||||||
- `headers`: Referer + User-Agent dla direct fetch
|
- `headers`: Referer + User-Agent dla direct fetch
|
||||||
Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
|
Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
|
||||||
"""
|
"""
|
||||||
|
|
@ -462,21 +462,21 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
||||||
use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
|
use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
|
||||||
# force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów
|
# force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów
|
||||||
# gdzie token IS bound do VPS IP (porn00 v-acctoken, pornxp sv.porn-xp.com
|
# gdzie token IS bound do VPS IP (porn00 v-acctoken, pornxp sv.porn-xp.com
|
||||||
# signed path) — mobile direct ZAWSZE 403, więc nie ma sensu próbować.
|
# signed path), mobile direct ZAWSZE 403, więc nie ma sensu próbować.
|
||||||
# Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy).
|
# Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy).
|
||||||
force_proxy = bool(link.raw and link.raw.get("force_proxy"))
|
force_proxy = bool(link.raw and link.raw.get("force_proxy"))
|
||||||
# mobile_direct_ok=True (extractor flag) → m3u8 może iść direct do mobile bo
|
# mobile_direct_ok=True (extractor flag) → m3u8 może iść direct do mobile bo
|
||||||
# CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
|
# CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
|
||||||
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
|
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
|
||||||
mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok"))
|
mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok"))
|
||||||
# Auto-detect time-bound CDN po domain — bez per-extractor flag setting.
|
# Auto-detect time-bound CDN po domain, bez per-extractor flag setting.
|
||||||
# Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/
|
# Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/
|
||||||
# youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP.
|
# youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP.
|
||||||
if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url):
|
if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url):
|
||||||
mobile_direct_ok = True
|
mobile_direct_ok = True
|
||||||
refetch_url = (link.raw or {}).get("refetch_url")
|
refetch_url = (link.raw or {}).get("refetch_url")
|
||||||
refetch_hoster = (link.raw or {}).get("refetch_hoster")
|
refetch_hoster = (link.raw or {}).get("refetch_hoster")
|
||||||
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL — `/proxy/hls`
|
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL, `/proxy/hls`
|
||||||
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
|
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
|
||||||
manifest_producer = (link.raw or {}).get("manifest_producer")
|
manifest_producer = (link.raw or {}).get("manifest_producer")
|
||||||
token = make_token(
|
token = make_token(
|
||||||
|
|
@ -498,15 +498,15 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
||||||
else:
|
else:
|
||||||
ext = "mp4"
|
ext = "mp4"
|
||||||
proxied = f"/proxy/{token}/play.{ext}"
|
proxied = f"/proxy/{token}/play.{ext}"
|
||||||
# `direct_url`: surowy CDN URL — mobile próbuje go PIERWSZY (0 VPS bandwidth).
|
# `direct_url`: surowy CDN URL, mobile próbuje go PIERWSZY (0 VPS bandwidth).
|
||||||
# ALE: dla type=m3u8/hls/mpd manifest URL musi być rewritowany żeby segmenty/keys
|
# ALE: dla type=m3u8/hls/mpd manifest URL musi być rewritowany żeby segmenty/keys
|
||||||
# też leciały przez proxy (inne IP może też mieć rate limit/token issues), plus
|
# też leciały przez proxy (inne IP może też mieć rate limit/token issues), plus
|
||||||
# ExoPlayer wybiera extractor po URL extension — `.mp4` w direct URL pornhat
|
# ExoPlayer wybiera extractor po URL extension, `.mp4` w direct URL pornhat
|
||||||
# → Mp4Extractor → fail bo content to HLS. Dla m3u8/mpd zwracamy proxied JAKO
|
# → Mp4Extractor → fail bo content to HLS. Dla m3u8/mpd zwracamy proxied JAKO
|
||||||
# direct (mobile używa go bezpośrednio, 1 hop przez VPS ale to jedyny sposób
|
# direct (mobile używa go bezpośrednio, 1 hop przez VPS ale to jedyny sposób
|
||||||
# żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource).
|
# żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource).
|
||||||
# Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez
|
# Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez
|
||||||
# proxy — bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
|
# proxy, bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
|
||||||
# → extra round-trip + ExoPlayer "no extractors" przed retry.
|
# → extra round-trip + ExoPlayer "no extractors" przed retry.
|
||||||
# mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token
|
# mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token
|
||||||
# (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.
|
# (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Playback telemetry — POST /playback-events (fire-and-forget z apki).
|
"""Playback telemetry, POST /playback-events (fire-and-forget z apki).
|
||||||
|
|
||||||
Apka po każdej próbie odtwarzania woła to z origin sceny + wynikiem (success/error).
|
Apka po każdej próbie odtwarzania woła to z origin sceny + wynikiem (success/error).
|
||||||
Sygnał zasila health/szybkość w rankingu źródeł (run_source_stats agreguje okno 7d).
|
Sygnał zasila health/szybkość w rankingu źródeł (run_source_stats agreguje okno 7d).
|
||||||
Best-effort: apka NIE czeka i ignoruje błąd — telemetria nie może psuć playbacku.
|
Best-effort: apka NIE czeka i ignoruje błąd, telemetria nie może psuć playbacku.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -22,12 +22,12 @@ router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[
|
||||||
|
|
||||||
|
|
||||||
class PlaybackEventIn(BaseModel):
|
class PlaybackEventIn(BaseModel):
|
||||||
# 'tube:<sitetag>' — zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
|
# 'tube:<sitetag>', zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
|
||||||
origin: str = Field(min_length=1, max_length=64)
|
origin: str = Field(min_length=1, max_length=64)
|
||||||
status: Literal["success", "error"]
|
status: Literal["success", "error"]
|
||||||
scene_id: uuid.UUID | None = None
|
scene_id: uuid.UUID | None = None
|
||||||
error_kind: str | None = Field(default=None, max_length=64)
|
error_kind: str | None = Field(default=None, max_length=64)
|
||||||
# Surowa treść błędu z native playera (ExoPlayer message) — diagnostyka realnych
|
# Surowa treść błędu z native playera (ExoPlayer message), diagnostyka realnych
|
||||||
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
|
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
|
||||||
error_detail: str | None = Field(default=None, max_length=512)
|
error_detail: str | None = Field(default=None, max_length=512)
|
||||||
ttff_ms: int | None = Field(default=None, ge=0, le=600_000)
|
ttff_ms: int | None = Field(default=None, ge=0, le=600_000)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"""Saved searches — zapisane słowa kluczowe per urządzenie.
|
"""Saved searches, zapisane słowa kluczowe per urządzenie.
|
||||||
|
|
||||||
User-report mobilism: sceny słabo opisane → user chce zapisywać często używane
|
User-report mobilism: sceny słabo opisane → user chce zapisywać często używane
|
||||||
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
|
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
|
||||||
|
|
||||||
Endpointy:
|
Endpointy:
|
||||||
GET /saved-searches — lista zapisów tego urządzenia (najnowsze pierwsze)
|
GET /saved-searches lista zapisów tego urządzenia (najnowsze pierwsze)
|
||||||
POST /saved-searches — dodaj {query} (idempotent na device+query)
|
POST /saved-searches dodaj {query} (idempotent na device+query)
|
||||||
DELETE /saved-searches/{id} — usuń
|
DELETE /saved-searches/{id} usuń
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ router = APIRouter(
|
||||||
dependencies=[Depends(require_api_key)],
|
dependencies=[Depends(require_api_key)],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Limit zapisów per urządzenie — to wygodny skrót, nie nieograniczona kolekcja.
|
# Limit zapisów per urządzenie, to wygodny skrót, nie nieograniczona kolekcja.
|
||||||
MAX_PER_DEVICE = 50
|
MAX_PER_DEVICE = 50
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ def add_saved_search(
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
device_id: Annotated[str, Depends(get_device_id)],
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
) -> SavedSearch:
|
) -> SavedSearch:
|
||||||
# Idempotent — ten sam (device, query) zwraca istniejący wiersz.
|
# Idempotent, ten sam (device, query) zwraca istniejący wiersz.
|
||||||
existing = session.execute(
|
existing = session.execute(
|
||||||
select(SavedSearch).where(
|
select(SavedSearch).where(
|
||||||
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""Scene favorites — ulubione sceny (single-user, równolegle do /favorites/performers).
|
"""Scene favorites, ulubione sceny (single-user, równolegle do /favorites/performers).
|
||||||
|
|
||||||
Endpointy:
|
Endpointy:
|
||||||
GET /scene-favorites — lista ulubionych scen (pełen SceneOut)
|
GET /scene-favorites lista ulubionych scen (pełen SceneOut)
|
||||||
POST /scene-favorites/{scene_id} — dodaj (idempotent)
|
POST /scene-favorites/{scene_id} dodaj (idempotent)
|
||||||
DELETE /scene-favorites/{scene_id} — usuń
|
DELETE /scene-favorites/{scene_id} usuń
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""GET /scenes — lista i szczegóły scen z bazy kanonicznej."""
|
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -70,7 +70,7 @@ def _default_scene_count(session: Session) -> int:
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
# JAV vertical — osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
|
# JAV vertical, osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
|
||||||
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
|
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
|
||||||
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
|
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
|
||||||
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
|
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
|
||||||
|
|
@ -89,11 +89,11 @@ def _jav_source_exists():
|
||||||
|
|
||||||
# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to
|
# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to
|
||||||
# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane
|
# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane
|
||||||
# per-row — przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
|
# per-row, przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
|
||||||
# puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu).
|
# puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu).
|
||||||
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
|
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
|
||||||
# wracają. Patrz reference_scenes_list_perf / task #22.
|
# wracają. Patrz reference_scenes_list_perf / task #22.
|
||||||
# Cache per device_id (blacklisty są teraz device-scoped — bug 2026-06-08).
|
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
|
||||||
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
|
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
|
||||||
_BLACKLIST_EMPTY_TTL = 300.0
|
_BLACKLIST_EMPTY_TTL = 300.0
|
||||||
|
|
||||||
|
|
@ -283,7 +283,7 @@ def list_scenes(
|
||||||
if len(tag_slug_list) > _MAX_TAGS:
|
if len(tag_slug_list) > _MAX_TAGS:
|
||||||
raise HTTPException(status_code=422, detail=f"too many tag filters (max {_MAX_TAGS})")
|
raise HTTPException(status_code=422, detail=f"too many tag filters (max {_MAX_TAGS})")
|
||||||
# AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny
|
# AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny
|
||||||
# exists() — zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
|
# exists(), zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
|
||||||
#
|
#
|
||||||
# PERF (2026-06-07): resolvujemy slug→tag_id w aplikacji i filtrujemy po LITERALNYM
|
# PERF (2026-06-07): resolvujemy slug→tag_id w aplikacji i filtrujemy po LITERALNYM
|
||||||
# tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze
|
# tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze
|
||||||
|
|
@ -337,7 +337,7 @@ def list_scenes(
|
||||||
base = base.where(~live_playback_exists())
|
base = base.where(~live_playback_exists())
|
||||||
|
|
||||||
if origin:
|
if origin:
|
||||||
# Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'.
|
# Substring match na origin, 'hqporner' złapie 'tube:hqpornercom'.
|
||||||
base = base.where(
|
base = base.where(
|
||||||
exists(
|
exists(
|
||||||
select(1).where(
|
select(1).where(
|
||||||
|
|
@ -350,14 +350,14 @@ def list_scenes(
|
||||||
|
|
||||||
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
|
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
|
||||||
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
|
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
|
||||||
# bramy nie stosujemy — jawnie chce ten origin, więc pokazujemy (regresja "No videos"
|
# bramy nie stosujemy, jawnie chce ten origin, więc pokazujemy (regresja "No videos"
|
||||||
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
|
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
|
||||||
if jav:
|
if jav:
|
||||||
base = base.where(_jav_source_exists())
|
base = base.where(_jav_source_exists())
|
||||||
elif origin is None:
|
elif origin is None:
|
||||||
base = base.where(~_jav_source_exists())
|
base = base.where(~_jav_source_exists())
|
||||||
|
|
||||||
# Blacklisty device (performer/studio/tag) — globalne wykluczenia, współdzielone z
|
# Blacklisty device (performer/studio/tag), globalne wykluczenia, współdzielone z
|
||||||
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
|
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
|
||||||
for _bl_clause in blacklist_clauses(session, device_id):
|
for _bl_clause in blacklist_clauses(session, device_id):
|
||||||
base = base.where(_bl_clause)
|
base = base.where(_bl_clause)
|
||||||
|
|
@ -373,7 +373,7 @@ def list_scenes(
|
||||||
base = base.where(Scene.release_date >= cutoff)
|
base = base.where(Scene.release_date >= cutoff)
|
||||||
|
|
||||||
if min_quality_p is not None:
|
if min_quality_p is not None:
|
||||||
# PlaybackSource.quality to wolny string — szukamy liczb w prefixie ('1080p',
|
# PlaybackSource.quality to wolny string, szukamy liczb w prefixie ('1080p',
|
||||||
# '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy
|
# '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy
|
||||||
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160.
|
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160.
|
||||||
from sqlalchemy import Integer, cast, or_
|
from sqlalchemy import Integer, cast, or_
|
||||||
|
|
@ -416,13 +416,13 @@ def list_scenes(
|
||||||
# dominującym kosztem (~4s na has_playback / min_duration / duży tag) i plan
|
# dominującym kosztem (~4s na has_playback / min_duration / duży tag) i plan
|
||||||
# był NIESTABILNY (literal LIMIT + count-nad-PK pomogły w części przypadków,
|
# był NIESTABILNY (literal LIMIT + count-nad-PK pomogły w części przypadków,
|
||||||
# ale planer i tak czasem skanuje cały zbiór zamiast urwać). Mobile paginuje
|
# ale planer i tak czasem skanuje cały zbiór zamiast urwać). Mobile paginuje
|
||||||
# po `has_more` (per_page+1 fetch), NIE po `total` — `total` to tylko licznik
|
# po `has_more` (per_page+1 fetch), NIE po `total`, `total` to tylko licznik
|
||||||
# "N+" w UI. Wyprowadzamy go z has_more PO fetchu (patrz niżej): dolna granica
|
# "N+" w UI. Wyprowadzamy go z has_more PO fetchu (patrz niżej): dolna granica
|
||||||
# + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy.
|
# + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy.
|
||||||
total_capped = False
|
total_capped = False
|
||||||
total: int | None = _default_scene_count(session) if _is_pure_default else None
|
total: int | None = _default_scene_count(session) if _is_pure_default else None
|
||||||
|
|
||||||
# Sort: KAŻDY tryb kończy się `Scene.id.desc()` — unikalny PK jako ostateczny
|
# Sort: KAŻDY tryb kończy się `Scene.id.desc()`, unikalny PK jako ostateczny
|
||||||
# tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy
|
# tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy
|
||||||
# ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek,
|
# ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek,
|
||||||
# więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu
|
# więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu
|
||||||
|
|
@ -436,7 +436,7 @@ def list_scenes(
|
||||||
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
|
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
|
||||||
)
|
)
|
||||||
elif sort == "studio":
|
elif sort == "studio":
|
||||||
# Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze.
|
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze.
|
||||||
ordered = (
|
ordered = (
|
||||||
base.outerjoin(Studio, Studio.id == Scene.studio_id)
|
base.outerjoin(Studio, Studio.id == Scene.studio_id)
|
||||||
.order_by(
|
.order_by(
|
||||||
|
|
@ -451,10 +451,10 @@ def list_scenes(
|
||||||
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
|
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch per_page+1 — obecność (per_page+1)-szego wiersza = jest kolejna strona.
|
# Fetch per_page+1, obecność (per_page+1)-szego wiersza = jest kolejna strona.
|
||||||
# To źródło prawdy dla paginacji (mobile getNextPageParam), niezależne od bounded
|
# To źródło prawdy dla paginacji (mobile getNextPageParam), niezależne od bounded
|
||||||
# `total`. Nadmiarowy wiersz odcinamy przed serializacją.
|
# `total`. Nadmiarowy wiersz odcinamy przed serializacją.
|
||||||
# LIMIT/OFFSET literalne (NIE bound-param) — patrz wyżej: sparametryzowany LIMIT
|
# LIMIT/OFFSET literalne (NIE bound-param), patrz wyżej: sparametryzowany LIMIT
|
||||||
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
|
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
|
||||||
# zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to
|
# zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to
|
||||||
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
|
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
|
||||||
|
|
@ -506,14 +506,14 @@ def similar_scenes(
|
||||||
device_id: Annotated[str, Depends(get_device_id)],
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
limit: int = Query(default=12, ge=1, le=30),
|
limit: int = Query(default=12, ge=1, le=30),
|
||||||
) -> SceneListOut:
|
) -> SceneListOut:
|
||||||
"""Sceny podobne do danej — ranking po WSPÓLNYCH performerach (mocny sygnał) i
|
"""Sceny podobne do danej, ranking po WSPÓLNYCH performerach (mocny sygnał) i
|
||||||
tagach (słabszy). Zwraca tylko grywalne sceny (żywy playback), zgodne z bramą JAV
|
tagach (słabszy). Zwraca tylko grywalne sceny (żywy playback), zgodne z bramą JAV
|
||||||
(JAV↔JAV, nie-JAV↔nie-JAV), z pominięciem blacklist/stubów/samej siebie.
|
(JAV↔JAV, nie-JAV↔nie-JAV), z pominięciem blacklist/stubów/samej siebie.
|
||||||
|
|
||||||
Wydajność: performerzy są selektywni, więc pula kandydatów z nich jest mała (cap
|
Wydajność: performerzy są selektywni, więc pula kandydatów z nich jest mała (cap
|
||||||
_CAND_CAP). Tagi liczymy TYLKO w obrębie tej puli (tanio). Fill z tagów (bounded
|
_CAND_CAP). Tagi liczymy TYLKO w obrębie tej puli (tanio). Fill z tagów (bounded
|
||||||
_TAG_FILL_CAP, HAVING >=2 wspólne) odpala się gdy scena ma mało/zero performerów
|
_TAG_FILL_CAP, HAVING >=2 wspólne) odpala się gdy scena ma mało/zero performerów
|
||||||
(anonimowe tube sceny) — inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
|
(anonimowe tube sceny), inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
|
||||||
wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id."""
|
wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id."""
|
||||||
scene = session.get(Scene, scene_id)
|
scene = session.get(Scene, scene_id)
|
||||||
if scene is None:
|
if scene is None:
|
||||||
|
|
@ -527,7 +527,7 @@ def similar_scenes(
|
||||||
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
|
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
# Odsiej ŚMIECIOWYCH performerów: kategorie/studia podszywające się pod performera
|
# Odsiej ŚMIECIOWYCH performerów: kategorie/studia podszywające się pod performera
|
||||||
# (over-attribution z luźnego tube-searcha — np. "Big Tits", "Brazzers", "Ball Licking").
|
# (over-attribution z luźnego tube-searcha, np. "Big Tits", "Brazzers", "Ball Licking").
|
||||||
# Sygnał: slug pokrywa się z tagiem I performer NIE ma kanonicznego refa (tpdb/stashdb).
|
# Sygnał: slug pokrywa się z tagiem I performer NIE ma kanonicznego refa (tpdb/stashdb).
|
||||||
# Prawdziwi performerzy z tym samym tagiem (tuby tagują sceny nazwiskiem) mają kanoniczny
|
# Prawdziwi performerzy z tym samym tagiem (tuby tagują sceny nazwiskiem) mają kanoniczny
|
||||||
# ref → zostają. Bez tego similar klastrował się po "Ball Licking" zamiast po treści.
|
# ref → zostają. Bez tego similar klastrował się po "Ball Licking" zamiast po treści.
|
||||||
|
|
@ -584,7 +584,7 @@ def similar_scenes(
|
||||||
.group_by(SceneTag.scene_id)
|
.group_by(SceneTag.scene_id)
|
||||||
).all()
|
).all()
|
||||||
tag_shared = {r[0]: r[1] for r in rows}
|
tag_shared = {r[0]: r[1] for r in rows}
|
||||||
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny) — inaczej pomijamy,
|
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny), inaczej pomijamy,
|
||||||
# bo szeroki tag skanuje setki tysięcy scen.
|
# bo szeroki tag skanuje setki tysięcy scen.
|
||||||
if tag_ids and len(cand_ids) < limit * 4:
|
if tag_ids and len(cand_ids) < limit * 4:
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
|
|
@ -642,7 +642,7 @@ _SXYPRN_POST_RE = re.compile(r"sxyprn\.com/post/([0-9a-f]{6,40})", re.IGNORECASE
|
||||||
|
|
||||||
def _sxyprn_thumb_url(page_url: str | None) -> str | None:
|
def _sxyprn_thumb_url(page_url: str | None) -> str | None:
|
||||||
"""Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera
|
"""Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera
|
||||||
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL — token żyje ~1h,
|
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL, token żyje ~1h,
|
||||||
więc poster resolvujemy przy serwowaniu (bug 2026-06-10)."""
|
więc poster resolvujemy przy serwowaniu (bug 2026-06-10)."""
|
||||||
if not page_url:
|
if not page_url:
|
||||||
return None
|
return None
|
||||||
|
|
@ -655,7 +655,7 @@ _YOUPORN_WATCH_RE = re.compile(r"youporn\.com/watch/(\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
|
def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
|
||||||
"""Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny
|
"""Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny
|
||||||
on-demand endpoint `/proxy/youporn-thumb/<video_id>` — zapisany podpis wygasa i CDN
|
on-demand endpoint `/proxy/youporn-thumb/<video_id>`, zapisany podpis wygasa i CDN
|
||||||
oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg`
|
oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg`
|
||||||
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
|
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
|
||||||
if not page_url or not thumb or "hash=" not in thumb:
|
if not page_url or not thumb or "hash=" not in thumb:
|
||||||
|
|
@ -667,7 +667,7 @@ def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
|
||||||
def _is_rotting_thumb(url: str) -> bool:
|
def _is_rotting_thumb(url: str) -> bool:
|
||||||
"""sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po
|
"""sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po
|
||||||
~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze
|
~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze
|
||||||
slim-thumbnaila — używamy tylko gdy scena nie ma żadnej innej miniaturki."""
|
slim-thumbnaila, używamy tylko gdy scena nie ma żadnej innej miniaturki."""
|
||||||
if "trafficdeposit.com" in url:
|
if "trafficdeposit.com" in url:
|
||||||
return True
|
return True
|
||||||
# imgs.xiaoshenke.net (fullporner) było tu wpisane 2026-08-02, bo host oddawał 200
|
# imgs.xiaoshenke.net (fullporner) było tu wpisane 2026-08-02, bo host oddawał 200
|
||||||
|
|
@ -678,12 +678,12 @@ def _is_rotting_thumb(url: str) -> bool:
|
||||||
# rotting-thumb jest ZEROWANY, więc sceny mające tylko fullporner traciły miniaturę
|
# rotting-thumb jest ZEROWANY, więc sceny mające tylko fullporner traciły miniaturę
|
||||||
# całkowicie („Jakiś problem z miniaturkami z fullporner"). Dotyczyło 53 tys. źródeł.
|
# całkowicie („Jakiś problem z miniaturkami z fullporner"). Dotyczyło 53 tys. źródeł.
|
||||||
# Gdyby atrapy wróciły, filtruj po ROZMIARZE odpowiedzi w image-proxy, nie po hoście.
|
# Gdyby atrapy wróciły, filtruj po ROZMIARZE odpowiedzi w image-proxy, nie po hoście.
|
||||||
# tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze
|
# tn.sextu.com (fullvideosporn), CDN odrzuca ruch z IP datacenter (429), więc nasze
|
||||||
# image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy
|
# image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy
|
||||||
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
|
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
|
||||||
if "tn.sextu.com" in url:
|
if "tn.sextu.com" in url:
|
||||||
return True
|
return True
|
||||||
# fastpic.org (galaxyporn i inne re-uploadery) — hosting zrywa połączenie zamiast
|
# fastpic.org (galaxyporn i inne re-uploadery), hosting zrywa połączenie zamiast
|
||||||
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
|
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
|
||||||
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
|
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
|
||||||
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
|
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
|
||||||
|
|
@ -693,14 +693,14 @@ def _is_rotting_thumb(url: str) -> bool:
|
||||||
def _needs_proxy(url: str) -> bool:
|
def _needs_proxy(url: str) -> bool:
|
||||||
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
|
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
|
||||||
Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img,
|
Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img,
|
||||||
inne sxyprn/eporner CDN-y) — expo-image nie wysyła Referera.
|
inne sxyprn/eporner CDN-y), expo-image nie wysyła Referera.
|
||||||
Self-hosted lub backend-internal URL-e (zaczynające się od `/`) skipujemy."""
|
Self-hosted lub backend-internal URL-e (zaczynające się od `/`) skipujemy."""
|
||||||
return url.startswith("http") and not url.startswith("/proxy/")
|
return url.startswith("http") and not url.startswith("/proxy/")
|
||||||
|
|
||||||
|
|
||||||
def _wrap_image_proxy(url: str, referer: str) -> str:
|
def _wrap_image_proxy(url: str, referer: str) -> str:
|
||||||
"""Wraps a thumbnail URL through /proxy/img/{token}/img.jpg. Klient nie musi
|
"""Wraps a thumbnail URL through /proxy/img/{token}/img.jpg. Klient nie musi
|
||||||
znać sekretu Referer — backend wstawi sam. Long TTL (30d) bo thumby
|
znać sekretu Referer, backend wstawi sam. Long TTL (30d) bo thumby
|
||||||
są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache."""
|
są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache."""
|
||||||
from app.api.stream_proxy import make_token
|
from app.api.stream_proxy import make_token
|
||||||
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
|
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
|
||||||
|
|
@ -717,7 +717,7 @@ def _build_scenes_out_batch(
|
||||||
) -> list[SceneOut]:
|
) -> list[SceneOut]:
|
||||||
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
|
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
|
||||||
|
|
||||||
Eliminuje N+1 z `_build_scene_out` w listach scen — `/scenes?per_page=24` szło
|
Eliminuje N+1 z `_build_scene_out` w listach scen, `/scenes?per_page=24` szło
|
||||||
z ~9.6s do <500ms. Pojedyncza scena (`/scenes/{id}`) nadal używa `_build_scene_out`
|
z ~9.6s do <500ms. Pojedyncza scena (`/scenes/{id}`) nadal używa `_build_scene_out`
|
||||||
bo overhead na batch nie ma sensu dla N=1.
|
bo overhead na batch nie ma sensu dla N=1.
|
||||||
|
|
||||||
|
|
@ -760,7 +760,7 @@ def _build_scenes_out_batch(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3) Tags + 4) External refs — kafelek listy ich nie używa; w light mode pomijamy
|
# 3) Tags + 4) External refs, kafelek listy ich nie używa; w light mode pomijamy
|
||||||
# (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}).
|
# (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}).
|
||||||
tags_by_scene: dict = defaultdict(list)
|
tags_by_scene: dict = defaultdict(list)
|
||||||
refs_by_scene: dict = defaultdict(list)
|
refs_by_scene: dict = defaultdict(list)
|
||||||
|
|
@ -788,7 +788,7 @@ def _build_scenes_out_batch(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę) — kafelek
|
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę), kafelek
|
||||||
# czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url.
|
# czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url.
|
||||||
pb_by_scene: dict = defaultdict(list)
|
pb_by_scene: dict = defaultdict(list)
|
||||||
if light:
|
if light:
|
||||||
|
|
@ -806,7 +806,7 @@ def _build_scenes_out_batch(
|
||||||
.order_by(PlaybackSource.origin.asc())
|
.order_by(PlaybackSource.origin.asc())
|
||||||
).all()
|
).all()
|
||||||
# Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). De-prioritize
|
# Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). De-prioritize
|
||||||
# sxyprn/trafficdeposit thumbnaile — są podpisane czasowo i ROTUJĄ (asset 404 po
|
# sxyprn/trafficdeposit thumbnaile, są podpisane czasowo i ROTUJĄ (asset 404 po
|
||||||
# ~tygodniach, nie da się re-signować; bug 2026-06-10). Wolimy miniaturkę z innego
|
# ~tygodniach, nie da się re-signować; bug 2026-06-10). Wolimy miniaturkę z innego
|
||||||
# źródła gdy istnieje; sxyprn bierzemy tylko gdy nic innego nie ma (świeże jeszcze
|
# źródła gdy istnieje; sxyprn bierzemy tylko gdy nic innego nie ma (świeże jeszcze
|
||||||
# działają, martwe → mobile pokazuje placeholder zamiast broken-image).
|
# działają, martwe → mobile pokazuje placeholder zamiast broken-image).
|
||||||
|
|
@ -847,7 +847,7 @@ def _build_scenes_out_batch(
|
||||||
if a_url and _needs_proxy(a_url):
|
if a_url and _needs_proxy(a_url):
|
||||||
a_url = _wrap_image_proxy(a_url, ref)
|
a_url = _wrap_image_proxy(a_url, ref)
|
||||||
# id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek
|
# id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek
|
||||||
# (SceneDetail re-fetchuje pełne źródła) — dummy sentinel.
|
# (SceneDetail re-fetchuje pełne źródła), dummy sentinel.
|
||||||
pb_by_scene[sid].append(
|
pb_by_scene[sid].append(
|
||||||
PlaybackSourceOut(
|
PlaybackSourceOut(
|
||||||
id=uuid.UUID(int=0), origin="", page_url="",
|
id=uuid.UUID(int=0), origin="", page_url="",
|
||||||
|
|
@ -999,7 +999,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
||||||
)
|
)
|
||||||
# Collapse źródła dzielące ten sam origin (hoster). Zmergowana scena często agreguje
|
# Collapse źródła dzielące ten sam origin (hoster). Zmergowana scena często agreguje
|
||||||
# kilka uploadów z JEDNEGO tube'a (re-enkody / wersje 4K: bug-report aa79a995 "2 linki,
|
# kilka uploadów z JEDNEGO tube'a (re-enkody / wersje 4K: bug-report aa79a995 "2 linki,
|
||||||
# oba do porntrex" = ta sama scena std+4K) — w UI to nierozróżnialne linki do tego
|
# oba do porntrex" = ta sama scena std+4K), w UI to nierozróżnialne linki do tego
|
||||||
# samego hostera (resolvują tym samym extractorem). Zostawiamy jeden najlepszy per
|
# samego hostera (resolvują tym samym extractorem). Zostawiamy jeden najlepszy per
|
||||||
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
|
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
|
||||||
# pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at).
|
# pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at).
|
||||||
|
|
@ -1022,7 +1022,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
||||||
for p in playback_rows:
|
for p in playback_rows:
|
||||||
out = PlaybackSourceOut.model_validate(p)
|
out = PlaybackSourceOut.model_validate(p)
|
||||||
# sxyprn: podmień martwy trafficdeposit URL na żywy on-demand resolver
|
# sxyprn: podmień martwy trafficdeposit URL na żywy on-demand resolver
|
||||||
# (/proxy/sxyprn-thumb/<id>) — TAK JAK lista (_build_scenes_out_batch). Bez tego
|
# (/proxy/sxyprn-thumb/<id>), TAK JAK lista (_build_scenes_out_batch). Bez tego
|
||||||
# detal sceny pokazywał martwą miniaturę sxyprn mimo że lista działała, i "refresh
|
# detal sceny pokazywał martwą miniaturę sxyprn mimo że lista działała, i "refresh
|
||||||
# thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22.
|
# thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22.
|
||||||
sxy = _sxyprn_thumb_url(p.page_url)
|
sxy = _sxyprn_thumb_url(p.page_url)
|
||||||
|
|
@ -1034,7 +1034,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
||||||
elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url):
|
elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url):
|
||||||
out.thumbnail_url = None
|
out.thumbnail_url = None
|
||||||
# Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera
|
# Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera
|
||||||
# (hqporner — fastporndelivery zwraca 403 bez Referer headera, expo-image
|
# (hqporner, fastporndelivery zwraca 403 bez Referer headera, expo-image
|
||||||
# nie wysyła go domyślnie). Token ma 30-dniowy TTL bo thumby są stabilne.
|
# nie wysyła go domyślnie). Token ma 30-dniowy TTL bo thumby są stabilne.
|
||||||
if out.thumbnail_url and _needs_proxy(out.thumbnail_url):
|
if out.thumbnail_url and _needs_proxy(out.thumbnail_url):
|
||||||
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
|
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
|
||||||
|
|
@ -1090,7 +1090,7 @@ def remove_tag_from_scene(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Usuwa relację scene↔tag (np. user uznał że tag jest błędny dla tej sceny).
|
"""Usuwa relację scene↔tag (np. user uznał że tag jest błędny dla tej sceny).
|
||||||
|
|
||||||
Idempotent: brak relacji = success. Nie kasuje samego Tag-a — inne sceny mogą
|
Idempotent: brak relacji = success. Nie kasuje samego Tag-a, inne sceny mogą
|
||||||
z niego korzystać. Sam tag zostaje w słowniku tagów.
|
z niego korzystać. Sam tag zostaje w słowniku tagów.
|
||||||
"""
|
"""
|
||||||
rel = session.execute(
|
rel = session.execute(
|
||||||
|
|
@ -1142,7 +1142,7 @@ def hide_scene(
|
||||||
) -> SceneHideOut:
|
) -> SceneHideOut:
|
||||||
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
|
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
|
||||||
jako dead → scena wypada z list (has_playback=false). Odwracalne w DB (dead_at).
|
jako dead → scena wypada z list (has_playback=false). Odwracalne w DB (dead_at).
|
||||||
Nie kasujemy wiersza sceny — zachowujemy refs/dedup, tylko znika z UI."""
|
Nie kasujemy wiersza sceny, zachowujemy refs/dedup, tylko znika z UI."""
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
|
|
@ -1273,7 +1273,7 @@ def enrich_tags_from_tube(
|
||||||
# Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie.
|
# Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie.
|
||||||
# Używamy PostgreSQL INSERT ... ON CONFLICT DO NOTHING zamiast ORM session.add()
|
# Używamy PostgreSQL INSERT ... ON CONFLICT DO NOTHING zamiast ORM session.add()
|
||||||
# bo `resolve_tag` robi session.flush() w pętli, emitując pending SceneTag INSERT
|
# bo `resolve_tag` robi session.flush() w pętli, emitując pending SceneTag INSERT
|
||||||
# z poprzednich iteracji — gdy 2 concurrent enrich-tags collide na tym samym
|
# z poprzednich iteracji, gdy 2 concurrent enrich-tags collide na tym samym
|
||||||
# (scene_id, tag_id), drugi flush dostaje UniqueViolation (GOON-H, 4 events
|
# (scene_id, tag_id), drugi flush dostaje UniqueViolation (GOON-H, 4 events
|
||||||
# w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently.
|
# w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently.
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
@ -1309,11 +1309,11 @@ def enrich_duration_from_tube(
|
||||||
scene_id: uuid.UUID,
|
scene_id: uuid.UUID,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> EnrichDurationOut:
|
) -> EnrichDurationOut:
|
||||||
"""Wyciąga duration z dowolnego tube playback_source — wszystkie znane tube'y
|
"""Wyciąga duration z dowolnego tube playback_source, wszystkie znane tube'y
|
||||||
udostępniają duration na detail page (og:video:duration lub LD-JSON ISO 8601).
|
udostępniają duration na detail page (og:video:duration lub LD-JSON ISO 8601).
|
||||||
|
|
||||||
Mobile wywołuje to przy otwarciu SceneDetail gdy scene.duration_sec jest null
|
Mobile wywołuje to przy otwarciu SceneDetail gdy scene.duration_sec jest null
|
||||||
AND ma tube source. Dla dedupu duration to najsilniejszy single signal — bez
|
AND ma tube source. Dla dedupu duration to najsilniejszy single signal, bez
|
||||||
niego sceny z weak title-only score są capowane na 0.85 (review queue).
|
niego sceny z weak title-only score są capowane na 0.85 (review queue).
|
||||||
|
|
||||||
Idempotent: zwraca aktualne duration_sec jeśli już ustawione.
|
Idempotent: zwraca aktualne duration_sec jeśli już ustawione.
|
||||||
|
|
@ -1379,7 +1379,7 @@ def enrich_studio_from_tube(
|
||||||
"""Wyciąga studio (DVD/series) z pornhat scene page'a.
|
"""Wyciąga studio (DVD/series) z pornhat scene page'a.
|
||||||
|
|
||||||
Pornhat ma `class="info-video js-ajax-dvd" data-setup='{"title": "Adult Time", ...}'`
|
Pornhat ma `class="info-video js-ajax-dvd" data-setup='{"title": "Adult Time", ...}'`
|
||||||
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern — na razie
|
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern, na razie
|
||||||
tylko pornhat (najczystsze studio metadata wśród free tubes).
|
tylko pornhat (najczystsze studio metadata wśród free tubes).
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
@ -1476,7 +1476,7 @@ def enrich_thumbnail_from_tube(
|
||||||
Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration).
|
Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration).
|
||||||
|
|
||||||
`force=true` (przycisk "Refresh thumbnail" na SceneDetail, zgłoszenie d3376a71):
|
`force=true` (przycisk "Refresh thumbnail" na SceneDetail, zgłoszenie d3376a71):
|
||||||
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a — dla zepsutych/stałych
|
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a, dla zepsutych/stałych
|
||||||
(rotting sxyprn/trafficdeposit, błędna grafika)."""
|
(rotting sxyprn/trafficdeposit, błędna grafika)."""
|
||||||
from app.extractors._fetch import browser_get
|
from app.extractors._fetch import browser_get
|
||||||
from app.extractors._models import TubePageError
|
from app.extractors._models import TubePageError
|
||||||
|
|
@ -1497,7 +1497,7 @@ def enrich_thumbnail_from_tube(
|
||||||
|
|
||||||
sources_with_thumb = [s for s in sources if s.thumbnail_url]
|
sources_with_thumb = [s for s in sources if s.thumbnail_url]
|
||||||
if sources_with_thumb and not force:
|
if sources_with_thumb and not force:
|
||||||
# już mamy — idempotent return (force=true pomija, żeby odświeżyć).
|
# już mamy, idempotent return (force=true pomija, żeby odświeżyć).
|
||||||
return EnrichThumbOut(
|
return EnrichThumbOut(
|
||||||
scene_id=scene_id,
|
scene_id=scene_id,
|
||||||
thumbnail_url=sources_with_thumb[0].thumbnail_url,
|
thumbnail_url=sources_with_thumb[0].thumbnail_url,
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class SceneOut(BaseModel):
|
||||||
external_refs: list[ExternalRefOut] = []
|
external_refs: list[ExternalRefOut] = []
|
||||||
playback_sources: list[PlaybackSourceOut] = []
|
playback_sources: list[PlaybackSourceOut] = []
|
||||||
# Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia
|
# Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia
|
||||||
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen — gdy
|
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen, gdy
|
||||||
# `created_at > last_seen_at` (favorite) → badge.
|
# `created_at > last_seen_at` (favorite) → badge.
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
|
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
|
||||||
|
|
@ -87,7 +87,7 @@ class SceneListOut(BaseModel):
|
||||||
page: int
|
page: int
|
||||||
per_page: int
|
per_page: int
|
||||||
# has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe),
|
# has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe),
|
||||||
# NIE z `total` — bo dla filtrowanych list `total` jest bounded ("1000+") żeby
|
# NIE z `total`, bo dla filtrowanych list `total` jest bounded ("1000+") żeby
|
||||||
# uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total.
|
# uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total.
|
||||||
has_more: bool = False
|
has_more: bool = False
|
||||||
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".
|
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Publiczna, crawlowalna powierzchnia SEO — programmatic entity pages.
|
"""Publiczna, crawlowalna powierzchnia SEO, programmatic entity pages.
|
||||||
|
|
||||||
Jedyny publiczny router HTML poza /static i healthchecks — NIE wymaga api key,
|
Jedyny publiczny router HTML poza /static i healthchecks, NIE wymaga api key,
|
||||||
bo Googlebot/użytkownik musi dotrzeć bez tokenu. Cel: łapać nawigacyjny long-tail
|
bo Googlebot/użytkownik musi dotrzeć bez tokenu. Cel: łapać nawigacyjny long-tail
|
||||||
(nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje,
|
(nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje,
|
||||||
a który realnie generuje organiczny ruch → pobranie apki.
|
a który realnie generuje organiczny ruch → pobranie apki.
|
||||||
|
|
@ -8,14 +8,14 @@ a który realnie generuje organiczny ruch → pobranie apki.
|
||||||
Zasady (świadome, nie przypadkowe):
|
Zasady (świadome, nie przypadkowe):
|
||||||
|
|
||||||
* **LINK-OUT only.** Strony renderują metadane i odsyłają do źródeł (`page_url`).
|
* **LINK-OUT only.** Strony renderują metadane i odsyłają do źródeł (`page_url`).
|
||||||
NIE eksponujemy `stream_url`/`embed_url` — to zostaje value-add apki i trzyma
|
NIE eksponujemy `stream_url`/`embed_url`, to zostaje value-add apki i trzyma
|
||||||
legalny profil "agregatora/wyszukiwarki" (dowozimy ruch tubom, nie re-streamujemy).
|
legalny profil "agregatora/wyszukiwarki" (dowozimy ruch tubom, nie re-streamujemy).
|
||||||
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
|
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
|
||||||
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
|
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
|
||||||
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
|
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
|
||||||
* **Blacklist respektowany** — te same wykluczenia performer/studio/tag co w /scenes.
|
* **Blacklist respektowany** te same wykluczenia performer/studio/tag co w /scenes.
|
||||||
Nie publikujemy SEO-stron dla zblacklistowanej treści.
|
Nie publikujemy SEO-stron dla zblacklistowanej treści.
|
||||||
* **Anti-thin-page** — strona encji powstaje tylko gdy ma realną zawartość
|
* **Anti-thin-page** strona encji powstaje tylko gdy ma realną zawartość
|
||||||
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
|
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
|
||||||
nie pusty doorway (Google karze masowe cienkie strony).
|
nie pusty doorway (Google karze masowe cienkie strony).
|
||||||
"""
|
"""
|
||||||
|
|
@ -49,7 +49,7 @@ from app.models.tag import Tag
|
||||||
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||||
|
|
||||||
# Analityka — wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony.
|
# Analityka, wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony.
|
||||||
# Puste = tag się nie renderuje (zero third-party requestów, zachowanie bez zmian).
|
# Puste = tag się nie renderuje (zero third-party requestów, zachowanie bez zmian).
|
||||||
# Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie.
|
# Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie.
|
||||||
templates.env.globals["gtm_id"] = os.environ.get("GOON_GTM_ID", "")
|
templates.env.globals["gtm_id"] = os.environ.get("GOON_GTM_ID", "")
|
||||||
|
|
@ -58,7 +58,7 @@ templates.env.globals["gsc_verify"] = os.environ.get("GOON_GSC_VERIFY", "")
|
||||||
|
|
||||||
# Limit URL-i na pojedynczy plik sitemap (spec: max 50k). Trzymamy z zapasem.
|
# Limit URL-i na pojedynczy plik sitemap (spec: max 50k). Trzymamy z zapasem.
|
||||||
_SITEMAP_PAGE = 25_000
|
_SITEMAP_PAGE = 25_000
|
||||||
# Ile scen renderujemy na stronie encji (performer/studio) — pełna lista 1000+ scen
|
# Ile scen renderujemy na stronie encji (performer/studio), pełna lista 1000+ scen
|
||||||
# to thin/slow; bierzemy najświeższe N, reszta i tak wpada przez sitemap scen.
|
# to thin/slow; bierzemy najświeższe N, reszta i tak wpada przez sitemap scen.
|
||||||
_SCENES_PER_ENTITY = 120
|
_SCENES_PER_ENTITY = 120
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ def _live_playback_exists():
|
||||||
def _not_blacklisted():
|
def _not_blacklisted():
|
||||||
"""Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag).
|
"""Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag).
|
||||||
|
|
||||||
Te same reguły co w GET /scenes — żeby SEO nie publikowało tego, co katalog ukrywa.
|
Te same reguły co w GET /scenes, żeby SEO nie publikowało tego, co katalog ukrywa.
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
~exists(
|
~exists(
|
||||||
|
|
@ -109,7 +109,7 @@ def _not_blacklisted():
|
||||||
|
|
||||||
|
|
||||||
def _indexable_scenes():
|
def _indexable_scenes():
|
||||||
"""SELECT Scene żywych, nie-zblacklistowanych scen — baza pod listy i sitemap."""
|
"""SELECT Scene żywych, nie-zblacklistowanych scen, baza pod listy i sitemap."""
|
||||||
stmt = select(Scene).where(_live_playback_exists())
|
stmt = select(Scene).where(_live_playback_exists())
|
||||||
for cond in _not_blacklisted():
|
for cond in _not_blacklisted():
|
||||||
stmt = stmt.where(cond)
|
stmt = stmt.where(cond)
|
||||||
|
|
@ -120,7 +120,7 @@ def _performer_indexable_exists():
|
||||||
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
|
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
|
||||||
|
|
||||||
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
|
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
|
||||||
`performer_page` — inaczej sitemap zgłaszałby URL-e dające 404.
|
`performer_page`, inaczej sitemap zgłaszałby URL-e dające 404.
|
||||||
"""
|
"""
|
||||||
sub = (
|
sub = (
|
||||||
select(1)
|
select(1)
|
||||||
|
|
@ -180,7 +180,7 @@ def performer_page(
|
||||||
if performer is None:
|
if performer is None:
|
||||||
raise HTTPException(status_code=404, detail="performer not found")
|
raise HTTPException(status_code=404, detail="performer not found")
|
||||||
|
|
||||||
# Sceny tej osoby — żywe, nie-blacklisted, najświeższe pierwsze.
|
# Sceny tej osoby, żywe, nie-blacklisted, najświeższe pierwsze.
|
||||||
scenes = (
|
scenes = (
|
||||||
session.execute(
|
session.execute(
|
||||||
_indexable_scenes()
|
_indexable_scenes()
|
||||||
|
|
@ -283,7 +283,7 @@ def scene_page(
|
||||||
if scene is None:
|
if scene is None:
|
||||||
raise HTTPException(status_code=404, detail="scene not found")
|
raise HTTPException(status_code=404, detail="scene not found")
|
||||||
|
|
||||||
# Źródła — żywe, deduplikowane po origin (pokazujemy 1 link per tube).
|
# Źródła, żywe, deduplikowane po origin (pokazujemy 1 link per tube).
|
||||||
sources_raw = (
|
sources_raw = (
|
||||||
session.execute(
|
session.execute(
|
||||||
select(PlaybackSource)
|
select(PlaybackSource)
|
||||||
|
|
@ -354,7 +354,7 @@ def landing(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Strona główna — crawl-entry. Najświeższe indeksowalne sceny + CTA."""
|
"""Strona główna, crawl-entry. Najświeższe indeksowalne sceny + CTA."""
|
||||||
scenes = (
|
scenes = (
|
||||||
session.execute(
|
session.execute(
|
||||||
_indexable_scenes()
|
_indexable_scenes()
|
||||||
|
|
@ -386,8 +386,8 @@ def landing(
|
||||||
|
|
||||||
@router.get("/get", response_class=HTMLResponse)
|
@router.get("/get", response_class=HTMLResponse)
|
||||||
def get_app(request: Request) -> HTMLResponse:
|
def get_app(request: Request) -> HTMLResponse:
|
||||||
"""Paid-traffic landing page — odchudzona pod konwersję instalacji APK.
|
"""Paid-traffic landing page, odchudzona pod konwersję instalacji APK.
|
||||||
noindex (patrz get.html) — nie ma konkurować w SERP z entity-stronami."""
|
noindex (patrz get.html), nie ma konkurować w SERP z entity-stronami."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"seo/get.html",
|
"seo/get.html",
|
||||||
|
|
@ -411,7 +411,7 @@ def page_2257(request: Request) -> HTMLResponse:
|
||||||
def gsc_site_verification(fname: str) -> PlainTextResponse:
|
def gsc_site_verification(fname: str) -> PlainTextResponse:
|
||||||
"""Plik weryfikacyjny Google Search Console (metoda 'Plik HTML').
|
"""Plik weryfikacyjny Google Search Console (metoda 'Plik HTML').
|
||||||
|
|
||||||
Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc' — bez '.html'). Google
|
Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc', bez '.html'). Google
|
||||||
pobiera /<token>.html i oczekuje body 'google-site-verification: <token>.html'.
|
pobiera /<token>.html i oczekuje body 'google-site-verification: <token>.html'.
|
||||||
Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest
|
Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest
|
||||||
jednosegmentowym *.html w rootcie).
|
jednosegmentowym *.html w rootcie).
|
||||||
|
|
@ -440,7 +440,7 @@ def _count(session: Session, stmt) -> int:
|
||||||
|
|
||||||
@router.get("/sitemap.xml")
|
@router.get("/sitemap.xml")
|
||||||
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
|
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
|
||||||
"""Sitemap index — listuje paginowane pod-sitemapy per typ encji."""
|
"""Sitemap index, listuje paginowane pod-sitemapy per typ encji."""
|
||||||
n_perf = _count(
|
n_perf = _count(
|
||||||
session,
|
session,
|
||||||
select(Performer.id).where(_performer_indexable_exists()),
|
select(Performer.id).where(_performer_indexable_exists()),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"""GET /sources — lista tube źródeł dla feature "Sites" (mobile top-level tab).
|
"""GET /sources, lista tube źródeł dla feature "Sites" (mobile top-level tab).
|
||||||
|
|
||||||
Bug-report 2026-05-24 (ea6f05f9, Scenes screen): user chce wybrać "pages"
|
Bug-report 2026-05-24 (ea6f05f9, Scenes screen): user chce wybrać "pages"
|
||||||
obok Scenes i Movies — widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
|
obok Scenes i Movies, widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
|
||||||
najnowsze sceny z konkretnego źródła.
|
najnowsze sceny z konkretnego źródła.
|
||||||
|
|
||||||
Endpoint enumeruje distinct `playback_sources.origin` z ŻYWYCH playback_sources
|
Endpoint enumeruje distinct `playback_sources.origin` z ŻYWYCH playback_sources
|
||||||
(`dead_at IS NULL`), tylko origins zaczynające się od 'tube:' (kanoniczne źródła
|
(`dead_at IS NULL`), tylko origins zaczynające się od 'tube:' (kanoniczne źródła
|
||||||
typu `canonical:tpdb_trailer` są pomijane — to nie są "scrapowane strony" w sensie
|
typu `canonical:tpdb_trailer` są pomijane, to nie są "scrapowane strony" w sensie
|
||||||
intencji feature'a).
|
intencji feature'a).
|
||||||
|
|
||||||
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
|
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
|
||||||
|
|
@ -42,7 +42,7 @@ class SourceRating(BaseModel):
|
||||||
richness: int
|
richness: int
|
||||||
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
|
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
|
||||||
health: int | None
|
health: int | None
|
||||||
"""0-5: czy realnie gra (telemetria odtwarzania) — 0=offline. None gdy brak danych."""
|
"""0-5: czy realnie gra (telemetria odtwarzania), 0=offline. None gdy brak danych."""
|
||||||
health_basis: str | None = None
|
health_basis: str | None = None
|
||||||
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
|
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
|
||||||
components: dict | None = None
|
components: dict | None = None
|
||||||
|
|
@ -51,28 +51,28 @@ class SourceRating(BaseModel):
|
||||||
|
|
||||||
class SourceOut(BaseModel):
|
class SourceOut(BaseModel):
|
||||||
origin: str
|
origin: str
|
||||||
"""Raw origin string z DB — np. 'tube:hqpornercom'. Używany jako parametr
|
"""Raw origin string z DB, np. 'tube:hqpornercom'. Używany jako parametr
|
||||||
`origin=` filtra w GET /scenes (substring match)."""
|
`origin=` filtra w GET /scenes (substring match)."""
|
||||||
|
|
||||||
sitetag: str
|
sitetag: str
|
||||||
"""Origin bez prefiksu 'tube:' — np. 'hqpornercom'. Stabilne ID tube'a (zgodne
|
"""Origin bez prefiksu 'tube:', np. 'hqpornercom'. Stabilne ID tube'a (zgodne
|
||||||
z `BaseDirectTubeScraper.sitetag`)."""
|
z `BaseDirectTubeScraper.sitetag`)."""
|
||||||
|
|
||||||
display_name: str
|
display_name: str
|
||||||
"""Czytelna nazwa do UI — np. 'hqporner.com'. Wyprowadzona z sitetag przez
|
"""Czytelna nazwa do UI, np. 'hqporner.com'. Wyprowadzona z sitetag przez
|
||||||
`_sitetag_to_display`. Tylko presentation; logikę trzymamy na sitetag/origin."""
|
`_sitetag_to_display`. Tylko presentation; logikę trzymamy na sitetag/origin."""
|
||||||
|
|
||||||
scene_count: int
|
scene_count: int
|
||||||
"""Liczba ŻYWYCH playback_sources (dead_at IS NULL) per origin. Approx scenes
|
"""Liczba ŻYWYCH playback_sources (dead_at IS NULL) per origin. Approx scenes
|
||||||
coverage — scena może mieć wiele sources tego samego origin (różne page_url),
|
coverage, scena może mieć wiele sources tego samego origin (różne page_url),
|
||||||
więc trochę zawyża rzeczywistą scene-distinct count, ale dla orientacji OK."""
|
więc trochę zawyża rzeczywistą scene-distinct count, ale dla orientacji OK."""
|
||||||
|
|
||||||
last_scraped_at: datetime | None
|
last_scraped_at: datetime | None
|
||||||
"""MAX(last_seen_at) — najświeższy scrape dla tego origin. Pozwala mobile pokazać
|
"""MAX(last_seen_at), najświeższy scrape dla tego origin. Pozwala mobile pokazać
|
||||||
'scrapowane Xh temu' i sortować świeżość."""
|
'scrapowane Xh temu' i sortować świeżość."""
|
||||||
|
|
||||||
rating: SourceRating | None = None
|
rating: SourceRating | None = None
|
||||||
"""Ocena 0-5★ (freshness/richness/health) z source_stats — None gdy jeszcze
|
"""Ocena 0-5★ (freshness/richness/health) z source_stats, None gdy jeszcze
|
||||||
nie policzona (job source-stats leci co kilka h)."""
|
nie policzona (job source-stats leci co kilka h)."""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -119,7 +119,7 @@ def list_sources(
|
||||||
) -> SourceListOut:
|
) -> SourceListOut:
|
||||||
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
|
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
|
||||||
|
|
||||||
Filter: `origin LIKE 'tube:%'` (drop canonical:* — TPDB trailery to inna semantyka).
|
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
|
||||||
"""
|
"""
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
select(
|
select(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Stream proxy — pomost VPS↔phone dla podpisanych URL-i CDN-ów.
|
"""Stream proxy, pomost VPS↔phone dla podpisanych URL-i CDN-ów.
|
||||||
|
|
||||||
Wiele hosterów (luluvids/medixiru/cdnvids/bigcdn) bindą podpisany URL do IP klienta
|
Wiele hosterów (luluvids/medixiru/cdnvids/bigcdn) bindą podpisany URL do IP klienta
|
||||||
który fetchował embed page. Gdy backend ekstraktuje URL z VPS-a, signature
|
który fetchował embed page. Gdy backend ekstraktuje URL z VPS-a, signature
|
||||||
weryfikuje VPS IP — telefon dostaje 403. Player na phonie kieruje requesty
|
weryfikuje VPS IP, telefon dostaje 403. Player na phonie kieruje requesty
|
||||||
*przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature
|
*przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature
|
||||||
poprawnie i serwuje content.
|
poprawnie i serwuje content.
|
||||||
|
|
||||||
|
|
@ -41,8 +41,8 @@ router = APIRouter(prefix="/proxy", tags=["proxy"])
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# In-memory bandwidth counter — bytes-out per CDN domain per hour bucket.
|
# In-memory bandwidth counter, bytes-out per CDN domain per hour bucket.
|
||||||
# Restart api resetuje counter (akceptowalne — to operational metric, nie billing).
|
# Restart api resetuje counter (akceptowalne, to operational metric, nie billing).
|
||||||
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
|
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
@ -90,7 +90,7 @@ TOKEN_TTL_SEC = 4 * 60 * 60 # 4h
|
||||||
|
|
||||||
|
|
||||||
# URL-level redirect cache: target_url -> (final_resolved_url, expires_ts).
|
# URL-level redirect cache: target_url -> (final_resolved_url, expires_ts).
|
||||||
# Mobile ExoPlayer robi range-requesty per seek/preload — każdy hituje proxy z tym
|
# Mobile ExoPlayer robi range-requesty per seek/preload, każdy hituje proxy z tym
|
||||||
# samym tokenem, proxy GET-uje target_url. Dla `porntrex.com/get_file/...` (a także
|
# samym tokenem, proxy GET-uje target_url. Dla `porntrex.com/get_file/...` (a także
|
||||||
# fpoxxx, freshporno) URL jest **single-use**: pierwszy GET → 302 → CDN URL (time-bound),
|
# fpoxxx, freshporno) URL jest **single-use**: pierwszy GET → 302 → CDN URL (time-bound),
|
||||||
# drugi GET → 410. Bez cache: drugi range = 410 → ExoPlayer fail → mobile fallback do
|
# drugi GET → 410. Bez cache: drugi range = 410 → ExoPlayer fail → mobile fallback do
|
||||||
|
|
@ -170,10 +170,10 @@ def make_token(
|
||||||
|
|
||||||
`refresh`: URL embed page do refetch gdy `url` zwraca 4xx. Proxy odbierze
|
`refresh`: URL embed page do refetch gdy `url` zwraca 4xx. Proxy odbierze
|
||||||
fresh stream URL z embed (np. mixdrop MDCore.wurl) gdy oryginalny token expired.
|
fresh stream URL z embed (np. mixdrop MDCore.wurl) gdy oryginalny token expired.
|
||||||
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.) — proxy
|
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.), proxy
|
||||||
dispatch do dedicated re-extract logic.
|
dispatch do dedicated re-extract logic.
|
||||||
`impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot
|
`impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot
|
||||||
detection — mxcontent, cloudflare-protected).
|
detection, mxcontent, cloudflare-protected).
|
||||||
`stable_bucket_sec`: kwantyzuje czas bazowy expiry do okna N sekund, więc token
|
`stable_bucket_sec`: kwantyzuje czas bazowy expiry do okna N sekund, więc token
|
||||||
(→ proxied URL) jest IDENTYCZNY dla wszystkich requestów w oknie. Bez tego `e`
|
(→ proxied URL) jest IDENTYCZNY dla wszystkich requestów w oknie. Bez tego `e`
|
||||||
zawiera bieżący timestamp → URL inny przy każdym fetchu listy → expo-image cache
|
zawiera bieżący timestamp → URL inny przy każdym fetchu listy → expo-image cache
|
||||||
|
|
@ -190,7 +190,7 @@ def make_token(
|
||||||
if impersonate:
|
if impersonate:
|
||||||
payload["i"] = 1
|
payload["i"] = 1
|
||||||
if producer:
|
if producer:
|
||||||
# Hoster oddający TREŚĆ manifestu (nie URL) — `/proxy/hls` zawoła producenta
|
# Hoster oddający TREŚĆ manifestu (nie URL), `/proxy/hls` zawoła producenta
|
||||||
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
|
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
|
||||||
payload["mp"] = producer
|
payload["mp"] = producer
|
||||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
@ -220,7 +220,7 @@ def parse_token(token: str) -> dict:
|
||||||
|
|
||||||
def _ascii_safe_url(url: str) -> str:
|
def _ascii_safe_url(url: str) -> str:
|
||||||
"""Encode non-ASCII chars w URL path/query, zachowując reserved chars dla URI.
|
"""Encode non-ASCII chars w URL path/query, zachowując reserved chars dla URI.
|
||||||
httpx wymaga ASCII headers — Referer z polskim/cyrillic/unicode (np. hqporner
|
httpx wymaga ASCII headers, Referer z polskim/cyrillic/unicode (np. hqporner
|
||||||
`Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote`
|
`Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote`
|
||||||
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
|
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
|
||||||
enkoduje znaki spoza ASCII."""
|
enkoduje znaki spoza ASCII."""
|
||||||
|
|
@ -255,8 +255,8 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
|
||||||
"""Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy.
|
"""Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy.
|
||||||
|
|
||||||
HLS manifest ma:
|
HLS manifest ma:
|
||||||
- linie URI (segmenty .ts / sub-playlisty .m3u8) — relatywne lub absolute
|
- linie URI (segmenty .ts / sub-playlisty .m3u8), relatywne lub absolute
|
||||||
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"` — też potrzebują rewrite
|
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"`, też potrzebują rewrite
|
||||||
Każdy URL → token + /proxy/{token}/<basename>.<ext>.
|
Każdy URL → token + /proxy/{token}/<basename>.<ext>.
|
||||||
"""
|
"""
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
|
|
@ -284,7 +284,7 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _absolutize_m3u8(content: str, base_url: str) -> str:
|
def _absolutize_m3u8(content: str, base_url: str) -> str:
|
||||||
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN —
|
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN,
|
||||||
NIE przez proxy. Dla direct-HLS passthrough (`/proxy/hls/...`): telefon ciągnie je
|
NIE przez proxy. Dla direct-HLS passthrough (`/proxy/hls/...`): telefon ciągnie je
|
||||||
bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin),
|
bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin),
|
||||||
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
|
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
|
||||||
|
|
@ -310,7 +310,7 @@ def sign_url(
|
||||||
referer: str | None = Query(default=None),
|
referer: str | None = Query(default=None),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Pomocniczy endpoint dla mobile do uzyskania świeżego tokena (np. po expiry).
|
"""Pomocniczy endpoint dla mobile do uzyskania świeżego tokena (np. po expiry).
|
||||||
Normalnie /resolve zwraca już proxy URL — to fallback."""
|
Normalnie /resolve zwraca już proxy URL, to fallback."""
|
||||||
return {"token": make_token(url, referer), "expires_in": TOKEN_TTL_SEC}
|
return {"token": make_token(url, referer), "expires_in": TOKEN_TTL_SEC}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -320,11 +320,11 @@ async def proxy_image(
|
||||||
_basename: str,
|
_basename: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Image proxy — używany dla thumbnaili z CDN-ów wymagających Referera
|
"""Image proxy, używany dla thumbnaili z CDN-ów wymagających Referera
|
||||||
(hqporner i inne porn-app sourcy). Mobile expo-image nie wysyła Referera
|
(hqporner i inne porn-app sourcy). Mobile expo-image nie wysyła Referera
|
||||||
domyślnie, CDN zwraca 403. Backend dodaje Referer i streamuje obrazek.
|
domyślnie, CDN zwraca 403. Backend dodaje Referer i streamuje obrazek.
|
||||||
|
|
||||||
Cache-Control: public,max-age=86400 — thumby są stabilne, klient może cachować."""
|
Cache-Control: public,max-age=86400, thumby są stabilne, klient może cachować."""
|
||||||
payload = parse_token(token)
|
payload = parse_token(token)
|
||||||
target = payload["u"]
|
target = payload["u"]
|
||||||
referer = payload["r"] or None
|
referer = payload["r"] or None
|
||||||
|
|
@ -334,18 +334,18 @@ async def proxy_image(
|
||||||
try:
|
try:
|
||||||
r = await client.get(target, headers=headers)
|
r = await client.get(target, headers=headers)
|
||||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
||||||
# CDN connect/timeout — transient (np. Cloudflare 523 origin unreachable
|
# CDN connect/timeout, transient (np. Cloudflare 523 origin unreachable
|
||||||
# gdy upstream host jest off). Log INFO + 503, mobile renderuje placeholder.
|
# gdy upstream host jest off). Log INFO + 503, mobile renderuje placeholder.
|
||||||
# Bez tego Sentry dostawał setki ERROR-ów (GOON-D/6) z każdym broken
|
# Bez tego Sentry dostawał setki ERROR-ów (GOON-D/6) z każdym broken
|
||||||
# tube'em — spam-szumiło real-issues.
|
# tube'em, spam-szumiło real-issues.
|
||||||
log.info("img proxy connect/timeout for %s: %s", target, e)
|
log.info("img proxy connect/timeout for %s: %s", target, e)
|
||||||
return Response(content=b"", status_code=503, media_type="image/jpeg")
|
return Response(content=b"", status_code=503, media_type="image/jpeg")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("img proxy fetch failed for %s: %s", target, e)
|
log.warning("img proxy fetch failed for %s: %s", target, e)
|
||||||
raise HTTPException(status_code=502, detail=f"img fetch failed: {e}") from e
|
raise HTTPException(status_code=502, detail=f"img fetch failed: {e}") from e
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
# Upstream 4xx/5xx dla thumba — degraded zamiast raise (placeholder w mobile).
|
# Upstream 4xx/5xx dla thumba, degraded zamiast raise (placeholder w mobile).
|
||||||
# GOON-5 (Cloudflare 523) i GOON-D — bezsensowny noise w Sentry, lepiej
|
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
|
||||||
# info log + 502 pass-through bez exception.
|
# info log + 502 pass-through bez exception.
|
||||||
log.info("img proxy upstream %d for %s", r.status_code, target)
|
log.info("img proxy upstream %d for %s", r.status_code, target)
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -362,7 +362,7 @@ async def proxy_image(
|
||||||
|
|
||||||
|
|
||||||
# sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token
|
# sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token
|
||||||
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować — resolvujemy bieżący
|
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować, resolvujemy bieżący
|
||||||
# og:image ze strony /post/<id> przy serwowaniu. Cache resolved poster URL ~40min
|
# og:image ze strony /post/<id> przy serwowaniu. Cache resolved poster URL ~40min
|
||||||
# (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/<id> → cache'uje bajty na
|
# (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/<id> → cache'uje bajty na
|
||||||
# stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post.
|
# stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post.
|
||||||
|
|
@ -376,10 +376,10 @@ _SXYPRN_PID_RE = re.compile(r"^[0-9a-f]{6,40}$")
|
||||||
|
|
||||||
# youporn on-demand thumbnail (bug-report bebf8c92 2026-07-25 "miniaturki z youporn
|
# youporn on-demand thumbnail (bug-report bebf8c92 2026-07-25 "miniaturki z youporn
|
||||||
# nie działają"). youporn przeszedł dla części katalogu na imgproxy z PODPISANYM URL-em
|
# nie działają"). youporn przeszedł dla części katalogu na imgproxy z PODPISANYM URL-em
|
||||||
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`) — taki URL
|
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`), taki URL
|
||||||
# wygasa, więc zapisany w bazie po czasie zwraca 410 (audit 2026-07-26: 8666 z 62930
|
# wygasa, więc zapisany w bazie po czasie zwraca 410 (audit 2026-07-26: 8666 z 62930
|
||||||
# źródeł youporn). Reszta katalogu ma stare statyczne `.../original/N.jpg`, które działają
|
# źródeł youporn). Reszta katalogu ma stare statyczne `.../original/N.jpg`, które działają
|
||||||
# bezterminowo — tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
|
# bezterminowo, tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
|
||||||
# świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/<video_id>`.
|
# świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/<video_id>`.
|
||||||
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
|
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
|
||||||
_YOUPORN_POSTER_TTL = 1800
|
_YOUPORN_POSTER_TTL = 1800
|
||||||
|
|
@ -417,7 +417,7 @@ async def youporn_thumb(video_id: str) -> Response:
|
||||||
)
|
)
|
||||||
if not m:
|
if not m:
|
||||||
return Response(content=b"", status_code=404, media_type="image/jpeg")
|
return Response(content=b"", status_code=404, media_type="image/jpeg")
|
||||||
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&`) — bez unescape
|
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&`), bez unescape
|
||||||
# podpis w query jest zepsuty i CDN oddaje 403.
|
# podpis w query jest zepsuty i CDN oddaje 403.
|
||||||
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
|
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
|
||||||
if poster.startswith("//"):
|
if poster.startswith("//"):
|
||||||
|
|
@ -492,7 +492,7 @@ async def sxyprn_thumb(post_id: str) -> Response:
|
||||||
async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None:
|
async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None:
|
||||||
"""Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl.
|
"""Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl.
|
||||||
Cookies persist w session, użytkowane potem do mp4 GET (same-session bind).
|
Cookies persist w session, użytkowane potem do mp4 GET (same-session bind).
|
||||||
UA + Accept wymagane — bez tego mixdrop zwraca minimalny body (bez packera).
|
UA + Accept wymagane, bez tego mixdrop zwraca minimalny body (bez packera).
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from yt_dlp.utils import decode_packed_codes
|
from yt_dlp.utils import decode_packed_codes
|
||||||
|
|
@ -622,7 +622,7 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
||||||
|
|
||||||
Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e
|
Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e
|
||||||
(variant/segmenty) zostają ABSOLUTNE na CDN → telefon ciągnie je DIRECT (phone IP,
|
(variant/segmenty) zostają ABSOLUTNE na CDN → telefon ciągnie je DIRECT (phone IP,
|
||||||
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty —
|
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty,
|
||||||
zweryfikowane cross-IP 2026-06-11). Przez VPS leci tylko ~1KB manifestu, nie segmenty.
|
zweryfikowane cross-IP 2026-06-11). Przez VPS leci tylko ~1KB manifestu, nie segmenty.
|
||||||
Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound
|
Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound
|
||||||
hostery dalej idą pełnym `/proxy/`."""
|
hostery dalej idą pełnym `/proxy/`."""
|
||||||
|
|
@ -674,7 +674,7 @@ async def proxy_stream(
|
||||||
refetch_hoster = payload.get("rh")
|
refetch_hoster = payload.get("rh")
|
||||||
|
|
||||||
# Jeśli ten target był już wcześniej follow-redirect-ed, użyj cached final URL.
|
# Jeśli ten target był już wcześniej follow-redirect-ed, użyj cached final URL.
|
||||||
# Powód: porntrex `get_file/` 410 po reuse — patrz `_REDIRECT_CACHE` docstring.
|
# Powód: porntrex `get_file/` 410 po reuse, patrz `_REDIRECT_CACHE` docstring.
|
||||||
cached_target = _redirect_cache_get(original_target)
|
cached_target = _redirect_cache_get(original_target)
|
||||||
target = cached_target or original_target
|
target = cached_target or original_target
|
||||||
|
|
||||||
|
|
@ -684,10 +684,10 @@ async def proxy_stream(
|
||||||
if range_h:
|
if range_h:
|
||||||
headers["Range"] = range_h
|
headers["Range"] = range_h
|
||||||
|
|
||||||
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny — proxy zwraca pełne odpowiedzi
|
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny, proxy zwraca pełne odpowiedzi
|
||||||
|
|
||||||
# Hostery które wymagają Chrome JA3 fingerprint (mxcontent / cloudflare-protected
|
# Hostery które wymagają Chrome JA3 fingerprint (mxcontent / cloudflare-protected
|
||||||
# CDNs) — od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
|
# CDNs), od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
|
||||||
# Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py).
|
# Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py).
|
||||||
if use_impersonate:
|
if use_impersonate:
|
||||||
return await _curl_cffi_stream(
|
return await _curl_cffi_stream(
|
||||||
|
|
@ -707,7 +707,7 @@ async def proxy_stream(
|
||||||
|
|
||||||
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
||||||
try:
|
try:
|
||||||
# Sprobój streaming send PIERWSZY — sprawdź content-type po headers,
|
# Sprobój streaming send PIERWSZY, sprawdź content-type po headers,
|
||||||
# potem decyzja: rewrite manifest vs stream binary.
|
# potem decyzja: rewrite manifest vs stream binary.
|
||||||
upstream = await client.send(
|
upstream = await client.send(
|
||||||
client.build_request(method, target, headers=headers),
|
client.build_request(method, target, headers=headers),
|
||||||
|
|
@ -719,7 +719,7 @@ async def proxy_stream(
|
||||||
ups_headers = dict(upstream.headers)
|
ups_headers = dict(upstream.headers)
|
||||||
await upstream.aclose()
|
await upstream.aclose()
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410) —
|
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410),
|
||||||
# invaliduj cache i daj mobile retry przez fresh /resolve. Bez tego stale
|
# invaliduj cache i daj mobile retry przez fresh /resolve. Bez tego stale
|
||||||
# cache trzymałby martwy CDN URL przez 30 min (TTL).
|
# cache trzymałby martwy CDN URL przez 30 min (TTL).
|
||||||
if cached_target is not None and status in (401, 403, 404, 410):
|
if cached_target is not None and status in (401, 403, 404, 410):
|
||||||
|
|
@ -728,7 +728,7 @@ async def proxy_stream(
|
||||||
|
|
||||||
# Pierwszy successful pass dla single-use targets (np. porntrex get_file):
|
# Pierwszy successful pass dla single-use targets (np. porntrex get_file):
|
||||||
# cache resolved final URL (po follow_redirects). Następne range-requesty
|
# cache resolved final URL (po follow_redirects). Następne range-requesty
|
||||||
# pójdą direct w CDN URL — get_file nie dostaje drugiego hita.
|
# pójdą direct w CDN URL, get_file nie dostaje drugiego hita.
|
||||||
if cached_target is None:
|
if cached_target is None:
|
||||||
final_url = str(upstream.url)
|
final_url = str(upstream.url)
|
||||||
if final_url != original_target:
|
if final_url != original_target:
|
||||||
|
|
@ -741,7 +741,7 @@ async def proxy_stream(
|
||||||
or "application/x-mpegurl" in ct
|
or "application/x-mpegurl" in ct
|
||||||
)
|
)
|
||||||
if is_m3u8:
|
if is_m3u8:
|
||||||
# Manifest content — buffer fully, rewrite, return as m3u8.
|
# Manifest content, buffer fully, rewrite, return as m3u8.
|
||||||
body = await upstream.aread()
|
body = await upstream.aread()
|
||||||
await upstream.aclose()
|
await upstream.aclose()
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|
@ -769,7 +769,7 @@ async def proxy_stream(
|
||||||
yield chunk
|
yield chunk
|
||||||
except httpx.TransportError as e:
|
except httpx.TransportError as e:
|
||||||
# Upstream CDN zerwał połączenie w trakcie streamu (peer closed before
|
# Upstream CDN zerwał połączenie w trakcie streamu (peer closed before
|
||||||
# full body — RemoteProtocolError, ReadError, timeout). Klient dostaje
|
# full body, RemoteProtocolError, ReadError, timeout). Klient dostaje
|
||||||
# uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji.
|
# uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji.
|
||||||
# Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija
|
# Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija
|
||||||
# outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11).
|
# outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11).
|
||||||
|
|
@ -792,7 +792,7 @@ async def proxy_stream(
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
raise
|
raise
|
||||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
||||||
# CDN connect failure / timeout — transient, log na INFO (nie ERROR do Sentry).
|
# CDN connect failure / timeout, transient, log na INFO (nie ERROR do Sentry).
|
||||||
# Zwracamy 503 zamiast 502 + Retry-After, mobile może retry-ować bez panic.
|
# Zwracamy 503 zamiast 502 + Retry-After, mobile może retry-ować bez panic.
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
log.info("proxy connect/timeout for %s: %s", target, e)
|
log.info("proxy connect/timeout for %s: %s", target, e)
|
||||||
|
|
@ -818,10 +818,10 @@ def _upstream_error_response(
|
||||||
Rationale per status:
|
Rationale per status:
|
||||||
- **429 Too Many Requests**: CDN rate-limit (np. fpo.xxx gdy proxy hammeruje
|
- **429 Too Many Requests**: CDN rate-limit (np. fpo.xxx gdy proxy hammeruje
|
||||||
get_file/). Pass-through 429 + Retry-After żeby mobile zrobiło backoff.
|
get_file/). Pass-through 429 + Retry-After żeby mobile zrobiło backoff.
|
||||||
Log INFO (nie ERROR) — to expected behavior CDN-a, nie nasz bug.
|
Log INFO (nie ERROR), to expected behavior CDN-a, nie nasz bug.
|
||||||
- **404/410**: video deleted/expired token. Pass-through żeby player wiedział.
|
- **404/410**: video deleted/expired token. Pass-through żeby player wiedział.
|
||||||
- **5xx upstream**: pochodzi z CDN-a, nie z naszego kodu. Log INFO.
|
- **5xx upstream**: pochodzi z CDN-a, nie z naszego kodu. Log INFO.
|
||||||
- **inne 4xx**: 502 (i Sentry warn) — może być nasza wina (bad referer itp.).
|
- **inne 4xx**: 502 (i Sentry warn), może być nasza wina (bad referer itp.).
|
||||||
"""
|
"""
|
||||||
retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After")
|
retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After")
|
||||||
if status == 429:
|
if status == 429:
|
||||||
|
|
@ -845,7 +845,7 @@ def _upstream_error_response(
|
||||||
media_type="text/plain",
|
media_type="text/plain",
|
||||||
)
|
)
|
||||||
if 500 <= status < 600:
|
if 500 <= status < 600:
|
||||||
# CDN-side error (np. Cloudflare 523 — origin unreachable). Pass-through
|
# CDN-side error (np. Cloudflare 523, origin unreachable). Pass-through
|
||||||
# 502 ale log INFO bo to nie nasza wina.
|
# 502 ale log INFO bo to nie nasza wina.
|
||||||
log.info("proxy upstream %d for %s", status, target)
|
log.info("proxy upstream %d for %s", status, target)
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -854,5 +854,5 @@ def _upstream_error_response(
|
||||||
headers={"Retry-After": "5"},
|
headers={"Retry-After": "5"},
|
||||||
media_type="text/plain",
|
media_type="text/plain",
|
||||||
)
|
)
|
||||||
# 4xx other (403 itp.) — raise żeby Sentry zarejestrował (może bug naszego kodu)
|
# 4xx other (403 itp.), raise żeby Sentry zarejestrował (może bug naszego kodu)
|
||||||
raise HTTPException(status_code=502, detail=f"upstream {status}")
|
raise HTTPException(status_code=502, detail=f"upstream {status}")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""GET /tags, /performers, /studios — listy taxonomies do filtrów na mobile.
|
"""GET /tags, /performers, /studios, listy taxonomies do filtrów na mobile.
|
||||||
|
|
||||||
Każdy endpoint wspiera:
|
Każdy endpoint wspiera:
|
||||||
- q: substring search po name_normalized (trgm fallback ilike)
|
- q: substring search po name_normalized (trgm fallback ilike)
|
||||||
|
|
@ -107,7 +107,7 @@ def list_tags(
|
||||||
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
|
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
|
||||||
|
|
||||||
if for_movies:
|
if for_movies:
|
||||||
# Movie tag count — zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
|
# Movie tag count, zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
|
||||||
# Tag-bez-żadnego-movie zwraca 0 (LEFT OUTER JOIN przez coalesce). Movies są
|
# Tag-bez-żadnego-movie zwraca 0 (LEFT OUTER JOIN przez coalesce). Movies są
|
||||||
# małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji.
|
# małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji.
|
||||||
_movie_live = exists().where(
|
_movie_live = exists().where(
|
||||||
|
|
@ -146,7 +146,7 @@ def list_tags(
|
||||||
]
|
]
|
||||||
return TagListOut(items=items, total=total, page=page, per_page=per_page)
|
return TagListOut(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
# Scenes path — czyta zdenormalizowany Tag.scene_count (refresh w tle przez
|
# Scenes path, czyta zdenormalizowany Tag.scene_count (refresh w tle przez
|
||||||
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
|
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
|
||||||
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
|
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
|
||||||
base = select(Tag)
|
base = select(Tag)
|
||||||
|
|
@ -236,13 +236,13 @@ def list_studios(
|
||||||
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
|
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
|
||||||
),
|
),
|
||||||
) -> StudioListOut:
|
) -> StudioListOut:
|
||||||
from app.models.scene import Scene # lokalny import — Scene FK do Studio
|
from app.models.scene import Scene # lokalny import, Scene FK do Studio
|
||||||
|
|
||||||
if order not in ("name", "scene_count", "popular"):
|
if order not in ("name", "scene_count", "popular"):
|
||||||
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
|
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
|
||||||
|
|
||||||
if for_movies:
|
if for_movies:
|
||||||
# Movies małe (~41k) — live aggregat OK. Scenes path niżej = denormalizacja.
|
# Movies małe (~41k), live aggregat OK. Scenes path niżej = denormalizacja.
|
||||||
_movie_live = exists().where(
|
_movie_live = exists().where(
|
||||||
and_(
|
and_(
|
||||||
MoviePlaybackSource.movie_id == Movie.id,
|
MoviePlaybackSource.movie_id == Movie.id,
|
||||||
|
|
@ -280,7 +280,7 @@ def list_studios(
|
||||||
]
|
]
|
||||||
return StudioListOut(items=items, total=total, page=page, per_page=per_page)
|
return StudioListOut(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
# Scenes path — czyta zdenormalizowany Studio.scene_count (refresh w tle).
|
# Scenes path, czyta zdenormalizowany Studio.scene_count (refresh w tle).
|
||||||
# Wcześniej agregat 1.69M scenes + EXISTS playback per-request. Patrz migracja 0019.
|
# Wcześniej agregat 1.69M scenes + EXISTS playback per-request. Patrz migracja 0019.
|
||||||
base = select(Studio)
|
base = select(Studio)
|
||||||
if q:
|
if q:
|
||||||
|
|
@ -340,13 +340,13 @@ class PerformerRescrapeOut(BaseModel):
|
||||||
# przy partial commits. 45s wall-clock + 50 scen max = ~12 fetches × 3s budgetowo.
|
# przy partial commits. 45s wall-clock + 50 scen max = ~12 fetches × 3s budgetowo.
|
||||||
# Większe rescrape'y user może odpalać wielokrotnie (idempotent dzięki has_thumb/
|
# Większe rescrape'y user może odpalać wielokrotnie (idempotent dzięki has_thumb/
|
||||||
# tag_count check).
|
# tag_count check).
|
||||||
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s — 5s margin na response build
|
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s, 5s margin na response build
|
||||||
_RESCRAPE_MAX_SCENES = 50
|
_RESCRAPE_MAX_SCENES = 50
|
||||||
# Re-fetch tagów dla scen z < N tagami. Niektórzy performerzy mają legit 1-2 tagi
|
# Re-fetch tagów dla scen z < N tagami. Niektórzy performerzy mają legit 1-2 tagi
|
||||||
# (niche), no harm w sprawdzeniu pierwszy raz; powtarzane wywołania są idempotent
|
# (niche), no harm w sprawdzeniu pierwszy raz; powtarzane wywołania są idempotent
|
||||||
# bo INSERT ... ON CONFLICT DO NOTHING.
|
# bo INSERT ... ON CONFLICT DO NOTHING.
|
||||||
_TAG_RESCRAPE_THRESHOLD = 3
|
_TAG_RESCRAPE_THRESHOLD = 3
|
||||||
# Mainstream tubes priority dla tagów — bogate metadane.
|
# Mainstream tubes priority dla tagów, bogate metadane.
|
||||||
_TAG_PRIORITY = [
|
_TAG_PRIORITY = [
|
||||||
"xhamstercom", "porntrexcom", "epornercom", "youporncom",
|
"xhamstercom", "porntrexcom", "epornercom", "youporncom",
|
||||||
"xvideoscom", "xnxxcom",
|
"xvideoscom", "xnxxcom",
|
||||||
|
|
@ -360,7 +360,7 @@ def performer_top_tags(
|
||||||
limit: int = Query(default=20, ge=1, le=50),
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
) -> TagListOut:
|
) -> TagListOut:
|
||||||
"""Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips,
|
"""Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips,
|
||||||
bug-report 1a4bf258 — zastępują dev-only przycisk Re-scrape). Liczymy tylko po
|
bug-report 1a4bf258, zastępują dev-only przycisk Re-scrape). Liczymy tylko po
|
||||||
scenach z żywym playbackiem (to co user realnie widzi na liście). scene_id index
|
scenach z żywym playbackiem (to co user realnie widzi na liście). scene_id index
|
||||||
pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request."""
|
pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request."""
|
||||||
live = exists().where(
|
live = exists().where(
|
||||||
|
|
@ -418,7 +418,7 @@ def rescrape_performer_scenes(
|
||||||
if perf is None:
|
if perf is None:
|
||||||
raise HTTPException(status_code=404, detail="performer not found")
|
raise HTTPException(status_code=404, detail="performer not found")
|
||||||
|
|
||||||
# 1) ID-only query — sceny ze ≥1 alive tube playback.
|
# 1) ID-only query, sceny ze ≥1 alive tube playback.
|
||||||
scene_ids = session.execute(
|
scene_ids = session.execute(
|
||||||
select(Scene.id)
|
select(Scene.id)
|
||||||
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
|
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
|
||||||
|
|
@ -468,7 +468,7 @@ def rescrape_performer_scenes(
|
||||||
capped = False
|
capped = False
|
||||||
cap_reason: str | None = None
|
cap_reason: str | None = None
|
||||||
started = _time.monotonic()
|
started = _time.monotonic()
|
||||||
# Narrow exception set — łapiemy TYLKO oczekiwane network/parse failures.
|
# Narrow exception set, łapiemy TYLKO oczekiwane network/parse failures.
|
||||||
# `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion.
|
# `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion.
|
||||||
NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError)
|
NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError)
|
||||||
|
|
||||||
|
|
@ -486,7 +486,7 @@ def rescrape_performer_scenes(
|
||||||
has_thumb = any(s.thumbnail_url for s in sources)
|
has_thumb = any(s.thumbnail_url for s in sources)
|
||||||
existing_tag_count = tag_counts.get(scene_id, 0)
|
existing_tag_count = tag_counts.get(scene_id, 0)
|
||||||
|
|
||||||
# SAVEPOINT — fail isolation. Pojedyncza scena z FK violation w SceneTag
|
# SAVEPOINT, fail isolation. Pojedyncza scena z FK violation w SceneTag
|
||||||
# insert nie odpaliłby outer transaction; bez nested rollback całe N scen
|
# insert nie odpaliłby outer transaction; bez nested rollback całe N scen
|
||||||
# po niej miałoby PendingRollbackError.
|
# po niej miałoby PendingRollbackError.
|
||||||
sp = session.begin_nested()
|
sp = session.begin_nested()
|
||||||
|
|
@ -504,7 +504,7 @@ def rescrape_performer_scenes(
|
||||||
thumb = extract_thumbnail_url(r.text)
|
thumb = extract_thumbnail_url(r.text)
|
||||||
if thumb:
|
if thumb:
|
||||||
# Update tylko źródła z którego pochodzi thumb (single playback).
|
# Update tylko źródła z którego pochodzi thumb (single playback).
|
||||||
# Wcześniej apply'owalismy do wszystkich siblings — wrong-CDN
|
# Wcześniej apply'owalismy do wszystkich siblings, wrong-CDN
|
||||||
# cross-attribution (np. xhamster thumb na porntrex entry).
|
# cross-attribution (np. xhamster thumb na porntrex entry).
|
||||||
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
|
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
|
||||||
# find()), więc 1 wystarczy.
|
# find()), więc 1 wystarczy.
|
||||||
|
|
@ -586,7 +586,7 @@ def refresh_performer(
|
||||||
performer_id: uuid.UUID,
|
performer_id: uuid.UUID,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> PerformerRefreshOut:
|
) -> PerformerRefreshOut:
|
||||||
"""On-demand search across all tubes dla pojedynczego performera. Synchronous —
|
"""On-demand search across all tubes dla pojedynczego performera. Synchronous,
|
||||||
blokujemy aż search skończy. Mobile pokazuje spinner.
|
blokujemy aż search skończy. Mobile pokazuje spinner.
|
||||||
|
|
||||||
Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style
|
Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style
|
||||||
|
|
@ -606,7 +606,7 @@ def refresh_performer(
|
||||||
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
|
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Lazy import — performer_driven ma ciężki connector tree
|
# Lazy import, performer_driven ma ciężki connector tree
|
||||||
from app.scheduler.performer_driven import run_performer_driven
|
from app.scheduler.performer_driven import run_performer_driven
|
||||||
|
|
||||||
# NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes).
|
# NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes).
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Watch history + continue watching.
|
"""Watch history + continue watching.
|
||||||
|
|
||||||
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
|
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
|
||||||
- Klik Watch (position_sec=0) — wciąga scenę do recent watch
|
- Klik Watch (position_sec=0), wciąga scenę do recent watch
|
||||||
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT) — z faktyczną pozycją
|
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT), z faktyczną pozycją
|
||||||
|
|
||||||
Continue watching rail na home: GET /watch/recent?limit=10 → top scen po last_played_at,
|
Continue watching rail na home: GET /watch/recent?limit=10 → top scen po last_played_at,
|
||||||
filtruje dead-finished (>=95% lub flag finished). Mobile pokazuje progress bar
|
filtruje dead-finished (>=95% lub flag finished). Mobile pokazuje progress bar
|
||||||
|
|
@ -55,7 +55,7 @@ def upsert_progress(
|
||||||
if session.get(Scene, scene_id) is None:
|
if session.get(Scene, scene_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="scene not found")
|
raise HTTPException(status_code=404, detail="scene not found")
|
||||||
|
|
||||||
# PG upsert — eliminuje race condition gdy mobile wysyła progress równolegle
|
# PG upsert, eliminuje race condition gdy mobile wysyła progress równolegle
|
||||||
# (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add →
|
# (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add →
|
||||||
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
|
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
@ -139,7 +139,7 @@ def upsert_movie_progress(
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
device_id: Annotated[str, Depends(get_device_id)],
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
) -> MovieProgressOut:
|
) -> MovieProgressOut:
|
||||||
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26 —
|
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26,
|
||||||
"przydałoby się oznaczenie filmów już obejrzanych")."""
|
"przydałoby się oznaczenie filmów już obejrzanych")."""
|
||||||
if session.get(Movie, movie_id) is None:
|
if session.get(Movie, movie_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="movie not found")
|
raise HTTPException(status_code=404, detail="movie not found")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""API key authentication.
|
"""API key authentication.
|
||||||
|
|
||||||
Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer <key>`.
|
Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer <key>`.
|
||||||
Gdy `settings.api_keys` jest puste — auth jest wyłączony (dev mode).
|
Gdy `settings.api_keys` jest puste, auth jest wyłączony (dev mode).
|
||||||
|
|
||||||
Dodatkowo (anti-tamper): gdy `ALLOWED_APP_SIG_HASH` jest ustawione, każdy request
|
Dodatkowo (anti-tamper): gdy `ALLOWED_APP_SIG_HASH` jest ustawione, każdy request
|
||||||
musi zawierać `X-App-Signature` z SHA256 (hex) signing certu APK. Mismatch → 403.
|
musi zawierać `X-App-Signature` z SHA256 (hex) signing certu APK. Mismatch → 403.
|
||||||
|
|
@ -30,7 +30,7 @@ def require_api_key(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not settings.auth_enabled:
|
if not settings.auth_enabled:
|
||||||
return # local/dev — wszystko otwarte
|
return # local/dev, wszystko otwarte
|
||||||
|
|
||||||
candidate: str | None = None
|
candidate: str | None = None
|
||||||
if x_api_key:
|
if x_api_key:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class Settings(BaseSettings):
|
||||||
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
||||||
|
|
||||||
|
|
||||||
# Sentry observability — pusty DSN = init no-op (devel/local). Cloud free tier
|
# Sentry observability, pusty DSN = init no-op (devel/local). Cloud free tier
|
||||||
# 5k errors/mies wystarczy dla 1-user app.
|
# 5k errors/mies wystarczy dla 1-user app.
|
||||||
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
|
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
|
||||||
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
|
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
|
||||||
|
|
@ -48,12 +48,12 @@ class Settings(BaseSettings):
|
||||||
title_token_set_min: int = 88
|
title_token_set_min: int = 88
|
||||||
date_window_days: int = 7
|
date_window_days: int = 7
|
||||||
|
|
||||||
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source —
|
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source,
|
||||||
# to permanentne orphany (free tubes nie hostują), ~56% ingestu TPDB/StashDB.
|
# to permanentne orphany (free tubes nie hostują), ~56% ingestu TPDB/StashDB.
|
||||||
# False = wciągaj jak dawniej. Tube'y z clip-store studiem NIE są skipowane (mają playback).
|
# False = wciągaj jak dawniej. Tube'y z clip-store studiem NIE są skipowane (mają playback).
|
||||||
skip_clip_store: bool = Field(default=True, validation_alias="GOON_SKIP_CLIP_STORE")
|
skip_clip_store: bool = Field(default=True, validation_alias="GOON_SKIP_CLIP_STORE")
|
||||||
|
|
||||||
# Minimalny duration sceny z tube/scraper przy ingescie — <N s = trailer/teaser/preview.
|
# Minimalny duration sceny z tube/scraper przy ingescie, <N s = trailer/teaser/preview.
|
||||||
# 0 = wyłączony. Nieznany duration nie jest wycinany. NIE dotyczy canonical (TPDB/StashDB).
|
# 0 = wyłączony. Nieznany duration nie jest wycinany. NIE dotyczy canonical (TPDB/StashDB).
|
||||||
min_ingest_duration_sec: int = Field(default=180, validation_alias="GOON_MIN_INGEST_DURATION_SEC")
|
min_ingest_duration_sec: int = Field(default=180, validation_alias="GOON_MIN_INGEST_DURATION_SEC")
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ class Settings(BaseSettings):
|
||||||
sched_performer_continuous_refresh_days: int = Field(
|
sched_performer_continuous_refresh_days: int = Field(
|
||||||
default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS"
|
default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS"
|
||||||
)
|
)
|
||||||
# Movie ingest — paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
|
# Movie ingest, paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
|
||||||
# pandamovies). Każdy connector zapisuje swój `Source` i robi delta od ostatniego
|
# pandamovies). Każdy connector zapisuje swój `Source` i robi delta od ostatniego
|
||||||
# successful run. Set to 0 to disable. Domyślnie 24h: movie sites rosną wolniej
|
# successful run. Set to 0 to disable. Domyślnie 24h: movie sites rosną wolniej
|
||||||
# niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej.
|
# niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej.
|
||||||
|
|
@ -92,31 +92,31 @@ class Settings(BaseSettings):
|
||||||
sched_browse_latest_max_pages: int = Field(
|
sched_browse_latest_max_pages: int = Field(
|
||||||
default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES"
|
default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES"
|
||||||
)
|
)
|
||||||
# Deep-crawl (Faza 2a) — pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
|
# Deep-crawl (Faza 2a), pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
|
||||||
# top-N. Round-robin po tube'ach, wznawialny kursor (app/_state/deepcrawl_state.json).
|
# top-N. Round-robin po tube'ach, wznawialny kursor (app/_state/deepcrawl_state.json).
|
||||||
# 0 = wyłączony. 60 stron/run × ~31 scen ≈ 1860 scen/run (~22 min, hard-timeout 1h).
|
# 0 = wyłączony. 60 stron/run × ~31 scen ≈ 1860 scen/run (~22 min, hard-timeout 1h).
|
||||||
sched_deep_crawl_hours: int = Field(default=1, validation_alias="GOON_SCHED_DEEP_CRAWL_HOURS")
|
sched_deep_crawl_hours: int = Field(default=1, validation_alias="GOON_SCHED_DEEP_CRAWL_HOURS")
|
||||||
deep_crawl_pages_per_run: int = Field(default=60, validation_alias="GOON_DEEP_CRAWL_PAGES_PER_RUN")
|
deep_crawl_pages_per_run: int = Field(default=60, validation_alias="GOON_DEEP_CRAWL_PAGES_PER_RUN")
|
||||||
deepcrawl_state_path: str = Field(default="", validation_alias="GOON_DEEPCRAWL_STATE_PATH")
|
deepcrawl_state_path: str = Field(default="", validation_alias="GOON_DEEPCRAWL_STATE_PATH")
|
||||||
# Bulk-dedup performers safety net — auto-merge duplikatów które resolver-time
|
# Bulk-dedup performers safety net, auto-merge duplikatów które resolver-time
|
||||||
# scoring pominął. 12h cadence: leci 2x dziennie (po porannym browse-latest run).
|
# scoring pominął. 12h cadence: leci 2x dziennie (po porannym browse-latest run).
|
||||||
sched_bulk_dedup_hours: int = Field(
|
sched_bulk_dedup_hours: int = Field(
|
||||||
default=12, validation_alias="GOON_SCHED_BULK_DEDUP_HOURS"
|
default=12, validation_alias="GOON_SCHED_BULK_DEDUP_HOURS"
|
||||||
)
|
)
|
||||||
# Thumb-asset dedup — scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
|
# Thumb-asset dedup, scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
|
||||||
# ten sam asset-id miniatury + długość). bulk_dedup tego nie łapie (brak phash/tytuł).
|
# ten sam asset-id miniatury + długość). bulk_dedup tego nie łapie (brak phash/tytuł).
|
||||||
# Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off.
|
# Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off.
|
||||||
sched_thumb_dedup_hours: int = Field(
|
sched_thumb_dedup_hours: int = Field(
|
||||||
default=12, validation_alias="GOON_SCHED_THUMB_DEDUP_HOURS"
|
default=12, validation_alias="GOON_SCHED_THUMB_DEDUP_HOURS"
|
||||||
)
|
)
|
||||||
# Title+duration dedup — scala missing-merge dupy (ten sam performer + identyczny
|
# Title+duration dedup, scala missing-merge dupy (ten sam performer + identyczny
|
||||||
# znormalizowany tytuł + długość co do sekundy), których bulk_dedup nie łapie (tube
|
# znormalizowany tytuł + długość co do sekundy), których bulk_dedup nie łapie (tube
|
||||||
# re-scrape / cross-tube np. porn00 vs xnxx, reports 28fe8181/32df33b1). Odrastają
|
# re-scrape / cross-tube np. porn00 vs xnxx, reports 28fe8181/32df33b1). Odrastają
|
||||||
# przy re-ingeście, stąd cyklicznie. 12h, playback-only (to co user widzi). 0 = off.
|
# przy re-ingeście, stąd cyklicznie. 12h, playback-only (to co user widzi). 0 = off.
|
||||||
sched_title_dedup_hours: int = Field(
|
sched_title_dedup_hours: int = Field(
|
||||||
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
|
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
|
||||||
)
|
)
|
||||||
# Blacklista zdegenerowanych phashy — wartości dzielonych przez ≥10 scen (zaślepka,
|
# Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka,
|
||||||
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
|
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
|
||||||
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
|
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
|
||||||
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
|
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
|
||||||
|
|
@ -124,18 +124,18 @@ class Settings(BaseSettings):
|
||||||
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
|
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
|
||||||
)
|
)
|
||||||
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
|
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
|
||||||
# w ogóle — streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
|
# w ogóle, streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
|
||||||
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
|
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
|
||||||
ingest_watchdog_movie_max_age_hours: int = Field(
|
ingest_watchdog_movie_max_age_hours: int = Field(
|
||||||
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
|
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
|
||||||
)
|
)
|
||||||
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
|
# Śmieciowi performerzy, kategorie i studia podszywające się pod osoby („Creampie",
|
||||||
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
|
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
|
||||||
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
|
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
|
||||||
sched_junk_performers_hours: int = Field(
|
sched_junk_performers_hours: int = Field(
|
||||||
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
|
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
|
||||||
)
|
)
|
||||||
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
|
# Ingest freshness watchdog, alert do Sentry gdy aktywny tube (origin
|
||||||
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
|
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
|
||||||
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
|
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
|
||||||
# widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence
|
# widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence
|
||||||
|
|
@ -143,19 +143,19 @@ class Settings(BaseSettings):
|
||||||
sched_ingest_watchdog_hours: int = Field(
|
sched_ingest_watchdog_hours: int = Field(
|
||||||
default=6, validation_alias="GOON_SCHED_INGEST_WATCHDOG_HOURS"
|
default=6, validation_alias="GOON_SCHED_INGEST_WATCHDOG_HOURS"
|
||||||
)
|
)
|
||||||
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS) — crawlowane raz dziennie z
|
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS), crawlowane raz dziennie z
|
||||||
# listingu, więc 48h ciszy = anomalia.
|
# listingu, więc 48h ciszy = anomalia.
|
||||||
ingest_watchdog_max_age_hours: int = Field(
|
ingest_watchdog_max_age_hours: int = Field(
|
||||||
default=48, validation_alias="GOON_INGEST_WATCHDOG_MAX_AGE_HOURS"
|
default=48, validation_alias="GOON_INGEST_WATCHDOG_MAX_AGE_HOURS"
|
||||||
)
|
)
|
||||||
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS) — kadencja jest
|
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS), kadencja jest
|
||||||
# nierówna (continuous queue ~30d refresh per performer, ingest orphan-heavy), więc
|
# nierówna (continuous queue ~30d refresh per performer, ingest orphan-heavy), więc
|
||||||
# 48h dawałoby false-positivy. 7d (168h): healthy search-tuby obserwowane <6h świeżości
|
# 48h dawałoby false-positivy. 7d (168h): healthy search-tuby obserwowane <6h świeżości
|
||||||
# (continuous tick hituje wszystkie tuby per performer), zamrożone ≥73h → ~28× margines.
|
# (continuous tick hituje wszystkie tuby per performer), zamrożone ≥73h → ~28× margines.
|
||||||
ingest_watchdog_search_max_age_hours: int = Field(
|
ingest_watchdog_search_max_age_hours: int = Field(
|
||||||
default=168, validation_alias="GOON_INGEST_WATCHDOG_SEARCH_MAX_AGE_HOURS"
|
default=168, validation_alias="GOON_INGEST_WATCHDOG_SEARCH_MAX_AGE_HOURS"
|
||||||
)
|
)
|
||||||
# TPDB movie enrichment — wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
|
# TPDB movie enrichment, wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
|
||||||
# reżyserem, plus kanoniczny TPDB UUID do dedupu mirrorów. paradisehill (primary)
|
# reżyserem, plus kanoniczny TPDB UUID do dedupu mirrorów. paradisehill (primary)
|
||||||
# prawie nie ma obsady, więc to domyka największą lukę zakładki movies. Batch
|
# prawie nie ma obsady, więc to domyka największą lukę zakładki movies. Batch
|
||||||
# najświeższych GRYWALNYCH filmów bez obsady/studia per run; 6h cadence. 0 = off.
|
# najświeższych GRYWALNYCH filmów bez obsady/studia per run; 6h cadence. 0 = off.
|
||||||
|
|
@ -165,20 +165,20 @@ class Settings(BaseSettings):
|
||||||
tpdb_movie_enrich_batch: int = Field(
|
tpdb_movie_enrich_batch: int = Field(
|
||||||
default=200, validation_alias="GOON_TPDB_MOVIE_ENRICH_BATCH"
|
default=200, validation_alias="GOON_TPDB_MOVIE_ENRICH_BATCH"
|
||||||
)
|
)
|
||||||
# Taxonomy scene_count refresh — przelicza denormalizowane liczniki scen na
|
# Taxonomy scene_count refresh, przelicza denormalizowane liczniki scen na
|
||||||
# tags/performers/studios (hot-path /tags|/performers|/studios|/favorites czyta
|
# tags/performers/studios (hot-path /tags|/performers|/studios|/favorites czyta
|
||||||
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence —
|
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence,
|
||||||
# counts do tego stale, dla sortu "popular" + badge "(N)" bez znaczenia. 0 = off.
|
# counts do tego stale, dla sortu "popular" + badge "(N)" bez znaczenia. 0 = off.
|
||||||
sched_taxonomy_counts_hours: int = Field(
|
sched_taxonomy_counts_hours: int = Field(
|
||||||
default=3, validation_alias="GOON_SCHED_TAXONOMY_COUNTS_HOURS"
|
default=3, validation_alias="GOON_SCHED_TAXONOMY_COUNTS_HOURS"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Hetzner Cloud bandwidth monitor — read-only API token (Security → API Tokens
|
# Hetzner Cloud bandwidth monitor, read-only API token (Security → API Tokens
|
||||||
# w panelu Hetzner Cloud). Bez tokenu monitor wyłączony (warning w log).
|
# w panelu Hetzner Cloud). Bez tokenu monitor wyłączony (warning w log).
|
||||||
# Free traffic per server: CX22=20TB, CPX21=20TB itd. Overage = €1/TB.
|
# Free traffic per server: CX22=20TB, CPX21=20TB itd. Overage = €1/TB.
|
||||||
hetzner_api_token: str | None = Field(default=None, validation_alias="HETZNER_API_TOKEN")
|
hetzner_api_token: str | None = Field(default=None, validation_alias="HETZNER_API_TOKEN")
|
||||||
hetzner_server_id: int | None = Field(default=None, validation_alias="HETZNER_SERVER_ID")
|
hetzner_server_id: int | None = Field(default=None, validation_alias="HETZNER_SERVER_ID")
|
||||||
# Alert thresholds (% of included_traffic) — Sentry severity levels.
|
# Alert thresholds (% of included_traffic), Sentry severity levels.
|
||||||
hetzner_alert_info_pct: int = Field(default=50, validation_alias="HETZNER_ALERT_INFO_PCT")
|
hetzner_alert_info_pct: int = Field(default=50, validation_alias="HETZNER_ALERT_INFO_PCT")
|
||||||
hetzner_alert_warning_pct: int = Field(default=80, validation_alias="HETZNER_ALERT_WARNING_PCT")
|
hetzner_alert_warning_pct: int = Field(default=80, validation_alias="HETZNER_ALERT_WARNING_PCT")
|
||||||
hetzner_alert_error_pct: int = Field(default=95, validation_alias="HETZNER_ALERT_ERROR_PCT")
|
hetzner_alert_error_pct: int = Field(default=95, validation_alias="HETZNER_ALERT_ERROR_PCT")
|
||||||
|
|
@ -188,14 +188,14 @@ class Settings(BaseSettings):
|
||||||
default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS"
|
default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Source ranking (Sites screen) — przelicz source_stats (freshness/richness/health
|
# Source ranking (Sites screen), przelicz source_stats (freshness/richness/health
|
||||||
# per origin). 0/None = wyłączone. Domyślnie 6h (richness to ciężki agregat po
|
# per origin). 0/None = wyłączone. Domyślnie 6h (richness to ciężki agregat po
|
||||||
# ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli).
|
# ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli).
|
||||||
sched_source_stats_hours: int = Field(
|
sched_source_stats_hours: int = Field(
|
||||||
default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS"
|
default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB) —
|
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB),
|
||||||
# używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare
|
# używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare
|
||||||
# 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN
|
# 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN
|
||||||
# IP-bound). Format env: `host:port:user:pass` (panel Bright Data). Pusty = brak.
|
# IP-bound). Format env: `host:port:user:pass` (panel Bright Data). Pusty = brak.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"""Connector registry helpers.
|
"""Connector registry helpers.
|
||||||
|
|
||||||
Lazy factories — importy connectorów wykonują się dopiero w `get_movie_connectors()`
|
Lazy factories, importy connectorów wykonują się dopiero w `get_movie_connectors()`
|
||||||
żeby uniknąć circular imports (modeles/db). Każdy entry: `(name, class)` w porządku
|
żeby uniknąć circular imports (modeles/db). Każdy entry: `(name, class)` w porządku
|
||||||
ingestu (primary FIRST, mirrory potem — `resolve_movie` wtedy ma do czego dokleić
|
ingestu (primary FIRST, mirrory potem, `resolve_movie` wtedy ma do czego dokleić
|
||||||
mirror playback sources).
|
mirror playback sources).
|
||||||
|
|
||||||
## Jak dodać nowe movie site
|
## Jak dodać nowe movie site
|
||||||
|
|
||||||
1. Napisz subclass `DooplayConnector` w `app/connectors/dooplay.py` (jeśli site używa
|
1. Napisz subclass `DooplayConnector` w `app/connectors/dooplay.py` (jeśli site używa
|
||||||
dooplay/PsyPlay WP theme) — wystarczy `name` + `base_url`. Jeśli inny theme,
|
dooplay/PsyPlay WP theme), wystarczy `name` + `base_url`. Jeśli inny theme,
|
||||||
napisz osobny connector implementujący `BaseMovieConnector.fetch_movies()`.
|
napisz osobny connector implementujący `BaseMovieConnector.fetch_movies()`.
|
||||||
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
|
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
|
||||||
3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie
|
3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie
|
||||||
|
|
@ -31,7 +31,7 @@ from __future__ import annotations
|
||||||
def get_movie_connectors() -> list[tuple[str, type]]:
|
def get_movie_connectors() -> list[tuple[str, type]]:
|
||||||
"""Zwraca listę (name, ConnectorCls) tuples w kolejności ingestu.
|
"""Zwraca listę (name, ConnectorCls) tuples w kolejności ingestu.
|
||||||
|
|
||||||
Lazy import — uniknięcie circular import bo connectory zaczepiają db/models.
|
Lazy import, uniknięcie circular import bo connectory zaczepiają db/models.
|
||||||
"""
|
"""
|
||||||
from app.connectors.dooplay import (
|
from app.connectors.dooplay import (
|
||||||
MangopornConnector,
|
MangopornConnector,
|
||||||
|
|
@ -43,10 +43,10 @@ def get_movie_connectors() -> list[tuple[str, type]]:
|
||||||
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
|
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
|
||||||
|
|
||||||
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
|
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
|
||||||
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content —
|
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content,
|
||||||
# 72 nowych 2026-05-28; streamporn/pandamovies zwracają stale 0 new), na końcu
|
# 72 nowych 2026-05-28; streamporn/pandamovies zwracają stale 0 new), na końcu
|
||||||
# streamporn + pandamovies. Powód reorderu (2026-05-30): gdy streamporn wiesza
|
# streamporn + pandamovies. Powód reorderu (2026-05-30): gdy streamporn wiesza
|
||||||
# się intermittentnie, mangoporn musi zdążyć przed nim — patrz per-connector
|
# się intermittentnie, mangoporn musi zdążyć przed nim, patrz per-connector
|
||||||
# timeout w _job_movie_ingest.
|
# timeout w _job_movie_ingest.
|
||||||
# streamporn.nl padł (0 new od 2026-05-23) → zastąpiony żywym streamporn.vip
|
# streamporn.nl padł (0 new od 2026-05-23) → zastąpiony żywym streamporn.vip
|
||||||
# (StreampornVipConnector, ocena 2026-07-02). Stara klasa .nl zostaje w dooplay.py
|
# (StreampornVipConnector, ocena 2026-07-02). Stara klasa .nl zostaje w dooplay.py
|
||||||
|
|
@ -56,15 +56,15 @@ def get_movie_connectors() -> list[tuple[str, type]]:
|
||||||
("mangoporn", MangopornConnector),
|
("mangoporn", MangopornConnector),
|
||||||
("streampornvip", StreampornVipConnector),
|
("streampornvip", StreampornVipConnector),
|
||||||
("pandamovies", PandamoviesConnector),
|
("pandamovies", PandamoviesConnector),
|
||||||
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD,
|
# freeomovie.to (bestia theme, nie dooplay), mirror: świeże filmy DVD,
|
||||||
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
|
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
|
||||||
("freeomovie", FreeoMovieConnector),
|
("freeomovie", FreeoMovieConnector),
|
||||||
# yourdailypornmovies.ws (własny motyw WP, nie dooplay) — ocena 2026-07-28.
|
# yourdailypornmovies.ws (własny motyw WP, nie dooplay), ocena 2026-07-28.
|
||||||
# SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
|
# SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
|
||||||
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
|
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
|
||||||
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
|
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
|
||||||
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
|
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
|
||||||
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
|
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
|
||||||
# gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed.
|
# gotowe moduły hosterów), vidara.to nieznana, leci jako zwykły embed.
|
||||||
("yourdailypornmovies", YourDailyPornMoviesConnector),
|
("yourdailypornmovies", YourDailyPornMoviesConnector),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ class RawScene(BaseModel):
|
||||||
(np. 'tpdb', 'stashdb')."""
|
(np. 'tpdb', 'stashdb')."""
|
||||||
|
|
||||||
raw: dict[str, Any] = Field(default_factory=dict)
|
raw: dict[str, Any] = Field(default_factory=dict)
|
||||||
"""Oryginalny payload z API — leci do external_records.raw."""
|
"""Oryginalny payload z API, leci do external_records.raw."""
|
||||||
|
|
||||||
|
|
||||||
class BaseConnector(abc.ABC):
|
class BaseConnector(abc.ABC):
|
||||||
|
|
@ -114,15 +114,15 @@ class BaseConnector(abc.ABC):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Movies — odrębny encja od scen, ale ten sam wzorzec connectorów
|
# Movies, odrębny encja od scen, ale ten sam wzorzec connectorów
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class RawMovieChapter(BaseModel):
|
class RawMovieChapter(BaseModel):
|
||||||
"""Pojedynczy rozdział filmu (movies czasem dzielą się na "Part 1/2/3" itp.).
|
"""Pojedynczy rozdział filmu (movies czasem dzielą się na "Part 1/2/3" itp.).
|
||||||
|
|
||||||
Identyfikatory chaptera nie są kanonizowane między źródłami — są lokalne dla movie,
|
Identyfikatory chaptera nie są kanonizowane między źródłami, są lokalne dla movie,
|
||||||
indeksowane przez `chapter_index`. Może linkować do separate scene (jeśli ta scena
|
indeksowane przez `chapter_index`. Może linkować do separate scene (jeśli ta scena
|
||||||
znana z TPDB/StashDB) — tym zajmuje się normalizator wyżej."""
|
znana z TPDB/StashDB), tym zajmuje się normalizator wyżej."""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
@ -133,7 +133,7 @@ class RawMovieChapter(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class RawMovie(BaseModel):
|
class RawMovie(BaseModel):
|
||||||
"""Surowy film z connectora — odpowiednik RawScene dla movies.
|
"""Surowy film z connectora, odpowiednik RawScene dla movies.
|
||||||
|
|
||||||
Performers / studio / tags reusable z RawPerformer / RawStudio / RawTag (te same
|
Performers / studio / tags reusable z RawPerformer / RawStudio / RawTag (te same
|
||||||
typy w obu pipelinach). Playback sources to lista mirrorów odtwarzania (paradisehill
|
typy w obu pipelinach). Playback sources to lista mirrorów odtwarzania (paradisehill
|
||||||
|
|
@ -170,7 +170,7 @@ class BaseMovieConnector(abc.ABC):
|
||||||
"""Connector dla source'a movies (paradisehill, psyplay, wp_movies).
|
"""Connector dla source'a movies (paradisehill, psyplay, wp_movies).
|
||||||
|
|
||||||
Symetrycznie do BaseConnector ale yielduje RawMovie. Każde źródło zna własną
|
Symetrycznie do BaseConnector ale yielduje RawMovie. Każde źródło zna własną
|
||||||
paginację i format ID — konwerter wyżej (resolver) dba o dedup między źródłami.
|
paginację i format ID, konwerter wyżej (resolver) dba o dedup między źródłami.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kind: SourceKind
|
kind: SourceKind
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
"""Direct tube scrapers.
|
"""Direct tube scrapers.
|
||||||
|
|
||||||
Każdy scraper hit'uje tube bezpośrednio HTTPm — różne tube'y to różne rate limit
|
Każdy scraper hit'uje tube bezpośrednio HTTPm, różne tube'y to różne rate limit
|
||||||
budgets, więc mogą iść równolegle. Wszystkie feedują sceny do tej samej
|
budgets, więc mogą iść równolegle. Wszystkie feedują sceny do tej samej
|
||||||
`Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver
|
`Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver
|
||||||
mergeuje idempotentnie po tym kluczu.
|
mergeuje idempotentnie po tym kluczu.
|
||||||
|
|
||||||
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"` — myląca pozostałość po usuniętym
|
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"`, myląca pozostałość po usuniętym
|
||||||
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA —
|
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA,
|
||||||
to nasze własne direct-scrapery tubów). Przemianowana na `"tube-scraper"`; wiersz
|
to nasze własne direct-scrapery tubów). Przemianowana na `"tube-scraper"`; wiersz
|
||||||
`sources` zaktualizowany w DB (UPDATE name) więc cała historia ingest_runs została.
|
`sources` zaktualizowany w DB (UPDATE name) więc cała historia ingest_runs została.
|
||||||
|
|
||||||
Search-based ścieżka (per performer name); category browse'ng przez `categoriesUrl`.
|
Search-based ścieżka (per performer name); category browse'ng przez `categoriesUrl`.
|
||||||
|
|
||||||
UWAGA — speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
|
UWAGA, speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
|
||||||
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
|
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
|
||||||
porndittcom, perverzija, fpoxxx, ...) ma URL templates + regex'y oparte na typowych
|
porndittcom, perverzija, fpoxxx, ...) ma URL templates + regex'y oparte na typowych
|
||||||
WordPress conventions. Wymagają post-deploy verification — gdy któryś nie zwraca
|
WordPress conventions. Wymagają post-deploy verification, gdy któryś nie zwraca
|
||||||
wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku.
|
wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku.
|
||||||
"""
|
"""
|
||||||
# Umbrella Source.name dla wszystkich direct-scraperów (deep-crawl, browse-latest,
|
# Umbrella Source.name dla wszystkich direct-scraperów (deep-crawl, browse-latest,
|
||||||
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące — nie ma zależności
|
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące, nie ma zależności
|
||||||
# od zewnętrznego porn-app API).
|
# od zewnętrznego porn-app API).
|
||||||
SCRAPER_SOURCE_NAME = "tube-scraper"
|
SCRAPER_SOURCE_NAME = "tube-scraper"
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
|
||||||
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
|
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
|
||||||
from app.connectors.direct_scrapers.eporner import EpornerScraper
|
from app.connectors.direct_scrapers.eporner import EpornerScraper
|
||||||
from app.connectors.direct_scrapers.fpoxxx import FpoxxxScraper
|
from app.connectors.direct_scrapers.fpoxxx import FpoxxxScraper
|
||||||
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401 — kept for backref; disabled
|
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401, kept for backref; disabled
|
||||||
from app.connectors.direct_scrapers.hqporner import HQPornerScraper
|
from app.connectors.direct_scrapers.hqporner import HQPornerScraper
|
||||||
from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper
|
from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper
|
||||||
from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper
|
from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper
|
||||||
|
|
@ -55,21 +55,21 @@ from app.connectors.direct_scrapers.xhamster import XHamsterScraper
|
||||||
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
|
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
|
||||||
from app.connectors.direct_scrapers.xnxx import XnxxScraper
|
from app.connectors.direct_scrapers.xnxx import XnxxScraper
|
||||||
from app.connectors.direct_scrapers.xvideos import XVideosScraper
|
from app.connectors.direct_scrapers.xvideos import XVideosScraper
|
||||||
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401 — kept for backref; delisted
|
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401, kept for backref; delisted
|
||||||
from app.connectors.direct_scrapers.youporn import YouPornScraper
|
from app.connectors.direct_scrapers.youporn import YouPornScraper
|
||||||
|
|
||||||
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
||||||
# Existing 4 (verified, in production)
|
# Existing 4 (verified, in production)
|
||||||
HQPornerScraper,
|
HQPornerScraper,
|
||||||
# HDPorn92Scraper — wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
|
# HDPorn92Scraper, wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
|
||||||
# (tylko happyleafmotion ads), JS hijackuje wszystkie kliki → `go.rmishe.com/smartpop/...`
|
# (tylko happyleafmotion ads), JS hijackuje wszystkie kliki → `go.rmishe.com/smartpop/...`
|
||||||
# popunder redirect. Mobile WebView page-as-hoster pokazuje ad redirect zamiast video.
|
# popunder redirect. Mobile WebView page-as-hoster pokazuje ad redirect zamiast video.
|
||||||
# 33,598 playback_sources mass-marked dead, 27,374 solo-orphan scenes deleted.
|
# 33,598 playback_sources mass-marked dead, 27,374 solo-orphan scenes deleted.
|
||||||
SxyLandScraper,
|
SxyLandScraper,
|
||||||
# ZeroDayXXScraper (0dayxx) — USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
|
# ZeroDayXXScraper (0dayxx), USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
|
||||||
# factory (0.1% canonical), zastępujemy lepszymi źródłami. Dane/pliki/extractor skasowane.
|
# factory (0.1% canonical), zastępujemy lepszymi źródłami. Dane/pliki/extractor skasowane.
|
||||||
# Mainstream (URL templates well-known)
|
# Mainstream (URL templates well-known)
|
||||||
# PornHub + RedTube — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
|
# PornHub + RedTube, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
|
||||||
# 2026-05-12 (0.4% canonical match), zamrożone dane skasowane z DB, pliki scraperów
|
# 2026-05-12 (0.4% canonical match), zamrożone dane skasowane z DB, pliki scraperów
|
||||||
# i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio.
|
# i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio.
|
||||||
XVideosScraper,
|
XVideosScraper,
|
||||||
|
|
@ -78,60 +78,60 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
||||||
YouPornScraper,
|
YouPornScraper,
|
||||||
PornTrexScraper,
|
PornTrexScraper,
|
||||||
EpornerScraper,
|
EpornerScraper,
|
||||||
# Aggregators (WordPress-like ?s= search; speculative — verify post-deploy)
|
# Aggregators (WordPress-like ?s= search; speculative, verify post-deploy)
|
||||||
# XMoviesForYouScraper — wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
|
# XMoviesForYouScraper, wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
|
||||||
# streamtape (DEAD_HOSTER_RE — malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
|
# streamtape (DEAD_HOSTER_RE, malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
|
||||||
# Mixdrop zrebrandował na m1xdrop.bz, yt-dlp out-of-date, packer/JS extract = fail.
|
# Mixdrop zrebrandował na m1xdrop.bz, yt-dlp out-of-date, packer/JS extract = fail.
|
||||||
# Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w
|
# Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w
|
||||||
# jadx). 1,321 solo-orphan scen.
|
# jadx). 1,321 solo-orphan scen.
|
||||||
# WatchPornScraper — RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
|
# WatchPornScraper, RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
|
||||||
# Site przebudowany na KVS (DoodStream-CAPTCHA z 2026-05-12 zniknął); browse
|
# Site przebudowany na KVS (DoodStream-CAPTCHA z 2026-05-12 zniknął); browse
|
||||||
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
|
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
|
||||||
# SiskaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
|
# SiskaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
|
||||||
# bo search siski zepsuty site-side — `?s=` ignoruje query). Patrz siska.py.
|
# bo search siski zepsuty site-side, `?s=` ignoruje query). Patrz siska.py.
|
||||||
# Porn4DaysScraper — wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
|
# Porn4DaysScraper, wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
|
||||||
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
|
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
|
||||||
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
|
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
|
||||||
# solo-orphan scen.
|
# solo-orphan scen.
|
||||||
# PornDishScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
|
# PornDishScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
|
||||||
# watchdog GOON-16: search `?s=` zamarzł 2026-05-07). WordPress → browse przez WP REST
|
# watchdog GOON-16: search `?s=` zamarzł 2026-05-07). WordPress → browse przez WP REST
|
||||||
# API (/wp-json/wp/v2/posts) jak perverzija: tytuł/data/thumb/studio(category)/tagi.
|
# API (/wp-json/wp/v2/posts) jak perverzija: tytuł/data/thumb/studio(category)/tagi.
|
||||||
# XxxFreeWatchScraper — wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
|
# XxxFreeWatchScraper, wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
|
||||||
# Cloudflare 403 z VPS IP, mobile WebView teoretycznie działa ale 0/790 scen miało jakikolwiek
|
# Cloudflare 403 z VPS IP, mobile WebView teoretycznie działa ale 0/790 scen miało jakikolwiek
|
||||||
# match do TPDB/StashDB. Pure orphan factory. Solo scenes deleted, scraper disabled.
|
# match do TPDB/StashDB. Pure orphan factory. Solo scenes deleted, scraper disabled.
|
||||||
# LatestPornVideoScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
# LatestPornVideoScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||||
# user 1da0375e: search-driven nie brał feedu "latest" → stary zestaw w apce).
|
# user 1da0375e: search-driven nie brał feedu "latest" → stary zestaw w apce).
|
||||||
# LatestLeaksScraper — wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
|
# LatestLeaksScraper, wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
|
||||||
# canonical match. Slug-concat tytuły, brak studio/duration/date signali. Solo orphany
|
# canonical match. Slug-concat tytuły, brak studio/duration/date signali. Solo orphany
|
||||||
# usunięte (~15k scen).
|
# usunięte (~15k scen).
|
||||||
MyPornerLeakScraper,
|
MyPornerLeakScraper,
|
||||||
# Added 2026-05-12 (theporndude survey): jeden z 14 free tubes na liście który
|
# Added 2026-05-12 (theporndude survey): jeden z 14 free tubes na liście który
|
||||||
# zwraca consistent search results. KVS engine, slug-aware scene URLs. Mostly
|
# zwraca consistent search results. KVS engine, slug-aware scene URLs. Mostly
|
||||||
# orphan ingest (auto-screenshots, no canonical phash match — sprawdzone), ale
|
# orphan ingest (auto-screenshots, no canonical phash match, sprawdzone), ale
|
||||||
# może łapać sceny popularnych performerów których jeszcze nie mamy w TPDB.
|
# może łapać sceny popularnych performerów których jeszcze nie mamy w TPDB.
|
||||||
# PornHat (pornhatcom) + PornDitt (porndittcom) — USUNIĘTE CAŁKOWICIE 2026-06-22
|
# PornHat (pornhatcom) + PornDitt (porndittcom), USUNIĘTE CAŁKOWICIE 2026-06-22
|
||||||
# (user request). Orphan factories (0.2% / weak-signal canonical match), zastępujemy
|
# (user request). Orphan factories (0.2% / weak-signal canonical match), zastępujemy
|
||||||
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
|
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
|
||||||
# Special
|
# Special
|
||||||
SxyPrnScraper,
|
SxyPrnScraper,
|
||||||
# PerverzijaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
# PerverzijaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||||
# user request). Search `?s=` → 429, homepage JS-renderowane; browse przez WP REST API
|
# user request). Search `?s=` → 429, homepage JS-renderowane; browse przez WP REST API
|
||||||
# (/wp-json/wp/v2/posts) daje tytuł/datę/thumb/studio(category)/tagi. Playback embed-iframe.
|
# (/wp-json/wp/v2/posts) daje tytuł/datę/thumb/studio(category)/tagi. Playback embed-iframe.
|
||||||
# FpoxxxScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
# FpoxxxScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||||
# user request). fpo.xxx to KVS, nie WordPress → search `?s=` zwracał 0; browse z
|
# user request). fpo.xxx to KVS, nie WordPress → search `?s=` zwracał 0; browse z
|
||||||
# `/new-<n>/` daje listing tile (tytuł/thumb/duration). Playback i tak phone-side (KVS).
|
# `/new-<n>/` daje listing tile (tytuł/thumb/duration). Playback i tak phone-side (KVS).
|
||||||
]
|
]
|
||||||
|
|
||||||
# Browse-mode scrapers — iterują `latest-vids` listing zamiast search-by-performer.
|
# Browse-mode scrapers, iterują `latest-vids` listing zamiast search-by-performer.
|
||||||
# Phash thumbnail fingerprint (waga 0.40 w composite scoring) auto-mergeuje do
|
# Phash thumbnail fingerprint (waga 0.40 w composite scoring) auto-mergeuje do
|
||||||
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
|
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
|
||||||
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
|
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
|
||||||
# `app/scheduler/browse_latest.py`.
|
# `app/scheduler/browse_latest.py`.
|
||||||
#
|
#
|
||||||
# **Pilot results (2026-05-12):**
|
# **Pilot results (2026-05-12):**
|
||||||
# - ShyfapScraper: 0/23 match (0%) — robi własne thumbnails ≠ canonical
|
# - ShyfapScraper: 0/23 match (0%), robi własne thumbnails ≠ canonical
|
||||||
# (phash Hamming 12-16). Plus rebranduje tytuły. **Wyłączony.**
|
# (phash Hamming 12-16). Plus rebranduje tytuły. **Wyłączony.**
|
||||||
# - FreshpornoScraper: 39/59 match (66%) — hot-linkuje studio thumbnaile
|
# - FreshpornoScraper: 39/59 match (66%), hot-linkuje studio thumbnaile
|
||||||
# (phash Hamming 0). Oryginalne tytuły + channels=studio 1:1. **Aktywny.**
|
# (phash Hamming 0). Oryginalne tytuły + channels=studio 1:1. **Aktywny.**
|
||||||
from app.connectors.direct_scrapers.freshporno import FreshpornoScraper # noqa: E402
|
from app.connectors.direct_scrapers.freshporno import FreshpornoScraper # noqa: E402
|
||||||
from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402
|
from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402
|
||||||
|
|
@ -154,62 +154,62 @@ from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper
|
||||||
ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
||||||
PerverzijaScraper,
|
PerverzijaScraper,
|
||||||
PornDishScraper,
|
PornDishScraper,
|
||||||
# WatchPornScraper — re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
|
# WatchPornScraper, re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
|
||||||
# DoodStream-CAPTCHA zniknął). Browse /latest-updates/: og:title, JSON-LD duration/
|
# DoodStream-CAPTCHA zniknął). Browse /latest-updates/: og:title, JSON-LD duration/
|
||||||
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
|
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
|
||||||
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
|
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
|
||||||
WatchPornScraper,
|
WatchPornScraper,
|
||||||
# YoupervScraper — dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
|
# YoupervScraper, dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
|
||||||
# hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy
|
# hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy
|
||||||
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
|
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
|
||||||
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
|
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
|
||||||
YoupervScraper,
|
YoupervScraper,
|
||||||
# GalaxyPornScraper — dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
|
# GalaxyPornScraper, dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
|
||||||
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
|
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
|
||||||
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
|
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
|
||||||
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
|
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
|
||||||
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
|
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
|
||||||
GalaxyPornScraper,
|
GalaxyPornScraper,
|
||||||
# PornBusyScraper — dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
|
# PornBusyScraper, dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
|
||||||
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
|
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
|
||||||
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
|
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
|
||||||
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
|
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
|
||||||
PornBusyScraper,
|
PornBusyScraper,
|
||||||
# PornMikeScraper — dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
|
# PornMikeScraper, dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
|
||||||
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
|
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
|
||||||
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
|
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
|
||||||
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
|
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
|
||||||
# UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez
|
# UWAGA: to klipy 5-12 min, nie pełne sceny, ~80% katalogu to jednak studia bez
|
||||||
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
|
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
|
||||||
PornMikeScraper,
|
PornMikeScraper,
|
||||||
# KPorner8Scraper — dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
|
# KPorner8Scraper, dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
|
||||||
# tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny
|
# tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny
|
||||||
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
|
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
|
||||||
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
|
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
|
||||||
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
|
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
|
||||||
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
|
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
|
||||||
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
|
# Pilot: seen 62, new 25, updated 37, 60% dopina się do scen, które już mamy.
|
||||||
KPorner8Scraper,
|
KPorner8Scraper,
|
||||||
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
|
# FullPornerScraper, dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
|
||||||
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
||||||
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
||||||
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO — za to
|
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO, za to
|
||||||
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
|
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
|
||||||
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
|
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
|
||||||
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
|
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
|
||||||
# bez performera scaliły się 0/23. phash liczony celowo — to jedyny sygnał dla
|
# bez performera scaliły się 0/23. phash liczony celowo, to jedyny sygnał dla
|
||||||
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
|
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
|
||||||
FullPornerScraper,
|
FullPornerScraper,
|
||||||
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
|
# SexuScraper (`sexu.py`), ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
|
||||||
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
|
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
|
||||||
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
|
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
|
||||||
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
|
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
|
||||||
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
|
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
|
||||||
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
|
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
|
||||||
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
|
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
|
||||||
# performera. Scraper i extractor zostają w repo — gdyby serwis kiedyś zaczął wrzucać
|
# performera. Scraper i extractor zostają w repo, gdyby serwis kiedyś zaczął wrzucać
|
||||||
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
|
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
|
||||||
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
|
# FullVideosPornScraper, dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
|
||||||
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
|
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
|
||||||
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
|
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
|
||||||
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
|
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
|
||||||
|
|
@ -224,105 +224,105 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
||||||
XnxxBrowseScraper,
|
XnxxBrowseScraper,
|
||||||
FreshpornoScraper,
|
FreshpornoScraper,
|
||||||
FpoxxxScraper,
|
FpoxxxScraper,
|
||||||
# LatestPornVideoScraper — browse od 2026-06-22 (user 1da0375e: search-driven
|
# LatestPornVideoScraper, browse od 2026-06-22 (user 1da0375e: search-driven
|
||||||
# nie brał feedu "latest"). Listing card: tytuł (z embedded "<Studio> YY MM DD"),
|
# nie brał feedu "latest"). Listing card: tytuł (z embedded "<Studio> YY MM DD"),
|
||||||
# thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma
|
# thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma
|
||||||
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
|
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
|
||||||
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
|
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
|
||||||
LatestPornVideoScraper,
|
LatestPornVideoScraper,
|
||||||
# SiskaScraper — re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
|
# SiskaScraper, re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
|
||||||
# zepsuty site-side (`?s=` ignoruje query), więc latest-browse z `/page/<n>/`.
|
# zepsuty site-side (`?s=` ignoruje query), więc latest-browse z `/page/<n>/`.
|
||||||
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
|
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
|
||||||
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
|
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
|
||||||
SiskaScraper,
|
SiskaScraper,
|
||||||
# PornXPScraper — pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
|
# PornXPScraper, pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
|
||||||
# release_date 100%, duration 100%, stream_url 100%, phash 100%. Najlepsze
|
# release_date 100%, duration 100%, stream_url 100%, phash 100%. Najlepsze
|
||||||
# sygnały spośród browse-mode scraperów. Stream direct mp4 (sv.porn-xp.com)
|
# sygnały spośród browse-mode scraperów. Stream direct mp4 (sv.porn-xp.com)
|
||||||
# 360/720 quality. Release year z `Released: <year>` na detail.
|
# 360/720 quality. Release year z `Released: <year>` na detail.
|
||||||
PornXPScraper,
|
PornXPScraper,
|
||||||
# Porn00Scraper — pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
|
# Porn00Scraper, pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
|
||||||
# date (0%) ALE performer 100%, duration 100%, stream_url 100% (KVS video_alt_url
|
# date (0%) ALE performer 100%, duration 100%, stream_url 100% (KVS video_alt_url
|
||||||
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
|
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
|
||||||
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
|
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
|
||||||
Porn00Scraper,
|
Porn00Scraper,
|
||||||
# PornDoeScraper — dołączony 2026-05-21 (theporndude audit). Każda scena ma
|
# PornDoeScraper, dołączony 2026-05-21 (theporndude audit). Każda scena ma
|
||||||
# kompletny JSON-LD VideoObject: title + uploadDate + duration + named studio
|
# kompletny JSON-LD VideoObject: title + uploadDate + duration + named studio
|
||||||
# (producer/publisher) + named performers (actor[]) + thumbnail. Najbogatsze
|
# (producer/publisher) + named performers (actor[]) + thumbnail. Najbogatsze
|
||||||
# strukturalne metadane spośród browse scraperów — composite fuzzy match ma
|
# strukturalne metadane spośród browse scraperów, composite fuzzy match ma
|
||||||
# komplet sygnałów. Phash hit-rate niski (własne crop-thumbnaile), studio +
|
# komplet sygnałów. Phash hit-rate niski (własne crop-thumbnaile), studio +
|
||||||
# performer + date + duration nadrabiają.
|
# performer + date + duration nadrabiają.
|
||||||
PornDoeScraper,
|
PornDoeScraper,
|
||||||
# YesPornVipScraper — dołączony 2026-05-27 (user audit). JSON-LD VideoObject
|
# YesPornVipScraper, dołączony 2026-05-27 (user audit). JSON-LD VideoObject
|
||||||
# + `<meta property="video:duration|release_date|tag">` per scena (Goon ma
|
# + `<meta property="video:duration|release_date|tag">` per scena (Goon ma
|
||||||
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
|
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
|
||||||
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
|
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
|
||||||
# 941k organic monthly (SE Ranking, comparable z porndoe 731k / porntrex 790k).
|
# 941k organic monthly (SE Ranking, comparable z porndoe 731k / porntrex 790k).
|
||||||
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen) — wysokie expected
|
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen), wysokie expected
|
||||||
# canonical match dla studio scenes. Korekta: theporndude scorecard rank 26
|
# canonical match dla studio scenes. Korekta: theporndude scorecard rank 26
|
||||||
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** — pdude.link
|
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** pdude.link
|
||||||
# redirect do porndudecams affiliate. Prawdziwa kanoniczna domena to TLD `.vip`.
|
# redirect do porndudecams affiliate. Prawdziwa kanoniczna domena to TLD `.vip`.
|
||||||
YesPornVipScraper,
|
YesPornVipScraper,
|
||||||
# FullmoviesScraper + HDPornGGScraper — dołączone 2026-06-01. KVS engine (sponsor_groups
|
# FullmoviesScraper + HDPornGGScraper, dołączone 2026-06-01. KVS engine (sponsor_groups
|
||||||
# stack, `/videos/<slug>/` + `/latest-updates/`). Studio teraz z PREFIKSU tytułu
|
# stack, `/videos/<slug>/` + `/latest-updates/`). Studio teraz z PREFIKSU tytułu
|
||||||
# ("Studio - Scene") — sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
|
# ("Studio - Scene"), sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
|
||||||
# zawsze Brazzers (mis-attribution, dlatego nigdy nie były włączone). Niosą paysite studio
|
# zawsze Brazzers (mis-attribution, dlatego nigdy nie były włączone). Niosą paysite studio
|
||||||
# content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy.
|
# content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy.
|
||||||
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
|
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
|
||||||
FullmoviesScraper,
|
FullmoviesScraper,
|
||||||
HDPornGGScraper,
|
HDPornGGScraper,
|
||||||
# EpornerApiScraper — dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
|
# EpornerApiScraper, dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
|
||||||
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner — odrzucone), ALE eporner ma
|
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner, odrzucone), ALE eporner ma
|
||||||
# publiczne JSON API (api/v2/video/search): 1 call = 100 filmów z title+length_sec+
|
# publiczne JSON API (api/v2/video/search): 1 call = 100 filmów z title+length_sec+
|
||||||
# keywords+added+thumb. ~100k filmów, deep-crawl przez crawl_page() (API, bez detail-fetch).
|
# keywords+added+thumb. ~100k filmów, deep-crawl przez crawl_page() (API, bez detail-fetch).
|
||||||
EpornerApiScraper,
|
EpornerApiScraper,
|
||||||
# XVideosBrowseScraper — dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
|
# XVideosBrowseScraper, dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
|
||||||
# + page-parse /models/ (performerzy) + /tags/. Sample: median ~10.5min, 93% ≥3min.
|
# + page-parse /models/ (performerzy) + /tags/. Sample: median ~10.5min, 93% ≥3min.
|
||||||
# Mega-katalog ~13M → deep_crawl._PAGE_CAP["xvideoscom"]=1800 (~50k najnowszych), nie
|
# Mega-katalog ~13M → deep_crawl._PAGE_CAP["xvideoscom"]=1800 (~50k najnowszych), nie
|
||||||
# full-crawl. (youporn pominięty — JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
|
# full-crawl. (youporn pominięty, JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
|
||||||
XVideosBrowseScraper,
|
XVideosBrowseScraper,
|
||||||
# HQFapScraper — PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
|
# HQFapScraper, PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
|
||||||
# biblioteka CDN serwowała `/upload/videos/video_down.mp4` stub; PRZYWRÓCONY 2026-07-07
|
# biblioteka CDN serwowała `/upload/videos/video_down.mp4` stub; PRZYWRÓCONY 2026-07-07
|
||||||
# bo strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP), user request.
|
# bo strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP), user request.
|
||||||
# HQFapScraper — ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
|
# HQFapScraper, ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
|
||||||
# Cloudflare z JS-challenge na /watch/: 403 z VPS, z residential IP przy pełnych
|
# Cloudflare z JS-challenge na /watch/: 403 z VPS, z residential IP przy pełnych
|
||||||
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy — czyli blokada nie jest
|
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy, czyli blokada nie jest
|
||||||
# po IP, tylko wymaga wykonania JS. WebView też nie przechodzi automatycznie (user
|
# po IP, tylko wymaga wykonania JS. WebView też nie przechodzi automatycznie (user
|
||||||
# widzi challenge: zgłoszenia 60576a84 / 244131ba / 92e9133e). Telemetria: ZERO prób
|
# widzi challenge: zgłoszenia 60576a84 / 244131ba / 92e9133e). Telemetria: ZERO prób
|
||||||
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS —
|
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS,
|
||||||
# nieproporcjonalne, decyzja: usunąć (jak 4k69 i pierwsze usunięcie hqfap 2026-06-25).
|
# nieproporcjonalne, decyzja: usunąć (jak 4k69 i pierwsze usunięcie hqfap 2026-06-25).
|
||||||
# HQPornerBrowseScraper — dołączony 2026-07-26. hqporner był TYLKO w
|
# HQPornerBrowseScraper, dołączony 2026-07-26. hqporner był TYLKO w
|
||||||
# ALL_DIRECT_SCRAPERS (search po performerze), więc świeże sceny wchodziły wyłącznie
|
# ALL_DIRECT_SCRAPERS (search po performerze), więc świeże sceny wchodziły wyłącznie
|
||||||
# przy trafieniu na znanego performera → 0 nowych scen w 7 dni (user-report). Browse
|
# przy trafieniu na znanego performera → 0 nowych scen w 7 dni (user-report). Browse
|
||||||
# (listing / + ?p=N) daje ingest niezależny od stanu performerów. external_id ten sam
|
# (listing / + ?p=N) daje ingest niezależny od stanu performerów. external_id ten sam
|
||||||
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
|
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
|
||||||
HQPornerBrowseScraper,
|
HQPornerBrowseScraper,
|
||||||
# FourK69Scraper — USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
|
# FourK69Scraper, USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
|
||||||
# JavflixScraper — JAV vertical (osobna sekcja). origin tube:javflix jest w
|
# JavflixScraper, JAV vertical (osobna sekcja). origin tube:javflix jest w
|
||||||
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
|
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
|
||||||
JavflixScraper,
|
JavflixScraper,
|
||||||
# VjavScraper — JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
|
# VjavScraper, JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
|
||||||
# TXXX network: sitemap id-walk (newest=max id) + JSON metadata API. Stream
|
# TXXX network: sitemap id-walk (newest=max id) + JSON metadata API. Stream
|
||||||
# videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10.
|
# videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10.
|
||||||
VjavScraper,
|
VjavScraper,
|
||||||
# SupjavScraper — JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
|
# SupjavScraper, JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
|
||||||
# CF-blokuje VPS → browse homepage przez Bright Data proxy. Embed-aggregator:
|
# CF-blokuje VPS → browse homepage przez Bright Data proxy. Embed-aggregator:
|
||||||
# data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10.
|
# data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10.
|
||||||
SupjavScraper,
|
SupjavScraper,
|
||||||
# NepornScraper — dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
|
# NepornScraper, dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
|
||||||
# porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration
|
# porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration
|
||||||
# meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa
|
# meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa
|
||||||
# "- HardX Update - ..." — fuzzy match po tytule). Resolve server-side _kvs,
|
# "- HardX Update - ...", fuzzy match po tytule). Resolve server-side _kvs,
|
||||||
# finalny remote_control.php portable cross-IP.
|
# finalny remote_control.php portable cross-IP.
|
||||||
NepornScraper,
|
NepornScraper,
|
||||||
# SuperpornScraper — dołączony 2026-06-10 (user request). superporn blokuje VPS IP
|
# SuperpornScraper, dołączony 2026-06-10 (user request). superporn blokuje VPS IP
|
||||||
# twardym CF 403 (każda impersonacja TLS), więc ingest HTML idzie przez Bright Data
|
# twardym CF 403 (każda impersonacja TLS), więc ingest HTML idzie przez Bright Data
|
||||||
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy — `_proxy` w
|
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy, `_proxy` w
|
||||||
# _browse_base. JSON-LD (title+desc+uploadDate+thumb+duration) + chipy pornstar/
|
# _browse_base. JSON-LD (title+desc+uploadDate+thumb+duration) + chipy pornstar/
|
||||||
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
|
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
|
||||||
# Bez proxy: scraper no-op (pusty iterator).
|
# Bez proxy: scraper no-op (pusty iterator).
|
||||||
SuperpornScraper,
|
SuperpornScraper,
|
||||||
# porntrex/hqporner/youporn — NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
|
# porntrex/hqporner/youporn, NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
|
||||||
# ShyfapScraper — wyłączony 2026-05-12 (pilot fail, 0% match — orphan factory).
|
# ShyfapScraper, wyłączony 2026-05-12 (pilot fail, 0% match, orphan factory).
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
"""BaseBrowseScraper — latest-vids browse mode (vs search-by-performer).
|
"""BaseBrowseScraper, latest-vids browse mode (vs search-by-performer).
|
||||||
|
|
||||||
Wzorzec: tube'y typu shyfap/freshporno/porn00/fullmovies/pornxp mają bogatą
|
Wzorzec: tube'y typu shyfap/freshporno/porn00/fullmovies/pornxp mają bogatą
|
||||||
metadata (title, studio, performers, tags, duration, release_date, description)
|
metadata (title, studio, performers, tags, duration, release_date, description)
|
||||||
na detail page'u — wystarczy do canonical fuzzy match w resolverze. Browse mode
|
na detail page'u, wystarczy do canonical fuzzy match w resolverze. Browse mode
|
||||||
iteruje "latest" page (sorted by upload date) i fetchuje detail per scene.
|
iteruje "latest" page (sorted by upload date) i fetchuje detail per scene.
|
||||||
|
|
||||||
Różnica vs `BaseSearchScraper`:
|
Różnica vs `BaseSearchScraper`:
|
||||||
- **search**: tube wyszukuje sceny po performer name (dla performer-driven
|
- **search**: tube wyszukuje sceny po performer name (dla performer-driven
|
||||||
backfill). Wymaga znanego performera.
|
backfill). Wymaga znanego performera.
|
||||||
- **browse**: tube listuje newest scenes (latest-vids endpoint). Nie wymaga
|
- **browse**: tube listuje newest scenes (latest-vids endpoint). Nie wymaga
|
||||||
żadnego query — chodzi o świeże sceny independent of performer state.
|
żadnego query, chodzi o świeże sceny independent of performer state.
|
||||||
|
|
||||||
Browse jest komplementarny do search:
|
Browse jest komplementarny do search:
|
||||||
- search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube)
|
- search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube)
|
||||||
|
|
@ -67,15 +67,15 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
|
||||||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
||||||
"""Parsuj scene detail HTML → RawScene z metadata.
|
"""Parsuj scene detail HTML → RawScene z metadata.
|
||||||
|
|
||||||
Zwraca None gdy scena niedostępna / parse fail — caller pominie ten URL,
|
Zwraca None gdy scena niedostępna / parse fail, caller pominie ten URL,
|
||||||
nie aborti całe browse."""
|
nie aborti całe browse."""
|
||||||
|
|
||||||
def crawl_page(self, page: int) -> list[RawScene] | None:
|
def crawl_page(self, page: int) -> list[RawScene] | None:
|
||||||
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
|
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
|
||||||
(top-N) i deep_crawl (kursor). Zwraca:
|
(top-N) i deep_crawl (kursor). Zwraca:
|
||||||
None — transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
|
None , transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
|
||||||
[] — pusty listing = koniec katalogu (caller: exhausted),
|
[] pusty listing = koniec katalogu (caller: exhausted),
|
||||||
[...] — sceny z tej strony.
|
[...], sceny z tej strony.
|
||||||
|
|
||||||
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
|
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
|
||||||
(call API zamiast listing→detail). HTML browse subclasses dostarczają
|
(call API zamiast listing→detail). HTML browse subclasses dostarczają
|
||||||
|
|
@ -120,7 +120,7 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
|
||||||
break
|
break
|
||||||
yield from scenes
|
yield from scenes
|
||||||
|
|
||||||
# Stub `search()` — BaseDirectTubeScraper wymaga implementacji. Dla browse-only
|
# Stub `search()`, BaseDirectTubeScraper wymaga implementacji. Dla browse-only
|
||||||
# tubes nie supportujemy performer-driven search; zwracamy pusty iterator. Tube'y
|
# tubes nie supportujemy performer-driven search; zwracamy pusty iterator. Tube'y
|
||||||
# które chcą *oba* tryby mogą override'ować search() osobno.
|
# które chcą *oba* tryby mogą override'ować search() osobno.
|
||||||
def search(
|
def search(
|
||||||
|
|
@ -148,7 +148,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
|
||||||
Format pasuje do `SceneFingerprint.value` w DB (TPDB/StashDB importują ten sam
|
Format pasuje do `SceneFingerprint.value` w DB (TPDB/StashDB importują ten sam
|
||||||
8x8 phash). Resolver Path 3 `find_by_phash_within` matchuje Hamming ≤5 (default).
|
8x8 phash). Resolver Path 3 `find_by_phash_within` matchuje Hamming ≤5 (default).
|
||||||
|
|
||||||
Wymaga lazy importu `imagehash`/`PIL` — żeby moduł browse_base importował się
|
Wymaga lazy importu `imagehash`/`PIL`, żeby moduł browse_base importował się
|
||||||
nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver
|
nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver
|
||||||
spadnie do composite scoring, jak gdyby fingerprintu nie było).
|
spadnie do composite scoring, jak gdyby fingerprintu nie było).
|
||||||
"""
|
"""
|
||||||
|
|
@ -169,7 +169,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
|
||||||
return None
|
return None
|
||||||
img = Image.open(io.BytesIO(r.content))
|
img = Image.open(io.BytesIO(r.content))
|
||||||
# phash domyślnie hash_size=8 → 64-bit hash → 16 hex chars. Mode 'L' (greyscale)
|
# phash domyślnie hash_size=8 → 64-bit hash → 16 hex chars. Mode 'L' (greyscale)
|
||||||
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame —
|
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame,
|
||||||
# convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey.
|
# convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey.
|
||||||
return str(imagehash.phash(img.convert("RGB")))
|
return str(imagehash.phash(img.convert("RGB")))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -183,7 +183,7 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
|
||||||
Standardowy helper dla scraperów które używają OpenGraph / ya:ovs / itp.
|
Standardowy helper dla scraperów które używają OpenGraph / ya:ovs / itp.
|
||||||
Cache compiled regex w module-scope dict (te same selectory powtarzają się).
|
Cache compiled regex w module-scope dict (te same selectory powtarzają się).
|
||||||
|
|
||||||
NB: separate patterns dla `"..."` i `'...'` content quote — wcześniej jeden
|
NB: separate patterns dla `"..."` i `'...'` content quote, wcześniej jeden
|
||||||
`[^"\']*` regex tnął title po wewnętrznym apostrofie (np. `<meta content="She's So Insatiable">`
|
`[^"\']*` regex tnął title po wewnętrznym apostrofie (np. `<meta content="She's So Insatiable">`
|
||||||
→ `She`, bug-report 2026-05-20). Teraz matchujemy dokładnie ten sam quote co opening.
|
→ `She`, bug-report 2026-05-20). Teraz matchujemy dokładnie ten sam quote co opening.
|
||||||
"""
|
"""
|
||||||
|
|
@ -191,8 +191,8 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
|
||||||
if key not in _META_RE_CACHE:
|
if key not in _META_RE_CACHE:
|
||||||
attr = "property" if property else "name"
|
attr = "property" if property else "name"
|
||||||
val = re.escape(property or name or "")
|
val = re.escape(property or name or "")
|
||||||
# double-quoted content (HTML standard) — preferred
|
# double-quoted content (HTML standard), preferred
|
||||||
# Pattern: <meta property="X" content="...inner..." > — inner allows apostrophes
|
# Pattern: <meta property="X" content="...inner..." > , inner allows apostrophes
|
||||||
_META_RE_CACHE[key] = re.compile(
|
_META_RE_CACHE[key] = re.compile(
|
||||||
rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"'
|
rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"'
|
||||||
rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'',
|
rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""BasePlayTubeScraper — wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
|
"""BasePlayTubeScraper, wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
|
||||||
|
|
||||||
Platforma rozpoznawalna po: `/watch/<slug>_<id>.html`, sitemap index
|
Platforma rozpoznawalna po: `/watch/<slug>_<id>.html`, sitemap index
|
||||||
`/sitemaps/videos/sitemap-N.xml` (z `<lastmod>`), JSON-LD VideoObject na detail
|
`/sitemaps/videos/sitemap-N.xml` (z `<lastmod>`), JSON-LD VideoObject na detail
|
||||||
|
|
@ -9,9 +9,9 @@ Listing NIE paginuje się GET-em (PlayTube doładowuje AJAX-em `aj/load-more/`),
|
||||||
więc crawl_page buduje katalog z sitemapów (sort lastmod desc = newest first)
|
więc crawl_page buduje katalog z sitemapów (sort lastmod desc = newest first)
|
||||||
i tnie na strony po `_PAGE_SIZE`. Działa dla browse_latest (pages 1-5) i
|
i tnie na strony po `_PAGE_SIZE`. Działa dla browse_latest (pages 1-5) i
|
||||||
deep_crawl (kursor do końca katalogu). Minus: sitemap laguje ~dobę za
|
deep_crawl (kursor do końca katalogu). Minus: sitemap laguje ~dobę za
|
||||||
najświeższymi uploadami — akceptowalne przy dziennym harmonogramie.
|
najświeższymi uploadami, akceptowalne przy dziennym harmonogramie.
|
||||||
|
|
||||||
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()` —
|
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()`,
|
||||||
PlayTube nie ma strukturalnego pola studio na scenie, studio siedzi w
|
PlayTube nie ma strukturalnego pola studio na scenie, studio siedzi w
|
||||||
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
|
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ def _extract_video_object(html: str) -> dict | None:
|
||||||
|
|
||||||
|
|
||||||
class BasePlayTubeScraper(BaseBrowseScraper):
|
class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
base_url: str # np. "https://hqfap.com" — subclass ustawia
|
base_url: str # np. "https://hqfap.com", subclass ustawia
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -123,9 +123,9 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
def _pick_studio(self, category_names: list[str]) -> str | None:
|
def _pick_studio(self, category_names: list[str]) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# crawl_page override (jak EpornerApiScraper) — listing nie jest stronicowalny
|
# crawl_page override (jak EpornerApiScraper), listing nie jest stronicowalny
|
||||||
# przez GET, źródłem paginacji jest sitemap. _listing_url/_extract_scene_urls
|
# przez GET, źródłem paginacji jest sitemap. _listing_url/_extract_scene_urls
|
||||||
# nieużywane, ale abstrakcyjne — dostarczamy no-op implementacje.
|
# nieużywane, ale abstrakcyjne, dostarczamy no-op implementacje.
|
||||||
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
|
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
|
||||||
return f"{self.base_url}/sitemap.xml"
|
return f"{self.base_url}/sitemap.xml"
|
||||||
|
|
||||||
|
|
@ -156,7 +156,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
sm = browser_get(sm_url, timeout=self._timeout)
|
sm = browser_get(sm_url, timeout=self._timeout)
|
||||||
sm.raise_for_status()
|
sm.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Brak jednego sitemapa ≠ fail całości — reszta katalogu wystarczy.
|
# Brak jednego sitemapa ≠ fail całości, reszta katalogu wystarczy.
|
||||||
log.warning("%s: sitemap fetch failed %s: %s", self.sitetag, sm_url, e)
|
log.warning("%s: sitemap fetch failed %s: %s", self.sitetag, sm_url, e)
|
||||||
continue
|
continue
|
||||||
for block in _URL_BLOCK_RE.findall(sm.text):
|
for block in _URL_BLOCK_RE.findall(sm.text):
|
||||||
|
|
@ -227,7 +227,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
thumbnail_url = video.get("thumbnailUrl") or None
|
thumbnail_url = video.get("thumbnailUrl") or None
|
||||||
|
|
||||||
# Pille: pornstar → performer; category → studio (hook `_pick_studio`)
|
# Pille: pornstar → performer; category → studio (hook `_pick_studio`)
|
||||||
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy — gwiazdki to nie dane.
|
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy, gwiazdki to nie dane.
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
category_names: list[str] = []
|
category_names: list[str] = []
|
||||||
seen_perf: set[str] = set()
|
seen_perf: set[str] = set()
|
||||||
|
|
@ -275,7 +275,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
if ph:
|
if ph:
|
||||||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||||
|
|
||||||
# Stream: JSON-LD contentUrl wygasa (token time-bound) — NIE zapisujemy
|
# Stream: JSON-LD contentUrl wygasa (token time-bound), NIE zapisujemy
|
||||||
# stream_url; extractor per-sitetag resolvuje świeży on-demand.
|
# stream_url; extractor per-sitetag resolvuje świeży on-demand.
|
||||||
playback_sources = [
|
playback_sources = [
|
||||||
RawPlaybackSource(
|
RawPlaybackSource(
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
"""BaseSearchScraper — shared search-page HTML scraping logika.
|
"""BaseSearchScraper, shared search-page HTML scraping logika.
|
||||||
|
|
||||||
Wzorzec stosowany przez wszystkie tube'y discovery scrapers:
|
Wzorzec stosowany przez wszystkie tube'y discovery scrapers:
|
||||||
1. Build search URL z `_search_url_template` (formatowane query+page).
|
1. Build search URL z `_search_url_template` (formatowane query+page).
|
||||||
2. Fetch HTML curl_cffi.
|
2. Fetch HTML curl_cffi.
|
||||||
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
|
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
|
||||||
opcjonalnie `slug` lub `id` jako tytuł source).
|
opcjonalnie `slug` lub `id` jako tytuł source).
|
||||||
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query) —
|
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query),
|
||||||
fuzzy search tube'ów często zwraca niezwiązane wyniki.
|
fuzzy search tube'ów często zwraca niezwiązane wyniki.
|
||||||
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
|
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
|
||||||
|
|
||||||
Subclass override:
|
Subclass override:
|
||||||
- `sitetag: str` — np. "pornhubcom"
|
- `sitetag: str`, np. "pornhubcom"
|
||||||
- `_search_url_template: str` — z `{query}` i `{page}` placeholderami
|
- `_search_url_template: str`, z `{query}` i `{page}` placeholderami
|
||||||
- `_scene_url_re: re.Pattern[str]` — regex z named group `url` (scene URL)
|
- `_scene_url_re: re.Pattern[str]`, regex z named group `url` (scene URL)
|
||||||
- `_title_from_match(match) -> str` — opcjonalny override (default: derive z URL slug)
|
- `_title_from_match(match) -> str`, opcjonalny override (default: derive z URL slug)
|
||||||
- `_token_filter_text(match) -> str` — co testować na query tokens (default: cała URL)
|
- `_token_filter_text(match) -> str`, co testować na query tokens (default: cała URL)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ from app.extractors import browser_get
|
||||||
|
|
||||||
# Image src extraction: matches src, data-src, data-original, data-lazy-src, data-lazy
|
# Image src extraction: matches src, data-src, data-original, data-lazy-src, data-lazy
|
||||||
# (lazy-load lib variants). Wymaga rozszerzenia obrazka żeby ograniczyć false positives
|
# (lazy-load lib variants). Wymaga rozszerzenia obrazka żeby ograniczyć false positives
|
||||||
# (sprite icons, spinners) — JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
|
# (sprite icons, spinners), JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
|
||||||
# dla scene thumbnails.
|
# dla scene thumbnails.
|
||||||
_IMG_SRC_RE = re.compile(
|
_IMG_SRC_RE = re.compile(
|
||||||
r'<img[^>]+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']'
|
r'<img[^>]+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']'
|
||||||
|
|
@ -53,7 +53,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
|
|
||||||
#: Regex matchujący scene URL w search HTML. Wymagana grupa `url` (full scene URL),
|
#: Regex matchujący scene URL w search HTML. Wymagana grupa `url` (full scene URL),
|
||||||
#: opcjonalna grupa `slug` (do title derivation gdy dostępny w URL).
|
#: opcjonalna grupa `slug` (do title derivation gdy dostępny w URL).
|
||||||
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder — subclass override
|
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder, subclass override
|
||||||
|
|
||||||
#: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby
|
#: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby
|
||||||
#: nie matchowały niezwiązanych slugów).
|
#: nie matchowały niezwiązanych slugów).
|
||||||
|
|
@ -74,12 +74,12 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
})
|
})
|
||||||
|
|
||||||
#: Window (chars) wokół scene URL match, w którym szukamy `<img>` jako thumbnail.
|
#: Window (chars) wokół scene URL match, w którym szukamy `<img>` jako thumbnail.
|
||||||
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>` —
|
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>`,
|
||||||
#: ±800 chars łapie ten pattern niezawodnie.
|
#: ±800 chars łapie ten pattern niezawodnie.
|
||||||
_thumbnail_window: int = 800
|
_thumbnail_window: int = 800
|
||||||
|
|
||||||
def _scene_url_from_match(self, m: re.Match[str]) -> str:
|
def _scene_url_from_match(self, m: re.Match[str]) -> str:
|
||||||
"""Domyślnie group(1) — subclass override gdy regex używa named groups inaczej."""
|
"""Domyślnie group(1), subclass override gdy regex używa named groups inaczej."""
|
||||||
try:
|
try:
|
||||||
return m.group("url")
|
return m.group("url")
|
||||||
except IndexError:
|
except IndexError:
|
||||||
|
|
@ -103,17 +103,17 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
|
|
||||||
def _format_query_for_url(self, query: str) -> str:
|
def _format_query_for_url(self, query: str) -> str:
|
||||||
"""Default: URL-encode (spaces → `+`). Subclass override gdy tube wymaga
|
"""Default: URL-encode (spaces → `+`). Subclass override gdy tube wymaga
|
||||||
innego formatu — np. KVS-style sites użyją slug (spaces → `-`).
|
innego formatu, np. KVS-style sites użyją slug (spaces → `-`).
|
||||||
"""
|
"""
|
||||||
return urllib.parse.quote_plus(query.strip())
|
return urllib.parse.quote_plus(query.strip())
|
||||||
|
|
||||||
def _fetch_scene_metadata(
|
def _fetch_scene_metadata(
|
||||||
self, scene_url: str
|
self, scene_url: str
|
||||||
) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None:
|
) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None:
|
||||||
"""Optional hook — subclass może override żeby fetch'ować scene detail page
|
"""Optional hook, subclass może override żeby fetch'ować scene detail page
|
||||||
i wyciągnąć studio/performerów/tagi. Default zwraca None (skip detail fetch).
|
i wyciągnąć studio/performerów/tagi. Default zwraca None (skip detail fetch).
|
||||||
|
|
||||||
Wywoływane PER SCENE w `search()` — dodaje +1 HTTP request per match. Subclass
|
Wywoływane PER SCENE w `search()`, dodaje +1 HTTP request per match. Subclass
|
||||||
powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata.
|
powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata.
|
||||||
|
|
||||||
Returns: (studio, performers, tags). Każde może być None / pusta lista.
|
Returns: (studio, performers, tags). Każde może być None / pusta lista.
|
||||||
|
|
@ -153,7 +153,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
if scene_url.startswith("//"):
|
if scene_url.startswith("//"):
|
||||||
scene_url = "https:" + scene_url
|
scene_url = "https:" + scene_url
|
||||||
elif scene_url.startswith("/"):
|
elif scene_url.startswith("/"):
|
||||||
# Relative URL — prefix host z search URL.
|
# Relative URL, prefix host z search URL.
|
||||||
base = urllib.parse.urlparse(url)
|
base = urllib.parse.urlparse(url)
|
||||||
scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
|
scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
|
||||||
if scene_url in seen:
|
if scene_url in seen:
|
||||||
|
|
@ -165,11 +165,11 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
if slug_lower in self._nav_slug_blacklist:
|
if slug_lower in self._nav_slug_blacklist:
|
||||||
continue
|
continue
|
||||||
# Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()`
|
# Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()`
|
||||||
# przepuszczał scenę gdy choć jeden token był w slug — dla performera
|
# przepuszczał scenę gdy choć jeden token był w slug, dla performera
|
||||||
# "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug
|
# "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug
|
||||||
# (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
|
# (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
|
||||||
# bo `any("ava" in slug)` =True. User reports: scena "ava devine
|
# bo `any("ava" in slug)` =True. User reports: scena "ava devine
|
||||||
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()` — slug musi
|
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()`, slug musi
|
||||||
# zawierać każdy ≥3-char token z imienia performera.
|
# zawierać każdy ≥3-char token z imienia performera.
|
||||||
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
|
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
|
||||||
continue
|
continue
|
||||||
|
|
@ -178,7 +178,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
|
|
||||||
# Thumbnail: search ±N chars around scene_url match for nearest <img src=>.
|
# Thumbnail: search ±N chars around scene_url match for nearest <img src=>.
|
||||||
# Większość tubes ma `<a href="<scene>"><img src="<thumb>"></a>` lub flat
|
# Większość tubes ma `<a href="<scene>"><img src="<thumb>"></a>` lub flat
|
||||||
# `<img src=><a href=>` — window 800 obejmuje oba.
|
# `<img src=><a href=>`, window 800 obejmuje oba.
|
||||||
window_start = max(0, m.start() - self._thumbnail_window)
|
window_start = max(0, m.start() - self._thumbnail_window)
|
||||||
window_end = min(len(r.text), m.end() + self._thumbnail_window)
|
window_end = min(len(r.text), m.end() + self._thumbnail_window)
|
||||||
window_html = r.text[window_start:window_end]
|
window_html = r.text[window_start:window_end]
|
||||||
|
|
@ -193,7 +193,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
|
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
|
||||||
|
|
||||||
# Opcjonalny metadata fetch (studio/dodatkowi performerzy/tagi). Default
|
# Opcjonalny metadata fetch (studio/dodatkowi performerzy/tagi). Default
|
||||||
# zwraca None — większość tube'ów ma tylko search HTML bez metadata.
|
# zwraca None, większość tube'ów ma tylko search HTML bez metadata.
|
||||||
# PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach.
|
# PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach.
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
extra_performers: list[RawPerformer] = []
|
extra_performers: list[RawPerformer] = []
|
||||||
|
|
@ -212,7 +212,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
description = meta[3]
|
description = meta[3]
|
||||||
|
|
||||||
# Performer z query zawsze obecny (driver scraping). Extra performers
|
# Performer z query zawsze obecny (driver scraping). Extra performers
|
||||||
# z detail page dorzucamy — dedupe po slug/name w resolverze.
|
# z detail page dorzucamy, dedupe po slug/name w resolverze.
|
||||||
all_performers = [RawPerformer(name=query.strip()), *extra_performers]
|
all_performers = [RawPerformer(name=query.strip()), *extra_performers]
|
||||||
|
|
||||||
yield RawScene(
|
yield RawScene(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""BaseDirectTubeScraper — kontrakt dla bezpośrednich scraperów tube'ów."""
|
"""BaseDirectTubeScraper, kontrakt dla bezpośrednich scraperów tube'ów."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
|
@ -13,7 +13,7 @@ class BaseDirectTubeScraper(abc.ABC):
|
||||||
żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
|
żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
|
||||||
|
|
||||||
sitetag: str
|
sitetag: str
|
||||||
"""Stabilny ID tube'a — używany w external_id `f"{sitetag}:{url}"`. Zgodny
|
"""Stabilny ID tube'a, używany w external_id `f"{sitetag}:{url}"`. Zgodny
|
||||||
z porn-app sitetag (hqpornercom, sxylandcom, itp.)."""
|
z porn-app sitetag (hqpornercom, sxylandcom, itp.)."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""eporner.com — direct HTML scrape search results.
|
"""eporner.com, direct HTML scrape search results.
|
||||||
|
|
||||||
Search: `https://www.eporner.com/search/<q>/<page>/` (1-indexed pages).
|
Search: `https://www.eporner.com/search/<q>/<page>/` (1-indexed pages).
|
||||||
Scene URL: `https://www.eporner.com/hd-porn/<id>/<slug>/` lub `/video-<id>/<slug>/`.
|
Scene URL: `https://www.eporner.com/hd-porn/<id>/<slug>/` lub `/video-<id>/<slug>/`.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""eporner.com — deep-crawl przez oficjalne JSON API (api/v2/video/search).
|
"""eporner.com, deep-crawl przez oficjalne JSON API (api/v2/video/search).
|
||||||
|
|
||||||
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów) — ALE eporner ma
|
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów), ALE eporner ma
|
||||||
publiczne API zwracające KOMPLETNĄ metadatę w jednym callu: `title`, `length_sec`
|
publiczne API zwracające KOMPLETNĄ metadatę w jednym callu: `title`, `length_sec`
|
||||||
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
|
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
|
||||||
`order=latest`, ~100/stronę → ~1000 szybkich calli (BEZ detail-fetch). To czyni
|
`order=latest`, ~100/stronę → ~1000 szybkich calli (BEZ detail-fetch). To czyni
|
||||||
eporner idealnym SSR-bogatym źródłem deep-crawla (analiza 2026-06-03: porntrex/hqporner
|
eporner idealnym SSR-bogatym źródłem deep-crawla (analiza 2026-06-03: porntrex/hqporner
|
||||||
odrzucone — KVS bez SSR duration; eporner-API je zastępuje).
|
odrzucone, KVS bez SSR duration; eporner-API je zastępuje).
|
||||||
|
|
||||||
Override `crawl_page()` (API flow). HTML-owe _listing_url/_extract/_parse to stuby
|
Override `crawl_page()` (API flow). HTML-owe _listing_url/_extract/_parse to stuby
|
||||||
(BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co
|
(BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co
|
||||||
|
|
@ -113,7 +113,7 @@ class EpornerApiScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# HTML stuby — nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
|
# HTML stuby, nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
|
||||||
def _listing_url(self, page: int) -> str: # pragma: no cover
|
def _listing_url(self, page: int) -> str: # pragma: no cover
|
||||||
raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu")
|
raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""fpo.xxx — latest-vids browse scraper (KVS engine).
|
"""fpo.xxx, latest-vids browse scraper (KVS engine).
|
||||||
|
|
||||||
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP —
|
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP,
|
||||||
search zwracał 0 (regex slug-URL nie pasował do `/video/<id>/`). Przerobione na
|
search zwracał 0 (regex slug-URL nie pasował do `/video/<id>/`). Przerobione na
|
||||||
BROWSE (latest z `/new-<n>/`), 2026-06-22 (user request: ożywić zamrożone tuby).
|
BROWSE (latest z `/new-<n>/`), 2026-06-22 (user request: ożywić zamrożone tuby).
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ Listing tile (`/new-<n>/`):
|
||||||
→ tytuł, miniatura, duration, URL sceny. Performerów/tagów listing nie ma czysto
|
→ tytuł, miniatura, duration, URL sceny. Performerów/tagów listing nie ma czysto
|
||||||
(tytuł bywa JAV-code "Imai Kaho-RKI-602 ..."), więc puste → dorabia canonical-merge.
|
(tytuł bywa JAV-code "Imai Kaho-RKI-602 ..."), więc puste → dorabia canonical-merge.
|
||||||
|
|
||||||
Playback: KVS (kt_player + license_code na detail page) — token IP-bound, resolve
|
Playback: KVS (kt_player + license_code na detail page), token IP-bound, resolve
|
||||||
PO STRONIE TELEFONU (fpoxxxResolver.ts / WebView fallback, extractor `fpoxxx`).
|
PO STRONIE TELEFONU (fpoxxxResolver.ts / WebView fallback, extractor `fpoxxx`).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""freshporno.org — latest-vids browse scraper.
|
"""freshporno.org, latest-vids browse scraper.
|
||||||
|
|
||||||
Pilot #2 (po shyfap fail). Hipoteza: freshporno zachowuje oryginalne studio titles
|
Pilot #2 (po shyfap fail). Hipoteza: freshporno zachowuje oryginalne studio titles
|
||||||
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
|
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
|
||||||
|
|
@ -9,7 +9,7 @@ URL patterns:
|
||||||
- Scene: `/videos/<slug>/`
|
- Scene: `/videos/<slug>/`
|
||||||
|
|
||||||
Listing: świadomie `/latest-updates/` zamiast roota `/`. Root jest KVS-owym
|
Listing: świadomie `/latest-updates/` zamiast roota `/`. Root jest KVS-owym
|
||||||
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request — serwer
|
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request, serwer
|
||||||
rotuje tam blok "featured" i na zimnej sesji potrafi podać stary zestaw zamiast
|
rotuje tam blok "featured" i na zimnej sesji potrafi podać stary zestaw zamiast
|
||||||
najnowszych (zaobserwowane 2026-06-15: 3 dni browse-latest skipowało wszystko bo
|
najnowszych (zaobserwowane 2026-06-15: 3 dni browse-latest skipowało wszystko bo
|
||||||
root podawał sceny z 20 maja; freshporno.org report). `/latest-updates/` to jawny
|
root podawał sceny z 20 maja; freshporno.org report). `/latest-updates/` to jawny
|
||||||
|
|
@ -108,7 +108,7 @@ class FreshpornoScraper(BaseBrowseScraper):
|
||||||
# Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body
|
# Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body
|
||||||
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
|
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
# Skipnij nav linki ze stringiem "Channels" jako anchor text — bierzemy specific channel
|
# Skipnij nav linki ze stringiem "Channels" jako anchor text, bierzemy specific channel
|
||||||
for m in _CHANNEL_LINK_RE.finditer(detail_html):
|
for m in _CHANNEL_LINK_RE.finditer(detail_html):
|
||||||
slug, name = m.group(1), m.group(2).strip()
|
slug, name = m.group(1), m.group(2).strip()
|
||||||
if name.lower() in ("channels", ""):
|
if name.lower() in ("channels", ""):
|
||||||
|
|
@ -120,7 +120,7 @@ class FreshpornoScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Performers — wszyscy `/models/<slug>/`
|
# Performers, wszyscy `/models/<slug>/`
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_perf: set[str] = set()
|
seen_perf: set[str] = set()
|
||||||
for m in _MODEL_LINK_RE.finditer(detail_html):
|
for m in _MODEL_LINK_RE.finditer(detail_html):
|
||||||
|
|
@ -154,7 +154,7 @@ class FreshpornoScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phash z thumbnail. Wiemy że freshporno używa internal screenshots (preview.mp4.jpg)
|
# Phash z thumbnail. Wiemy że freshporno używa internal screenshots (preview.mp4.jpg)
|
||||||
# więc to też może nie matchować canonical phashy — ale test pokaże.
|
# więc to też może nie matchować canonical phashy, ale test pokaże.
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||||
|
|
@ -170,8 +170,8 @@ class FreshpornoScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Release date — freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
|
# Release date, freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
|
||||||
# To data wrzucenia na freshporno, NIE oryginalna release_date studio — ale dla
|
# To data wrzucenia na freshporno, NIE oryginalna release_date studio, ale dla
|
||||||
# świeżych scen (uploaded niedługo po publikacji) różnica ≤ 3-7 dni, mieści się w
|
# świeżych scen (uploaded niedługo po publikacji) różnica ≤ 3-7 dni, mieści się w
|
||||||
# `date_window_days=7` w resolverze. Bez tego pola scene NULL → match score 0 →
|
# `date_window_days=7` w resolverze. Bez tego pola scene NULL → match score 0 →
|
||||||
# duplicate scene zamiast freshporno PS dodane do TPDB canonical (bug-report
|
# duplicate scene zamiast freshporno PS dodane do TPDB canonical (bug-report
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""fullmovies.xxx — latest-vids browse scraper.
|
"""fullmovies.xxx, latest-vids browse scraper.
|
||||||
|
|
||||||
Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`,
|
Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`,
|
||||||
`/networks/<slug>/`, `/models/<slug>/`, `/tags/<slug>/`. og:image to `img.fullmovies.xxx/...`
|
`/networks/<slug>/`, `/models/<slug>/`, `/tags/<slug>/`. og:image to `img.fullmovies.xxx/...`
|
||||||
— **prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
|
**prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ class FullmoviesScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
|
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
|
||||||
# Sidebar listuje WSZYSTKIE sieci → `_NETWORK_LINK_RE.finditer().first()` zawsze
|
# Sidebar listuje WSZYSTKIE sieci → `_NETWORK_LINK_RE.finditer().first()` zawsze
|
||||||
# zwracał pierwszą z listy (Brazzers) dla każdej sceny — mis-attribution. Tytuł
|
# zwracał pierwszą z listy (Brazzers) dla każdej sceny, mis-attribution. Tytuł
|
||||||
# po oczyszczeniu ma format "Studio - Opis" (np. "Fake Hostel - ...").
|
# po oczyszczeniu ma format "Studio - Opis" (np. "Fake Hostel - ...").
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
if " - " in title:
|
if " - " in title:
|
||||||
|
|
@ -104,7 +104,7 @@ class FullmoviesScraper(BaseBrowseScraper):
|
||||||
tags.append(RawTag(external_id=f"fullmoviesxxx:tag:{slug}", name=name, slug=slug))
|
tags.append(RawTag(external_id=f"fullmoviesxxx:tag:{slug}", name=name, slug=slug))
|
||||||
|
|
||||||
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
|
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
|
||||||
# canonical — auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
|
# canonical, auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
|
||||||
# thumbnaile). Matching trzyma się na title+performer+duration (seed: 92% tagged),
|
# thumbnaile). Matching trzyma się na title+performer+duration (seed: 92% tagged),
|
||||||
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""fullporner.com — browse scraper. Dodany 2026-07-27.
|
"""fullporner.com, browse scraper. Dodany 2026-07-27.
|
||||||
|
|
||||||
Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s
|
Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s
|
||||||
na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu
|
na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu
|
||||||
|
|
@ -11,17 +11,17 @@ Listing jest ściśle chronologiczny, mimo że nagłówek mówi „Featured Vide
|
||||||
**Metadane są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
|
**Metadane są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
|
||||||
mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu
|
mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu
|
||||||
studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
|
studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
|
||||||
– Dylan Moore – Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
|
- Dylan Moore - Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
|
||||||
(dokładna, nie względna) i długość co do sekundy.
|
(dokładna, nie względna) i długość co do sekundy.
|
||||||
|
|
||||||
Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które
|
Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które
|
||||||
już mamy, przez `composite_auto` — composite radzi sobie mimo złych tytułów, bo waży
|
już mamy, przez `composite_auto`, composite radzi sobie mimo złych tytułów, bo waży
|
||||||
performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po
|
performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po
|
||||||
odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash
|
odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash
|
||||||
miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać.
|
miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać.
|
||||||
|
|
||||||
**Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23
|
**Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23
|
||||||
przypadków — bez performera composite nie ma na czym pracować, a studia tu nie ma
|
przypadków, bez performera composite nie ma na czym pracować, a studia tu nie ma
|
||||||
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
|
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
|
||||||
tylko sceny z obsadą.
|
tylko sceny z obsadą.
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ _THUMB = "https://imgs.xiaoshenke.net/thumb/{}.jpg"
|
||||||
|
|
||||||
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
|
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
|
||||||
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
|
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
|
||||||
# `<span class="create">1785081937</span></div><div>36:33</div>` — data i długość
|
# `<span class="create">1785081937</span></div><div>36:33</div>`, data i długość
|
||||||
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
|
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
|
||||||
_INFO_RE = re.compile(
|
_INFO_RE = re.compile(
|
||||||
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
|
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
|
||||||
|
|
@ -64,7 +64,7 @@ _PERF_BLOCK_RE = re.compile(r"<b>\s*Pornstars?:\s*</b>(.*?)</p>", re.IGNORECASE
|
||||||
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
|
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
|
||||||
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
|
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
|
||||||
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
|
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
|
||||||
# `<iframe src="//xiaoshenke.net/video/4458723/14">` — id wspak, /14 = maska jakości.
|
# `<iframe src="//xiaoshenke.net/video/4458723/14">`, id wspak, /14 = maska jakości.
|
||||||
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
|
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
|
||||||
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
|
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ class FullPornerScraper(BaseBrowseScraper):
|
||||||
pb = _PERF_BLOCK_RE.search(detail_html)
|
pb = _PERF_BLOCK_RE.search(detail_html)
|
||||||
if pb:
|
if pb:
|
||||||
for name in _PERF_RE.findall(pb.group(1)):
|
for name in _PERF_RE.findall(pb.group(1)):
|
||||||
# Strona pisze performerów z małej („melissa straton") — do wyświetlania
|
# Strona pisze performerów z małej („melissa straton"), do wyświetlania
|
||||||
# podnosimy, dopasowanie i tak idzie po slugu.
|
# podnosimy, dopasowanie i tak idzie po slugu.
|
||||||
name = html_mod.unescape(name).strip().title()
|
name = html_mod.unescape(name).strip().title()
|
||||||
slug = slugify(name)
|
slug = slugify(name)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ.
|
"""fullvideosporn.com (= sextu.com pod nową marką), browse scraper z BRAMKĄ NA OBSADĘ.
|
||||||
|
|
||||||
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
|
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
|
||||||
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
|
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
|
||||||
|
|
@ -6,15 +6,15 @@ pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
|
||||||
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
|
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
|
||||||
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
|
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
|
||||||
Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
|
Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
|
||||||
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem — wtedy sygnał jest dobry: 88-89%
|
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem, wtedy sygnał jest dobry: 88-89%
|
||||||
nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
|
nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
|
||||||
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
|
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
|
||||||
|
|
||||||
Pozostałe pułapki, wszystkie obsłużone niżej:
|
Pozostałe pułapki, wszystkie obsłużone niżej:
|
||||||
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** — te bywają
|
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** te bywają
|
||||||
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
|
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
|
||||||
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
|
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
|
||||||
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** — na całej stronie
|
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** na całej stronie
|
||||||
jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
|
jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
|
||||||
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
|
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
|
||||||
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
|
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
|
||||||
|
|
@ -65,7 +65,7 @@ _UNIT_DAYS = {
|
||||||
|
|
||||||
def _submitted_to_date(detail_html: str):
|
def _submitted_to_date(detail_html: str):
|
||||||
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
|
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
|
||||||
to jest data uploadu na tube (nie premiery studia) — traktujemy jak inne tuby."""
|
to jest data uploadu na tube (nie premiery studia), traktujemy jak inne tuby."""
|
||||||
m = _SUBMITTED_RE.search(detail_html)
|
m = _SUBMITTED_RE.search(detail_html)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
|
|
@ -127,7 +127,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
||||||
# Sekcje metadanych — każda w swoim <h3 class="item">.
|
# Sekcje metadanych, każda w swoim <h3 class="item">.
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
|
|
@ -162,7 +162,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
|
||||||
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
|
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
|
||||||
)
|
)
|
||||||
|
|
||||||
# BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to
|
# BRAMKA: bez obsady nie ingestujemy (patrz docstring, 2/3 katalogu to
|
||||||
# spunowane tytuły bez performera, które weszłyby jako puste orphany).
|
# spunowane tytuły bez performera, które weszłyby jako puste orphany).
|
||||||
if not performers:
|
if not performers:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
"""galaxyporn.net — browse scraper (WordPress retrotube). Dodany 2026-07-26.
|
"""galaxyporn.net, browse scraper (WordPress retrotube). Dodany 2026-07-26.
|
||||||
|
|
||||||
Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings,
|
Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings,
|
||||||
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem — 92% performerów
|
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem, 92% performerów
|
||||||
z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny.
|
z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny.
|
||||||
|
|
||||||
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
|
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
|
||||||
|
|
||||||
Metadane:
|
Metadane:
|
||||||
- obsada: `<div id="video-actors">` — czysta, BEZ pollution (na całej stronie sceny
|
- obsada: `<div id="video-actors">`, czysta, BEZ pollution (na całej stronie sceny
|
||||||
są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami)
|
są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami)
|
||||||
- studio + data: prefiks tytułu `Studio YY MM DD Performer – Title` albo `[Studio]`
|
- studio + data: prefiks tytułu `Studio YY MM DD Performer - Title` albo `[Studio]`
|
||||||
- tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio)
|
- tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio)
|
||||||
- thumbnail: `data-main-thumb` / og:image
|
- thumbnail: `data-main-thumb` / og:image
|
||||||
|
|
||||||
**Duration — uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
|
**Duration, uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
|
||||||
payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje
|
payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje
|
||||||
`duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby
|
`duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby
|
||||||
NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen).
|
NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen).
|
||||||
Dlatego liczymy ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na
|
Dlatego liczymy ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na
|
||||||
scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od
|
scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od
|
||||||
realnej (2226 s vs ~2276 s na próbce, ~2%) — akceptowalne, bo alternatywą jest brak.
|
realnej (2226 s vs ~2276 s na próbce, ~2%), akceptowalne, bo alternatywą jest brak.
|
||||||
|
|
||||||
Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro /
|
Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro /
|
||||||
sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący
|
sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący
|
||||||
|
|
@ -58,7 +58,7 @@ _TAGS_BLOCK_RE = re.compile(r'<div class="tags-list">(.*?)</div>', re.IGNORECASE
|
||||||
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
|
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
|
||||||
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
|
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
|
||||||
|
|
||||||
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch – Swapping Off The Pervy Maid`
|
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch - Swapping Off The Pervy Maid`
|
||||||
_TITLE_STUDIO_DATE_RE = re.compile(r"^(?P<studio>[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P<y>\d{2})\s+(?P<m>\d{2})\s+(?P<d>\d{2})\s+(?P<rest>.+)$")
|
_TITLE_STUDIO_DATE_RE = re.compile(r"^(?P<studio>[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P<y>\d{2})\s+(?P<m>\d{2})\s+(?P<d>\d{2})\s+(?P<rest>.+)$")
|
||||||
# `[Onlyfans] Some Title` / `[Darkkotv] …`
|
# `[Onlyfans] Some Title` / `[Darkkotv] …`
|
||||||
_TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$")
|
_TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$")
|
||||||
|
|
@ -105,7 +105,7 @@ def _resolve_duration(iframe_url: str, *, timeout: float, attempts: int = 3) ->
|
||||||
data = json.loads(_decrypt(r.text))
|
data = json.loads(_decrypt(r.text))
|
||||||
thumb = (data.get("thumbnail") or "").strip()
|
thumb = (data.get("thumbnail") or "").strip()
|
||||||
if not thumb:
|
if not thumb:
|
||||||
return None # payload OK, ale brak miniatur — retry nie pomoże
|
return None # payload OK, ale brak miniatur, retry nie pomoże
|
||||||
vtt_url = host + thumb if thumb.startswith("/") else thumb
|
vtt_url = host + thumb if thumb.startswith("/") else thumb
|
||||||
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
|
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
|
||||||
if vr.status_code != 200:
|
if vr.status_code != 200:
|
||||||
|
|
@ -145,7 +145,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Obsada — blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
|
# Obsada, blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
|
||||||
# ma dokładnie tyle linków /actor/ ile realnych aktorów).
|
# ma dokładnie tyle linków /actor/ ile realnych aktorów).
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_p: set[str] = set()
|
seen_p: set[str] = set()
|
||||||
|
|
@ -186,7 +186,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
|
||||||
slug=slugify(studio_name),
|
slug=slugify(studio_name),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tagi — bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
|
# Tagi, bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
seen_t: set[str] = set()
|
seen_t: set[str] = set()
|
||||||
studio_slug = slugify(studio_name) if studio_name else ""
|
studio_slug = slugify(studio_name) if studio_name else ""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""HDPorn92Scraper — direct HTML scrape hdporn92.com search.
|
"""HDPorn92Scraper, direct HTML scrape hdporn92.com search.
|
||||||
|
|
||||||
Search: `https://hdporn92.com/page/<n>/?s=<query>`. Scene URL format:
|
Search: `https://hdporn92.com/page/<n>/?s=<query>`. Scene URL format:
|
||||||
`https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać
|
`https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""hdporn.gg — latest-vids browse scraper.
|
"""hdporn.gg, latest-vids browse scraper.
|
||||||
|
|
||||||
Engine podobny do freshporno: `/videos/<slug>/` URL, `/networks/<slug>/` = studio,
|
Engine podobny do freshporno: `/videos/<slug>/` URL, `/networks/<slug>/` = studio,
|
||||||
`/models/<slug>/` = performer, `/tags/<slug>/` = tag.
|
`/models/<slug>/` = performer, `/tags/<slug>/` = tag.
|
||||||
|
|
||||||
Quirk: og:image to internal CDN `img.hdporn.gg/...` — przed merging do prod
|
Quirk: og:image to internal CDN `img.hdporn.gg/...`, przed merging do prod
|
||||||
sprawdzamy phash distance (gate-keeper: jeśli Hamming >5 dla >70% scen → orphan
|
sprawdzamy phash distance (gate-keeper: jeśli Hamming >5 dla >70% scen → orphan
|
||||||
factory, wyłącz; analogia do shyfap).
|
factory, wyłącz; analogia do shyfap).
|
||||||
"""
|
"""
|
||||||
|
|
@ -63,9 +63,9 @@ class HDPornGGScraper(BaseBrowseScraper):
|
||||||
title = meta_content(detail_html, property="og:title")
|
title = meta_content(detail_html, property="og:title")
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
# og:title typowo zawiera ": Free HD Porn" suffix — usuń
|
# og:title typowo zawiera ": Free HD Porn" suffix, usuń
|
||||||
title = re.sub(r":\s*Free HD Porn\s*$", "", title, flags=re.IGNORECASE).strip()
|
title = re.sub(r":\s*Free HD Porn\s*$", "", title, flags=re.IGNORECASE).strip()
|
||||||
# I "Brazzers - " prefix często też w title — zostaw, bo studio name w title
|
# I "Brazzers - " prefix często też w title, zostaw, bo studio name w title
|
||||||
# to silny sygnał dla fuzzy match.
|
# to silny sygnał dla fuzzy match.
|
||||||
|
|
||||||
description = meta_content(detail_html, property="og:description")
|
description = meta_content(detail_html, property="og:description")
|
||||||
|
|
@ -112,7 +112,7 @@ class HDPornGGScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
|
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
|
||||||
# canonical — auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
|
# canonical, auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
|
||||||
# Matching trzyma się na title+performer+duration (seed: 92% tagged), więc download
|
# Matching trzyma się na title+performer+duration (seed: 92% tagged), więc download
|
||||||
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""hqfap.com — latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
|
"""hqfap.com, latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
|
||||||
|
|
||||||
Dołączony 2026-06-10 (user request), usunięty 2026-06-25 (cała biblioteka CDN padła
|
Dołączony 2026-06-10 (user request), usunięty 2026-06-25 (cała biblioteka CDN padła
|
||||||
na stub), przywrócony 2026-07-07 (strona wróciła na CDN vstor.top z realnymi plikami,
|
na stub), przywrócony 2026-07-07 (strona wróciła na CDN vstor.top z realnymi plikami,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"""HQPornerScraper — direct HTML scrape hqporner search page.
|
"""HQPornerScraper, direct HTML scrape hqporner search page.
|
||||||
|
|
||||||
Search URL: `https://hqporner.com/?q=<query>&p=<page>`. Static HTML zwraca ~50
|
Search URL: `https://hqporner.com/?q=<query>&p=<page>`. Static HTML zwraca ~50
|
||||||
linków `/hdporn/<id>-<slug>.html` per strona. Tytuł deducimy ze slug'a (porn-app
|
linków `/hdporn/<id>-<slug>.html` per strona. Tytuł deducimy ze slug'a (porn-app
|
||||||
data API zwraca dokładniejszy ale wymaga round-trip — dla MVP slug-derived OK,
|
data API zwraca dokładniejszy ale wymaga round-trip, dla MVP slug-derived OK,
|
||||||
resolver i tak je sciagnie z TPDB merge).
|
resolver i tak je sciagnie z TPDB merge).
|
||||||
|
|
||||||
Search fuzzy: hqporner zwraca "Lola Noir" gdy szukamy "Noir" itp. Dlatego
|
Search fuzzy: hqporner zwraca "Lola Noir" gdy szukamy "Noir" itp. Dlatego
|
||||||
filtrujemy wyniki po tym czy slug zawiera query (lub jego token) — analogicznie
|
filtrujemy wyniki po tym czy slug zawiera query (lub jego token), analogicznie
|
||||||
jak `fetch_scenes_for_search` w pornapp connectorze.
|
jak `fetch_scenes_for_search` w pornapp connectorze.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -55,7 +55,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
|
||||||
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
|
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
|
||||||
query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3}
|
query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3}
|
||||||
# Człon <3 znaki ("Lady Vi", "Mia Li", "Ai Uehara") wypada z `query_tokens`, więc
|
# Człon <3 znaki ("Lady Vi", "Mia Li", "Ai Uehara") wypada z `query_tokens`, więc
|
||||||
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'" —
|
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'",
|
||||||
# czyli KAŻDA scena z tym słowem (audit 2026-07-28: Lady Vi miała 116 przypisań
|
# czyli KAŻDA scena z tym słowem (audit 2026-07-28: Lady Vi miała 116 przypisań
|
||||||
# z tube, z czego 115 tylko przez „lady", realnie o niej 1). Gdy nazwa ma taki
|
# z tube, z czego 115 tylko przez „lady", realnie o niej 1). Gdy nazwa ma taki
|
||||||
# człon, wymagamy dodatkowo CAŁEJ nazwy sklejonej bez separatorów (slug
|
# człon, wymagamy dodatkowo CAŁEJ nazwy sklejonej bez separatorów (slug
|
||||||
|
|
@ -92,7 +92,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
|
||||||
page_url=scene_url,
|
page_url=scene_url,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
# Wymuszamy hint performera = query — search per performer name znaczy
|
# Wymuszamy hint performera = query, search per performer name znaczy
|
||||||
# że scena prawie na pewno o nim. Resolver dorobi ScenePerformer link.
|
# że scena prawie na pewno o nim. Resolver dorobi ScenePerformer link.
|
||||||
performers=[RawPerformer(name=query.strip())],
|
performers=[RawPerformer(name=query.strip())],
|
||||||
raw={
|
raw={
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""HQPornerBrowseScraper — latest-vids browse dla hqporner.com.
|
"""HQPornerBrowseScraper, latest-vids browse dla hqporner.com.
|
||||||
|
|
||||||
Dlaczego: hqporner był TYLKO w `ALL_DIRECT_SCRAPERS` (search po nazwisku performera),
|
Dlaczego: hqporner był TYLKO w `ALL_DIRECT_SCRAPERS` (search po nazwisku performera),
|
||||||
więc nowe sceny wchodziły wyłącznie wtedy, gdy worker akurat szukał pasującego
|
więc nowe sceny wchodziły wyłącznie wtedy, gdy worker akurat szukał pasującego
|
||||||
|
|
@ -17,7 +17,7 @@ Detail (`/hdporn/<id>-<slug>.html`) ma komplet metadanych:
|
||||||
`external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`.
|
`external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`.
|
||||||
Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami.
|
Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami.
|
||||||
|
|
||||||
Miniaturki fastporndelivery wymagają Referera (bez niego 403) — backend i tak owija je
|
Miniaturki fastporndelivery wymagają Referera (bez niego 403), backend i tak owija je
|
||||||
w `/proxy/img/`, a phash liczymy z jawnym refererem.
|
w `/proxy/img/`, a phash liczymy z jawnym refererem.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -134,7 +134,7 @@ class HQPornerBrowseScraper(BaseBrowseScraper):
|
||||||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||||
|
|
||||||
return RawScene(
|
return RawScene(
|
||||||
# Ten sam format co HQPornerScraper.search — inaczej duplikat sceny.
|
# Ten sam format co HQPornerScraper.search, inaczej duplikat sceny.
|
||||||
external_id=f"{self.sitetag}:{scene_url}",
|
external_id=f"{self.sitetag}:{scene_url}",
|
||||||
title=title,
|
title=title,
|
||||||
duration_sec=duration_sec,
|
duration_sec=duration_sec,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""javflix.cc — JAV browse scraper (WordPress, English-subbed JAV).
|
"""javflix.cc, JAV browse scraper (WordPress, English-subbed JAV).
|
||||||
|
|
||||||
Osobna pula JAV (kody typu BKD-368, tytuły azjatyckie), origin `tube:javflix`.
|
Osobna pula JAV (kody typu BKD-368, tytuły azjatyckie), origin `tube:javflix`.
|
||||||
NIE deduplikuje się z zachodnim katalogiem — świadomie orphan vertical (sekcja JAV).
|
NIE deduplikuje się z zachodnim katalogiem, świadomie orphan vertical (sekcja JAV).
|
||||||
|
|
||||||
Struktura (RE 2026-07-10):
|
Struktura (RE 2026-07-10):
|
||||||
- listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/)
|
- listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/)
|
||||||
|
|
@ -76,7 +76,7 @@ class JavflixScraper(BaseBrowseScraper):
|
||||||
up = _itemprop(detail_html, "uploadDate")
|
up = _itemprop(detail_html, "uploadDate")
|
||||||
release_date = _parse_iso_date(up) if up else None
|
release_date = _parse_iso_date(up) if up else None
|
||||||
|
|
||||||
# JAV code (BKD-368) ze sluga — kanoniczny identyfikator, dodajemy jako tag
|
# JAV code (BKD-368) ze sluga, kanoniczny identyfikator, dodajemy jako tag
|
||||||
# (searchable) bo javflix nie ma osobnego pola kodu.
|
# (searchable) bo javflix nie ma osobnego pola kodu.
|
||||||
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
|
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
|
||||||
cm = _CODE_RE.match(slug)
|
cm = _CODE_RE.match(slug)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""8kporner.com — browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
|
"""8kporner.com, browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
|
||||||
|
|
||||||
Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane
|
Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane
|
||||||
pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny
|
pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny
|
||||||
|
|
@ -9,8 +9,8 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
|
||||||
1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` → 403 również
|
1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` → 403 również
|
||||||
PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc
|
PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc
|
||||||
`_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos&
|
`_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos&
|
||||||
page=latest` (20). To jednocześnie SUFIT — deep crawl jest niemożliwy.
|
page=latest` (20). To jednocześnie SUFIT, deep crawl jest niemożliwy.
|
||||||
2. **Paginacja nie działa** — `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
|
2. **Paginacja nie działa** `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
|
||||||
co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`).
|
co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`).
|
||||||
3. **Forma `/watch/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id=
|
3. **Forma `/watch/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id=
|
||||||
<id>` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny
|
<id>` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny
|
||||||
|
|
@ -21,22 +21,22 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
|
||||||
strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie
|
strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie
|
||||||
ma żadnego performera i weszłaby jako puste orphany.
|
ma żadnego performera i weszłaby jako puste orphany.
|
||||||
|
|
||||||
Bramka się broni — z 117 performerów wciągniętych pilotem **113 (97%) ma ref
|
Bramka się broni, z 117 performerów wciągniętych pilotem **113 (97%) ma ref
|
||||||
tpdb/stashdb**, najwyżej ze wszystkich źródeł dodanych w tej rundzie. Pilotowy ingest:
|
tpdb/stashdb**, najwyżej ze wszystkich źródeł dodanych w tej rundzie. Pilotowy ingest:
|
||||||
`seen 62, new 25, updated 37, errors 0` — czyli 60% scen przypięło się do czegoś, co
|
`seen 62, new 25, updated 37, errors 0`, czyli 60% scen przypięło się do czegoś, co
|
||||||
już mamy (to nie jest fabryka orphanów, tylko dodatkowe, lepsze jakościowo źródło).
|
już mamy (to nie jest fabryka orphanów, tylko dodatkowe, lepsze jakościowo źródło).
|
||||||
|
|
||||||
**Studio.** Scena NIE linkuje studia osobno — na stronie sceny wszystko jest pillem
|
**Studio.** Scena NIE linkuje studia osobno, na stronie sceny wszystko jest pillem
|
||||||
`/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami.
|
`/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami.
|
||||||
Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi studiami.
|
Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi studiami.
|
||||||
Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy
|
Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy
|
||||||
to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej
|
to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej
|
||||||
powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio.
|
powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio.
|
||||||
Trafień jest mało (1 na 62 w pilocie) — większość scen po prostu nie ma pilla studia,
|
Trafień jest mało (1 na 62 w pilocie), większość scen po prostu nie ma pilla studia,
|
||||||
ale kosztuje to jeden fetch na run, więc zostaje.
|
ale kosztuje to jeden fetch na run, więc zostaje.
|
||||||
|
|
||||||
Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
|
Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
|
||||||
canonical — dopasowanie musi opierać się na tytule, obsadzie i długości.
|
canonical, dopasowanie musi opierać się na tytule, obsadzie i długości.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ _SCENE_HREF_RE = re.compile(
|
||||||
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
|
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
|
||||||
_STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE)
|
_STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE)
|
||||||
|
|
||||||
# Pozycje z `/studios`, które są normalnymi gatunkami — jako studio dałyby fałszywkę
|
# Pozycje z `/studios`, które są normalnymi gatunkami, jako studio dałyby fałszywkę
|
||||||
# na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki
|
# na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki
|
||||||
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
|
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
|
||||||
_AMBIGUOUS_STUDIOS = {
|
_AMBIGUOUS_STUDIOS = {
|
||||||
|
|
@ -119,7 +119,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
||||||
|
|
||||||
def _load_catalog(self) -> list[str] | None:
|
def _load_catalog(self) -> list[str] | None:
|
||||||
"""Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz
|
"""Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz
|
||||||
docstring), więc to jedyne dostępne źródło URL-i — i zarazem sufit katalogu."""
|
docstring), więc to jedyne dostępne źródło URL-i, i zarazem sufit katalogu."""
|
||||||
if self._catalog is not None:
|
if self._catalog is not None:
|
||||||
return self._catalog
|
return self._catalog
|
||||||
urls: list[str] = []
|
urls: list[str] = []
|
||||||
|
|
@ -145,7 +145,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
||||||
|
|
||||||
def crawl_page(self, page: int) -> list[RawScene] | None:
|
def crawl_page(self, page: int) -> list[RawScene] | None:
|
||||||
"""Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/`
|
"""Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/`
|
||||||
daje 403) i z pauzą — CF tnie przy szybszym tempie."""
|
daje 403) i z pauzą, CF tnie przy szybszym tempie."""
|
||||||
catalog = self._load_catalog()
|
catalog = self._load_catalog()
|
||||||
if catalog is None:
|
if catalog is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -174,7 +174,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
||||||
scene = super()._parse_detail(scene_url, detail_html)
|
scene = super()._parse_detail(scene_url, detail_html)
|
||||||
if scene is None:
|
if scene is None:
|
||||||
return None
|
return None
|
||||||
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą) — bez niej
|
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą), bez niej
|
||||||
# większość katalogu wpadłaby jako puste orphany.
|
# większość katalogu wpadłaby jako puste orphany.
|
||||||
if not scene.performers:
|
if not scene.performers:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""latestleaks.co — direct HTML scrape.
|
"""latestleaks.co, direct HTML scrape.
|
||||||
|
|
||||||
Search: `https://latestleaks.co/page/<n>/?s=<q>`.
|
Search: `https://latestleaks.co/page/<n>/?s=<q>`.
|
||||||
Scene URL: `https://latestleaks.co/<slug>/`.
|
Scene URL: `https://latestleaks.co/<slug>/`.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""latestpornvideo.com — latest-vids browse scraper.
|
"""latestpornvideo.com, latest-vids browse scraper.
|
||||||
|
|
||||||
Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem
|
Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem
|
||||||
(user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których
|
(user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których
|
||||||
|
|
@ -6,7 +6,7 @@ akurat szukamy → feed strony "latest" nigdy nie wpada, w apce widać stary zes
|
||||||
a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z
|
a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z
|
||||||
`/page/<n>/`, page 1 = `/`), 2026-06-22.
|
`/page/<n>/`, page 1 = `/`), 2026-06-22.
|
||||||
|
|
||||||
Listing card (zero detail-fetchy — detail page nie ma performerów ani duration):
|
Listing card (zero detail-fetchy, detail page nie ma performerów ani duration):
|
||||||
<article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ...">
|
<article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ...">
|
||||||
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
|
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
|
||||||
data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg"
|
data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg"
|
||||||
|
|
@ -51,7 +51,7 @@ _CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")
|
||||||
_THUMB_NAME_RE = re.compile(r"/([A-Za-z0-9][A-Za-z0-9-]*?)-(\d{4})-(\d{2})-(\d{2})-", re.IGNORECASE)
|
_THUMB_NAME_RE = re.compile(r"/([A-Za-z0-9][A-Za-z0-9-]*?)-(\d{4})-(\d{2})-(\d{2})-", re.IGNORECASE)
|
||||||
# Tytuł: `<Studio> YY MM DD <rest>` (np. "MySexMobile 20 10 23 Abella Danger").
|
# Tytuł: `<Studio> YY MM DD <rest>` (np. "MySexMobile 20 10 23 Abella Danger").
|
||||||
_TITLE_DATE_RE = re.compile(r"^(.*?)\s*\b(\d{2})\s+(\d{2})\s+(\d{2})\b")
|
_TITLE_DATE_RE = re.compile(r"^(.*?)\s*\b(\d{2})\s+(\d{2})\s+(\d{2})\b")
|
||||||
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos" — to nie jest tag.
|
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos", to nie jest tag.
|
||||||
_CAT_SKIP = {"latest-porn-videos", "uncategorized", ""}
|
_CAT_SKIP = {"latest-porn-videos", "uncategorized", ""}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -137,7 +137,7 @@ class LatestPornVideoScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos".
|
# Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos".
|
||||||
# `tag-*` POMIJAMY — to mieszanka fragmentów imion performerów i gatunków,
|
# `tag-*` POMIJAMY, to mieszanka fragmentów imion performerów i gatunków,
|
||||||
# bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum.
|
# bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum.
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
seen_tag: set[str] = set()
|
seen_tag: set[str] = set()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""mypornerleak.com — performer-page listing scrape (search-based, performer-driven).
|
"""mypornerleak.com, performer-page listing scrape (search-based, performer-driven).
|
||||||
|
|
||||||
2026-06-16 fix (zamrożony od 05-07): treść serwowana jest pod subdomeną
|
2026-06-16 fix (zamrożony od 05-07): treść serwowana jest pod subdomeną
|
||||||
`w8.mypornerleak.com` (load-balancer wN.), a stary regex szukał scen na gołym
|
`w8.mypornerleak.com` (load-balancer wN.), a stary regex szukał scen na gołym
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
listingu i sceny, więc fetchujemy `mypornerleak.com/actor/<slug>/` a linki scen
|
listingu i sceny, więc fetchujemy `mypornerleak.com/actor/<slug>/` a linki scen
|
||||||
normalizujemy wN.→ kanoniczny (stabilny page_url/dedup).
|
normalizujemy wN.→ kanoniczny (stabilny page_url/dedup).
|
||||||
|
|
||||||
Wszystko z LISTINGU (bez detail-fetcha) — karta `<article>` koduje metadane:
|
Wszystko z LISTINGU (bez detail-fetcha), karta `<article>` koduje metadane:
|
||||||
- klasa `actors-<slug>` (multi) → performerzy; `category-<slug>` (multi) → tagi
|
- klasa `actors-<slug>` (multi) → performerzy; `category-<slug>` (multi) → tagi
|
||||||
- `<a href title="...">` → URL sceny + czysty tytuł
|
- `<a href title="...">` → URL sceny + czysty tytuł
|
||||||
- `<span class="duration">MM:SS|HH:MM:SS`
|
- `<span class="duration">MM:SS|HH:MM:SS`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""mypornerleak.com — latest BROWSE scraper via WordPress REST API, obok search scrapera.
|
"""mypornerleak.com, latest BROWSE scraper via WordPress REST API, obok search scrapera.
|
||||||
|
|
||||||
MyPornerLeakScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada
|
MyPornerLeakScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada
|
||||||
świeżość wprost z WP REST (`/wp-json/wp/v2/posts?_embed=1`). W odróżnieniu od
|
świeżość wprost z WP REST (`/wp-json/wp/v2/posts?_embed=1`). W odróżnieniu od
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
"""neporn.com — latest-vids browse scraper (KVS engine).
|
"""neporn.com, latest-vids browse scraper (KVS engine).
|
||||||
|
|
||||||
Dołączony 2026-06-10 (user request). Paysite re-upload (HardX, DAP itp.),
|
Dołączony 2026-06-10 (user request). Paysite re-upload (HardX, DAP itp.),
|
||||||
~40k+ scen po numeracji video id.
|
~40k+ scen po numeracji video id.
|
||||||
|
|
||||||
Sygnały per scena:
|
Sygnały per scena:
|
||||||
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl
|
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl
|
||||||
(uwaga: bywa zmanglowany "https:https://..." — normalizujemy)
|
(uwaga: bywa zmanglowany "https:https://...", normalizujemy)
|
||||||
- `<meta property="video:duration">` = sekundy wprost
|
- `<meta property="video:duration">` = sekundy wprost
|
||||||
- Performerzy: `<a class="link" href="https://neporn.com/models/<slug>/">`
|
- Performerzy: `<a class="link" href="https://neporn.com/models/<slug>/">`
|
||||||
(nazwa w spanie z ikoną — bierzemy slug i tytułujemy). Świeże uploady NIE mają
|
(nazwa w spanie z ikoną, bierzemy slug i tytułujemy). Świeże uploady NIE mają
|
||||||
jeszcze linków modelek — fallback: flashvars `video_tags` (na świeżych scenach
|
jeszcze linków modelek, fallback: flashvars `video_tags` (na świeżych scenach
|
||||||
to czyste nazwiska, np. "emily willis, gianna dior"); bierzemy wpisy 2-3 słowa
|
to czyste nazwiska, np. "emily willis, gianna dior"); bierzemy wpisy 2-3 słowa
|
||||||
obecne w tytule i bez generycznych fraz (stoplist), bo starsze sceny mają tam
|
obecne w tytule i bez generycznych fraz (stoplist), bo starsze sceny mają tam
|
||||||
też szum ("deep throat", "natural tits", "hd porn").
|
też szum ("deep throat", "natural tits", "hd porn").
|
||||||
- Kategorie: linki `/categories/<slug>/` w bloku info (tagi z `/tags/` to szum
|
- Kategorie: linki `/categories/<slug>/` w bloku info (tagi z `/tags/` to szum
|
||||||
typu "hd porn", "2020" — pomijamy)
|
typu "hd porn", "2020", pomijamy)
|
||||||
|
|
||||||
Studio: brak strukturalnego pola — tytuł bywa "- HardX Update - ..." ale format
|
Studio: brak strukturalnego pola, tytuł bywa "- HardX Update - ..." ale format
|
||||||
niespójny, zostawiamy fuzzy matchowi po tytule.
|
niespójny, zostawiamy fuzzy matchowi po tytule.
|
||||||
|
|
||||||
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
|
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
|
||||||
|
|
@ -111,7 +111,7 @@ class NepornScraper(BaseBrowseScraper):
|
||||||
description = (video.get("description") or "").strip() or None
|
description = (video.get("description") or "").strip() or None
|
||||||
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
|
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
# JSON-LD emituje "https:https://cdn..." — utnij zdublowany scheme.
|
# JSON-LD emituje "https:https://cdn...", utnij zdublowany scheme.
|
||||||
thumbnail_url = re.sub(r"^https?:(?=https?://)", "", thumbnail_url)
|
thumbnail_url = re.sub(r"^https?:(?=https?://)", "", thumbnail_url)
|
||||||
|
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
|
|
@ -128,7 +128,7 @@ class NepornScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Fallback dla świeżych uploadów bez linków modelek: nazwiska z flashvars
|
# Fallback dla świeżych uploadów bez linków modelek: nazwiska z flashvars
|
||||||
# video_tags — 2-3 słowa, obecne w tytule, bez generycznych fraz.
|
# video_tags, 2-3 słowa, obecne w tytule, bez generycznych fraz.
|
||||||
title_cf = title.casefold()
|
title_cf = title.casefold()
|
||||||
tags_m = _VIDEO_TAGS_RE.search(detail_html)
|
tags_m = _VIDEO_TAGS_RE.search(detail_html)
|
||||||
for entry in (tags_m.group(1).split(",") if tags_m else []):
|
for entry in (tags_m.group(1).split(",") if tags_m else []):
|
||||||
|
|
@ -160,7 +160,7 @@ class NepornScraper(BaseBrowseScraper):
|
||||||
seen_tag.add(slug)
|
seen_tag.add(slug)
|
||||||
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
|
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
|
||||||
|
|
||||||
# Phash z KVS screenshotów — niski hit-rate oczekiwany (jak porn00),
|
# Phash z KVS screenshotów, niski hit-rate oczekiwany (jak porn00),
|
||||||
# graceful miss → composite scoring (performer+title+duration).
|
# graceful miss → composite scoring (performer+title+duration).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""perverzija.com — latest browse scraper via WordPress REST API.
|
"""perverzija.com, latest browse scraper via WordPress REST API.
|
||||||
|
|
||||||
Historia: dawniej search scraper (`?s=`), ale 2026-06 perverzija rate-limituje search
|
Historia: dawniej search scraper (`?s=`), ale 2026-06 perverzija rate-limituje search
|
||||||
(429) a homepage jest JS-renderowane (brak linków postów w surowym HTML) → search
|
(429) a homepage jest JS-renderowane (brak linków postów w surowym HTML) → search
|
||||||
|
|
@ -8,7 +8,7 @@ na stronę. VPS dociera (curl_cffi bypassuje JA3; 200 nie 403). Przerobione na b
|
||||||
2026-06-22 (user request).
|
2026-06-22 (user request).
|
||||||
|
|
||||||
Z REST `?_embed=1` bierzemy: tytuł, datę, miniaturę (featured_media), STUDIO
|
Z REST `?_embed=1` bierzemy: tytuł, datę, miniaturę (featured_media), STUDIO
|
||||||
(taksonomia `category` — np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
|
(taksonomia `category`, np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
|
||||||
(`post_tag`). Performerów REST nie wystawia (custom taksonomia `stars` bez show_in_rest)
|
(`post_tag`). Performerów REST nie wystawia (custom taksonomia `stars` bez show_in_rest)
|
||||||
→ puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł
|
→ puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł
|
||||||
i tak ma nazwiska).
|
i tak ma nazwiska).
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
"""porn00.org — latest-vids browse scraper.
|
"""porn00.org, latest-vids browse scraper.
|
||||||
|
|
||||||
URL patterns:
|
URL patterns:
|
||||||
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
|
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
|
||||||
- Scene: `/video/<slug>/`
|
- Scene: `/video/<slug>/`
|
||||||
- Performer: `/<slug>/` (np. `/august-skye/`) — w sekcji "Pornstars:" na detail
|
- Performer: `/<slug>/` (np. `/august-skye/`), w sekcji "Pornstars:" na detail
|
||||||
- Categories: `/category-name/<slug>/`
|
- Categories: `/category-name/<slug>/`
|
||||||
|
|
||||||
Sygnały dostępne:
|
Sygnały dostępne:
|
||||||
- Title (listing card + h1 + og:title)
|
- Title (listing card + h1 + og:title)
|
||||||
- Performer(s) (z sekcji "Pornstars:" na detail page — pojedynczy slug per link)
|
- Performer(s) (z sekcji "Pornstars:" na detail page, pojedynczy slug per link)
|
||||||
- Categories (z sekcji "Categories:" — `/category-name/<slug>/`)
|
- Categories (z sekcji "Categories:", `/category-name/<slug>/`)
|
||||||
- Duration (listing card `<div class="duration">MM:SS</div>`)
|
- Duration (listing card `<div class="duration">MM:SS</div>`)
|
||||||
- Direct mp4 (KVS engine — `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
|
- Direct mp4 (KVS engine, `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
|
||||||
- Thumbnail (own CDN `/contents/videos_screenshots/.../1.jpg`)
|
- Thumbnail (own CDN `/contents/videos_screenshots/.../1.jpg`)
|
||||||
|
|
||||||
BRAK:
|
BRAK:
|
||||||
|
|
@ -78,7 +78,7 @@ _CATEGORY_LINK_RE = re.compile(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`.
|
# Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`.
|
||||||
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp. — bierzemy
|
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp., bierzemy
|
||||||
# wszystko do najbliższego `'` lub `"`.
|
# wszystko do najbliższego `'` lub `"`.
|
||||||
_VIDEO_URL_RE = re.compile(
|
_VIDEO_URL_RE = re.compile(
|
||||||
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
|
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
|
||||||
|
|
@ -107,7 +107,7 @@ class Porn00Scraper(BaseBrowseScraper):
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Cache listing card meta — duration + thumb + title. Detail page nie ma
|
# Cache listing card meta, duration + thumb + title. Detail page nie ma
|
||||||
# tych pól w meta (brak og:duration), więc listing jest source of truth.
|
# tych pól w meta (brak og:duration), więc listing jest source of truth.
|
||||||
self._listing_cache: dict[str, dict] = {}
|
self._listing_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
@ -145,7 +145,7 @@ class Porn00Scraper(BaseBrowseScraper):
|
||||||
# Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180.
|
# Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180.
|
||||||
thumb = meta_content(detail_html, property="og:image") or meta.get("thumb")
|
thumb = meta_content(detail_html, property="og:image") or meta.get("thumb")
|
||||||
|
|
||||||
# Performers — porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
|
# Performers, porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
|
||||||
# `/category-name/`). Wszystkie linki tego pattern to performerzy.
|
# `/category-name/`). Wszystkie linki tego pattern to performerzy.
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_perf: set[str] = set()
|
seen_perf: set[str] = set()
|
||||||
|
|
@ -177,14 +177,14 @@ class Porn00Scraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Direct mp4 z KVS flashvars — preferujemy 720p (video_alt_url) nad 360p (video_url).
|
# Direct mp4 z KVS flashvars, preferujemy 720p (video_alt_url) nad 360p (video_url).
|
||||||
stream_url: str | None = None
|
stream_url: str | None = None
|
||||||
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
|
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
|
||||||
stream_url = vm.group(1)
|
stream_url = vm.group(1)
|
||||||
elif (vm := _VIDEO_URL_RE.search(detail_html)):
|
elif (vm := _VIDEO_URL_RE.search(detail_html)):
|
||||||
stream_url = vm.group(1)
|
stream_url = vm.group(1)
|
||||||
|
|
||||||
# Phash — porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
|
# Phash, porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
|
||||||
# więc canonical phash match raczej fail. Próbujemy mimo to.
|
# więc canonical phash match raczej fail. Próbujemy mimo to.
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumb:
|
if thumb:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""porn4days.pw — direct HTML scrape.
|
"""porn4days.pw, direct HTML scrape.
|
||||||
|
|
||||||
Search: `https://porn4days.pw/page/<n>/?s=<q>`.
|
Search: `https://porn4days.pw/page/<n>/?s=<q>`.
|
||||||
Scene URL: `https://porn4days.pw/<slug>/`.
|
Scene URL: `https://porn4days.pw/<slug>/`.
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
"""pornbusy.com — browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
|
"""pornbusy.com, browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
|
||||||
|
|
||||||
Ripy studyjne (~60%) + JAV z kodami (~30%). Ocena orphan-risk LOW z pomiarem:
|
Ripy studyjne (~60%) + JAV z kodami (~30%). Ocena orphan-risk LOW z pomiarem:
|
||||||
80% scen z próbki 100 ma performera, który u nas MA już ref tpdb/stashdb, a 21%
|
80% scen z próbki 100 ma performera, który u nas MA już ref tpdb/stashdb, a 21%
|
||||||
tytułów mocno matchuje sceny które już mamy (czyli się scalą, nie zorphanują).
|
tytułów mocno matchuje sceny które już mamy (czyli się scalą, nie zorphanują).
|
||||||
|
|
||||||
**Paginacja homepage jest ZEPSUTA** — `/page/2/` i `/page/3/` zwracają ten sam
|
**Paginacja homepage jest ZEPSUTA** `/page/2/` i `/page/3/` zwracają ten sam
|
||||||
zestaw co strona 1 (zweryfikowane). Dlatego listing bierzemy z SITEMAPY:
|
zestaw co strona 1 (zweryfikowane). Dlatego listing bierzemy z SITEMAPY:
|
||||||
`sitemap_index.xml` → 10× `post-sitemapN.xml` po ~500 URL-i, każdy z `<lastmod>`,
|
`sitemap_index.xml` → 10× `post-sitemapN.xml` po ~500 URL-i, każdy z `<lastmod>`,
|
||||||
posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE`
|
posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE`
|
||||||
(wzorzec jak `_playtube.BasePlayTubeScraper`).
|
(wzorzec jak `_playtube.BasePlayTubeScraper`).
|
||||||
|
|
||||||
Metadane — każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
|
Metadane, każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
|
||||||
tytułu-slug:
|
tytułu-slug:
|
||||||
- title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description
|
- title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description
|
||||||
- obsada: `<div id="video-actors">` — CZYSTA, bez pollution (na stronie sceny jest
|
- obsada: `<div id="video-actors">`, CZYSTA, bez pollution (na stronie sceny jest
|
||||||
dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related)
|
dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related)
|
||||||
- kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`)
|
- kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`)
|
||||||
- studio: **brak** — pornbusy nie ma pola studia ani prefiksu w tytule
|
- studio: **brak** pornbusy nie ma pola studia ani prefiksu w tytule
|
||||||
|
|
||||||
Playback: `itemprop="embedURL"` → extractor `pornbusycom` (loadvid / seekplayer /
|
Playback: `itemprop="embedURL"` → extractor `pornbusycom` (loadvid / seekplayer /
|
||||||
zpi.cx ≈ 69% katalogu).
|
zpi.cx ≈ 69% katalogu).
|
||||||
|
|
@ -96,7 +96,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
||||||
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
|
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
|
||||||
self._catalog: list[str] | None = None
|
self._catalog: list[str] | None = None
|
||||||
|
|
||||||
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta) —
|
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta),
|
||||||
# paginację robi sitemap w `crawl_page`. Te dwie metody są abstrakcyjne w bazie.
|
# paginację robi sitemap w `crawl_page`. Te dwie metody są abstrakcyjne w bazie.
|
||||||
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
|
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
|
||||||
return _SITEMAP_INDEX
|
return _SITEMAP_INDEX
|
||||||
|
|
@ -131,7 +131,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
||||||
if not loc:
|
if not loc:
|
||||||
continue
|
continue
|
||||||
url = loc.group(1)
|
url = loc.group(1)
|
||||||
# Pomijamy strony taksonomii/nav — sceny to `/<slug>/` jednosegmentowe.
|
# Pomijamy strony taksonomii/nav, sceny to `/<slug>/` jednosegmentowe.
|
||||||
if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")):
|
if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")):
|
||||||
continue
|
continue
|
||||||
lm = _LASTMOD_RE.search(block)
|
lm = _LASTMOD_RE.search(block)
|
||||||
|
|
@ -189,7 +189,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
||||||
dsm = _DESC_RE.search(detail_html)
|
dsm = _DESC_RE.search(detail_html)
|
||||||
description = html.unescape(dsm.group(1)).strip() if dsm else None
|
description = html.unescape(dsm.group(1)).strip() if dsm else None
|
||||||
if description and description.strip().lower() == title.strip().lower():
|
if description and description.strip().lower() == title.strip().lower():
|
||||||
description = None # opis bywa kopią tytułu — nie duplikujemy
|
description = None # opis bywa kopią tytułu, nie duplikujemy
|
||||||
|
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_p: set[str] = set()
|
seen_p: set[str] = set()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
"""porndish.com — latest browse scraper via WordPress REST API.
|
"""porndish.com, latest browse scraper via WordPress REST API.
|
||||||
|
|
||||||
Historia: dawniej search scraper (`?s=`), zamarzł 2026-05-07 (search przestał dawać
|
Historia: dawniej search scraper (`?s=`), zamarzł 2026-05-07 (search przestał dawać
|
||||||
nowe sceny — 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
|
nowe sceny, 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
|
||||||
dociera, więc czysty kanał to REST API: `/wp-json/wp/v2/posts?_embed=1` daje
|
dociera, więc czysty kanał to REST API: `/wp-json/wp/v2/posts?_embed=1` daje
|
||||||
ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24
|
ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24
|
||||||
(ten sam wzorzec co perverzija).
|
(ten sam wzorzec co perverzija).
|
||||||
|
|
||||||
Z REST `_embed`: tytuł, data, miniatura (featured_media), STUDIO (taksonomia
|
Z REST `_embed`: tytuł, data, miniatura (featured_media), STUDIO (taksonomia
|
||||||
`category` — np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag` — porndish
|
`category`, np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag`, porndish
|
||||||
miesza w nich performerów z gatunkami, bierzemy jak jest; canonical-merge i tak
|
miesza w nich performerów z gatunkami, bierzemy jak jest; canonical-merge i tak
|
||||||
dorabia performerów z TPDB/StashDB, a tytuł ma nazwiska). Performerów osobno nie
|
dorabia performerów z TPDB/StashDB, a tytuł ma nazwiska). Performerów osobno nie
|
||||||
wyciągamy (post_tag ich nie rozdziela od gatunków bez listy known-performers).
|
wyciągamy (post_tag ich nie rozdziela od gatunków bez listy known-performers).
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""porndoe.com — latest-vids browse scraper.
|
"""porndoe.com, latest-vids browse scraper.
|
||||||
|
|
||||||
Dołączony 2026-05-21 (theporndude audit). Jedyny verified high-value candidate
|
Dołączony 2026-05-21 (theporndude audit). Jedyny verified high-value candidate
|
||||||
z 172 tube'ów na theporndude.com/top-porn-tube-sites + /full-porn-movies-sites.
|
z 172 tube'ów na theporndude.com/top-porn-tube-sites + /full-porn-movies-sites.
|
||||||
|
|
@ -11,7 +11,7 @@ Czemu wart: każda scena ma kompletny **JSON-LD VideoObject** schema:
|
||||||
|
|
||||||
To wystarczy do composite fuzzy match w resolverze (studio + performer Jaccard +
|
To wystarczy do composite fuzzy match w resolverze (studio + performer Jaccard +
|
||||||
date proximity + title token-set + duration). Phash hit-rate niski (porndoe robi
|
date proximity + title token-set + duration). Phash hit-rate niski (porndoe robi
|
||||||
własne crop-thumbnaile 390x219, nie hot-linkuje studio art) — ale rich metadata
|
własne crop-thumbnaile 390x219, nie hot-linkuje studio art), ale rich metadata
|
||||||
nadrabia, jak pornxp/porn00.
|
nadrabia, jak pornxp/porn00.
|
||||||
|
|
||||||
URL patterns:
|
URL patterns:
|
||||||
|
|
@ -21,7 +21,7 @@ URL patterns:
|
||||||
- Performer: `/pornstars-profile/<slug>`
|
- Performer: `/pornstars-profile/<slug>`
|
||||||
- Tags/categories: `/categories/<slug>`
|
- Tags/categories: `/categories/<slug>`
|
||||||
|
|
||||||
Playback: stream URL NIE jest inline w SSR HTML — player JS init dopiero po user
|
Playback: stream URL NIE jest inline w SSR HTML, player JS init dopiero po user
|
||||||
"Play" click. Dajemy playback_source z page_url + origin `tube:porndoecom`;
|
"Play" click. Dajemy playback_source z page_url + origin `tube:porndoecom`;
|
||||||
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
|
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
|
||||||
INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z
|
INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z
|
||||||
|
|
@ -51,7 +51,7 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BASE = "https://porndoe.com"
|
_BASE = "https://porndoe.com"
|
||||||
|
|
||||||
# Scene listing — `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
|
# Scene listing, `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
|
||||||
_SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE)
|
_SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE)
|
||||||
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
|
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ _TAG_LINK_RE = re.compile(
|
||||||
r'href="/category/\d+/([a-z0-9\-]+)"[^>]*>([^<]+)</a>', re.IGNORECASE
|
r'href="/category/\d+/([a-z0-9\-]+)"[^>]*>([^<]+)</a>', re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
# ISO 8601 duration — porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
|
# ISO 8601 duration, porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
|
||||||
_ISO_DUR_RE = re.compile(
|
_ISO_DUR_RE = re.compile(
|
||||||
r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE
|
r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
@ -237,7 +237,7 @@ class PornDoeScraper(BaseBrowseScraper):
|
||||||
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phash z thumbnail (porndoe robi własne crop-thumbnaile — niski hit-rate
|
# Phash z thumbnail (porndoe robi własne crop-thumbnaile, niski hit-rate
|
||||||
# oczekiwany, ale graceful: brak match → resolver spada do composite scoring).
|
# oczekiwany, ale graceful: brak match → resolver spada do composite scoring).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
|
|
@ -245,7 +245,7 @@ class PornDoeScraper(BaseBrowseScraper):
|
||||||
if ph:
|
if ph:
|
||||||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||||
|
|
||||||
# Playback — page_url do scena strony. Stream JS-rendered, więc extractor
|
# Playback, page_url do scena strony. Stream JS-rendered, więc extractor
|
||||||
# `porndoecom` → `_vps_blocked_fallback.extract` (mobile WebView scrape).
|
# `porndoecom` → `_vps_blocked_fallback.extract` (mobile WebView scrape).
|
||||||
playback_sources = [
|
playback_sources = [
|
||||||
RawPlaybackSource(
|
RawPlaybackSource(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""pornmike.com — browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
|
"""pornmike.com, browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
|
||||||
|
|
||||||
Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia
|
Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia
|
||||||
u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan).
|
u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan).
|
||||||
|
|
@ -6,7 +6,7 @@ Pomiar: 82% performerów ma u nas ref tpdb/stashdb, 19-20/20 kanałów znamy jak
|
||||||
|
|
||||||
Metadane w CAŁOŚCI z JSON-LD `@graph` → `ItemPage.mainEntity` (VideoObject): name,
|
Metadane w CAŁOŚCI z JSON-LD `@graph` → `ItemPage.mainEntity` (VideoObject): name,
|
||||||
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
|
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
|
||||||
`og:`, zero itemprop — więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
|
`og:`, zero itemprop, więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
|
||||||
jest przepisany pod SEO, patrz niżej).
|
jest przepisany pod SEO, patrz niżej).
|
||||||
|
|
||||||
**Świadome decyzje:**
|
**Świadome decyzje:**
|
||||||
|
|
@ -21,7 +21,7 @@ jest przepisany pod SEO, patrz niżej).
|
||||||
zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości.
|
zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości.
|
||||||
- Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2`
|
- Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2`
|
||||||
jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok
|
jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok
|
||||||
sidebara (stąd 62 linki, realnie 52 sceny) — dedup po external_id go pochłania.
|
sidebara (stąd 62 linki, realnie 52 sceny), dedup po external_id go pochłania.
|
||||||
- Ingest tylko EN (`/videos/`), pomijamy niemiecki mirror (`/de/filme/`).
|
- Ingest tylko EN (`/videos/`), pomijamy niemiecki mirror (`/de/filme/`).
|
||||||
|
|
||||||
**Uwaga jakościowa:** to KLIPY 5-12 min (mediana ~487 s) wobec 1800-2400 s w tubach,
|
**Uwaga jakościowa:** to KLIPY 5-12 min (mediana ~487 s) wobec 1800-2400 s w tubach,
|
||||||
|
|
@ -29,7 +29,7 @@ które przyjęliśmy. Dla ~20% katalogu (Tushy/Blacked Raw/Milfy/Anilos) będą
|
||||||
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
|
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
|
||||||
czyli realnie nowa podaż.
|
czyli realnie nowa podaż.
|
||||||
|
|
||||||
Playback: `<source>` direct mp4 (twincdn) — extractor `pornmike`, bez tokenu, bez
|
Playback: `<source>` direct mp4 (twincdn), extractor `pornmike`, bez tokenu, bez
|
||||||
Referera, przenośny cross-IP.
|
Referera, przenośny cross-IP.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""PornTrex.com — direct HTML scrape search results.
|
"""PornTrex.com, direct HTML scrape search results.
|
||||||
|
|
||||||
Search: `https://www.porntrex.com/search/<q>/` (single page, brak ?page=).
|
Search: `https://www.porntrex.com/search/<q>/` (single page, brak ?page=).
|
||||||
Scene URL: `https://www.porntrex.com/video/<id>/<slug>/`
|
Scene URL: `https://www.porntrex.com/video/<id>/<slug>/`
|
||||||
|
|
||||||
Porntrex pagination niespójne między widokami — używamy `?from=<offset>` gdy page>1.
|
Porntrex pagination niespójne między widokami, używamy `?from=<offset>` gdy page>1.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""porntrex.com — latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
|
"""porntrex.com, latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
|
||||||
|
|
||||||
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS — daje
|
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS, daje
|
||||||
pokrycie back-catalogu performerów. Ten browse dokłada gwarancję świeżości wprost
|
pokrycie back-catalogu performerów. Ten browse dokłada gwarancję świeżości wprost
|
||||||
z feedu `/latest-updates/<n>/` (próg watchdog 48h zamiast 168h, nie zależy od kolejki
|
z feedu `/latest-updates/<n>/` (próg watchdog 48h zamiast 168h, nie zależy od kolejki
|
||||||
performerów). Wzorzec jak xvideos (search + browse równolegle).
|
performerów). Wzorzec jak xvideos (search + browse równolegle).
|
||||||
|
|
@ -9,7 +9,7 @@ KVS listing tile:
|
||||||
<div ... data-item-id="<id>"><a href="https://www.porntrex.com/video/<id>/<slug>">
|
<div ... data-item-id="<id>"><a href="https://www.porntrex.com/video/<id>/<slug>">
|
||||||
<img data-src="//ptx.cdntrex.com/contents/.../300x168/1.jpg" alt="<Tytuł>">
|
<img data-src="//ptx.cdntrex.com/contents/.../300x168/1.jpg" alt="<Tytuł>">
|
||||||
<div class="duration">MM:SS</div>
|
<div class="duration">MM:SS</div>
|
||||||
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable) — bez zmian.
|
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable), bez zmian.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""pornxp.ph — latest-vids browse scraper.
|
"""pornxp.ph, latest-vids browse scraper.
|
||||||
|
|
||||||
URL patterns:
|
URL patterns:
|
||||||
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
|
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
|
||||||
URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs
|
URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs
|
||||||
`/videos/94528971837`) — **`data-id` (np. `94528971`) jest stable** i tego
|
`/videos/94528971837`), **`data-id` (np. `94528971`) jest stable** i tego
|
||||||
używamy dla external_id zamiast całego URL.
|
używamy dla external_id zamiast całego URL.
|
||||||
- Detail: `/videos/<id_with_suffix>`.
|
- Detail: `/videos/<id_with_suffix>`.
|
||||||
- Tags: `/tags/<URL-encoded-name>`. Trzy kategorie wnioskowane heurystyką
|
- Tags: `/tags/<URL-encoded-name>`. Trzy kategorie wnioskowane heurystyką
|
||||||
|
|
@ -15,10 +15,10 @@ Rich signals (perfekt dla canonical match scoring):
|
||||||
- Performers (z tags w `<div class="tags">`, Capital + space + Capital)
|
- Performers (z tags w `<div class="tags">`, Capital + space + Capital)
|
||||||
- Release year (regex `Released:` na detail page bodyText)
|
- Release year (regex `Released:` na detail page bodyText)
|
||||||
- Duration (`<div class="item_dur">MM:SS</div>` listing card)
|
- Duration (`<div class="item_dur">MM:SS</div>` listing card)
|
||||||
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`) — no hoster
|
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`), no hoster
|
||||||
- Animated preview (`data-preview="//t.porn-xp.com/.../<id>.mp4"`)
|
- Animated preview (`data-preview="//t.porn-xp.com/.../<id>.mp4"`)
|
||||||
|
|
||||||
Thumbnail: `<img class="item_img" src="/<id>.jpg">` — relatywny, pornxp's own CDN.
|
Thumbnail: `<img class="item_img" src="/<id>.jpg">`, relatywny, pornxp's own CDN.
|
||||||
Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical.
|
Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -45,10 +45,10 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BASE = "https://pornxp.ph"
|
_BASE = "https://pornxp.ph"
|
||||||
|
|
||||||
# Listing card — DOTALL bo HTML cards są wieloliniowe.
|
# Listing card, DOTALL bo HTML cards są wieloliniowe.
|
||||||
# Wariant 1 (eager): `<img class="item_img" src="/<id>.jpg">`
|
# Wariant 1 (eager): `<img class="item_img" src="/<id>.jpg">`
|
||||||
# Wariant 2 (lazy): `<img class="item_img lazy" src="/images/fluid_spinner.svg" data-src="/<id>.jpg">`
|
# Wariant 2 (lazy): `<img class="item_img lazy" src="/images/fluid_spinner.svg" data-src="/<id>.jpg">`
|
||||||
# Łapiemy obie warianty — w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
|
# Łapiemy obie warianty, w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
|
||||||
_LISTING_CARD_RE = re.compile(
|
_LISTING_CARD_RE = re.compile(
|
||||||
r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
|
r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
|
||||||
r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>'
|
r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>'
|
||||||
|
|
@ -61,7 +61,7 @@ _LISTING_CARD_RE = re.compile(
|
||||||
_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
|
_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
|
||||||
_IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', re.IGNORECASE)
|
_IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', re.IGNORECASE)
|
||||||
|
|
||||||
# Detail page — tags wrapper. Sometimes <div class="tags">, sometimes inline.
|
# Detail page, tags wrapper. Sometimes <div class="tags">, sometimes inline.
|
||||||
# Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie.
|
# Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie.
|
||||||
_DETAIL_TAGS_BLOCK_RE = re.compile(
|
_DETAIL_TAGS_BLOCK_RE = re.compile(
|
||||||
r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL,
|
r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL,
|
||||||
|
|
@ -71,8 +71,8 @@ _TAG_LINK_RE = re.compile(
|
||||||
)
|
)
|
||||||
_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE)
|
_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE)
|
||||||
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
|
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
|
||||||
# Direct mp4/m3u8 sources — preferujemy 720 nad 360. Format często protocol-relative:
|
# Direct mp4/m3u8 sources, preferujemy 720 nad 360. Format często protocol-relative:
|
||||||
# `<source src="//sv.porn-xp.com/.../720.mp4">` — normalize do `https://...` w consumerze.
|
# `<source src="//sv.porn-xp.com/.../720.mp4">`, normalize do `https://...` w consumerze.
|
||||||
_SOURCE_RE = re.compile(
|
_SOURCE_RE = re.compile(
|
||||||
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
|
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
@ -99,11 +99,11 @@ def _classify_tag(name: str) -> str:
|
||||||
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
|
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
|
||||||
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
|
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
|
||||||
- Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`)
|
- Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`)
|
||||||
- Tag/category: pozostałe — lowercase single word LUB Cap single word
|
- Tag/category: pozostałe, lowercase single word LUB Cap single word
|
||||||
(`oral`, `Lesbians`, `Incest`, `BBC`)
|
(`oral`, `Lesbians`, `Incest`, `BBC`)
|
||||||
|
|
||||||
Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag.
|
Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag.
|
||||||
To akceptowalne — composite score scoring tags ma niższą wagę niż studio match,
|
To akceptowalne, composite score scoring tags ma niższą wagę niż studio match,
|
||||||
więc fallback z 1+ performer match wystarczy.
|
więc fallback z 1+ performer match wystarczy.
|
||||||
"""
|
"""
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
|
|
@ -136,7 +136,7 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Cache listing card metadata per scene URL — populated w `_extract_scene_urls`,
|
# Cache listing card metadata per scene URL, populated w `_extract_scene_urls`,
|
||||||
# consumed w `_parse_detail`. Detail page sam nie ma `<div class="item_dur">`
|
# consumed w `_parse_detail`. Detail page sam nie ma `<div class="item_dur">`
|
||||||
# ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde
|
# ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde
|
||||||
# _extract_scene_urls override'uje).
|
# _extract_scene_urls override'uje).
|
||||||
|
|
@ -188,12 +188,12 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
||||||
# Listing-card meta (preferowane — detail page nie ma duration/thumb)
|
# Listing-card meta (preferowane, detail page nie ma duration/thumb)
|
||||||
meta = self._listing_cache.get(scene_url, {})
|
meta = self._listing_cache.get(scene_url, {})
|
||||||
data_id = meta.get("data_id")
|
data_id = meta.get("data_id")
|
||||||
if not data_id:
|
if not data_id:
|
||||||
# URL nie pasuje do listingu (random suffix mismatch po pagination redo).
|
# URL nie pasuje do listingu (random suffix mismatch po pagination redo).
|
||||||
# Wyciągnij data-id z URL: /videos/<id>... — pierwsze 8-10 cyfr.
|
# Wyciągnij data-id z URL: /videos/<id>..., pierwsze 8-10 cyfr.
|
||||||
id_match = re.search(r"/videos/(\d{6,12})", scene_url)
|
id_match = re.search(r"/videos/(\d{6,12})", scene_url)
|
||||||
data_id = id_match.group(1) if id_match else None
|
data_id = id_match.group(1) if id_match else None
|
||||||
|
|
||||||
|
|
@ -207,8 +207,8 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
duration_sec = meta.get("duration_sec")
|
duration_sec = meta.get("duration_sec")
|
||||||
thumb = meta.get("thumb")
|
thumb = meta.get("thumb")
|
||||||
|
|
||||||
# Release year — `Released: 2016`. RawScene ma `release_date` (typu `date`),
|
# Release year, `Released: 2016`. RawScene ma `release_date` (typu `date`),
|
||||||
# nie samo year — wpisujemy Jan 1 jako placeholder żeby resolver miał year
|
# nie samo year, wpisujemy Jan 1 jako placeholder żeby resolver miał year
|
||||||
# signal (date proximity scoring tylko sprawdza year w composite).
|
# signal (date proximity scoring tylko sprawdza year w composite).
|
||||||
release_date: date | None = None
|
release_date: date | None = None
|
||||||
if (m := _RELEASED_RE.search(detail_html)):
|
if (m := _RELEASED_RE.search(detail_html)):
|
||||||
|
|
@ -251,7 +251,7 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
tags.append(RawTag(external_id=ext_id, name=display, slug=slug))
|
tags.append(RawTag(external_id=ext_id, name=display, slug=slug))
|
||||||
|
|
||||||
# Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`.
|
# Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`.
|
||||||
# URL-e są protocol-relative — normalize do `https:`. Preferujemy 720 nad 360.
|
# URL-e są protocol-relative, normalize do `https:`. Preferujemy 720 nad 360.
|
||||||
def _norm(u: str) -> str:
|
def _norm(u: str) -> str:
|
||||||
return "https:" + u if u.startswith("//") else u
|
return "https:" + u if u.startswith("//") else u
|
||||||
|
|
||||||
|
|
@ -264,7 +264,7 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
break
|
break
|
||||||
stream_url = stream_url or all_sources[0]
|
stream_url = stream_url or all_sources[0]
|
||||||
|
|
||||||
# Phash z thumbnail (pornxp własny CDN — expected niski match rate, ale
|
# Phash z thumbnail (pornxp własny CDN, expected niski match rate, ale
|
||||||
# try). Reseter ścieżek do canonical odbędzie się głównie przez
|
# try). Reseter ścieżek do canonical odbędzie się głównie przez
|
||||||
# studio+performer+year+title scoring.
|
# studio+performer+year+title scoring.
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
|
|
@ -275,7 +275,7 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
# Normalize page_url: pornxp homepage serwuje random URL suffix per request
|
# Normalize page_url: pornxp homepage serwuje random URL suffix per request
|
||||||
# (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource
|
# (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource
|
||||||
# unique key to `(origin, page_url)` — bez normalize generujemy 3x duplikaty
|
# unique key to `(origin, page_url)`, bez normalize generujemy 3x duplikaty
|
||||||
# na każdym scrape run. Canonical URL = `/videos/<data_id>`.
|
# na każdym scrape run. Canonical URL = `/videos/<data_id>`.
|
||||||
canonical_url = (
|
canonical_url = (
|
||||||
f"{_BASE}/videos/{data_id}" if data_id else scene_url
|
f"{_BASE}/videos/{data_id}" if data_id else scene_url
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""sexu.com — browse scraper. Dodany 2026-07-27.
|
"""sexu.com, browse scraper. Dodany 2026-07-27.
|
||||||
|
|
||||||
Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen:
|
Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen:
|
||||||
obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X,
|
obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X,
|
||||||
|
|
@ -15,7 +15,7 @@ odrzuciłaby dobre dane.
|
||||||
1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych
|
1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych
|
||||||
tagów sceny tylko 5 (reszta to sidebar „popularne wyszukiwania"). Bez zawężenia
|
tagów sceny tylko 5 (reszta to sidebar „popularne wyszukiwania"). Bez zawężenia
|
||||||
do bloku „Tags:" wpisalibyśmy każdej scenie te same 20 śmieci.
|
do bloku „Tags:" wpisalibyśmy każdej scenie te same 20 śmieci.
|
||||||
2. **W bloku tagów siedzą też linki `/search?q=`** — to fraz-y pod SEO („pleases",
|
2. **W bloku tagów siedzą też linki `/search?q=`** to fraz-y pod SEO („pleases",
|
||||||
„hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
|
„hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
|
||||||
|
|
||||||
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
|
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
|
||||||
|
|
@ -25,7 +25,7 @@ bo performer w tabeli studios to zanieczyszczenie, nie dana.
|
||||||
Data jest względna („7 hours ago", „yesterday"), więc liczymy ją wstecz od teraz.
|
Data jest względna („7 hours ago", „yesterday"), więc liczymy ją wstecz od teraz.
|
||||||
To i tak data uploadu na tube, nie premiery studia.
|
To i tak data uploadu na tube, nie premiery studia.
|
||||||
|
|
||||||
Playback: `POST /api/video-info` (extractor `sexu`) — wymaga Bright Data, bo
|
Playback: `POST /api/video-info` (extractor `sexu`), wymaga Bright Data, bo
|
||||||
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
|
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -67,7 +67,7 @@ _BLOCK_END_RE = re.compile(r"_container|player-tags__title")
|
||||||
|
|
||||||
|
|
||||||
def _block(detail_html: str, label: str) -> str:
|
def _block(detail_html: str, label: str) -> str:
|
||||||
"""Fragment HTML należący do bloku `<label>:` — patrz pułapka 1 w docstringu."""
|
"""Fragment HTML należący do bloku `<label>:`, patrz pułapka 1 w docstringu."""
|
||||||
start = detail_html.find(f'player-tags__title">{label}:')
|
start = detail_html.find(f'player-tags__title">{label}:')
|
||||||
if start < 0:
|
if start < 0:
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"""shyfap.net — latest-vids browse scraper.
|
"""shyfap.net, latest-vids browse scraper.
|
||||||
|
|
||||||
Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail
|
Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail
|
||||||
page'u (meta tags + body links): title, studio, performers, tags, duration,
|
page'u (meta tags + body links): title, studio, performers, tags, duration,
|
||||||
description, upload_date, embed_url.
|
description, upload_date, embed_url.
|
||||||
|
|
||||||
Pierwszy pilot scrapera browse-mode (2026-05-12) — weryfikacja czy detail-page
|
Pierwszy pilot scrapera browse-mode (2026-05-12), weryfikacja czy detail-page
|
||||||
metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00,
|
metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00,
|
||||||
fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
|
fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
|
||||||
|
|
||||||
|
|
@ -37,7 +37,7 @@ _PORNSTAR_LINK_RE = re.compile(
|
||||||
_TAG_LINK_RE = re.compile(
|
_TAG_LINK_RE = re.compile(
|
||||||
r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE
|
r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE
|
||||||
)
|
)
|
||||||
# /video/<slug>_v<id>/ — id z URL używamy jako stable internal ID (np. w external_id),
|
# /video/<slug>_v<id>/ id z URL używamy jako stable internal ID (np. w external_id),
|
||||||
# nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL.
|
# nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL.
|
||||||
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
|
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
sitetag = "shyfapnet"
|
sitetag = "shyfapnet"
|
||||||
|
|
||||||
def _listing_url(self, page: int) -> str:
|
def _listing_url(self, page: int) -> str:
|
||||||
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk — sufiks `_1`
|
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk, sufiks `_1`
|
||||||
# zawsze, dodatkowy `/N/` dla pagination)
|
# zawsze, dodatkowy `/N/` dla pagination)
|
||||||
if page <= 1:
|
if page <= 1:
|
||||||
return f"{_BASE}/videos_1/"
|
return f"{_BASE}/videos_1/"
|
||||||
|
|
@ -108,7 +108,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
if not embed_url and internal_id:
|
if not embed_url and internal_id:
|
||||||
embed_url = f"{_BASE}/embed/{internal_id}"
|
embed_url = f"{_BASE}/embed/{internal_id}"
|
||||||
|
|
||||||
# Studio — pierwszy `/studio/<slug>_s<id>/` link na stronie
|
# Studio, pierwszy `/studio/<slug>_s<id>/` link na stronie
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
m_studio = _STUDIO_LINK_RE.search(detail_html)
|
m_studio = _STUDIO_LINK_RE.search(detail_html)
|
||||||
if m_studio:
|
if m_studio:
|
||||||
|
|
@ -119,7 +119,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
slug=slug,
|
slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Performers — wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
|
# Performers, wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_perf: set[str] = set()
|
seen_perf: set[str] = set()
|
||||||
for m_p in _PORNSTAR_LINK_RE.finditer(detail_html):
|
for m_p in _PORNSTAR_LINK_RE.finditer(detail_html):
|
||||||
|
|
@ -134,7 +134,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tags — wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
|
# Tags, wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
seen_tag: set[str] = set()
|
seen_tag: set[str] = set()
|
||||||
for m_t in _TAG_LINK_RE.finditer(detail_html):
|
for m_t in _TAG_LINK_RE.finditer(detail_html):
|
||||||
|
|
@ -146,8 +146,8 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug)
|
RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Playback source — embed_url (mobile WebView fallback). Stream extraction
|
# Playback source, embed_url (mobile WebView fallback). Stream extraction
|
||||||
# przez app/extractors/__init__.py wymaga osobnego registry entry — dla
|
# przez app/extractors/__init__.py wymaga osobnego registry entry, dla
|
||||||
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
|
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
|
||||||
playback_sources = [
|
playback_sources = [
|
||||||
RawPlaybackSource(
|
RawPlaybackSource(
|
||||||
|
|
@ -161,7 +161,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
|
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
|
||||||
# Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny.
|
# Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny.
|
||||||
# Niezależne od shyfap title-rebrandingu — bierze się z frame'u sceny.
|
# Niezależne od shyfap title-rebrandingu, bierze się z frame'u sceny.
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""siska.video — latest-vids browse scraper.
|
"""siska.video, latest-vids browse scraper.
|
||||||
|
|
||||||
Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła
|
Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła
|
||||||
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest
|
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
"""superporn.com — latest-vids browse scraper (przez Bright Data ISP proxy).
|
"""superporn.com, latest-vids browse scraper (przez Bright Data ISP proxy).
|
||||||
|
|
||||||
Dołączony 2026-06-10 (user request). superporn twardo blokuje VPS IP Cloudflarem
|
Dołączony 2026-06-10 (user request). superporn twardo blokuje VPS IP Cloudflarem
|
||||||
(403 na KAŻDEJ impersonacji TLS — chrome/safari/firefox), więc ingest HTML idzie
|
(403 na KAŻDEJ impersonacji TLS, chrome/safari/firefox), więc ingest HTML idzie
|
||||||
przez Bright Data ISP proxy (`settings.brightdata_proxy_url`). Gdy proxy
|
przez Bright Data ISP proxy (`settings.brightdata_proxy_url`). Gdy proxy
|
||||||
nieskonfigurowane → scraper no-op (pusty iterator, log warning).
|
nieskonfigurowane → scraper no-op (pusty iterator, log warning).
|
||||||
|
|
||||||
Proxy używamy TYLKO do scrape HTML. Playback NIE idzie przez proxy: `<source>` mp4
|
Proxy używamy TYLKO do scrape HTML. Playback NIE idzie przez proxy: `<source>` mp4
|
||||||
(cdnst*.superporn.com) ma token IP-bound do fetchera (403 cross-IP), więc resolve
|
(cdnst*.superporn.com) ma token IP-bound do fetchera (403 cross-IP), więc resolve
|
||||||
musi nastąpić po stronie telefonu — extractor `superporncom` → `_vps_blocked_fallback`
|
musi nastąpić po stronie telefonu, extractor `superporncom` → `_vps_blocked_fallback`
|
||||||
(mobile WebView ładuje stronę z residential IP telefonu, INJECTED_JS bierze video.src).
|
(mobile WebView ładuje stronę z residential IP telefonu, INJECTED_JS bierze video.src).
|
||||||
Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa).
|
Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa).
|
||||||
|
|
||||||
Sygnały (SSR HTML):
|
Sygnały (SSR HTML):
|
||||||
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl, duration
|
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl, duration
|
||||||
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer — pomijamy).
|
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer, pomijamy).
|
||||||
- Performerzy: chip `<a class="chip-link" href=".../pornstar/<slug>"><span>Name</span>`
|
- Performerzy: chip `<a class="chip-link" href=".../pornstar/<slug>"><span>Name</span>`
|
||||||
- Kategorie: chip `<a class="chip-link" href="/<slug>"><span>Name</span>` (bez
|
- Kategorie: chip `<a class="chip-link" href="/<slug>"><span>Name</span>` (bez
|
||||||
`/pornstar/`) — w bloku `#collapse-categories`.
|
`/pornstar/`), w bloku `#collapse-categories`.
|
||||||
- Duration backup: `<video ... data-video-duration="2328">` (sekundy).
|
- Duration backup: `<video ... data-video-duration="2328">` (sekundy).
|
||||||
|
|
||||||
Listing: `?page=N` (newest-first; sitemap lastmod jest z 2024, bezużyteczny).
|
Listing: `?page=N` (newest-first; sitemap lastmod jest z 2024, bezużyteczny).
|
||||||
|
|
@ -69,7 +69,7 @@ _ISO_DUR_FULL_RE = re.compile(
|
||||||
|
|
||||||
def _clean(text: str) -> str:
|
def _clean(text: str) -> str:
|
||||||
"""superporn dwukrotnie HTML-koduje encje w JSON-LD (`&#39;` zamiast `'`).
|
"""superporn dwukrotnie HTML-koduje encje w JSON-LD (`&#39;` zamiast `'`).
|
||||||
Unescape iteracyjnie (max 2) — bez over-decode dla zwykłych nazw."""
|
Unescape iteracyjnie (max 2), bez over-decode dla zwykłych nazw."""
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
new = html.unescape(text)
|
new = html.unescape(text)
|
||||||
if new == text:
|
if new == text:
|
||||||
|
|
@ -95,7 +95,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Bright Data ISP proxy — bez niego superporn jest nieosiągalny z VPS.
|
# Bright Data ISP proxy, bez niego superporn jest nieosiągalny z VPS.
|
||||||
self._proxy = get_settings().brightdata_proxy_url
|
self._proxy = get_settings().brightdata_proxy_url
|
||||||
if not self._proxy:
|
if not self._proxy:
|
||||||
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
|
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
|
||||||
|
|
@ -106,7 +106,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
||||||
return f"{_BASE}/?page={page}"
|
return f"{_BASE}/?page={page}"
|
||||||
|
|
||||||
def crawl_page(self, page: int):
|
def crawl_page(self, page: int):
|
||||||
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403) — sygnalizuj
|
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403), sygnalizuj
|
||||||
# "exhausted" (pusta lista), żeby deep-crawl nie retry'ował w kółko.
|
# "exhausted" (pusta lista), żeby deep-crawl nie retry'ował w kółko.
|
||||||
if not self._proxy:
|
if not self._proxy:
|
||||||
return []
|
return []
|
||||||
|
|
@ -164,7 +164,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
||||||
seen_tag.add(slug)
|
seen_tag.add(slug)
|
||||||
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
|
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
|
||||||
|
|
||||||
# Phash z thumbnaila — proxy NIE potrzebny (img*.superporn.com działa z VPS).
|
# Phash z thumbnaila, proxy NIE potrzebny (img*.superporn.com działa z VPS).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""supjav.com — JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
|
"""supjav.com, JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
|
||||||
|
|
||||||
Osobna pula JAV (sekcja JAV), origin `tube:supjav` w JAV_ORIGINS. supjav agreguje
|
Osobna pula JAV (sekcja JAV), origin `tube:supjav` w JAV_ORIGINS. supjav agreguje
|
||||||
embedy (serwery TV/FST/ST/VOE), gdzie realny hoster jest schowany za `data-link`
|
embedy (serwery TV/FST/ST/VOE), gdzie realny hoster jest schowany za `data-link`
|
||||||
|
|
@ -10,7 +10,7 @@ Browse (RE 2026-07-10):
|
||||||
- karty: `<div class="post"><a href="/<id>.html" title="<title>" rel="bookmark"
|
- karty: `<div class="post"><a href="/<id>.html" title="<title>" rel="bookmark"
|
||||||
class="img"><img src=".../images/YYYY/MM/<CODE>.jpg!320x216.jpg" class="thumb">`.
|
class="img"><img src=".../images/YYYY/MM/<CODE>.jpg!320x216.jpg" class="thumb">`.
|
||||||
- Metadane bierzemy Z KARTY (tytuł/thumb/URL/kod JAV/data z roku-miesiąca ścieżki
|
- Metadane bierzemy Z KARTY (tytuł/thumb/URL/kod JAV/data z roku-miesiąca ścieżki
|
||||||
thumb), NIE z detalu — detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
|
thumb), NIE z detalu, detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
|
||||||
per-post fetch byłby wolny i lossy. Studio/tagi (poza kodem) dorabia enrich później.
|
per-post fetch byłby wolny i lossy. Studio/tagi (poza kodem) dorabia enrich później.
|
||||||
|
|
||||||
supjav blokuje VPS IP twardym CF → listing idzie przez Bright Data ISP proxy z retry
|
supjav blokuje VPS IP twardym CF → listing idzie przez Bright Data ISP proxy z retry
|
||||||
|
|
@ -36,7 +36,7 @@ _BASE = "https://supjav.com"
|
||||||
# atrybutu nie zawiera surowego "), więc tytuły z `>` (np. `<<3-Day Offer>>`) przechodzą.
|
# atrybutu nie zawiera surowego "), więc tytuły z `>` (np. `<<3-Day Offer>>`) przechodzą.
|
||||||
# Link-obraz karty: <a href="URL" ... title="TITLE"><img src|data-original="THUMB">.
|
# Link-obraz karty: <a href="URL" ... title="TITLE"><img src|data-original="THUMB">.
|
||||||
# supjav miesza kolejność atrybutów (href/rel/class="img"/title w dwóch wariantach), więc
|
# supjav miesza kolejność atrybutów (href/rel/class="img"/title w dwóch wariantach), więc
|
||||||
# NIE wymagamy pozycji class="img" — wymóg `>\s*<img` i tak izoluje link-obraz od
|
# NIE wymagamy pozycji class="img", wymóg `>\s*<img` i tak izoluje link-obraz od
|
||||||
# link-tekstu (ten drugi po `>` ma tekst, nie <img>). Thumb eager `src=` lub lazy
|
# link-tekstu (ten drugi po `>` ma tekst, nie <img>). Thumb eager `src=` lub lazy
|
||||||
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
|
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
|
||||||
_CARD_RE = re.compile(
|
_CARD_RE = re.compile(
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""sxyland.com — performer-page scrape (search-based, performer-driven).
|
"""sxyland.com, performer-page scrape (search-based, performer-driven).
|
||||||
|
|
||||||
2026-06-16 fix (zamrożony od 06-07): sxyland porzucił URL scen `/<numeric_id>/<slug>/`
|
2026-06-16 fix (zamrożony od 06-07): sxyland porzucił URL scen `/<numeric_id>/<slug>/`
|
||||||
na rzecz `/<slug>/`, więc stary regex (wymagał cyfry w ścieżce) dawał 0. WordPress `?s=`
|
na rzecz `/<slug>/`, więc stary regex (wymagał cyfry w ścieżce) dawał 0. WordPress `?s=`
|
||||||
filtruje, ale miesza — czystsze są **strony performera** `/actor/<slug>/`
|
filtruje, ale miesza, czystsze są **strony performera** `/actor/<slug>/`
|
||||||
(performer-driven query = nazwa performera → slugify → /actor/<slug>/).
|
(performer-driven query = nazwa performera → slugify → /actor/<slug>/).
|
||||||
|
|
||||||
Bogate metadane (per-scene detail fetch — sxyland to WP tube, taksonomie na scenie):
|
Bogate metadane (per-scene detail fetch, sxyland to WP tube, taksonomie na scenie):
|
||||||
- performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`)
|
- performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`)
|
||||||
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
|
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
|
||||||
- studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio
|
- studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio
|
||||||
|
|
@ -181,7 +181,7 @@ class SxyLandScraper(BaseDirectTubeScraper):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tagi — TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
|
# Tagi, TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
seen_tag: set[str] = set()
|
seen_tag: set[str] = set()
|
||||||
block_m = _TAGS_BLOCK_RE.search(detail)
|
block_m = _TAGS_BLOCK_RE.search(detail)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""sxyprn.com — performer-page scrape (search-based, performer-driven).
|
"""sxyprn.com, performer-page scrape (search-based, performer-driven).
|
||||||
|
|
||||||
2026-06-16 fix (zamrożony od 05-07): sxyprn NIE ma free-text searcha. Stary endpoint
|
2026-06-16 fix (zamrożony od 05-07): sxyprn NIE ma free-text searcha. Stary endpoint
|
||||||
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
|
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
|
||||||
|
|
@ -42,19 +42,19 @@ log = logging.getLogger(__name__)
|
||||||
_BASE = "https://sxyprn.com"
|
_BASE = "https://sxyprn.com"
|
||||||
|
|
||||||
# js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera)
|
# js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera)
|
||||||
# + aria-label (połamani performerzy — nieużywane).
|
# + aria-label (połamani performerzy, nieużywane).
|
||||||
_CARD_ANCHOR_RE = re.compile(
|
_CARD_ANCHOR_RE = re.compile(
|
||||||
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
|
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc
|
# Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc
|
||||||
# NIE używamy `[^>]*` (łamie się na tym `>`) — leniwe `.*?` do wartości HH:MM:SS.
|
# NIE używamy `[^>]*` (łamie się na tym `>`), leniwe `.*?` do wartości HH:MM:SS.
|
||||||
_DURATION_RE = re.compile(
|
_DURATION_RE = re.compile(
|
||||||
r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL
|
r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL
|
||||||
)
|
)
|
||||||
_SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE)
|
_SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE)
|
||||||
_THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", re.IGNORECASE)
|
_THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", re.IGNORECASE)
|
||||||
# post_time z tytułem — keyowane po ID, bo leży w post_control PO vid_container.
|
# post_time z tytułem, keyowane po ID, bo leży w post_control PO vid_container.
|
||||||
_POSTTIME_RE = re.compile(
|
_POSTTIME_RE = re.compile(
|
||||||
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
|
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
@ -89,7 +89,7 @@ def _clean_title(raw: str) -> tuple[str, list[str]]:
|
||||||
|
|
||||||
class SxyPrnScraper(BaseSearchScraper):
|
class SxyPrnScraper(BaseSearchScraper):
|
||||||
sitetag = "sxyprncom"
|
sitetag = "sxyprncom"
|
||||||
# _search_url_template/_scene_url_re z bazy nieużywane — mamy custom search().
|
# _search_url_template/_scene_url_re z bazy nieużywane, mamy custom search().
|
||||||
_card_window = 2600 # od js-pop anchora do post_control z tytułem
|
_card_window = 2600 # od js-pop anchora do post_control z tytułem
|
||||||
|
|
||||||
def _performer_path(self, query: str) -> str:
|
def _performer_path(self, query: str) -> str:
|
||||||
|
|
@ -131,7 +131,7 @@ class SxyPrnScraper(BaseSearchScraper):
|
||||||
continue
|
continue
|
||||||
title, tags = _clean_title(tm.group(1))
|
title, tags = _clean_title(tm.group(1))
|
||||||
title_l = title.lower()
|
title_l = title.lower()
|
||||||
# Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule) —
|
# Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule),
|
||||||
# chroni przed śmieciem na stronie i przed over-attribution.
|
# chroni przed śmieciem na stronie i przed over-attribution.
|
||||||
if query_tokens and not all(tok in title_l for tok in query_tokens):
|
if query_tokens and not all(tok in title_l for tok in query_tokens):
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
"""vjav.com — JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
|
"""vjav.com, JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
|
||||||
|
|
||||||
Osobna pula JAV (tytuły azjatyckie + `title_jp`, studia typu JAVHD), origin `tube:vjav`.
|
Osobna pula JAV (tytuły azjatyckie + `title_jp`, studia typu JAVHD), origin `tube:vjav`.
|
||||||
Świadomie orphan vertical (sekcja JAV) — NIE deduplikuje się z zachodnim katalogiem.
|
Świadomie orphan vertical (sekcja JAV), NIE deduplikuje się z zachodnim katalogiem.
|
||||||
|
|
||||||
vjav to SPA (ktk_player + blob) — listing renderuje się JS-owo, więc HTML listingu
|
vjav to SPA (ktk_player + blob), listing renderuje się JS-owo, więc HTML listingu
|
||||||
nie ma linków do scen. Zamiast tego:
|
nie ma linków do scen. Zamiast tego:
|
||||||
- **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest
|
- **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest
|
||||||
= najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N =
|
= najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N =
|
||||||
kolejny wycinek PER_PAGE.
|
kolejny wycinek PER_PAGE.
|
||||||
- **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json`
|
- **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json`
|
||||||
(floor = id//1000*1000) — title (+title_jp), duration, post_date, channel.title
|
(floor = id//1000*1000), title (+title_jp), duration, post_date, channel.title
|
||||||
(studio), models{} (performerki), categories{}+tags{}.
|
(studio), models{} (performerki), categories{}+tags{}.
|
||||||
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
|
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
|
||||||
get_file HLS). RE 2026-07-10.
|
get_file HLS). RE 2026-07-10.
|
||||||
|
|
@ -62,7 +62,7 @@ def _parse_duration(value: str | None) -> int | None:
|
||||||
# --- bramka jakości tytułu ----------------------------------------------------------
|
# --- bramka jakości tytułu ----------------------------------------------------------
|
||||||
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
|
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
|
||||||
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
|
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
|
||||||
# w apce nieodnajdywalne — nie mają ani tytułu, ani performera, po którym da się trafić.
|
# w apce nieodnajdywalne, nie mają ani tytułu, ani performera, po którym da się trafić.
|
||||||
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
|
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
|
||||||
# bo sprawdzała tylko jeden z czterech wzorców).
|
# bo sprawdzała tylko jeden z czterech wzorców).
|
||||||
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
|
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
|
||||||
|
|
@ -74,7 +74,7 @@ def _has_cjk(text: str) -> bool:
|
||||||
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
|
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
|
||||||
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało
|
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało
|
||||||
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
|
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
|
||||||
wyszła przy pomiarze pierwszej wersji bramki — bez tego wyjątku poszłoby 708 z 708
|
wyszła przy pomiarze pierwszej wersji bramki, bez tego wyjątku poszłoby 708 z 708
|
||||||
tytułów CJK z próbki."""
|
tytułów CJK z próbki."""
|
||||||
return sum(1 for c in text if " " <= c <= "鿿" or "" <= c <= "") >= 3
|
return sum(1 for c in text if " " <= c <= "鿿" or "" <= c <= "") >= 3
|
||||||
|
|
||||||
|
|
@ -119,7 +119,7 @@ class VjavScraper(BaseBrowseScraper):
|
||||||
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
|
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
|
||||||
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
|
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
|
||||||
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
|
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
|
||||||
# działania, nie optymalizacją — bez klucza scraper się wyłącza.
|
# działania, nie optymalizacją, bez klucza scraper się wyłącza.
|
||||||
self._proxy = get_settings().brightdata_proxy_url
|
self._proxy = get_settings().brightdata_proxy_url
|
||||||
if not self._proxy:
|
if not self._proxy:
|
||||||
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
|
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
|
||||||
|
|
@ -130,7 +130,7 @@ class VjavScraper(BaseBrowseScraper):
|
||||||
zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
|
zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
|
||||||
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
|
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
|
||||||
wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na
|
wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na
|
||||||
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx —
|
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx,
|
||||||
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
|
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
|
||||||
try:
|
try:
|
||||||
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
|
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
|
||||||
|
|
@ -238,7 +238,7 @@ class VjavScraper(BaseBrowseScraper):
|
||||||
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec
|
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec
|
||||||
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
|
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
|
||||||
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
|
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
|
||||||
kończyło CAŁY run — źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
|
kończyło CAŁY run, źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
|
||||||
2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`.
|
2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`.
|
||||||
|
|
||||||
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
|
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
|
||||||
|
|
@ -268,7 +268,7 @@ class VjavScraper(BaseBrowseScraper):
|
||||||
out: list[RawScene] = []
|
out: list[RawScene] = []
|
||||||
for vid in chunk:
|
for vid in chunk:
|
||||||
try:
|
try:
|
||||||
# Proxy także tutaj — API metadanych siedzi na tym samym hoście, więc
|
# Proxy także tutaj, API metadanych siedzi na tym samym hoście, więc
|
||||||
# obejmuje je ten sam 429 co sitemap.
|
# obejmuje je ten sam 429 co sitemap.
|
||||||
res = browser_get(
|
res = browser_get(
|
||||||
self._meta_url(vid),
|
self._meta_url(vid),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""watchporn.to — browse scraper (KVS engine). Re-enabled 2026-07-02.
|
"""watchporn.to, browse scraper (KVS engine). Re-enabled 2026-07-02.
|
||||||
|
|
||||||
Był search-scraperem (`?s=`), zamarzł, a potem site przebudowano na KVS z nowym
|
Był search-scraperem (`?s=`), zamarzł, a potem site przebudowano na KVS z nowym
|
||||||
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął — teraz KVS
|
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął, teraz KVS
|
||||||
flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound).
|
flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound).
|
||||||
|
|
||||||
Browse `/latest-updates/` → detail page:
|
Browse `/latest-updates/` → detail page:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""XHamster.com — direct HTML scrape search results.
|
"""XHamster.com, direct HTML scrape search results.
|
||||||
|
|
||||||
Search: `https://xhamster.com/search/<q>?page=<n>`
|
Search: `https://xhamster.com/search/<q>?page=<n>`
|
||||||
Scene URL: `https://xhamster.com/videos/<slug>-<id>`
|
Scene URL: `https://xhamster.com/videos/<slug>-<id>`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""xmoviesforyou.com — direct HTML scrape.
|
"""xmoviesforyou.com, direct HTML scrape.
|
||||||
|
|
||||||
Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination).
|
Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination).
|
||||||
Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment).
|
Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment).
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""XNXX.com — direct HTML scrape search results.
|
"""XNXX.com, direct HTML scrape search results.
|
||||||
|
|
||||||
Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed)
|
Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed)
|
||||||
Scene URL: `https://www.xnxx.com/video-<id>/<slug>`
|
Scene URL: `https://www.xnxx.com/video-<id>/<slug>`
|
||||||
|
|
@ -12,7 +12,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
|
||||||
|
|
||||||
class XnxxScraper(BaseSearchScraper):
|
class XnxxScraper(BaseSearchScraper):
|
||||||
sitetag = "xnxxcom"
|
sitetag = "xnxxcom"
|
||||||
# `/<page-1>` — handle override in search() by replacing {page}.
|
# `/<page-1>`, handle override in search() by replacing {page}.
|
||||||
_search_url_template = "https://www.xnxx.com/search/{query}/{page}"
|
_search_url_template = "https://www.xnxx.com/search/{query}/{page}"
|
||||||
_scene_url_re = re.compile(
|
_scene_url_re = re.compile(
|
||||||
r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"',
|
r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""xnxx.com — BROWSE scraper (JSON-LD), obok search scrapera.
|
"""xnxx.com, BROWSE scraper (JSON-LD), obok search scrapera.
|
||||||
|
|
||||||
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail) — i TYLKO to
|
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail), i TYLKO to
|
||||||
bierzemy. Mimo wspólnego silnika z xvideos, xnxx detail NIE wystawia w SSR linków
|
bierzemy. Mimo wspólnego silnika z xvideos, xnxx detail NIE wystawia w SSR linków
|
||||||
`/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia
|
`/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia
|
||||||
canonical-merge + istniejący performer-search. XnxxScraper (search) zostaje; browse
|
canonical-merge + istniejący performer-search. XnxxScraper (search) zostaje; browse
|
||||||
|
|
@ -8,13 +8,13 @@ dokłada sygnał świeżości. Tytuł z JSON-LD bywa HTML-encoded (`,`/`&e
|
||||||
html.unescape.
|
html.unescape.
|
||||||
|
|
||||||
Listing: xnxx NIE ma czystego SSR `/new/` (404), ale `/best/<YYYY-MM>/<page>` jest
|
Listing: xnxx NIE ma czystego SSR `/new/` (404), ale `/best/<YYYY-MM>/<page>` jest
|
||||||
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc — pokrywa świeży
|
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc, pokrywa świeży
|
||||||
content (sortowanie best-of-month, nie ściśle chronologiczne, ale dla sygnału
|
content (sortowanie best-of-month, nie ściśle chronologiczne, ale dla sygnału
|
||||||
świeżości wystarcza; ścisłą chronologię i tak daje performer-search). Homepage
|
świeżości wystarcza; ścisłą chronologię i tak daje performer-search). Homepage
|
||||||
xnxx jest JS-renderowany (0 linków w surowym HTML), stąd /best/.
|
xnxx jest JS-renderowany (0 linków w surowym HTML), stąd /best/.
|
||||||
|
|
||||||
Playback bez zmian (extractor `xnxxcom`). Phash pominięty (xnxx crop-thumbnaile,
|
Playback bez zmian (extractor `xnxxcom`). Phash pominięty (xnxx crop-thumbnaile,
|
||||||
0% hit do canonical — jak xvideos).
|
0% hit do canonical, jak xvideos).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ class XnxxBrowseScraper(BaseBrowseScraper):
|
||||||
if isinstance(thumbnail_url, list):
|
if isinstance(thumbnail_url, list):
|
||||||
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
|
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
|
||||||
|
|
||||||
# performers/tags puste — xnxx detail nie ma ich w SSR (patrz docstring).
|
# performers/tags puste, xnxx detail nie ma ich w SSR (patrz docstring).
|
||||||
return RawScene(
|
return RawScene(
|
||||||
external_id=f"{self.sitetag}:{scene_url}",
|
external_id=f"{self.sitetag}:{scene_url}",
|
||||||
title=title,
|
title=title,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""XVideos.com — direct HTML scrape search results.
|
"""XVideos.com, direct HTML scrape search results.
|
||||||
|
|
||||||
Search: `https://www.xvideos.com/?k=<q>&p=<page-1>` (xvideos używa 0-indexed pages)
|
Search: `https://www.xvideos.com/?k=<q>&p=<page-1>` (xvideos używa 0-indexed pages)
|
||||||
Scene URL: `https://www.xvideos.com/video<digits>/<slug>`
|
Scene URL: `https://www.xvideos.com/video<digits>/<slug>`
|
||||||
|
|
@ -13,7 +13,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
|
||||||
|
|
||||||
class XVideosScraper(BaseSearchScraper):
|
class XVideosScraper(BaseSearchScraper):
|
||||||
sitetag = "xvideoscom"
|
sitetag = "xvideoscom"
|
||||||
# 0-indexed page — w base classie computed jako `page=N`, więc override _build_url.
|
# 0-indexed page, w base classie computed jako `page=N`, więc override _build_url.
|
||||||
_search_url_template = "https://www.xvideos.com/?k={query}&p={page}"
|
_search_url_template = "https://www.xvideos.com/?k={query}&p={page}"
|
||||||
_scene_url_re = re.compile(
|
_scene_url_re = re.compile(
|
||||||
r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"',
|
r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"',
|
||||||
|
|
@ -21,10 +21,10 @@ class XVideosScraper(BaseSearchScraper):
|
||||||
)
|
)
|
||||||
|
|
||||||
def search(self, query, *, page=1, limit=None):
|
def search(self, query, *, page=1, limit=None):
|
||||||
# XVideos używa 0-indexed pages — `page=1` w API → `&p=0` w URL.
|
# XVideos używa 0-indexed pages, `page=1` w API → `&p=0` w URL.
|
||||||
# Override żeby base class fetch'nął zewnętrzny URL z (page-1).
|
# Override żeby base class fetch'nął zewnętrzny URL z (page-1).
|
||||||
# Najprościej: dostosujmy URL w override przed wywołaniem super().search().
|
# Najprościej: dostosujmy URL w override przed wywołaniem super().search().
|
||||||
# Ale super() używa self._search_url_template — robimy clone z poprawionym page.
|
# Ale super() używa self._search_url_template, robimy clone z poprawionym page.
|
||||||
original = self._search_url_template
|
original = self._search_url_template
|
||||||
self._search_url_template = original.replace("{page}", str(page - 1))
|
self._search_url_template = original.replace("{page}", str(page - 1))
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"""xvideos.com — deep-crawl browse scraper (JSON-LD + page-parse).
|
"""xvideos.com, deep-crawl browse scraper (JSON-LD + page-parse).
|
||||||
|
|
||||||
xvideos SSR-uje JSON-LD VideoObject (duration, name, uploadDate) ORAZ na detail-stronie
|
xvideos SSR-uje JSON-LD VideoObject (duration, name, uploadDate) ORAZ na detail-stronie
|
||||||
linki `/models/<slug>` (performerzy tej sceny) + `/tags/<slug>` (tagi). Sample 2026-06-03
|
linki `/models/<slug>` (performerzy tej sceny) + `/tags/<slug>` (tagi). Sample 2026-06-03
|
||||||
(15 scen): median ~10.5min, 93% ≥3min — dobry full-scene content (nie trailery).
|
(15 scen): median ~10.5min, 93% ≥3min, dobry full-scene content (nie trailery).
|
||||||
|
|
||||||
Mega-katalog (~13M) → deep_crawl z per-tube page-cap (xvideoscom w deep_crawl._PAGE_CAP),
|
Mega-katalog (~13M) → deep_crawl z per-tube page-cap (xvideoscom w deep_crawl._PAGE_CAP),
|
||||||
żeby nie monopolizował round-robin ani nie zalał bazy. Listing: /new/<page> (newest).
|
żeby nie monopolizował round-robin ani nie zalał bazy. Listing: /new/<page> (newest).
|
||||||
Scene: /video.<hash>/<slug>. Playback: page_url + origin tube:xvideoscom (istniejący
|
Scene: /video.<hash>/<slug>. Playback: page_url + origin tube:xvideoscom (istniejący
|
||||||
extractor `xvideoscom` resolvuje stream mobile-side). Phash pominięty (xvideos robi
|
extractor `xvideoscom` resolvuje stream mobile-side). Phash pominięty (xvideos robi
|
||||||
własne crop-thumbnaile — 0% hit do canonical, jak fullmovies/hdporn).
|
własne crop-thumbnaile, 0% hit do canonical, jak fullmovies/hdporn).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ _JSONLD_RE = re.compile(
|
||||||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
|
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
|
||||||
)
|
)
|
||||||
# Model anchor: `<a href="/models/<slug>" ...><span ...></span><span class="name">Display Name</span>...`.
|
# Model anchor: `<a href="/models/<slug>" ...><span ...></span><span class="name">Display Name</span>...`.
|
||||||
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora —
|
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora,
|
||||||
# poprzedni wzorzec `>([^<]{2,60})</a>` wymagał tekstu zaraz po `>` i NIGDY nie pasował do
|
# poprzedni wzorzec `>([^<]{2,60})</a>` wymagał tekstu zaraz po `>` i NIGDY nie pasował do
|
||||||
# obecnego markupu xvideos → wszystkie browse-scraped sceny xvideos lądowały z 0 performerami
|
# obecnego markupu xvideos → wszystkie browse-scraped sceny xvideos lądowały z 0 performerami
|
||||||
# (bug-report 2026-06-07 "czemu nie ma aktorów, są na stronie"). `(?:(?!</a>).)*?` trzyma match
|
# (bug-report 2026-06-07 "czemu nie ma aktorów, są na stronie"). `(?:(?!</a>).)*?` trzyma match
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""xxxfree.watch — direct HTML scrape.
|
"""xxxfree.watch, direct HTML scrape.
|
||||||
|
|
||||||
Domain: `xxxfree.watch` (sitetag `xxxfreewatch` is legacy from porn-app DEFAULT_SITETAGS).
|
Domain: `xxxfree.watch` (sitetag `xxxfreewatch` is legacy from porn-app DEFAULT_SITETAGS).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
"""yesporn.vip — latest-vids browse scraper.
|
"""yesporn.vip, latest-vids browse scraper.
|
||||||
|
|
||||||
Dołączony 2026-05-27. Identyfikowany przez user audit jako "scraper-of-paysites"
|
Dołączony 2026-05-27. Identyfikowany przez user audit jako "scraper-of-paysites"
|
||||||
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
|
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
|
||||||
audit pomylił domeny: `yespornvip.com` (z theporndude rankingu) redirectuje przez
|
audit pomylił domeny: `yespornvip.com` (z theporndude rankingu) redirectuje przez
|
||||||
pdude.link do `porndudecams.com` affiliate spam — kanoniczna domena ma TLD `.vip`.
|
pdude.link do `porndudecams.com` affiliate spam, kanoniczna domena ma TLD `.vip`.
|
||||||
|
|
||||||
Czemu wart (parity z porndoe):
|
Czemu wart (parity z porndoe):
|
||||||
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
|
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
|
||||||
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
|
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
|
||||||
(BunnyCDN: `yesnn.b-cdn.net/contents/videos_screenshots/...`).
|
(BunnyCDN: `yesnn.b-cdn.net/contents/videos_screenshots/...`).
|
||||||
- **`<meta property="video:duration" content="2340">`** — durations już w sekundach
|
- **`<meta property="video:duration" content="2340">`** durations już w sekundach
|
||||||
(fallback gdy ISO-duration parse fail).
|
(fallback gdy ISO-duration parse fail).
|
||||||
- **`<meta property="video:release_date">`** — ISO 8601 z timezone, redundant z
|
- **`<meta property="video:release_date">`** ISO 8601 z timezone, redundant z
|
||||||
JSON-LD uploadDate ale czystszy format.
|
JSON-LD uploadDate ale czystszy format.
|
||||||
- **`<meta property="video:tag">`** (multiple) — kanoniczna lista tagów (np.
|
- **`<meta property="video:tag">`** (multiple), kanoniczna lista tagów (np.
|
||||||
"Big Ass", "Threesome"). Główne źródło tagów; alternatywnie DOM ma `btn gold`
|
"Big Ass", "Threesome"). Główne źródło tagów; alternatywnie DOM ma `btn gold`
|
||||||
linki ale te miksują performerów/studio z tagami.
|
linki ale te miksują performerów/studio z tagami.
|
||||||
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
|
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
|
||||||
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
|
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
|
||||||
multiple). Slugi mają stable per-type salt (`*-i459s7` dla modeli, `*-7p72tp`
|
multiple). Slugi mają stable per-type salt (`*-i459s7` dla modeli, `*-7p72tp`
|
||||||
dla channels) — zachowują się jak hash z site-version, ale stabilne przez
|
dla channels), zachowują się jak hash z site-version, ale stabilne przez
|
||||||
sesje.
|
sesje.
|
||||||
|
|
||||||
External_id strategia: `yespornvip:<numeric_video_id>` (`/video/69841/...` → `69841`).
|
External_id strategia: `yespornvip:<numeric_video_id>` (`/video/69841/...` → `69841`).
|
||||||
|
|
@ -31,11 +31,11 @@ URL patterns:
|
||||||
- Scene: `/video/<id>/<slug>/` (id numeryczny, slug = title slug + 6-char salt)
|
- Scene: `/video/<id>/<slug>/` (id numeryczny, slug = title slug + 6-char salt)
|
||||||
- Studio: `/channels/<slug>/`
|
- Studio: `/channels/<slug>/`
|
||||||
- Performer: `/models/<slug>/`
|
- Performer: `/models/<slug>/`
|
||||||
- Search: `/search/<query>/` (nie używane w browse-mode — można dorobić jako
|
- Search: `/search/<query>/` (nie używane w browse-mode, można dorobić jako
|
||||||
osobny tryb dla performer-driven backfill jeśli będzie potrzeba)
|
osobny tryb dla performer-driven backfill jeśli będzie potrzeba)
|
||||||
|
|
||||||
Playback: download endpoint `/view_video_download.php?id=<id>&format=<480|720|1080>`
|
Playback: download endpoint `/view_video_download.php?id=<id>&format=<480|720|1080>`
|
||||||
z `data-attach-session="PHPSESSID"` — wymaga session cookie, więc nie direct mp4
|
z `data-attach-session="PHPSESSID"`, wymaga session cookie, więc nie direct mp4
|
||||||
z server-side. Plus jest `embedUrl: /embed/<id>` w JSON-LD. Extractor →
|
z server-side. Plus jest `embedUrl: /embed/<id>` w JSON-LD. Extractor →
|
||||||
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
|
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
|
||||||
mobile WebView fetcha embed z phone IP, INJECTED_JS scrape'uje `<video>.src`.
|
mobile WebView fetcha embed z phone IP, INJECTED_JS scrape'uje `<video>.src`.
|
||||||
|
|
@ -66,7 +66,7 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BASE = "https://yesporn.vip"
|
_BASE = "https://yesporn.vip"
|
||||||
|
|
||||||
# Listing — scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
|
# Listing, scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
|
||||||
# Slug `<slug>` zawiera stable per-type salt (`*-npu57w` dla videos).
|
# Slug `<slug>` zawiera stable per-type salt (`*-npu57w` dla videos).
|
||||||
_SCENE_URL_RE = re.compile(
|
_SCENE_URL_RE = re.compile(
|
||||||
r'href="(https://yesporn\.vip/video/(\d+)/[a-z0-9\-]+/)"',
|
r'href="(https://yesporn\.vip/video/(\d+)/[a-z0-9\-]+/)"',
|
||||||
|
|
@ -87,13 +87,13 @@ _PERFORMER_LINK_RE = re.compile(
|
||||||
)
|
)
|
||||||
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
# JSON-LD VideoObject — pełny blok między `<script type="application/ld+json">` tagami.
|
# JSON-LD VideoObject, pełny blok między `<script type="application/ld+json">` tagami.
|
||||||
_JSONLD_RE = re.compile(
|
_JSONLD_RE = re.compile(
|
||||||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# `<meta property="video:tag" content="Big Ass">` — multiple, jeden tag per meta.
|
# `<meta property="video:tag" content="Big Ass">`, multiple, jeden tag per meta.
|
||||||
_META_TAG_RE = re.compile(
|
_META_TAG_RE = re.compile(
|
||||||
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
|
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
@ -122,7 +122,7 @@ def _parse_iso_date(value: str | None) -> date | None:
|
||||||
"""`2026-05-26T19:23:29Z` / `2026-05-26T19:23:29.EDT` → date."""
|
"""`2026-05-26T19:23:29Z` / `2026-05-26T19:23:29.EDT` → date."""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate — strip żeby
|
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate, strip żeby
|
||||||
# `fromisoformat` nie crash'ował. video:release_date meta ma czysty `Z`.
|
# `fromisoformat` nie crash'ował. video:release_date meta ma czysty `Z`.
|
||||||
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
|
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
|
||||||
try:
|
try:
|
||||||
|
|
@ -265,7 +265,7 @@ class YesPornVipScraper(BaseBrowseScraper):
|
||||||
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
|
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
|
||||||
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
|
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
|
||||||
# w 20-scene dry-run 2026-05-27. Trade-off: stracimy hipotetyczne legit
|
# w 20-scene dry-run 2026-05-27. Trade-off: stracimy hipotetyczne legit
|
||||||
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował) —
|
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował),
|
||||||
# akceptowalne bo tags mają wagę tylko 0.05 w composite scoring resolvera.
|
# akceptowalne bo tags mają wagę tylko 0.05 w composite scoring resolvera.
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
seen_tag: set[str] = set()
|
seen_tag: set[str] = set()
|
||||||
|
|
@ -283,17 +283,17 @@ class YesPornVipScraper(BaseBrowseScraper):
|
||||||
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phash z thumbnailUrl — BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
|
# Phash z thumbnailUrl, BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
|
||||||
# Hit-rate vs canonical TPDB/StashDB nieznany do pilot run; graceful: brak
|
# Hit-rate vs canonical TPDB/StashDB nieznany do pilot run; graceful: brak
|
||||||
# phash → resolver spada do composite scoring (studio + performer + date +
|
# phash → resolver spada do composite scoring (studio + performer + date +
|
||||||
# duration + title token-set) — wszystkie dostępne dzięki JSON-LD.
|
# duration + title token-set), wszystkie dostępne dzięki JSON-LD.
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||||
if ph:
|
if ph:
|
||||||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||||
|
|
||||||
# Playback — page_url do strony sceny. Direct mp4 (`view_video_download.php`)
|
# Playback, page_url do strony sceny. Direct mp4 (`view_video_download.php`)
|
||||||
# wymaga PHPSESSID cookie (data-attach-session attribute), więc nie usable
|
# wymaga PHPSESSID cookie (data-attach-session attribute), więc nie usable
|
||||||
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
|
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
|
||||||
# mobile WebView z phone IP łapie session natively, INJECTED_JS scrape.
|
# mobile WebView z phone IP łapie session natively, INJECTED_JS scrape.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""youperv.com — browse scraper (DataLife Engine). Dodany 2026-07-26.
|
"""youperv.com, browse scraper (DataLife Engine). Dodany 2026-07-26.
|
||||||
|
|
||||||
Ripy paysite ze studiami (Brazzers Exxtra, Blacked, Evil Angel, Deeper, Private…),
|
Ripy paysite ze studiami (Brazzers Exxtra, Blacked, Evil Angel, Deeper, Private…),
|
||||||
tytuły w formacie `Studio - Performer - Title` (71% próbki 132 tytułów), świeże
|
tytuły w formacie `Studio - Performer - Title` (71% próbki 132 tytułów), świeże
|
||||||
|
|
@ -8,20 +8,20 @@ co do sekundy + duration = mocny sygnał do canonical match.
|
||||||
Listing: homepage (newest) + `/page/N/`, 19 scen/stronę, zero overlapu między
|
Listing: homepage (newest) + `/page/N/`, 19 scen/stronę, zero overlapu między
|
||||||
stronami. Scene URL: `/<kategoria>/<id>-<slug>.html`.
|
stronami. Scene URL: `/<kategoria>/<id>-<slug>.html`.
|
||||||
|
|
||||||
**KRYTYCZNE — scoping obsady**: performerzy MUSZĄ być czytani tylko z bloku
|
**KRYTYCZNE, scoping obsady**: performerzy MUSZĄ być czytani tylko z bloku
|
||||||
`<div class="fmeta">` … `Related`. Na całej stronie jest 19-26 linków
|
`<div class="fmeta">` … `Related`. Na całej stronie jest 19-26 linków
|
||||||
`xfsearch/pornstar/` (blok Related), w samym fmeta 1-2 realnych. Bez scopingu
|
`xfsearch/pornstar/` (blok Related), w samym fmeta 1-2 realnych. Bez scopingu
|
||||||
powtórzylibyśmy błąd, przez który odrzuciliśmy xxxfiles (page-wide pollution
|
powtórzylibyśmy błąd, przez który odrzuciliśmy xxxfiles (page-wide pollution
|
||||||
zaśmiecająca bazę performerów).
|
zaśmiecająca bazę performerów).
|
||||||
|
|
||||||
Tytuł zostaje z prefiksem studia (jak hdporngg/porn00) — token_set_ratio i tak
|
Tytuł zostaje z prefiksem studia (jak hdporngg/porn00), token_set_ratio i tak
|
||||||
złapie canonical, a prefiks niesie dodatkowy sygnał.
|
złapie canonical, a prefiks niesie dodatkowy sygnał.
|
||||||
|
|
||||||
Playback: direct mp4 `<source>` na files.klubnichka-hd.com, BEZ tokena/expiry, ale
|
Playback: direct mp4 `<source>` na files.klubnichka-hd.com, BEZ tokena/expiry, ale
|
||||||
CDN ma hotlink-guard na Referer (bez nagłówka 403, z nagłówkiem 206 cross-IP).
|
CDN ma hotlink-guard na Referer (bez nagłówka 403, z nagłówkiem 206 cross-IP).
|
||||||
Rozwiązuje extractor `youpervcom` (VPS-side, mobile gra direct, zero WebView/proxy).
|
Rozwiązuje extractor `youpervcom` (VPS-side, mobile gra direct, zero WebView/proxy).
|
||||||
|
|
||||||
Głębokość: deep-crawl capowany (`_PAGE_CAP` w deep_crawl.py) — strony ~2100+ to
|
Głębokość: deep-crawl capowany (`_PAGE_CAP` w deep_crawl.py), strony ~2100+ to
|
||||||
stara amatorka bez performerów, z martwymi linkami (HTTP 500 na CDN).
|
stara amatorka bez performerów, z martwymi linkami (HTTP 500 na CDN).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""YouPorn.com — direct HTML scrape search results.
|
"""YouPorn.com, direct HTML scrape search results.
|
||||||
|
|
||||||
Search: `https://www.youporn.com/search/?query=<q>&page=<n>`
|
Search: `https://www.youporn.com/search/?query=<q>&page=<n>`
|
||||||
Scene URL: `https://www.youporn.com/watch/<id>/<slug>/`
|
Scene URL: `https://www.youporn.com/watch/<id>/<slug>/`
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"""youporn.com — latest BROWSE scraper (JSON-LD + page-parse), obok search scrapera.
|
"""youporn.com, latest BROWSE scraper (JSON-LD + page-parse), obok search scrapera.
|
||||||
|
|
||||||
YouPornScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada świeżość
|
YouPornScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada świeżość
|
||||||
wprost z `/browse/time/?page=<n>` (newest-first, SSR). Detail page ma JSON-LD
|
wprost z `/browse/time/?page=<n>` (newest-first, SSR). Detail page ma JSON-LD
|
||||||
VideoObject (name/duration/uploadDate/thumbnail) — i TYLKO to bierzemy.
|
VideoObject (name/duration/uploadDate/thumbnail), i TYLKO to bierzemy.
|
||||||
|
|
||||||
UWAGA: performerów/tagów z detail-strony NIE wyciągamy. JSON-LD nie ma pola `actor`,
|
UWAGA: performerów/tagów z detail-strony NIE wyciągamy. JSON-LD nie ma pola `actor`,
|
||||||
a linki `/pornstar/` i `/category/` na stronie są zaśmiecone sidebarem (popularne
|
a linki `/pornstar/` i `/category/` na stronie są zaśmiecone sidebarem (popularne
|
||||||
pornstars/related) bez czystego scene-scoped kontenera — naiwny regex podpinał te
|
pornstars/related) bez czystego scene-scoped kontenera, naiwny regex podpinał te
|
||||||
same 2 pornstars do KAŻDEJ sceny (mass-misattribution). Browse to tylko sygnał
|
same 2 pornstars do KAŻDEJ sceny (mass-misattribution). Browse to tylko sygnał
|
||||||
świeżości (próg watchdog 48h); performerów/tagi dorabia canonical-merge + istniejący
|
świeżości (próg watchdog 48h); performerów/tagi dorabia canonical-merge + istniejący
|
||||||
search scraper (performer-driven). Listing SSR (/watch/<id>/); homepage JS-renderowany.
|
search scraper (performer-driven). Listing SSR (/watch/<id>/); homepage JS-renderowany.
|
||||||
|
|
@ -96,7 +96,7 @@ class YouPornBrowseScraper(BaseBrowseScraper):
|
||||||
if isinstance(thumbnail_url, list):
|
if isinstance(thumbnail_url, list):
|
||||||
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
|
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
|
||||||
|
|
||||||
# performers/tags celowo puste — patrz docstring (sidebar pollution, brak
|
# performers/tags celowo puste, patrz docstring (sidebar pollution, brak
|
||||||
# scene-scoped kontenera). Dorabia canonical-merge + search scraper.
|
# scene-scoped kontenera). Dorabia canonical-merge + search scraper.
|
||||||
return RawScene(
|
return RawScene(
|
||||||
external_id=f"{self.sitetag}:{scene_url}",
|
external_id=f"{self.sitetag}:{scene_url}",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""dooplay (a.k.a. PsyPlay) WordPress theme scraper — generic dla mangoporn/streamporn/pandamovies.
|
"""dooplay (a.k.a. PsyPlay) WordPress theme scraper, generic dla mangoporn/streamporn/pandamovies.
|
||||||
|
|
||||||
Te 3 strony to dokładnie ten sam template (theme=dooplay + PsyPlay player plugin),
|
Te 3 strony to dokładnie ten sam template (theme=dooplay + PsyPlay player plugin),
|
||||||
więc parametryzujemy connector po `(base_url, source_name)` i odpalamy 3 instancje.
|
więc parametryzujemy connector po `(base_url, source_name)` i odpalamy 3 instancje.
|
||||||
|
|
@ -15,7 +15,7 @@ Detail: `/movies/<slug>/` ma rich meta:
|
||||||
- <span class="dt_rating_vgs" itemprop="ratingValue">N</span> rating 0-10
|
- <span class="dt_rating_vgs" itemprop="ratingValue">N</span> rating 0-10
|
||||||
- <li ... data-fl-source="<embed_url>"><a href="<embed_link>">Host</a></li> player options
|
- <li ... data-fl-source="<embed_url>"><a href="<embed_link>">Host</a></li> player options
|
||||||
|
|
||||||
Player ma multi-host options (DoodStream, LuluStream, RPMShare etc.) — każdy embed
|
Player ma multi-host options (DoodStream, LuluStream, RPMShare etc.), każdy embed
|
||||||
URL idzie jako osobny `playback_source` z origin=`{site}:{host}` żeby później mobile
|
URL idzie jako osobny `playback_source` z origin=`{site}:{host}` żeby później mobile
|
||||||
mógł wybrać czyim embedem chce odpalić scenę.
|
mógł wybrać czyim embedem chce odpalić scenę.
|
||||||
"""
|
"""
|
||||||
|
|
@ -48,9 +48,9 @@ USER_AGENT = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---- selektory (theme-agnostic — działa dla dowolnego dooplay) -----------
|
# ---- selektory (theme-agnostic, działa dla dowolnego dooplay) -----------
|
||||||
|
|
||||||
# Listing item — dwa wzorce w zależności od witryny:
|
# Listing item, dwa wzorce w zależności od witryny:
|
||||||
# 1. mangoporn: zwykłe <a href="https://site/movies/<slug>/"> bez klasy
|
# 1. mangoporn: zwykłe <a href="https://site/movies/<slug>/"> bez klasy
|
||||||
# (theme wyrendurował SEO-friendly URL bezpośrednio w grid)
|
# (theme wyrendurował SEO-friendly URL bezpośrednio w grid)
|
||||||
# 2. streamporn/pandamovies: <a class="ml-mask jt" href="<base>/<slug>/">
|
# 2. streamporn/pandamovies: <a class="ml-mask jt" href="<base>/<slug>/">
|
||||||
|
|
@ -62,14 +62,14 @@ _LIST_ITEM_RE = re.compile(
|
||||||
r'<a\s+href="(?P<url2>https?://[^"]+/movies/[a-z0-9-]+/)"',
|
r'<a\s+href="(?P<url2>https?://[^"]+/movies/[a-z0-9-]+/)"',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# Tolerantny title — mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
|
# Tolerantny title, mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
|
||||||
# (raw PsyPlay theme) używają <h3 itemprop="name">. Łapiemy oba przez itemprop="name".
|
# (raw PsyPlay theme) używają <h3 itemprop="name">. Łapiemy oba przez itemprop="name".
|
||||||
_TITLE_RE = re.compile(
|
_TITLE_RE = re.compile(
|
||||||
r'<h[1-6][^>]*\sitemprop="name"[^>]*>([^<]+)</h[1-6]>'
|
r'<h[1-6][^>]*\sitemprop="name"[^>]*>([^<]+)</h[1-6]>'
|
||||||
r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>',
|
r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>',
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
# dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów —
|
# dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów,
|
||||||
# różne thema dziedziczą podstawowy markup ale customizują URL słowniki.
|
# różne thema dziedziczą podstawowy markup ale customizują URL słowniki.
|
||||||
_YEAR_RE = re.compile(
|
_YEAR_RE = re.compile(
|
||||||
r'/(?:year|release-year)/(\d{4})/"\s*rel="tag"', re.IGNORECASE
|
r'/(?:year|release-year)/(\d{4})/"\s*rel="tag"', re.IGNORECASE
|
||||||
|
|
@ -111,7 +111,7 @@ _GENRE_TAG_RE = re.compile(
|
||||||
# Player options: data-fl-source jest oryginalnym embed URL hostera, data-fl-url
|
# Player options: data-fl-source jest oryginalnym embed URL hostera, data-fl-url
|
||||||
# to page URL u hostera. Stare theme (mangoporn): `<li class="hosts-buttons-wpx">`.
|
# to page URL u hostera. Stare theme (mangoporn): `<li class="hosts-buttons-wpx">`.
|
||||||
# Nowe theme (pandamovies od ~2026-04): `<div class="Rtable1-cell" data-fl-url=...
|
# Nowe theme (pandamovies od ~2026-04): `<div class="Rtable1-cell" data-fl-url=...
|
||||||
# data-fl-source=...>`. Trzeba też tolerować order-independent attrs — nowe theme
|
# data-fl-source=...>`. Trzeba też tolerować order-independent attrs, nowe theme
|
||||||
# emituje url BEFORE source, stare odwrotnie. Łapiemy oba wzorce dwoma osobnymi
|
# emituje url BEFORE source, stare odwrotnie. Łapiemy oba wzorce dwoma osobnymi
|
||||||
# regexami i konsolidujemy w `_iter_player_options`.
|
# regexami i konsolidujemy w `_iter_player_options`.
|
||||||
_PLAYER_OPTION_RE = re.compile(
|
_PLAYER_OPTION_RE = re.compile(
|
||||||
|
|
@ -126,7 +126,7 @@ _PLAYER_OPTION_RE = re.compile(
|
||||||
# Attrs są w kolejności url→source, source często pusty (`data-fl-source=""` dla
|
# Attrs są w kolejności url→source, source często pusty (`data-fl-source=""` dla
|
||||||
# doodstream/mixdrop/easyvidplayer). Capturujemy CAŁY opening tag w group(1)
|
# doodstream/mixdrop/easyvidplayer). Capturujemy CAŁY opening tag w group(1)
|
||||||
# żeby data-fl-source należał gwarantowanie do TEGO konkretnego div (wcześniejszy
|
# żeby data-fl-source należał gwarantowanie do TEGO konkretnego div (wcześniejszy
|
||||||
# window-lookback 600 chars mógł pickować poprzedni cell — cross-attribution
|
# window-lookback 600 chars mógł pickować poprzedni cell, cross-attribution
|
||||||
# doodstream→mixdrop entry, code-review #14).
|
# doodstream→mixdrop entry, code-review #14).
|
||||||
_PLAYER_OPTION_DIV_RE = re.compile(
|
_PLAYER_OPTION_DIV_RE = re.compile(
|
||||||
r'(<div[^>]*\bclass="Rtable1-cell"[^>]*>)\s*'
|
r'(<div[^>]*\bclass="Rtable1-cell"[^>]*>)\s*'
|
||||||
|
|
@ -135,7 +135,7 @@ _PLAYER_OPTION_DIV_RE = re.compile(
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
_DATA_FL_SOURCE_RE = re.compile(r'data-fl-source="([^"]*)"', re.IGNORECASE)
|
_DATA_FL_SOURCE_RE = re.compile(r'data-fl-source="([^"]*)"', re.IGNORECASE)
|
||||||
# Poster — JSON-LD `thumbnailUrl` jest najbardziej stabilny (każdy dooplay/PsyPlay
|
# Poster, JSON-LD `thumbnailUrl` jest najbardziej stabilny (każdy dooplay/PsyPlay
|
||||||
# theme z SEO ma JSON-LD VideoObject schema). Fallback na class="poster" img dla starych
|
# theme z SEO ma JSON-LD VideoObject schema). Fallback na class="poster" img dla starych
|
||||||
# instalacji bez schema. Trzeci fallback: og:image meta tag.
|
# instalacji bez schema. Trzeci fallback: og:image meta tag.
|
||||||
_POSTER_JSONLD_RE = re.compile(
|
_POSTER_JSONLD_RE = re.compile(
|
||||||
|
|
@ -168,7 +168,7 @@ class DooplayConnector(BaseMovieConnector):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _fetch(self, url: str) -> str:
|
def _fetch(self, url: str) -> str:
|
||||||
"""browser_get z chrome120 impersonation — psyplay sites czasem blokują
|
"""browser_get z chrome120 impersonation, psyplay sites czasem blokują
|
||||||
czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to."""
|
czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to."""
|
||||||
if not url.startswith("http"):
|
if not url.startswith("http"):
|
||||||
url = self.base_url.rstrip("/") + url
|
url = self.base_url.rstrip("/") + url
|
||||||
|
|
@ -187,12 +187,12 @@ class DooplayConnector(BaseMovieConnector):
|
||||||
)
|
)
|
||||||
return r.text
|
return r.text
|
||||||
|
|
||||||
# Bezpiecznik — dooplay listing potrafi mieć tysiące stron (streamporn.nl ma
|
# Bezpiecznik, dooplay listing potrafi mieć tysiące stron (streamporn.nl ma
|
||||||
# >2k filmów). Bez tego ingest wisi godzinami, jest killowany przy restartcie
|
# >2k filmów). Bez tego ingest wisi godzinami, jest killowany przy restartcie
|
||||||
# schedulera, blokując kolejne connectory w queue (bug-report 2026-05-28: "od
|
# schedulera, blokując kolejne connectory w queue (bug-report 2026-05-28: "od
|
||||||
# wczoraj nie ma nowych filmów" — streamporn wisiał od 5-24, blokował
|
# wczoraj nie ma nowych filmów", streamporn wisiał od 5-24, blokował
|
||||||
# mangoporn + pandamovies). Listing jest sortowany po dacie uploadu (NIE
|
# mangoporn + pandamovies). Listing jest sortowany po dacie uploadu (NIE
|
||||||
# release_date filmu — release może być z 2013 a upload z dziś), ale upload
|
# release_date filmu, release może być z 2013 a upload z dziś), ale upload
|
||||||
# date nie jest w markupie, więc filtrowanie po `since` przez release_date
|
# date nie jest w markupie, więc filtrowanie po `since` przez release_date
|
||||||
# nie działa. Pragmatyczny cap stron: 3 dla delta (≈150 nowych pozycji/dzień
|
# nie działa. Pragmatyczny cap stron: 3 dla delta (≈150 nowych pozycji/dzień
|
||||||
# to znacznie powyżej realnego upload-rate), 50 dla full ingestu (`since=None`).
|
# to znacznie powyżej realnego upload-rate), 50 dla full ingestu (`since=None`).
|
||||||
|
|
@ -335,7 +335,7 @@ def _parse_dooplay_detail(
|
||||||
poster_url = candidate
|
poster_url = candidate
|
||||||
break
|
break
|
||||||
|
|
||||||
# Performers — tylko sekcja "Pornstars" ma /pornstar/<slug>/ linki, dooplay
|
# Performers, tylko sekcja "Pornstars" ma /pornstar/<slug>/ linki, dooplay
|
||||||
# filtruje cast w tej sekcji. Jaccard może łapać dubel ale dedup robimy w
|
# filtruje cast w tej sekcji. Jaccard może łapać dubel ale dedup robimy w
|
||||||
# resolverze (po performer_id).
|
# resolverze (po performer_id).
|
||||||
performers = [
|
performers = [
|
||||||
|
|
@ -360,14 +360,14 @@ def _parse_dooplay_detail(
|
||||||
|
|
||||||
# Playback sources: każdy host (Doodstream/Lulu/RPM/...) jako osobny entry.
|
# Playback sources: każdy host (Doodstream/Lulu/RPM/...) jako osobny entry.
|
||||||
# Dedup po href żeby ten sam host nie wpadł 2x. Raw landing page (origin=
|
# Dedup po href żeby ten sam host nie wpadł 2x. Raw landing page (origin=
|
||||||
# source_name, bez :host) appendujemy TYLKO gdy nie ma żadnych sub-hosters —
|
# source_name, bez :host) appendujemy TYLKO gdy nie ma żadnych sub-hosters,
|
||||||
# inaczej myli usera (otwiera WebView z reklamami zamiast video; bug-report
|
# inaczej myli usera (otwiera WebView z reklamami zamiast video; bug-report
|
||||||
# 2026-05-16: "mangoporn przekierowuje do strony, reklama full screen").
|
# 2026-05-16: "mangoporn przekierowuje do strony, reklama full screen").
|
||||||
playback_sources: list[RawPlaybackSource] = []
|
playback_sources: list[RawPlaybackSource] = []
|
||||||
seen_hrefs: set[str] = set()
|
seen_hrefs: set[str] = set()
|
||||||
|
|
||||||
# Hostery file-download (non-streamable) + malware. Mobile player nie potrafi
|
# Hostery file-download (non-streamable) + malware. Mobile player nie potrafi
|
||||||
# ich odtworzyć — rapidgator/nitroflare/frdl serwują .zip/.rar/.mp4 do download
|
# ich odtworzyć, rapidgator/nitroflare/frdl serwują .zip/.rar/.mp4 do download
|
||||||
# (premium login required), streamtape ma malware drive-by .reg. Skipujemy
|
# (premium login required), streamtape ma malware drive-by .reg. Skipujemy
|
||||||
# przy ingest żeby nie zaśmiecać UI martwym contentem (bug-report 2026-05-18).
|
# przy ingest żeby nie zaśmiecać UI martwym contentem (bug-report 2026-05-18).
|
||||||
SKIP_HOSTERS = {"rapidgator", "nitroflare", "nitro", "frdl", "streamtape"}
|
SKIP_HOSTERS = {"rapidgator", "nitroflare", "nitro", "frdl", "streamtape"}
|
||||||
|
|
@ -400,7 +400,7 @@ def _parse_dooplay_detail(
|
||||||
_emit_host_entry(m.group("href") or "", (m.group("source") or "").strip() or None)
|
_emit_host_entry(m.group("href") or "", (m.group("source") or "").strip() or None)
|
||||||
|
|
||||||
# Nowy `<div class="Rtable1-cell">` markup (pandamovies od ~2026-04 + nowe
|
# Nowy `<div class="Rtable1-cell">` markup (pandamovies od ~2026-04 + nowe
|
||||||
# streamporn instances). data-fl-source jest opcjonalny — capturujemy CAŁY
|
# streamporn instances). data-fl-source jest opcjonalny, capturujemy CAŁY
|
||||||
# opening tag w group(1), data-fl-source extract z TEGO tagu (nie z window
|
# opening tag w group(1), data-fl-source extract z TEGO tagu (nie z window
|
||||||
# lookback po HTMLu, bo to mogło pickować poprzedni cell).
|
# lookback po HTMLu, bo to mogło pickować poprzedni cell).
|
||||||
for m in _PLAYER_OPTION_DIV_RE.finditer(html):
|
for m in _PLAYER_OPTION_DIV_RE.finditer(html):
|
||||||
|
|
@ -411,7 +411,7 @@ def _parse_dooplay_detail(
|
||||||
_emit_host_entry(href, source)
|
_emit_host_entry(href, source)
|
||||||
|
|
||||||
if not playback_sources:
|
if not playback_sources:
|
||||||
# Brak sub-hosters znalezionych — fallback do landing page (mobile otworzy
|
# Brak sub-hosters znalezionych, fallback do landing page (mobile otworzy
|
||||||
# w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest
|
# w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest
|
||||||
# niepotrzebnym ad-pageiem.
|
# niepotrzebnym ad-pageiem.
|
||||||
playback_sources.append(
|
playback_sources.append(
|
||||||
|
|
@ -457,13 +457,13 @@ class StreampornVipConnector(DooplayConnector):
|
||||||
def _listing_path(self, page: int) -> str:
|
def _listing_path(self, page: int) -> str:
|
||||||
"""Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od
|
"""Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od
|
||||||
2026-07-11 (0 nowych przez 23 dni), ale strona żyła i konektor parsował
|
2026-07-11 (0 nowych przez 23 dni), ale strona żyła i konektor parsował
|
||||||
poprawnie — tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
|
poprawnie, tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
|
||||||
oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dało
|
oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dało
|
||||||
roczniki 2012-2015, strona główna wyłącznie 2026 (po 10-11 źródeł playbacku).
|
roczniki 2012-2015, strona główna wyłącznie 2026 (po 10-11 źródeł playbacku).
|
||||||
`?orderby=date` motyw ignoruje (odpowiedź identyczna z domyślną).
|
`?orderby=date` motyw ignoruje (odpowiedź identyczna z domyślną).
|
||||||
|
|
||||||
To ta sama klasa błędu co przy freshporno: właściwa strona, zły endpoint
|
To ta sama klasa błędu co przy freshporno: właściwa strona, zły endpoint
|
||||||
listingu — patrz [[reference_kvs_root_rotates_use_latest_updates]].
|
listingu, patrz [[reference_kvs_root_rotates_use_latest_updates]].
|
||||||
|
|
||||||
Strony 1 i 2 współdzielą ~44 linki (sidebar/widgety), ale treść główna jest
|
Strony 1 i 2 współdzielą ~44 linki (sidebar/widgety), ale treść główna jest
|
||||||
stronicowana normalnie; dedup po URL w `fetch_movies` i tak je odsiewa.
|
stronicowana normalnie; dedup po URL w `fetch_movies` i tak je odsiewa.
|
||||||
|
|
@ -482,7 +482,7 @@ class MangopornConnector(DooplayConnector):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers (zduplikowane z paradisehill.py — celowo, żeby connectory były niezależne)
|
# Helpers (zduplikowane z paradisehill.py, celowo, żeby connectory były niezależne)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_TAG_RE = re.compile(r"<[^>]+>")
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""freeomovie.to — movie source (WordPress "bestia" theme, NIE dooplay).
|
"""freeomovie.to, movie source (WordPress "bestia" theme, NIE dooplay).
|
||||||
|
|
||||||
Dodany 2026-07-02 (ocena). Pełnometrażowe filmy, świeże (auto-poster, dziś), tytuły
|
Dodany 2026-07-02 (ocena). Pełnometrażowe filmy, świeże (auto-poster, dziś), tytuły
|
||||||
DVD → title-trigram mirror-attach do canonical (paradisehill/TPDB movies), dokładając
|
DVD → title-trigram mirror-attach do canonical (paradisehill/TPDB movies), dokładając
|
||||||
|
|
@ -18,7 +18,7 @@ Struktura:
|
||||||
doczepia się do canonical po tytule, a TPDB enrichment dokłada obsadę/studio autorytatywnie.
|
doczepia się do canonical po tytule, a TPDB enrichment dokłada obsadę/studio autorytatywnie.
|
||||||
|
|
||||||
Playback: TABS hostery → MoviePlaybackSource per host (voe/luluvid/vidhide resolvują się
|
Playback: TABS hostery → MoviePlaybackSource per host (voe/luluvid/vidhide resolvują się
|
||||||
VPS-side; myvidplay = DoodStream clone, phone-side — wymaga myvidplay.com w DOOD_HOSTS).
|
VPS-side; myvidplay = DoodStream clone, phone-side, wymaga myvidplay.com w DOOD_HOSTS).
|
||||||
Pomijamy streamtape (martwy malware) + mxdrop (flaky).
|
Pomijamy streamtape (martwy malware) + mxdrop (flaky).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -151,7 +151,7 @@ class FreeoMovieConnector(BaseMovieConnector):
|
||||||
name = part.strip()
|
name = part.strip()
|
||||||
if not name or name.lower() == "xxx movies":
|
if not name or name.lower() == "xxx movies":
|
||||||
if name.lower() == "xxx movies":
|
if name.lower() == "xxx movies":
|
||||||
break # dalej idzie obsada/studio — nie tagi
|
break # dalej idzie obsada/studio, nie tagi
|
||||||
continue
|
continue
|
||||||
sl = slugify(name)
|
sl = slugify(name)
|
||||||
if not sl or sl in seen_tag:
|
if not sl or sl in seen_tag:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"""Paradisehill connector — primary source dla movies (full-length adult films).
|
"""Paradisehill connector, primary source dla movies (full-length adult films).
|
||||||
|
|
||||||
Site notes:
|
Site notes:
|
||||||
- Age-gate: wymagany cookie `is18=1` (POST /is18/ zwraca 400 z curla, ale samo dorzucenie
|
- Age-gate: wymagany cookie `is18=1` (POST /is18/ zwraca 400 z curla, ale samo dorzucenie
|
||||||
cookie do GET-a działa — site jest tolerancyjny).
|
cookie do GET-a działa, site jest tolerancyjny).
|
||||||
- Listing: `/all/?sort=created_at&page=N` — paginacja po 28 filmów, mikro-data Schema.org Movie.
|
- Listing: `/all/?sort=created_at&page=N`, paginacja po 28 filmów, mikro-data Schema.org Movie.
|
||||||
- Detail: `/<hex_id>/` — pełne meta + Video.js playlist (chaptery jako "Part 1/2/3").
|
- Detail: `/<hex_id>/`, pełne meta + Video.js playlist (chaptery jako "Part 1/2/3").
|
||||||
|
|
||||||
Co ekstraktujemy:
|
Co ekstraktujemy:
|
||||||
- Schema.org microdata: name, description, director, datePublished (upload), image, thumbnailUrl
|
- Schema.org microdata: name, description, director, datePublished (upload), image, thumbnailUrl
|
||||||
|
|
@ -12,7 +12,7 @@ Co ekstraktujemy:
|
||||||
- Genres: ze Schema.org `itemprop="genre"` (pierwszy = movie's main genre)
|
- Genres: ze Schema.org `itemprop="genre"` (pierwszy = movie's main genre)
|
||||||
- Year: parsowany z description gdy obecny ("This 1999 film..."), bo `datePublished` to upload_date
|
- Year: parsowany z description gdy obecny ("This 1999 film..."), bo `datePublished` to upload_date
|
||||||
- Chapters: liczba `<li>...Part N</li>` w playliście Video.js
|
- Chapters: liczba `<li>...Part N</li>` w playliście Video.js
|
||||||
- Playback: na MVP `page_url` only — Video.js playlist URL jest dynamicznie ładowany przez JS
|
- Playback: na MVP `page_url` only, Video.js playlist URL jest dynamicznie ładowany przez JS
|
||||||
i wymaga login session. Mobile może otworzyć page w WebView (degradacja lepsza niż brak).
|
i wymaga login session. Mobile może otworzyć page w WebView (degradacja lepsza niż brak).
|
||||||
|
|
||||||
External_id: hex slug z URL-a (np. `259448f6b75ee` z `/259448f6b75ee/`).
|
External_id: hex slug z URL-a (np. `259448f6b75ee` z `/259448f6b75ee/`).
|
||||||
|
|
@ -49,7 +49,7 @@ LISTING_PATH = "/all/" # ?sort=created_at&page=N
|
||||||
SOURCE_NAME = "paradisehill"
|
SOURCE_NAME = "paradisehill"
|
||||||
|
|
||||||
|
|
||||||
# Microdata extraction — Schema.org tagi są stabilne i niezagubione przy lekkich
|
# Microdata extraction, Schema.org tagi są stabilne i niezagubione przy lekkich
|
||||||
# zmianach themu (yii2 widget renderuje je inwariantnie).
|
# zmianach themu (yii2 widget renderuje je inwariantnie).
|
||||||
_TITLE_RE = re.compile(
|
_TITLE_RE = re.compile(
|
||||||
r'<h1\s+class="title-inside"\s+itemprop="name">([^<]+)</h1>', re.IGNORECASE
|
r'<h1\s+class="title-inside"\s+itemprop="name">([^<]+)</h1>', re.IGNORECASE
|
||||||
|
|
@ -72,7 +72,7 @@ _CHAPTER_RE = re.compile(
|
||||||
r'<a\s+href="#"\s+class="js-list-item"\s+data-index="(\d+)">([^<]+)</a>',
|
r'<a\s+href="#"\s+class="js-list-item"\s+data-index="(\d+)">([^<]+)</a>',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# videoList JS array w detail page — może mieć multiple parts (Video.js playlist):
|
# videoList JS array w detail page, może mieć multiple parts (Video.js playlist):
|
||||||
# var videoList = [{"sources":[{"src":"...part1.mp4","type":"video/mp4"}]}, ...]
|
# var videoList = [{"sources":[{"src":"...part1.mp4","type":"video/mp4"}]}, ...]
|
||||||
# Bez parsowania tego mobile WebView gra tylko pierwszy part, kolejne pomija.
|
# Bez parsowania tego mobile WebView gra tylko pierwszy part, kolejne pomija.
|
||||||
# Bug-reports `c5693926`/`418270e4` 2026-05-21 ("ładuje tylko 1 z 4 części").
|
# Bug-reports `c5693926`/`418270e4` 2026-05-21 ("ładuje tylko 1 z 4 części").
|
||||||
|
|
@ -155,7 +155,7 @@ class ParadisehillConnector(BaseMovieConnector):
|
||||||
|
|
||||||
`since`: stop gdy datePublished < since. `limit`: stop po N filmach.
|
`since`: stop gdy datePublished < since. `limit`: stop po N filmach.
|
||||||
Aktualnie 28 movies/page; site rośnie ~5/dzień, więc pełen crawl to ~tysiące
|
Aktualnie 28 movies/page; site rośnie ~5/dzień, więc pełen crawl to ~tysiące
|
||||||
stron — w prod używamy `since` żeby zobaczyć tylko delta od poprzedniego runa.
|
stron, w prod używamy `since` żeby zobaczyć tylko delta od poprzedniego runa.
|
||||||
"""
|
"""
|
||||||
seen = 0
|
seen = 0
|
||||||
page = 1
|
page = 1
|
||||||
|
|
@ -179,14 +179,14 @@ class ParadisehillConnector(BaseMovieConnector):
|
||||||
if movie is None:
|
if movie is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# `since` filter — datePublished (= data uploadu na paradisehill) poniżej
|
# `since` filter, datePublished (= data uploadu na paradisehill) poniżej
|
||||||
# progu = stop crawla (listing chronologiczny).
|
# progu = stop crawla (listing chronologiczny).
|
||||||
#
|
#
|
||||||
# UWAGA: release_date to DATA (bez godziny). Wcześniej combine()→00:00
|
# UWAGA: release_date to DATA (bez godziny). Wcześniej combine()→00:00
|
||||||
# porównywane z TIMESTAMPEM `since` ucinało crawl na PIERWSZYM filmie z dnia
|
# porównywane z TIMESTAMPEM `since` ucinało crawl na PIERWSZYM filmie z dnia
|
||||||
# == since (midnight < since o dowolnej porze dnia) → uploady tego samego dnia
|
# == since (midnight < since o dowolnej porze dnia) → uploady tego samego dnia
|
||||||
# systematycznie ginęły, bo movie-ingest jest dzienny (seen=0-2/run mimo
|
# systematycznie ginęły, bo movie-ingest jest dzienny (seen=0-2/run mimo
|
||||||
# świeżych filmów na froncie strony — bug-report 2026-06-01 "Movies stoją").
|
# świeżych filmów na froncie strony, bug-report 2026-06-01 "Movies stoją").
|
||||||
# Fix: porównuj po DACIE z 1-dniowym grace; ponowny fetch świeżych jest tani
|
# Fix: porównuj po DACIE z 1-dniowym grace; ponowny fetch świeżych jest tani
|
||||||
# (external_records upsert pomija niezmieniony hash).
|
# (external_records upsert pomija niezmieniony hash).
|
||||||
if since is not None and movie.release_date is not None:
|
if since is not None and movie.release_date is not None:
|
||||||
|
|
@ -243,8 +243,8 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Year — najpierw z tytułu, potem z opisu. datePublished to upload date paradisehill
|
# Year, najpierw z tytułu, potem z opisu. datePublished to upload date paradisehill
|
||||||
# (np. 2026-05) a nie production year (np. 1999) — useless dla year filtering.
|
# (np. 2026-05) a nie production year (np. 1999), useless dla year filtering.
|
||||||
release_year: int | None = None
|
release_year: int | None = None
|
||||||
m_yt = _YEAR_IN_TITLE_RE.search(title)
|
m_yt = _YEAR_IN_TITLE_RE.search(title)
|
||||||
if m_yt:
|
if m_yt:
|
||||||
|
|
@ -271,9 +271,9 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
|
||||||
name=_decode_html(m_studio.group(2).strip()),
|
name=_decode_html(m_studio.group(2).strip()),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Genre — pierwszy itemprop="genre" w samym block-inside (nie w recommendations).
|
# Genre, pierwszy itemprop="genre" w samym block-inside (nie w recommendations).
|
||||||
# Recommended films też mają itemprop="genre" więc match limity do block-inside.
|
# Recommended films też mają itemprop="genre" więc match limity do block-inside.
|
||||||
# Wcześniejszy regex wymagał `</div></div><div class="similar"` — ale paradisehill
|
# Wcześniejszy regex wymagał `</div></div><div class="similar"`, ale paradisehill
|
||||||
# czasami ma `</div></noindex>...<div class="similar"` (banner skin z 2026-05-19),
|
# czasami ma `</div></noindex>...<div class="similar"` (banner skin z 2026-05-19),
|
||||||
# przez co block_match failował → fallback do html[:8000] → 0 tagów. Bug-report
|
# przez co block_match failował → fallback do html[:8000] → 0 tagów. Bug-report
|
||||||
# `3c999b27` 2026-05-21 ("Brak kategorii"). Robust: szukaj similar jako stop boundary,
|
# `3c999b27` 2026-05-21 ("Brak kategorii"). Robust: szukaj similar jako stop boundary,
|
||||||
|
|
@ -295,7 +295,7 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
|
||||||
# Paradisehill miesza dwa szablony per-page:
|
# Paradisehill miesza dwa szablony per-page:
|
||||||
# v1: `itemprop="genre">Female Domination</span>`
|
# v1: `itemprop="genre">Female Domination</span>`
|
||||||
# v2: `itemprop="genre"><a href="/category/...">All Sex</a></span>` (od 2026-05)
|
# v2: `itemprop="genre"><a href="/category/...">All Sex</a></span>` (od 2026-05)
|
||||||
# Optional `<a>` wrapper między `itemprop` a tekstem — bez tego v2 dawał empty.
|
# Optional `<a>` wrapper między `itemprop` a tekstem, bez tego v2 dawał empty.
|
||||||
for m_genre in re.finditer(
|
for m_genre in re.finditer(
|
||||||
r'itemprop="genre"[^>]*>\s*(?:<a[^>]*>)?\s*([^<]+)', block, re.IGNORECASE,
|
r'itemprop="genre"[^>]*>\s*(?:<a[^>]*>)?\s*([^<]+)', block, re.IGNORECASE,
|
||||||
):
|
):
|
||||||
|
|
@ -332,7 +332,7 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
|
||||||
backdrop_url=backdrop_url,
|
backdrop_url=backdrop_url,
|
||||||
url=page_url,
|
url=page_url,
|
||||||
studio=studio,
|
studio=studio,
|
||||||
performers=[], # Paradisehill rzadko ma cast linki — uzupełnimy przez mirrory.
|
performers=[], # Paradisehill rzadko ma cast linki, uzupełnimy przez mirrory.
|
||||||
tags=tags,
|
tags=tags,
|
||||||
chapters=chapters,
|
chapters=chapters,
|
||||||
playback_sources=playback_sources,
|
playback_sources=playback_sources,
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ Lista scen: GET /scenes?per_page=200&page=N&date={YYYY-MM-DD} (delta filter)
|
||||||
Format: {data: [...], meta: {current_page, last_page, per_page, total}}
|
Format: {data: [...], meta: {current_page, last_page, per_page, total}}
|
||||||
|
|
||||||
Sceny TPDB zwracają już rozwiniętych performerów (`performers[]`), studio (`site`) i tagi (`tags[]`).
|
Sceny TPDB zwracają już rozwiniętych performerów (`performers[]`), studio (`site`) i tagi (`tags[]`).
|
||||||
W związku z tym pojedyncze GET /scenes wystarcza do MVP — nie musimy uderzać oddzielnie po performera.
|
W związku z tym pojedyncze GET /scenes wystarcza do MVP, nie musimy uderzać oddzielnie po performera.
|
||||||
|
|
||||||
Format performera w scenie:
|
Format performera w scenie:
|
||||||
- performer.id — ID przypisania performer↔scene (NIE używać do dedup)
|
- performer.id ID przypisania performer↔scene (NIE używać do dedup)
|
||||||
- performer.name — imię w tej konkretnej scenie (może być alias, np. „Mia M.")
|
- performer.name imię w tej konkretnej scenie (może być alias, np. „Mia M.")
|
||||||
- performer.parent.id — kanoniczne UUID performerki w TPDB → external_id
|
- performer.parent.id, kanoniczne UUID performerki w TPDB → external_id
|
||||||
- performer.parent.name / .extra.gender / .extra.birthday — kanoniczne metadane
|
- performer.parent.name / .extra.gender / .extra.birthday, kanoniczne metadane
|
||||||
|
|
||||||
Format studia: scene.site = {id, name, slug, parent: {...}, network: {...}}
|
Format studia: scene.site = {id, name, slug, parent: {...}, network: {...}}
|
||||||
"""
|
"""
|
||||||
|
|
@ -35,7 +35,7 @@ from tenacity import (
|
||||||
def _is_retryable_http_error(exc: BaseException) -> bool:
|
def _is_retryable_http_error(exc: BaseException) -> bool:
|
||||||
"""Retry transport errors + 5xx + 429; NIE retry 4xx (404/422 = permanent).
|
"""Retry transport errors + 5xx + 429; NIE retry 4xx (404/422 = permanent).
|
||||||
|
|
||||||
401/403 NIE są retryowalne tutaj — TPDB token expiry musiałby być
|
401/403 NIE są retryowalne tutaj, TPDB token expiry musiałby być
|
||||||
obsłużony jako auth refresh (TODO gdyby zaczęły się pojawiać). Aktualnie
|
obsłużony jako auth refresh (TODO gdyby zaczęły się pojawiać). Aktualnie
|
||||||
expire'a się raz na rok, więc nie warto kombinować.
|
expire'a się raz na rok, więc nie warto kombinować.
|
||||||
"""
|
"""
|
||||||
|
|
@ -100,7 +100,7 @@ class TPDBConnector(BaseConnector):
|
||||||
def _get(self, client: httpx.Client, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
def _get(self, client: httpx.Client, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
resp = client.get(path, params=params)
|
resp = client.get(path, params=params)
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
# let tenacity retry — but raise something it knows
|
# let tenacity retry, but raise something it knows
|
||||||
raise httpx.HTTPStatusError("rate limited", request=resp.request, response=resp)
|
raise httpx.HTTPStatusError("rate limited", request=resp.request, response=resp)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
@ -125,13 +125,13 @@ class TPDBConnector(BaseConnector):
|
||||||
) -> Iterator[RawScene]:
|
) -> Iterator[RawScene]:
|
||||||
"""Pobiera wszystkie sceny TPDB dla performera o podanym kanonicznym ID.
|
"""Pobiera wszystkie sceny TPDB dla performera o podanym kanonicznym ID.
|
||||||
|
|
||||||
TPDB API: GET /performers/<id>/scenes — dedykowany endpoint.
|
TPDB API: GET /performers/<id>/scenes, dedykowany endpoint.
|
||||||
(Inne warianty są broken: /scenes?performers[]=<uuid> zwraca zawsze total=0,
|
(Inne warianty są broken: /scenes?performers[]=<uuid> zwraca zawsze total=0,
|
||||||
/scenes?performer_id=<uuid> → 422.)
|
/scenes?performer_id=<uuid> → 422.)
|
||||||
|
|
||||||
404 = performer usunięty z TPDB (np. b959ccbb 2026-05-16 Sentry GOON-N).
|
404 = performer usunięty z TPDB (np. b959ccbb 2026-05-16 Sentry GOON-N).
|
||||||
Wcześniej leciało raise → exception bąbelek do scheduler.performer_driven
|
Wcześniej leciało raise → exception bąbelek do scheduler.performer_driven
|
||||||
→ cały run failed. Teraz warn + yield empty — caller widzi 0 scen i
|
→ cały run failed. Teraz warn + yield empty, caller widzi 0 scen i
|
||||||
kontynuuje z następnym performer.
|
kontynuuje z następnym performer.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -157,11 +157,11 @@ class TPDBConnector(BaseConnector):
|
||||||
) -> Iterator[RawScene]:
|
) -> Iterator[RawScene]:
|
||||||
"""Pobiera wszystkie sceny TPDB dla site/studio o podanym ID.
|
"""Pobiera wszystkie sceny TPDB dla site/studio o podanym ID.
|
||||||
|
|
||||||
TPDB API: GET /sites/<id>/scenes — dedykowany endpoint analogiczny
|
TPDB API: GET /sites/<id>/scenes, dedykowany endpoint analogiczny
|
||||||
do /performers/<id>/scenes. Bez paginacji limit zwraca total scenes
|
do /performers/<id>/scenes. Bez paginacji limit zwraca total scenes
|
||||||
z meta.total (Brazzers=272, Naughty America=631 w czasie pisania).
|
z meta.total (Brazzers=272, Naughty America=631 w czasie pisania).
|
||||||
|
|
||||||
404 = site usunięty z TPDB — analogicznie do fetch_scenes_for_performer.
|
404 = site usunięty z TPDB, analogicznie do fetch_scenes_for_performer.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
yield from self._paginate_scenes(
|
yield from self._paginate_scenes(
|
||||||
|
|
@ -215,7 +215,7 @@ class TPDBConnector(BaseConnector):
|
||||||
def fetch_movie(self, movie_uuid: str) -> dict[str, Any] | None:
|
def fetch_movie(self, movie_uuid: str) -> dict[str, Any] | None:
|
||||||
"""GET /movies/<uuid> → pojedynczy film (detail). None gdy 404/błąd.
|
"""GET /movies/<uuid> → pojedynczy film (detail). None gdy 404/błąd.
|
||||||
|
|
||||||
NB: list endpoint jest już hydrated, więc detail rzadko potrzebny — trzymamy
|
NB: list endpoint jest już hydrated, więc detail rzadko potrzebny, trzymamy
|
||||||
dla spójności / gdyby TPDB kiedyś przeniósł część pól tylko do detalu."""
|
dla spójności / gdyby TPDB kiedyś przeniósł część pól tylko do detalu."""
|
||||||
with self._client() as client:
|
with self._client() as client:
|
||||||
try:
|
try:
|
||||||
|
|
@ -384,7 +384,7 @@ def _parse_movie(raw: dict[str, Any]) -> RawMovie | None:
|
||||||
director = ", ".join(d.get("name") for d in directors if isinstance(d, dict) and d.get("name")) or None
|
director = ", ".join(d.get("name") for d in directors if isinstance(d, dict) and d.get("name")) or None
|
||||||
|
|
||||||
d = _parse_date(raw.get("date"))
|
d = _parse_date(raw.get("date"))
|
||||||
# duration bywa 1s-placeholderem na movies — poniżej 60s traktujemy jak brak.
|
# duration bywa 1s-placeholderem na movies, poniżej 60s traktujemy jak brak.
|
||||||
dur = raw.get("duration")
|
dur = raw.get("duration")
|
||||||
duration_sec = int(dur) if dur and int(dur) > 60 else None
|
duration_sec = int(dur) if dur and int(dur) > 60 else None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""yourdailypornmovies.ws — konektor filmów. Dodany 2026-07-28.
|
"""yourdailypornmovies.ws, konektor filmów. Dodany 2026-07-28.
|
||||||
|
|
||||||
WordPress z własnym motywem (NIE dooplay), więc nie da się podpiąć pod
|
WordPress z własnym motywem (NIE dooplay), więc nie da się podpiąć pod
|
||||||
`DooplayConnector` jak mangoporn/pandamovies/streamporn.
|
`DooplayConnector` jak mangoporn/pandamovies/streamporn.
|
||||||
|
|
@ -12,7 +12,7 @@ okładki DVD z `imgs1cdn.adultempire.com`, nie re-hosty z pandanetwork.
|
||||||
Katalog: min. 8000 pozycji (strona 200 nadal oddaje wyniki, po 40 na stronę), sięga
|
Katalog: min. 8000 pozycji (strona 200 nadal oddaje wyniki, po 40 na stronę), sięga
|
||||||
2003 roku, strona 1 ma bieżący rok.
|
2003 roku, strona 1 ma bieżący rok.
|
||||||
|
|
||||||
**Pułapka: `/tag/` w całym HTML to 2177 linków** — to chmura tagów w sidebarze,
|
**Pułapka: `/tag/` w całym HTML to 2177 linków** to chmura tagów w sidebarze,
|
||||||
identyczny wzorzec jak przy sexu. Tagi (i obsada) MUSZĄ być czytane z bloków
|
identyczny wzorzec jak przy sexu. Tagi (i obsada) MUSZĄ być czytane z bloków
|
||||||
`div.xmll`, inaczej każdy film dostanie te same dwa tysiące śmieci.
|
`div.xmll`, inaczej każdy film dostanie te same dwa tysiące śmieci.
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ Czego strona nie ma: długości filmu.
|
||||||
|
|
||||||
Playback: 3 hostery w iframe. `voe.sx` i `mxdrop.sx` zweryfikowane, że resolwują
|
Playback: 3 hostery w iframe. `voe.sx` i `mxdrop.sx` zweryfikowane, że resolwują
|
||||||
istniejącymi modułami (`app/extractors/hosters/voe.py` → m3u8, `mixdrop.py` → mp4).
|
istniejącymi modułami (`app/extractors/hosters/voe.py` → m3u8, `mixdrop.py` → mp4).
|
||||||
`vidara.to` jest nam nieznany — zapisujemy go jako zwykły embed, telefon otworzy go
|
`vidara.to` jest nam nieznany, zapisujemy go jako zwykły embed, telefon otworzy go
|
||||||
w WebView, ale nie liczymy na niego.
|
w WebView, ale nie liczymy na niego.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -60,7 +60,7 @@ _CARD_RE = re.compile(
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd — patrz pułapka w docstringu.
|
# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd, patrz pułapka w docstringu.
|
||||||
_XMLL_RE = re.compile(r'<div class="xmll">.*?</div>', re.DOTALL)
|
_XMLL_RE = re.compile(r'<div class="xmll">.*?</div>', re.DOTALL)
|
||||||
_ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)</a>')
|
_ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)</a>')
|
||||||
_TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)</a>')
|
_TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)</a>')
|
||||||
|
|
@ -143,7 +143,7 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Playback najpierw — bez niego film jest bezużyteczny, nie tworzymy orphana.
|
# Playback najpierw, bez niego film jest bezużyteczny, nie tworzymy orphana.
|
||||||
playback: list[RawPlaybackSource] = []
|
playback: list[RawPlaybackSource] = []
|
||||||
seen_host: set[str] = set()
|
seen_host: set[str] = set()
|
||||||
for m in _IFRAME_RE.finditer(detail):
|
for m in _IFRAME_RE.finditer(detail):
|
||||||
|
|
@ -219,7 +219,7 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
|
||||||
performers=performers,
|
performers=performers,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
playback_sources=playback,
|
playback_sources=playback,
|
||||||
# id produktu Adult Empire — mocniejsza kotwica do dedupu niż tytuł
|
# id produktu Adult Empire, mocniejsza kotwica do dedupu niż tytuł
|
||||||
# (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał).
|
# (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał).
|
||||||
raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
|
raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""TPDB movie enrichment + dedup.
|
"""TPDB movie enrichment + dedup.
|
||||||
|
|
||||||
TPDB `/movies` jest naszym kanonicznym źródłem metadanych filmów: obsada, kategorie
|
TPDB `/movies` jest naszym kanonicznym źródłem metadanych filmów: obsada, kategorie
|
||||||
(tagi), studio, reżyser, rok — plus stabilny UUID do dedupu. paradisehill (primary
|
(tagi), studio, reżyser, rok, plus stabilny UUID do dedupu. paradisehill (primary
|
||||||
movie source) prawie nie ma obsady, więc TPDB wypełnia największą lukę.
|
movie source) prawie nie ma obsady, więc TPDB wypełnia największą lukę.
|
||||||
|
|
||||||
Zasada: TPDB TYLKO wzbogaca ISTNIEJĄCE filmy (z paradisehill/dooplay) i dedupuje,
|
Zasada: TPDB TYLKO wzbogaca ISTNIEJĄCE filmy (z paradisehill/dooplay) i dedupuje,
|
||||||
NIGDY nie tworzy nowych — TPDB nie ma playbacku, więc nowy film byłby niegrywalny.
|
NIGDY nie tworzy nowych, TPDB nie ma playbacku, więc nowy film byłby niegrywalny.
|
||||||
|
|
||||||
Flow per film:
|
Flow per film:
|
||||||
1. skip, jeśli film ma już TPDB movie ref (movie_external_refs jest tylko-movie,
|
1. skip, jeśli film ma już TPDB movie ref (movie_external_refs jest tylko-movie,
|
||||||
|
|
@ -19,7 +19,7 @@ Flow per film:
|
||||||
poster/rating (fill-only), przypnij MovieExternalRef(tpdb, uuid).
|
poster/rating (fill-only), przypnij MovieExternalRef(tpdb, uuid).
|
||||||
|
|
||||||
Match jest zachowawczy (próg 0.90 + guard roku), bo TPDB search zwraca dużo (np.
|
Match jest zachowawczy (próg 0.90 + guard roku), bo TPDB search zwraca dużo (np.
|
||||||
"Pirates" → gay Knightbreeders przed feature) — bez scoringu wzięlibyśmy zły film.
|
"Pirates" → gay Knightbreeders przed feature), bez scoringu wzięlibyśmy zły film.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -168,7 +168,7 @@ def _best_match(
|
||||||
|
|
||||||
|
|
||||||
def _fill_scalar_fields(movie: Movie, norm) -> None:
|
def _fill_scalar_fields(movie: Movie, norm) -> None:
|
||||||
"""Fill-only — nie nadpisujemy pól ustawionych przez primary (paradisehill/mirror)."""
|
"""Fill-only, nie nadpisujemy pól ustawionych przez primary (paradisehill/mirror)."""
|
||||||
if norm.director and not movie.director:
|
if norm.director and not movie.director:
|
||||||
movie.director = norm.director
|
movie.director = norm.director
|
||||||
if norm.release_year and not movie.release_year:
|
if norm.release_year and not movie.release_year:
|
||||||
|
|
@ -243,7 +243,7 @@ def enrich_movie(
|
||||||
movie.studio_id = studio.id
|
movie.studio_id = studio.id
|
||||||
|
|
||||||
# Dedup wchodzących performerów po external_id (TPDB potrafi wylistować tego samego
|
# Dedup wchodzących performerów po external_id (TPDB potrafi wylistować tego samego
|
||||||
# kanonicznego performera 2×: pod aliasem i kanonicznie) — bez tego resolve_performer
|
# kanonicznego performera 2×: pod aliasem i kanonicznie), bez tego resolve_performer
|
||||||
# próbuje wstawić performer_external_refs 2× → UniqueViolation (jak w scene_resolver).
|
# próbuje wstawić performer_external_refs 2× → UniqueViolation (jak w scene_resolver).
|
||||||
resolved: list[tuple[uuid.UUID, str | None]] = []
|
resolved: list[tuple[uuid.UUID, str | None]] = []
|
||||||
seen_perf_keys: set[str] = set()
|
seen_perf_keys: set[str] = set()
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,16 @@ Public API:
|
||||||
- `try_extract(sitetag, page_url) -> list[StreamSource] | None`
|
- `try_extract(sitetag, page_url) -> list[StreamSource] | None`
|
||||||
- `StreamSource` (dataclass)
|
- `StreamSource` (dataclass)
|
||||||
- `HosterDead` (exception)
|
- `HosterDead` (exception)
|
||||||
- `extract_stream_from_hoster(iframe_url, *, referer)` — generic packer-based hoster extract
|
- `extract_stream_from_hoster(iframe_url, *, referer)`, generic packer-based hoster extract
|
||||||
- `fetch_tube_html(url)` — Chrome TLS fingerprint fetch (curl_cffi)
|
- `fetch_tube_html(url)`, Chrome TLS fingerprint fetch (curl_cffi)
|
||||||
- `browser_get(url)` — low-level
|
- `browser_get(url)`, low-level
|
||||||
|
|
||||||
Architektura: każdy tube ma osobny moduł `app.extractors.tubes.<tube>` który eksportuje
|
Architektura: każdy tube ma osobny moduł `app.extractors.tubes.<tube>` który eksportuje
|
||||||
`extract(page_url) -> list[StreamSource] | None`. Registry niżej mapuje sitetag →
|
`extract(page_url) -> list[StreamSource] | None`. Registry niżej mapuje sitetag →
|
||||||
modułowy extractor. `try_extract()` to thin wrapper z exception handlingiem.
|
modułowy extractor. `try_extract()` to thin wrapper z exception handlingiem.
|
||||||
|
|
||||||
Po removalu porn-app dependency, ten moduł jest jedynym mechanizmem rozwiązywania
|
Po removalu porn-app dependency, ten moduł jest jedynym mechanizmem rozwiązywania
|
||||||
streamów — playback.py nie wpada już do porn-app /stream API.
|
streamów, playback.py nie wpada już do porn-app /stream API.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -63,14 +63,14 @@ log = logging.getLogger(__name__)
|
||||||
# (lub po Fazie 2 migracji: `tube:<sitetag>`).
|
# (lub po Fazie 2 migracji: `tube:<sitetag>`).
|
||||||
#
|
#
|
||||||
# Mainstream tubes (pornhub/xvideos/xnxx/xhamster/redtube/youporn/porntrex) używają
|
# Mainstream tubes (pornhub/xvideos/xnxx/xhamster/redtube/youporn/porntrex) używają
|
||||||
# yt-dlp jako extractor — battle-tested, aktualizowane przez upstream przy zmianach
|
# yt-dlp jako extractor, battle-tested, aktualizowane przez upstream przy zmianach
|
||||||
# HTML. Aggregator tubes (xmoviesforyou/watchporn/siska/...) używają generic
|
# HTML. Aggregator tubes (xmoviesforyou/watchporn/siska/...) używają generic
|
||||||
# embed-iframe extractor (page → /e/<id> iframe → P.A.C.K.E.R. unpack). Custom kod
|
# embed-iframe extractor (page → /e/<id> iframe → P.A.C.K.E.R. unpack). Custom kod
|
||||||
# tylko tam gdzie tube ma niestandardowy schemat (eporner XHR, sxyprn URL transform).
|
# tylko tam gdzie tube ma niestandardowy schemat (eporner XHR, sxyprn URL transform).
|
||||||
_REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
_REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||||
# hqporner — dedicated extractor zwraca multi-quality `<source>` mp4 URLs
|
# hqporner, dedicated extractor zwraca multi-quality `<source>` mp4 URLs
|
||||||
# (bigcdn.cc / hqwo.cc / flyflv) z `force_proxy=True`. CDN URLs IP-bound do
|
# (bigcdn.cc / hqwo.cc / flyflv) z `force_proxy=True`. CDN URLs IP-bound do
|
||||||
# VPS, więc playback.py routuje przez proxy — mobile dostaje quality picker
|
# VPS, więc playback.py routuje przez proxy, mobile dostaje quality picker
|
||||||
# + natywny ExoPlayer, bez WebView.
|
# + natywny ExoPlayer, bez WebView.
|
||||||
# Bug-report e8ddd8d4: WebView fallback (`_vps_blocked_fallback`) ładował
|
# Bug-report e8ddd8d4: WebView fallback (`_vps_blocked_fallback`) ładował
|
||||||
# hqporner.com scene page w WebView, ale ta strona ma ad-iframes (adtng,
|
# hqporner.com scene page w WebView, ale ta strona ma ad-iframes (adtng,
|
||||||
|
|
@ -81,25 +81,25 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||||
"hqpornercom": hqporner.extract,
|
"hqpornercom": hqporner.extract,
|
||||||
"epornercom": eporner.extract,
|
"epornercom": eporner.extract,
|
||||||
"sxyprncom": sxyprn.extract,
|
"sxyprncom": sxyprn.extract,
|
||||||
# Mainstream tubes — yt-dlp
|
# Mainstream tubes, yt-dlp
|
||||||
# NB: 2026-05-18 cross-IP test potwierdził że xvideos/xnxx/pornhub/youporn/redtube
|
# NB: 2026-05-18 cross-IP test potwierdził że xvideos/xnxx/pornhub/youporn/redtube
|
||||||
# CDN URLs są **time-bound** (nie IP-bound) — mobile_direct_ok auto-detect w
|
# CDN URLs są **time-bound** (nie IP-bound), mobile_direct_ok auto-detect w
|
||||||
# playback.py daje mobile direct fetch, zero VPS bandwidth.
|
# playback.py daje mobile direct fetch, zero VPS bandwidth.
|
||||||
# pornhub + redtube — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Scrapery były
|
# pornhub + redtube, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Scrapery były
|
||||||
# disabled od 2026-05-12 (0.4% canonical match — głównie skrócone amatorskie clipy),
|
# disabled od 2026-05-12 (0.4% canonical match, głównie skrócone amatorskie clipy),
|
||||||
# zamrożone sceny/źródła skasowane z DB. Brak ekstraktorów → zero resolve.
|
# zamrożone sceny/źródła skasowane z DB. Brak ekstraktorów → zero resolve.
|
||||||
"xvideoscom": _ytdlp.extract,
|
"xvideoscom": _ytdlp.extract,
|
||||||
"xnxxcom": _ytdlp.extract,
|
"xnxxcom": _ytdlp.extract,
|
||||||
"youporncom": _ytdlp.extract,
|
"youporncom": _ytdlp.extract,
|
||||||
# porntrex KVS — 2026-05-22 VPS znów dociera (HTTP 200). Dedykowany extractor:
|
# porntrex KVS, 2026-05-22 VPS znów dociera (HTTP 200). Dedykowany extractor:
|
||||||
# flashvars `video_url` → `get_file` 302 → CDN time-bound signed URL
|
# flashvars `video_url` → `get_file` 302 → CDN time-bound signed URL
|
||||||
# (`expires`+`md5`, NIE IP-bound) → mobile gra direct, zero VPS bandwidth.
|
# (`expires`+`md5`, NIE IP-bound) → mobile gra direct, zero VPS bandwidth.
|
||||||
"porntrexcom": porntrex.extract,
|
"porntrexcom": porntrex.extract,
|
||||||
# fpoxxx — KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 →
|
# fpoxxx, KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 →
|
||||||
# `videos3.fpo.xxx/remote_control.php?acctoken=<base64>` — zdekodowany acctoken
|
# `videos3.fpo.xxx/remote_control.php?acctoken=<base64>`, zdekodowany acctoken
|
||||||
# zawiera WBITY IP serwera-resolvera → definitywnie IP-bound. WebView only.
|
# zawiera WBITY IP serwera-resolvera → definitywnie IP-bound. WebView only.
|
||||||
"fpoxxx": _vps_blocked_fallback.extract,
|
"fpoxxx": _vps_blocked_fallback.extract,
|
||||||
# sxyland — embeduje playmogo.com/e/<id> (= klon DoodStream: doodcdn.io + pass_md5
|
# sxyland, embeduje playmogo.com/e/<id> (= klon DoodStream: doodcdn.io + pass_md5
|
||||||
# + niewidzialny CF Turnstile; Chrome-DevTools verify 2026-06-08, bug-report 827a50a1).
|
# + niewidzialny CF Turnstile; Chrome-DevTools verify 2026-06-08, bug-report 827a50a1).
|
||||||
# Strona sxyland NIE jest Turnstile-gated (VPS curl wyciąga iframe URL z HTML), więc
|
# Strona sxyland NIE jest Turnstile-gated (VPS curl wyciąga iframe URL z HTML), więc
|
||||||
# _embed_iframe wyłuskuje embed playmogo i oddaje jako type='hoster' → mobile
|
# _embed_iframe wyłuskuje embed playmogo i oddaje jako type='hoster' → mobile
|
||||||
|
|
@ -107,49 +107,49 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||||
# mp4 → autoplay. Wcześniej _vps_blocked_fallback ładował CAŁĄ stronę sxyland w WebView
|
# mp4 → autoplay. Wcześniej _vps_blocked_fallback ładował CAŁĄ stronę sxyland w WebView
|
||||||
# (ads + klik-to-play + brak autoplay = dokładnie objaw z reportu 827a50a1).
|
# (ads + klik-to-play + brak autoplay = dokładnie objaw z reportu 827a50a1).
|
||||||
"sxylandcom": _embed_iframe.extract,
|
"sxylandcom": _embed_iframe.extract,
|
||||||
# Aggregator tubes — generic embed-iframe → hoster unpacker
|
# Aggregator tubes, generic embed-iframe → hoster unpacker
|
||||||
"latestpornvideocom": latestpornvideo.extract,
|
"latestpornvideocom": latestpornvideo.extract,
|
||||||
"xmoviesforyoucom": _embed_iframe.extract,
|
"xmoviesforyoucom": _embed_iframe.extract,
|
||||||
# watchporn — 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął).
|
# watchporn, 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął).
|
||||||
# flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound.
|
# flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound.
|
||||||
"watchporn": watchporn.extract,
|
"watchporn": watchporn.extract,
|
||||||
# youperv — fluidplayer, zwykły <source> mp4 na files.klubnichka-hd.com. Bez tokena
|
# youperv, fluidplayer, zwykły <source> mp4 na files.klubnichka-hd.com. Bez tokena
|
||||||
# i bez IP-bindingu; CDN pilnuje tylko Referera (cross-IP 206 z VPS) → mobile direct.
|
# i bez IP-bindingu; CDN pilnuje tylko Referera (cross-IP 206 z VPS) → mobile direct.
|
||||||
"youpervcom": youperv.extract,
|
"youpervcom": youperv.extract,
|
||||||
# galaxyporn — iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy,
|
# galaxyporn, iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy,
|
||||||
# extractor tylko wyłuskuje iframe. HLS Referer+IP-bound → /proxy/hls.
|
# extractor tylko wyłuskuje iframe. HLS Referer+IP-bound → /proxy/hls.
|
||||||
"galaxyporncom": galaxyporn.extract,
|
"galaxyporncom": galaxyporn.extract,
|
||||||
# pornbusy — dispatch po embedURL: loadvid (POST-manifest przez /proxy/hls),
|
# pornbusy, dispatch po embedURL: loadvid (POST-manifest przez /proxy/hls),
|
||||||
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
|
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
|
||||||
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
|
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
|
||||||
"pornbusycom": pornbusy.extract,
|
"pornbusycom": pornbusy.extract,
|
||||||
# pornmike — gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry.
|
# pornmike, gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry.
|
||||||
# Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView.
|
# Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView.
|
||||||
"pornmike": pornmike.extract,
|
"pornmike": pornmike.extract,
|
||||||
# 8kporner — JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!).
|
# 8kporner, JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!).
|
||||||
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
|
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
|
||||||
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
|
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
|
||||||
"8kpornercom": kporner8.extract,
|
"8kpornercom": kporner8.extract,
|
||||||
# fullporner — iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
|
# fullporner, iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
|
||||||
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
|
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
|
||||||
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
|
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
|
||||||
"fullporner": fullporner.extract,
|
"fullporner": fullporner.extract,
|
||||||
# sexu — POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
|
# sexu, POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
|
||||||
# Bierzemy HLS `playerData.src`, NIE mp4 `sources` — te mają w tokenie `ip=`
|
# Bierzemy HLS `playerData.src`, NIE mp4 `sources`, te mają w tokenie `ip=`
|
||||||
# requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi
|
# requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi
|
||||||
# hls_needs_passthrough i manifest idzie przez /proxy/hls, segmenty direct.
|
# hls_needs_passthrough i manifest idzie przez /proxy/hls, segmenty direct.
|
||||||
"sexu": sexu.extract,
|
"sexu": sexu.extract,
|
||||||
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
|
# fullvideosporn (= sextu), TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
|
||||||
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
|
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
|
||||||
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
|
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
|
||||||
"fullvideosporn": fullvideosporn.extract,
|
"fullvideosporn": fullvideosporn.extract,
|
||||||
"siskavideo": _embed_iframe.extract,
|
"siskavideo": _embed_iframe.extract,
|
||||||
"porn4dayspw": _embed_iframe.extract,
|
"porn4dayspw": _embed_iframe.extract,
|
||||||
"porndishcom": _embed_iframe.extract,
|
"porndishcom": _embed_iframe.extract,
|
||||||
# xxxfreewatch — DELISTED 2026-05-18. 790 solo-orphan scen, 0% match, CF-walled z VPS.
|
# xxxfreewatch, DELISTED 2026-05-18. 790 solo-orphan scen, 0% match, CF-walled z VPS.
|
||||||
"latestleaksco": _embed_iframe.extract,
|
"latestleaksco": _embed_iframe.extract,
|
||||||
"mypornerleakcom": _embed_iframe.extract,
|
"mypornerleakcom": _embed_iframe.extract,
|
||||||
# xhamster — 2026-06-08 PRZEPIĘTE z _vps_blocked_fallback na natywny server-side HLS.
|
# xhamster, 2026-06-08 PRZEPIĘTE z _vps_blocked_fallback na natywny server-side HLS.
|
||||||
# Re-test (DevTools + cross-IP): VPS pobiera scene page bez CF challenge, master m3u8
|
# Re-test (DevTools + cross-IP): VPS pobiera scene page bez CF challenge, master m3u8
|
||||||
# w SSR HTML, manifest+segmenty time-bound (portable, nie IP-bound). Mobile gra HLS
|
# w SSR HTML, manifest+segmenty time-bound (portable, nie IP-bound). Mobile gra HLS
|
||||||
# direct, multi-quality, zero VPS proxy/WebView/reklam. Patrz tubes/xhamster.py.
|
# direct, multi-quality, zero VPS proxy/WebView/reklam. Patrz tubes/xhamster.py.
|
||||||
|
|
@ -158,24 +158,24 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||||
"xhamstercom": xhamster.extract,
|
"xhamstercom": xhamster.extract,
|
||||||
# Freshporno KVS (function/0 + license). 2026-06-04 DevTools + cross-IP re-test
|
# Freshporno KVS (function/0 + license). 2026-06-04 DevTools + cross-IP re-test
|
||||||
# NAPRAWIA błąd z #20: finalny cdn4.freshporno.org/remote_control.php jest PORTABLE
|
# NAPRAWIA błąd z #20: finalny cdn4.freshporno.org/remote_control.php jest PORTABLE
|
||||||
# (token time-bound nie IP-bound — VPS odtworzył token z residential → 206) ale
|
# (token time-bound nie IP-bound, VPS odtworzył token z residential → 206) ale
|
||||||
# wymaga browser-TLS (curl_cffi chrome/ExoPlayer → 206; plain curl → 000). W #20
|
# wymaga browser-TLS (curl_cffi chrome/ExoPlayer → 206; plain curl → 000). W #20
|
||||||
# testowałem plain-curl-em poza sesją → 000 → błędnie „nieosiągalny" → WebView.
|
# testowałem plain-curl-em poza sesją → 000 → błędnie „nieosiągalny" → WebView.
|
||||||
# Teraz backend-resolve jak yespornvip/pornditt (_kvs używa curl_cffi chrome).
|
# Teraz backend-resolve jak yespornvip/pornditt (_kvs używa curl_cffi chrome).
|
||||||
# Native, multi-quality, zero proxy/WebView. (zweryfikowane na emulatorze przed deploy)
|
# Native, multi-quality, zero proxy/WebView. (zweryfikowane na emulatorze przed deploy)
|
||||||
"freshpornoorg": freshporno.extract,
|
"freshpornoorg": freshporno.extract,
|
||||||
# porn00 — KVS (plain get_file + license). 2026-06-04 DevTools + cross-IP re-test
|
# porn00, KVS (plain get_file + license). 2026-06-04 DevTools + cross-IP re-test
|
||||||
# NAPRAWIA błąd z #20: finalny fe.porn00.org/...?token=&expires= jest PORTABLE
|
# NAPRAWIA błąd z #20: finalny fe.porn00.org/...?token=&expires= jest PORTABLE
|
||||||
# (token time-bound nie IP-bound — Bright Data residential proxy z innego IP → 206)
|
# (token time-bound nie IP-bound, Bright Data residential proxy z innego IP → 206)
|
||||||
# ale wymaga browser-TLS (curl_cffi chrome → 206; plain curl → 403). W #20
|
# ale wymaga browser-TLS (curl_cffi chrome → 206; plain curl → 403). W #20
|
||||||
# testowałem finalny URL plain-curl-em → 403 → błędnie „IP-bound" → WebView.
|
# testowałem finalny URL plain-curl-em → 403 → błędnie „IP-bound" → WebView.
|
||||||
# Teraz backend-resolve przez _kvs (curl_cffi chrome), native multi-quality,
|
# Teraz backend-resolve przez _kvs (curl_cffi chrome), native multi-quality,
|
||||||
# ZERO proxy (wcześniej force_proxy łamał no-proxy). Same mechanizm co freshporno.
|
# ZERO proxy (wcześniej force_proxy łamał no-proxy). Same mechanizm co freshporno.
|
||||||
"porn00org": porn00.extract,
|
"porn00org": porn00.extract,
|
||||||
# pornxp — `<source> //sr.porn-xp.com/<token>/.../720.mp4` (redirect → xpxp.eu).
|
# pornxp, `<source> //sr.porn-xp.com/<token>/.../720.mp4` (redirect → xpxp.eu).
|
||||||
# 2026-06-01 (task #20): 403 cross-IP → token w path IP-bound. WebView only.
|
# 2026-06-01 (task #20): 403 cross-IP → token w path IP-bound. WebView only.
|
||||||
"pornxpph": _vps_blocked_fallback.extract,
|
"pornxpph": _vps_blocked_fallback.extract,
|
||||||
# yesporn.vip — KVS engine. VPS znów dociera (HTTP 200, odblokowane jak porntrex),
|
# yesporn.vip, KVS engine. VPS znów dociera (HTTP 200, odblokowane jak porntrex),
|
||||||
# więc resolvujemy SERVER-SIDE: dekoduj flashvars `video_url`/alt/alt2 (function/0/ +
|
# więc resolvujemy SERVER-SIDE: dekoduj flashvars `video_url`/alt/alt2 (function/0/ +
|
||||||
# license_code, algo kt_player) → follow get_file 302 → portable cdn5 url (time-bound,
|
# license_code, algo kt_player) → follow get_file 302 → portable cdn5 url (time-bound,
|
||||||
# NIE IP/cookie-bound, zweryfikowane cross-IP 2026-05-31). Mobile gra direct natywnie,
|
# NIE IP/cookie-bound, zweryfikowane cross-IP 2026-05-31). Mobile gra direct natywnie,
|
||||||
|
|
@ -183,60 +183,60 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||||
# ad-heavy stronę a scrape łapał preroll-reklamę (bkcdn) zamiast wideo.
|
# ad-heavy stronę a scrape łapał preroll-reklamę (bkcdn) zamiast wideo.
|
||||||
"yespornvip": yespornvip.extract,
|
"yespornvip": yespornvip.extract,
|
||||||
# Direct-scraping tubes (mają też search scraper w connectors/direct_scrapers/)
|
# Direct-scraping tubes (mają też search scraper w connectors/direct_scrapers/)
|
||||||
# — używają identycznego embed-iframe pattern dla streamingu.
|
# używają identycznego embed-iframe pattern dla streamingu.
|
||||||
# hdporn92com — DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe,
|
# hdporn92com, DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe,
|
||||||
# JS hijackuje kliki na popunder. Wszystkie playback_sources mass-marked dead.
|
# JS hijackuje kliki na popunder. Wszystkie playback_sources mass-marked dead.
|
||||||
# 0dayxx + pornditt + pornhat — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request): orphan
|
# 0dayxx + pornditt + pornhat, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request): orphan
|
||||||
# factories (0–0.2% canonical match), zastępujemy lepszymi źródłami. Dane skasowane.
|
# factories (0-0.2% canonical match), zastępujemy lepszymi źródłami. Dane skasowane.
|
||||||
# CF-protected tube — curl_cffi w fetch_tube_html bypassa JA3, embed-iframe pattern.
|
# CF-protected tube, curl_cffi w fetch_tube_html bypassa JA3, embed-iframe pattern.
|
||||||
# perverzija — natywny HLS (2026-07-28). Player xtremestream ma w HTML tylko
|
# perverzija, natywny HLS (2026-07-28). Player xtremestream ma w HTML tylko
|
||||||
# preroll/midroll (grały w pętli, bug 40ec88cc/92e9133e), a wideo dociąga runtime:
|
# preroll/midroll (grały w pętli, bug 40ec88cc/92e9133e), a wideo dociąga runtime:
|
||||||
# xs1.php?data=<md5>&q=<480|720> zwraca GOTOWY manifest HLS z pełnymi URL-ami
|
# xs1.php?data=<md5>&q=<480|720> zwraca GOTOWY manifest HLS z pełnymi URL-ami
|
||||||
# segmentów. Zamiast WebView oddajemy m3u8 wprost.
|
# segmentów. Zamiast WebView oddajemy m3u8 wprost.
|
||||||
"perverzijacom": perverzija.extract,
|
"perverzijacom": perverzija.extract,
|
||||||
# Special: WebView-only (Yii2 session-bound player).
|
# Special: WebView-only (Yii2 session-bound player).
|
||||||
"paradisehillcc": paradisehill.extract,
|
"paradisehillcc": paradisehill.extract,
|
||||||
# PornDoe — dołączony 2026-05-21 (theporndude audit). Stream URL nie inline w
|
# PornDoe, dołączony 2026-05-21 (theporndude audit). Stream URL nie inline w
|
||||||
# SSR HTML (player JS init po Play click), więc WebView fallback: mobile pobiera
|
# SSR HTML (player JS init po Play click), więc WebView fallback: mobile pobiera
|
||||||
# /watch/<id> z phone IP, player JS dekoduje video.src, INJECTED_JS scrape.
|
# /watch/<id> z phone IP, player JS dekoduje video.src, INJECTED_JS scrape.
|
||||||
# 0 VPS bandwidth — zgodne z pre-public bandwidth/anonimowość priorytet.
|
# 0 VPS bandwidth, zgodne z pre-public bandwidth/anonimowość priorytet.
|
||||||
"porndoecom": _vps_blocked_fallback.extract,
|
"porndoecom": _vps_blocked_fallback.extract,
|
||||||
# fullmovies.xxx + hdporn.gg — BRAKOWAŁO extractora (try_extract→None→"no stream";
|
# fullmovies.xxx + hdporn.gg, BRAKOWAŁO extractora (try_extract→None→"no stream";
|
||||||
# fullmovies.xxx + hdporn.gg — ta sama platforma (`<source>/get_file/8512/`).
|
# fullmovies.xxx + hdporn.gg, ta sama platforma (`<source>/get_file/8512/`).
|
||||||
# 2026-06-04 (DevTools + cross-IP, naprawia „loading forever" + bug 19866e9e):
|
# 2026-06-04 (DevTools + cross-IP, naprawia „loading forever" + bug 19866e9e):
|
||||||
# get_file binduje fpvcdn do IP FETCHERA + jest stateless + ważny ≥90s, więc
|
# get_file binduje fpvcdn do IP FETCHERA + jest stateless + ważny ≥90s, więc
|
||||||
# oddajemy get_file NIEZRESOLWOWANY (mobile_direct) — telefon follow-uje 302 →
|
# oddajemy get_file NIEZRESOLWOWANY (mobile_direct), telefon follow-uje 302 →
|
||||||
# fpvcdn z IP telefonu → gra. POMIJAMY 4K (time-out 30s na fpvcdn = przyczyna
|
# fpvcdn z IP telefonu → gra. POMIJAMY 4K (time-out 30s na fpvcdn = przyczyna
|
||||||
# „loading forever"; 720/480p gra ~1s). Native, multi-quality, ZERO proxy/WebView.
|
# „loading forever"; 720/480p gra ~1s). Native, multi-quality, ZERO proxy/WebView.
|
||||||
# (#19866e9e wcześniej źle: założyłem „get_file 403 IP-bound" testem plain-curl.)
|
# (#19866e9e wcześniej źle: założyłem „get_file 403 IP-bound" testem plain-curl.)
|
||||||
"fullmoviesxxx": fullmovies.extract,
|
"fullmoviesxxx": fullmovies.extract,
|
||||||
"hdporngg": hdporngg.extract,
|
"hdporngg": hdporngg.extract,
|
||||||
# hqfap (PlayTube CMS) — JSON-LD contentUrl = direct mp4. USUNIĘTY 2026-06-25 (cała
|
# hqfap (PlayTube CMS), JSON-LD contentUrl = direct mp4. USUNIĘTY 2026-06-25 (cała
|
||||||
# biblioteka serwowała `/upload/videos/video_down.mp4` stub), PRZYWRÓCONY 2026-07-07:
|
# biblioteka serwowała `/upload/videos/video_down.mp4` stub), PRZYWRÓCONY 2026-07-07:
|
||||||
# strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP, zweryfikowane),
|
# strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP, zweryfikowane),
|
||||||
# user request. 4k69 zostaje usunięty (nie sprawdzany ponownie).
|
# user request. 4k69 zostaje usunięty (nie sprawdzany ponownie).
|
||||||
# 2026-07-28: hqfap wdrożył Cloudflare z JS-challenge na strony /watch/ — 403 zarówno
|
# 2026-07-28: hqfap wdrożył Cloudflare z JS-challenge na strony /watch/ 403 zarówno
|
||||||
# z VPS, jak i z residential IP, także przy pełnym komplecie nagłówków przeglądarki
|
# z VPS, jak i z residential IP, także przy pełnym komplecie nagłówków przeglądarki
|
||||||
# (strona główna dalej 200, więc blokada jest celowana w strony wideo). Phone-side
|
# (strona główna dalej 200, więc blokada jest celowana w strony wideo). Phone-side
|
||||||
# `resolveHqfapPage` (zwykły fetch) nie ma jak przejść challenge, więc padał i user
|
# `resolveHqfapPage` (zwykły fetch) nie ma jak przejść challenge, więc padał i user
|
||||||
# lądował na stronie zamiast na wideo (bug-report 92e9133e "Hqfap przekierowuje na
|
# lądował na stronie zamiast na wideo (bug-report 92e9133e "Hqfap przekierowuje na
|
||||||
# stronę"; telemetria to PRZEMILCZAŁA — zero eventów od 27.07, bo błąd jest przed
|
# stronę"; telemetria to PRZEMILCZAŁA, zero eventów od 27.07, bo błąd jest przed
|
||||||
# playerem). WebView (Chrome engine + JS + cookie) challenge przechodzi, więc
|
# playerem). WebView (Chrome engine + JS + cookie) challenge przechodzi, więc
|
||||||
# przełączamy na tryb WebView; INJECTED_JS i tak wyłuska mp4 i wróci na native player.
|
# przełączamy na tryb WebView; INJECTED_JS i tak wyłuska mp4 i wróci na native player.
|
||||||
# "hqfapcom" — ZDJĘTY 2026-08-02, patrz komentarz w direct_scrapers/__init__.py.
|
# "hqfapcom", ZDJĘTY 2026-08-02, patrz komentarz w direct_scrapers/__init__.py.
|
||||||
# javflix (JAV, WordPress) — przyciski serwerów to `<a class="myLink" href="<embed>">`
|
# javflix (JAV, WordPress), przyciski serwerów to `<a class="myLink" href="<embed>">`
|
||||||
# (streamtape/voe/doodstream/emturbovid). Generyczny _embed_iframe łapie je anchor-hoster
|
# (streamtape/voe/doodstream/emturbovid). Generyczny _embed_iframe łapie je anchor-hoster
|
||||||
# patternem → type='hoster', telefon resolwuje (voe backend, dood/filemoon phone-side).
|
# patternem → type='hoster', telefon resolwuje (voe backend, dood/filemoon phone-side).
|
||||||
# Wrapper javflix.extract odsiewa placeholder players.mp4.
|
# Wrapper javflix.extract odsiewa placeholder players.mp4.
|
||||||
"javflix": javflix.extract,
|
"javflix": javflix.extract,
|
||||||
# vjav (JAV, TXXX network) — videofile.php -> get_file HLS (patrz tubes/vjav.py).
|
# vjav (JAV, TXXX network), videofile.php -> get_file HLS (patrz tubes/vjav.py).
|
||||||
"vjav": vjav.extract,
|
"vjav": vjav.extract,
|
||||||
# supjav (JAV, embed-aggregator) — data-link reverse-hex -> lk1 -> hoster (type=hoster).
|
# supjav (JAV, embed-aggregator), data-link reverse-hex -> lk1 -> hoster (type=hoster).
|
||||||
"supjav": supjav.extract,
|
"supjav": supjav.extract,
|
||||||
# neporn — KVS function/0 + license (jak freshporno). Server-side _kvs resolve →
|
# neporn, KVS function/0 + license (jak freshporno). Server-side _kvs resolve →
|
||||||
# data001.neporn.com/remote_control.php portable (cross-IP 206, 2026-06-10).
|
# data001.neporn.com/remote_control.php portable (cross-IP 206, 2026-06-10).
|
||||||
"neporncom": neporn.extract,
|
"neporncom": neporn.extract,
|
||||||
# superporn — `<source>` mp4 (cdnst*.superporn.com) token IP-bound do fetchera
|
# superporn, `<source>` mp4 (cdnst*.superporn.com) token IP-bound do fetchera
|
||||||
# (403 cross-IP, test 2026-06-10), a sama strona CF-blocked z VPS. Resolve MUSI
|
# (403 cross-IP, test 2026-06-10), a sama strona CF-blocked z VPS. Resolve MUSI
|
||||||
# być phone-side: WebView ładuje stronę z residential IP telefonu, INJECTED_JS
|
# być phone-side: WebView ładuje stronę z residential IP telefonu, INJECTED_JS
|
||||||
# bierze video.src. Ingest HTML idzie osobno przez Bright Data proxy (scraper).
|
# bierze video.src. Ingest HTML idzie osobno przez Bright Data proxy (scraper).
|
||||||
|
|
@ -251,7 +251,7 @@ def try_extract(sitetag: str, page_url: str) -> list[StreamSource] | None:
|
||||||
- brak extractora dla tego sitetag
|
- brak extractora dla tego sitetag
|
||||||
- extractor zwrócił None / nie znalazł URL'a
|
- extractor zwrócił None / nie znalazł URL'a
|
||||||
|
|
||||||
Raises HosterDead gdy embed page wprost mówi że video deleted/not found —
|
Raises HosterDead gdy embed page wprost mówi że video deleted/not found,
|
||||||
caller (playback.py) łapie i oznacza playback_source.dead_at.
|
caller (playback.py) łapie i oznacza playback_source.dead_at.
|
||||||
"""
|
"""
|
||||||
extractor = _REGISTRY.get(sitetag)
|
extractor = _REGISTRY.get(sitetag)
|
||||||
|
|
@ -273,7 +273,7 @@ def supported_sitetags() -> tuple[str, ...]:
|
||||||
|
|
||||||
def is_vps_blocked_fallback(sitetag: str) -> bool:
|
def is_vps_blocked_fallback(sitetag: str) -> bool:
|
||||||
"""True gdy sitetag resolvuje się TYLKO przez WebView fallback (IP-bound CDN /
|
"""True gdy sitetag resolvuje się TYLKO przez WebView fallback (IP-bound CDN /
|
||||||
ad-heavy / CAPTCHA — np. fpoxxx, pornxpph). Takie źródła dają gorszy
|
ad-heavy / CAPTCHA, np. fpoxxx, pornxpph). Takie źródła dają gorszy
|
||||||
UX (reklamy, czarny ekran) niż natywny KVS/direct resolve, więc UI powinien je
|
UX (reklamy, czarny ekran) niż natywny KVS/direct resolve, więc UI powinien je
|
||||||
rankować NIŻEJ gdy scena ma też natywne źródło (bug-report 2026-06-07: scena
|
rankować NIŻEJ gdy scena ma też natywne źródło (bug-report 2026-06-07: scena
|
||||||
pokazywała fpoxxx-WebView przed działającym freshporno bo sort był alfabetyczny)."""
|
pokazywała fpoxxx-WebView przed działającym freshporno bo sort był alfabetyczny)."""
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ _DEFAULT_UA = (
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FetchResult:
|
class FetchResult:
|
||||||
"""Mini response-like object — drop-in dla httpx.Response w naszych use case'ach."""
|
"""Mini response-like object, drop-in dla httpx.Response w naszych use case'ach."""
|
||||||
|
|
||||||
status_code: int
|
status_code: int
|
||||||
text: str
|
text: str
|
||||||
|
|
@ -60,7 +60,7 @@ def browser_get(
|
||||||
) -> FetchResult:
|
) -> FetchResult:
|
||||||
"""GET z Chrome TLS fingerprint (curl_cffi). Spada do httpx gdy curl_cffi brak.
|
"""GET z Chrome TLS fingerprint (curl_cffi). Spada do httpx gdy curl_cffi brak.
|
||||||
|
|
||||||
`proxy` (http://user:pass@host:port) — routuje request przez proxy. Używane
|
`proxy` (http://user:pass@host:port), routuje request przez proxy. Używane
|
||||||
tylko do ingestu HTML tubów blokujących VPS IP (np. superporn przez Bright Data
|
tylko do ingestu HTML tubów blokujących VPS IP (np. superporn przez Bright Data
|
||||||
ISP proxy). NIE dla streamów."""
|
ISP proxy). NIE dla streamów."""
|
||||||
if not _HAS_CURL_CFFI:
|
if not _HAS_CURL_CFFI:
|
||||||
|
|
@ -78,7 +78,7 @@ def browser_get(
|
||||||
impersonate=impersonate,
|
impersonate=impersonate,
|
||||||
allow_redirects=follow_redirects,
|
allow_redirects=follow_redirects,
|
||||||
proxies={"http": proxy, "https": proxy} if proxy else None,
|
proxies={"http": proxy, "https": proxy} if proxy else None,
|
||||||
verify=not proxy, # Bright Data MITM CA — curl_cffi nie ma go w bundlu
|
verify=not proxy, # Bright Data MITM CA, curl_cffi nie ma go w bundlu
|
||||||
)
|
)
|
||||||
return FetchResult(status_code=r.status_code, text=r.text, url=str(r.url))
|
return FetchResult(status_code=r.status_code, text=r.text, url=str(r.url))
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ def fetch_tube_html(url: str, *, timeout: float = 60.0, max_retries: int = 2) ->
|
||||||
(browser_get) żeby ominąć JA3 fingerprint blocks na CF-fronted tube'ach.
|
(browser_get) żeby ominąć JA3 fingerprint blocks na CF-fronted tube'ach.
|
||||||
|
|
||||||
Retry: 5xx i empty body retry max_retries razy z exponential backoff (0.5s, 1s).
|
Retry: 5xx i empty body retry max_retries razy z exponential backoff (0.5s, 1s).
|
||||||
Dla freshporno itp. które czasem zwracają 503/empty — bez retry user dostawał
|
Dla freshporno itp. które czasem zwracają 503/empty, bez retry user dostawał
|
||||||
"extractor None" z transient hiccup.
|
"extractor None" z transient hiccup.
|
||||||
"""
|
"""
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ from typing import Any
|
||||||
class StreamSource:
|
class StreamSource:
|
||||||
"""Pojedynczy resolved stream URL.
|
"""Pojedynczy resolved stream URL.
|
||||||
|
|
||||||
Mapuje na `StreamLink` w playback API (api/playback.py) — `link` → `stream_url`,
|
Mapuje na `StreamLink` w playback API (api/playback.py), `link` → `stream_url`,
|
||||||
`quality` → `quality`, `type` → `type`.
|
`quality` → `quality`, `type` → `type`.
|
||||||
|
|
||||||
`referer` — opcjonalny override Referera używanego przez stream_proxy. Niektóre
|
`referer`, opcjonalny override Referera używanego przez stream_proxy. Niektóre
|
||||||
CDN-y (KVS-style watchporn.to, fpo.xxx itp.) zwracają 410/403 gdy Referer nie
|
CDN-y (KVS-style watchporn.to, fpo.xxx itp.) zwracają 410/403 gdy Referer nie
|
||||||
pasuje do *embed page'a* (np. proxy używa `Referer: 0dayxx.com` ale CDN expectuje
|
pasuje do *embed page'a* (np. proxy używa `Referer: 0dayxx.com` ale CDN expectuje
|
||||||
`Referer: watchporn.to`). Gdy None → caller (playback.py) używa `page_url`.
|
`Referer: watchporn.to`). Gdy None → caller (playback.py) używa `page_url`.
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
"""Universal duration extractor for tube pages.
|
"""Universal duration extractor for tube pages.
|
||||||
|
|
||||||
Direct scrapery (xvideos, xnxx, youporn, porntrex, …) są search-only — pobierają
|
Direct scrapery (xvideos, xnxx, youporn, porntrex, …) są search-only, pobierają
|
||||||
listing i wycioskują tylko URL + slug-as-title. Duration pojawia się dopiero na
|
listing i wycioskują tylko URL + slug-as-title. Duration pojawia się dopiero na
|
||||||
detail page i jest dostępne w jednym z patternów:
|
detail page i jest dostępne w jednym z patternów:
|
||||||
|
|
||||||
1. **OpenGraph numeric** (youporn, redtube, eporner):
|
1. **OpenGraph numeric** (youporn, redtube, eporner):
|
||||||
`<meta property="og:video:duration" content="992">` — sekundy.
|
`<meta property="og:video:duration" content="992">`, sekundy.
|
||||||
2. **OpenGraph ISO 8601** (rzadkie):
|
2. **OpenGraph ISO 8601** (rzadkie):
|
||||||
`<meta property="og:video:duration" content="PT16M32S">`.
|
`<meta property="og:video:duration" content="PT16M32S">`.
|
||||||
3. **Schema.org VideoObject LD-JSON** (xvideos, xnxx, KVS-based):
|
3. **Schema.org VideoObject LD-JSON** (xvideos, xnxx, KVS-based):
|
||||||
`"duration": "PT00H07M10S"` w JSON-LD `<script type="application/ld+json">`.
|
`"duration": "PT00H07M10S"` w JSON-LD `<script type="application/ld+json">`.
|
||||||
4. **itemprop microdata** (sxyland, 0dayxx, niektóre WordPress):
|
4. **itemprop microdata** (sxyland, 0dayxx, niektóre WordPress):
|
||||||
`<meta itemprop="duration" content="P0DT0H21M13S">` — ISO 8601 z opcjonalnym
|
`<meta itemprop="duration" content="P0DT0H21M13S">`, ISO 8601 z opcjonalnym
|
||||||
`P<days>D` prefix + opcjonalnym `T` blokiem HMS.
|
`P<days>D` prefix + opcjonalnym `T` blokiem HMS.
|
||||||
|
|
||||||
Funkcja zwraca pierwszy znaleziony match jako int seconds, lub None.
|
Funkcja zwraca pierwszy znaleziony match jako int seconds, lub None.
|
||||||
|
|
@ -29,7 +29,7 @@ _ITEMPROP_DURATION_RE = re.compile(
|
||||||
r'itemprop="duration"[^>]*content="([^"]+)"', re.IGNORECASE
|
r'itemprop="duration"[^>]*content="([^"]+)"', re.IGNORECASE
|
||||||
)
|
)
|
||||||
# Hqporner-style meta description: "Video duration is 6min 55sec" lub "1h 23min 5sec".
|
# Hqporner-style meta description: "Video duration is 6min 55sec" lub "1h 23min 5sec".
|
||||||
# Generic — pasuje też do innych tube'ów które dorzucają w meta opis duration prozą.
|
# Generic, pasuje też do innych tube'ów które dorzucają w meta opis duration prozą.
|
||||||
_META_DESC_DURATION_RE = re.compile(
|
_META_DESC_DURATION_RE = re.compile(
|
||||||
r'(?:duration\s+is\s+|<meta\s+name="description"\s+content="[^"]*duration\s+is\s+)'
|
r'(?:duration\s+is\s+|<meta\s+name="description"\s+content="[^"]*duration\s+is\s+)'
|
||||||
r'(?:(\d+)\s*h(?:our)?s?)?\s*(?:(\d+)\s*min)?\s*(?:(\d+)\s*sec)?',
|
r'(?:(\d+)\s*h(?:our)?s?)?\s*(?:(\d+)\s*min)?\s*(?:(\d+)\s*sec)?',
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ Hostery embed-page'y stosują JWPlayer + P.A.C.K.E.R. obfuskację:
|
||||||
i chowają `sources: [{file: "https://...m3u8"}]` w packed JS.
|
i chowają `sources: [{file: "https://...m3u8"}]` w packed JS.
|
||||||
|
|
||||||
Tu jest:
|
Tu jest:
|
||||||
- `unpack_packer(js)` — dekoder P.A.C.K.E.R.
|
- `unpack_packer(js)`, dekoder P.A.C.K.E.R.
|
||||||
- `extract_stream_from_hoster(iframe_url, *, referer)` — fetch embed → unpack → m3u8/mp4
|
- `extract_stream_from_hoster(iframe_url, *, referer)`, fetch embed → unpack → m3u8/mp4
|
||||||
|
|
||||||
Te funkcje są używane przez:
|
Te funkcje są używane przez:
|
||||||
1. Per-tube extractors (latestpornvideo, hqporner fallback) — page → embed iframe → tu
|
1. Per-tube extractors (latestpornvideo, hqporner fallback), page → embed iframe → tu
|
||||||
2. Movies playback (api/playback.py movies_router) — direct hoster URL → tu
|
2. Movies playback (api/playback.py movies_router), direct hoster URL → tu
|
||||||
|
|
||||||
Nie ma już zależności od PornAppClient / porn-app API.
|
Nie ma już zależności od PornAppClient / porn-app API.
|
||||||
"""
|
"""
|
||||||
|
|
@ -25,10 +25,10 @@ from app.extractors._models import HosterDead
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# P.A.C.K.E.R. javascript unpacker — odwraca obfuskację wzorca:
|
# P.A.C.K.E.R. javascript unpacker, odwraca obfuskację wzorca:
|
||||||
# eval(function(p,a,c,k,e,d){while(c--)if(k[c])p=p.replace(...);return p}
|
# eval(function(p,a,c,k,e,d){while(c--)if(k[c])p=p.replace(...);return p}
|
||||||
# ('PAYLOAD', BASE, COUNT, 'kw1|kw2|...'.split('|'), 0, {}))
|
# ('PAYLOAD', BASE, COUNT, 'kw1|kw2|...'.split('|'), 0, {}))
|
||||||
# StreamWish, doodporn, mixdrop, filemoon — wszystkie używają tego packera do schowania
|
# StreamWish, doodporn, mixdrop, filemoon, wszystkie używają tego packera do schowania
|
||||||
# `sources: [{file: "https://...m3u8"}]` w JWPlayer config.
|
# `sources: [{file: "https://...m3u8"}]` w JWPlayer config.
|
||||||
_PACKER_ARGS_RE = re.compile(
|
_PACKER_ARGS_RE = re.compile(
|
||||||
r"\}\s*\(\s*'((?:\\'|[^'])+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\'|[^'])*)'\s*\.split\('\|'\)",
|
r"\}\s*\(\s*'((?:\\'|[^'])+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\'|[^'])*)'\s*\.split\('\|'\)",
|
||||||
|
|
@ -102,7 +102,7 @@ def _looks_like_ad(url: str) -> bool:
|
||||||
|
|
||||||
# Niektóre hostery (doodporn) chowają mp4/m3u8 w słowniku zmiennych i odwołują się do
|
# Niektóre hostery (doodporn) chowają mp4/m3u8 w słowniku zmiennych i odwołują się do
|
||||||
# nich w `sources: [{file: links.hls2}]`. Wtedy regex powyżej nie złapie. Drugi pass
|
# nich w `sources: [{file: links.hls2}]`. Wtedy regex powyżej nie złapie. Drugi pass
|
||||||
# bierze pierwszy `.m3u8|.mp4|.mpd` URL z całego unpacked HTML — heurystyka, ale
|
# bierze pierwszy `.m3u8|.mp4|.mpd` URL z całego unpacked HTML, heurystyka, ale
|
||||||
# pierwszy taki URL to zwykle master playlist video.
|
# pierwszy taki URL to zwykle master playlist video.
|
||||||
_HOSTER_FALLBACK_URL_RE = re.compile(
|
_HOSTER_FALLBACK_URL_RE = re.compile(
|
||||||
r'https?://[^\s"\'<>]+\.(?:m3u8|mp4|mpd)(?:\?[^\s"\'<>]*)?',
|
r'https?://[^\s"\'<>]+\.(?:m3u8|mp4|mpd)(?:\?[^\s"\'<>]*)?',
|
||||||
|
|
@ -111,7 +111,7 @@ _HOSTER_FALLBACK_URL_RE = re.compile(
|
||||||
|
|
||||||
|
|
||||||
# Sygnatury "video not found" / "deleted" które hostery wstawiają w HTML embed page.
|
# Sygnatury "video not found" / "deleted" które hostery wstawiają w HTML embed page.
|
||||||
# Gdy widzimy te markery, to wiemy że link jest martwy — raise HosterDead, caller w
|
# Gdy widzimy te markery, to wiemy że link jest martwy, raise HosterDead, caller w
|
||||||
# playback.py oznaczy playback_source.dead_at.
|
# playback.py oznaczy playback_source.dead_at.
|
||||||
_HOSTER_DEAD_PATTERNS = (
|
_HOSTER_DEAD_PATTERNS = (
|
||||||
"Video not found",
|
"Video not found",
|
||||||
|
|
@ -124,20 +124,20 @@ _HOSTER_DEAD_PATTERNS = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# KVS (Kernel Video Sharing) player markers — kt_player.js + license_code w HTML.
|
# KVS (Kernel Video Sharing) player markers, kt_player.js + license_code w HTML.
|
||||||
# Używają go fpo.xxx, 0day.kim, hdporn92, sxyland, i wiele innych WordPress-based
|
# Używają go fpo.xxx, 0day.kim, hdporn92, sxyland, i wiele innych WordPress-based
|
||||||
# tubes. KVS encryptuje URL `function/0/<encrypted>` license_code'em — regex fallback
|
# tubes. KVS encryptuje URL `function/0/<encrypted>` license_code'em, regex fallback
|
||||||
# (`_HOSTER_FALLBACK_URL_RE`) złapie zamiast tego URL `event_reporting2` (tracking
|
# (`_HOSTER_FALLBACK_URL_RE`) złapie zamiast tego URL `event_reporting2` (tracking
|
||||||
# pixel zwracający 1×1 GIF zamiast video). Jak widzimy markery KVS, idziemy od razu
|
# pixel zwracający 1×1 GIF zamiast video). Jak widzimy markery KVS, idziemy od razu
|
||||||
# do yt-dlp którego generic extractor poprawnie deszyfruje URL.
|
# do yt-dlp którego generic extractor poprawnie deszyfruje URL.
|
||||||
_KVS_MARKERS = ("kt_player(", "license_code")
|
_KVS_MARKERS = ("kt_player(", "license_code")
|
||||||
|
|
||||||
|
|
||||||
# File hosters / known dead — rapidgator/nitroflare/frdl wymagają premium account
|
# File hosters / known dead, rapidgator/nitroflare/frdl wymagają premium account
|
||||||
# (zwracają HTML z formularzem logowania zamiast video). Zwróć None bez fetch'u —
|
# (zwracają HTML z formularzem logowania zamiast video). Zwróć None bez fetch'u,
|
||||||
# caller w movies playback dorzuci embed-only fallback i mobile i tak otworzy
|
# caller w movies playback dorzuci embed-only fallback i mobile i tak otworzy
|
||||||
# WebView (gdzie user może zalogować się premium jeśli chce).
|
# WebView (gdzie user może zalogować się premium jeśli chce).
|
||||||
# Streamtape USUNIĘTY z blacklistu 2026-05-15 — ma dedicated extractor (innerHTML
|
# Streamtape USUNIĘTY z blacklistu 2026-05-15, ma dedicated extractor (innerHTML
|
||||||
# substring decode → /get_video → 302 → tapecontent.net mp4). Większość 12k URLów
|
# substring decode → /get_video → 302 → tapecontent.net mp4). Większość 12k URLów
|
||||||
# w naszej DB jest DMCA-dead ale ~5% żyje.
|
# w naszej DB jest DMCA-dead ale ~5% żyje.
|
||||||
_FILE_HOSTER_RE = re.compile(
|
_FILE_HOSTER_RE = re.compile(
|
||||||
|
|
@ -169,8 +169,8 @@ def extract_stream_from_hoster(
|
||||||
return None
|
return None
|
||||||
# Per-hoster dedicated extractors (specific URL shapes / decode patterns).
|
# Per-hoster dedicated extractors (specific URL shapes / decode patterns).
|
||||||
# Mixdrop: P.A.C.K.E.R. → MDCore.wurl protocol-relative `//host/v2/<id>.mp4?s=...`
|
# Mixdrop: P.A.C.K.E.R. → MDCore.wurl protocol-relative `//host/v2/<id>.mp4?s=...`
|
||||||
# — generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme).
|
# generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme).
|
||||||
# `miixdrop` (double-i) to current canonical domain — wszystkie legacy
|
# `miixdrop` (double-i) to current canonical domain, wszystkie legacy
|
||||||
# `mixdrop.{ag,sb,my,co,...}` + `m1xdrop.bz` 301-ują tam. Bez `miixdrop`
|
# `mixdrop.{ag,sb,my,co,...}` + `m1xdrop.bz` 301-ują tam. Bez `miixdrop`
|
||||||
# w dispatch URLs already-on-new-domain (upstream tubes które zaktualizowały
|
# w dispatch URLs already-on-new-domain (upstream tubes które zaktualizowały
|
||||||
# embed src) trafiałyby do generic logic, gdzie regex `https?://...\.mp4`
|
# embed src) trafiałyby do generic logic, gdzie regex `https?://...\.mp4`
|
||||||
|
|
@ -182,7 +182,7 @@ def extract_stream_from_hoster(
|
||||||
return sources[0].link
|
return sources[0].link
|
||||||
# Fall through to generic logic gdyby dedicated zwrócił None.
|
# Fall through to generic logic gdyby dedicated zwrócił None.
|
||||||
# xtremestream (perverzija): videojs+hls.js player. Embed `/player/index.php?data=<HASH>`
|
# xtremestream (perverzija): videojs+hls.js player. Embed `/player/index.php?data=<HASH>`
|
||||||
# — m3u8 master playlist serwowany przez `/player/xs1.php?data=<HASH>` (ten sam HASH,
|
# m3u8 master playlist serwowany przez `/player/xs1.php?data=<HASH>` (ten sam HASH,
|
||||||
# inny endpoint; potwierdzone w inline JS `m3u8_loader_url`). Brak packera/file: w
|
# inny endpoint; potwierdzone w inline JS `m3u8_loader_url`). Brak packera/file: w
|
||||||
# HTML → generic logic zwracała None → WebView fallback. Trywialna podmiana endpointu.
|
# HTML → generic logic zwracała None → WebView fallback. Trywialna podmiana endpointu.
|
||||||
_xtr = re.search(
|
_xtr = re.search(
|
||||||
|
|
@ -200,9 +200,9 @@ def extract_stream_from_hoster(
|
||||||
sources = streamtape.extract(iframe_url, timeout=timeout)
|
sources = streamtape.extract(iframe_url, timeout=timeout)
|
||||||
if sources:
|
if sources:
|
||||||
return sources[0].link
|
return sources[0].link
|
||||||
return None # streamtape ma własną HosterDead obsługę — generic fallback by się sypał
|
return None # streamtape ma własną HosterDead obsługę, generic fallback by się sypał
|
||||||
# Shared SPA+AES-CBC engine: embedseek/seekplayer/rpmplay/upns/player4me/easyvidplayer
|
# Shared SPA+AES-CBC engine: embedseek/seekplayer/rpmplay/upns/player4me/easyvidplayer
|
||||||
# — wszystkie używają tego samego silnika (`/api/v1/video` z AES-CBC encrypted
|
# wszystkie używają tego samego silnika (`/api/v1/video` z AES-CBC encrypted
|
||||||
# m3u8 source). Razem ~159k playback sources w DB.
|
# m3u8 source). Razem ~159k playback sources w DB.
|
||||||
from app.extractors.hosters import seekplayer_engine
|
from app.extractors.hosters import seekplayer_engine
|
||||||
if seekplayer_engine.matches(iframe_url):
|
if seekplayer_engine.matches(iframe_url):
|
||||||
|
|
@ -226,7 +226,7 @@ def extract_stream_from_hoster(
|
||||||
if sources:
|
if sources:
|
||||||
return sources[0].link
|
return sources[0].link
|
||||||
return None
|
return None
|
||||||
# filemoon "Byse" SPA — server-side resolve jest bezcelowy: stream URL z API
|
# filemoon "Byse" SPA, server-side resolve jest bezcelowy: stream URL z API
|
||||||
# jest IP-bound do requestera, więc VPS dostałby URL działający tylko z VPS.
|
# jest IP-bound do requestera, więc VPS dostałby URL działający tylko z VPS.
|
||||||
# Zwracamy None od razu → _embed_iframe Stage 2 → type='hoster' → mobile
|
# Zwracamy None od razu → _embed_iframe Stage 2 → type='hoster' → mobile
|
||||||
# filemoonHoster.ts robi POST /playback + AES-256-GCM z IP użytkownika.
|
# filemoonHoster.ts robi POST /playback + AES-256-GCM z IP użytkownika.
|
||||||
|
|
@ -238,7 +238,7 @@ def extract_stream_from_hoster(
|
||||||
):
|
):
|
||||||
log.debug("hoster %s: filemoon SPA → type=hoster (mobile-side resolve)", iframe_url)
|
log.debug("hoster %s: filemoon SPA → type=hoster (mobile-side resolve)", iframe_url)
|
||||||
return None
|
return None
|
||||||
# DoodStream + klony (playmogo/doply/myvidplay/dood.*/doodstream) — protokół pass_md5
|
# DoodStream + klony (playmogo/doply/myvidplay/dood.*/doodstream), protokół pass_md5
|
||||||
# + niewidzialny Cloudflare Turnstile wiązany z IP requestera. Server-side resolve
|
# + niewidzialny Cloudflare Turnstile wiązany z IP requestera. Server-side resolve
|
||||||
# bezcelowy (VPS dostaje Turnstile gate / IP-bound token). Zwracamy None → caller
|
# bezcelowy (VPS dostaje Turnstile gate / IP-bound token). Zwracamy None → caller
|
||||||
# oddaje embed jako type='hoster' → mobile doodstream.ts robi pass_md5 z IP usera
|
# oddaje embed jako type='hoster' → mobile doodstream.ts robi pass_md5 z IP usera
|
||||||
|
|
@ -305,7 +305,7 @@ def extract_stream_from_hoster(
|
||||||
if (url := _first_non_ad(_HOSTER_FALLBACK_URL_RE, html, 0)):
|
if (url := _first_non_ad(_HOSTER_FALLBACK_URL_RE, html, 0)):
|
||||||
return url
|
return url
|
||||||
|
|
||||||
# 4) yt-dlp last resort — battle-tested extractory dla streamtape, dood, mixdrop,
|
# 4) yt-dlp last resort, battle-tested extractory dla streamtape, dood, mixdrop,
|
||||||
# filemoon, voe, vidoza, etc. Nie używamy go domyślnie (slow + lots of HTTP),
|
# filemoon, voe, vidoza, etc. Nie używamy go domyślnie (slow + lots of HTTP),
|
||||||
# tylko gdy nasze własne metody zawiodły.
|
# tylko gdy nasze własne metody zawiodły.
|
||||||
ytdlp_url = _try_ytdlp_hoster(iframe_url, timeout=timeout)
|
ytdlp_url = _try_ytdlp_hoster(iframe_url, timeout=timeout)
|
||||||
|
|
@ -324,7 +324,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
|
||||||
"""yt-dlp wrapper dla hosters których nasz P.A.C.K.E.R. unpacker nie ogarnął.
|
"""yt-dlp wrapper dla hosters których nasz P.A.C.K.E.R. unpacker nie ogarnął.
|
||||||
|
|
||||||
yt-dlp ma extractory dla popularnych hosterów (streamtape, dood, mixdrop, filemoon,
|
yt-dlp ma extractory dla popularnych hosterów (streamtape, dood, mixdrop, filemoon,
|
||||||
voe, vidoza, streamwish, ...) — bezpośredni dostęp do `_extract_info`. Te extractory
|
voe, vidoza, streamwish, ...), bezpośredni dostęp do `_extract_info`. Te extractory
|
||||||
robią multi-step AJAX / token rotation / regex unpacking dla każdego hostera.
|
robią multi-step AJAX / token rotation / regex unpacking dla każdego hostera.
|
||||||
|
|
||||||
Catch-all exception handling: jeśli yt-dlp nie ma extractora dla tego hostera lub
|
Catch-all exception handling: jeśli yt-dlp nie ma extractora dla tego hostera lub
|
||||||
|
|
@ -366,7 +366,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
|
||||||
# "fake video" lub błąd (zgłoszone 2026-05-10 #30c4d3cf perverzija).
|
# "fake video" lub błąd (zgłoszone 2026-05-10 #30c4d3cf perverzija).
|
||||||
return any(ext in low for ext in (".m3u8", ".mp4", ".mpd", ".webm", ".ts"))
|
return any(ext in low for ext in (".m3u8", ".mp4", ".mpd", ".webm", ".ts"))
|
||||||
|
|
||||||
# Best video format URL — yt-dlp już rankuje formats, pierwszy w `formats` zwykle jest
|
# Best video format URL, yt-dlp już rankuje formats, pierwszy w `formats` zwykle jest
|
||||||
# najlepszy, albo `info["url"]` dla single-format extractorów.
|
# najlepszy, albo `info["url"]` dla single-format extractorów.
|
||||||
formats = info.get("formats") or [info]
|
formats = info.get("formats") or [info]
|
||||||
for fmt in formats:
|
for fmt in formats:
|
||||||
|
|
@ -375,7 +375,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
|
||||||
url = fmt.get("url")
|
url = fmt.get("url")
|
||||||
if _looks_like_video_url(url):
|
if _looks_like_video_url(url):
|
||||||
return url
|
return url
|
||||||
# Fallback: top-level URL — ale tylko gdy faktycznie wygląda na video.
|
# Fallback: top-level URL, ale tylko gdy faktycznie wygląda na video.
|
||||||
top = info.get("url")
|
top = info.get("url")
|
||||||
if _looks_like_video_url(top):
|
if _looks_like_video_url(top):
|
||||||
return top
|
return top
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""cdn.loadvid.com — HLS hoster oddający manifest przez POST (nie URL).
|
"""cdn.loadvid.com, HLS hoster oddający manifest przez POST (nie URL).
|
||||||
|
|
||||||
Protokół (reverse-engineer 2026-07-27, potwierdzony live):
|
Protokół (reverse-engineer 2026-07-27, potwierdzony live):
|
||||||
1. GET /videos/play/<hash> → HTML z `<meta name="csrf-token">` + inline
|
1. GET /videos/play/<hash> → HTML z `<meta name="csrf-token">` + inline
|
||||||
|
|
@ -15,7 +15,7 @@ Rozwiązanie: `extract()` zwraca **embed URL** oznaczony `manifest_producer=load
|
||||||
`/proxy/hls/<token>/play.m3u8` rozpoznaje ten marker i zamiast GET-a woła
|
`/proxy/hls/<token>/play.m3u8` rozpoznaje ten marker i zamiast GET-a woła
|
||||||
`fetch_manifest()`, po czym serwuje manifest telefonowi. Segmenty w manifeście są
|
`fetch_manifest()`, po czym serwuje manifest telefonowi. Segmenty w manifeście są
|
||||||
ABSOLUTNE i w pełni przenośne (zweryfikowane: 206 `bytes 0-1023` BEZ jakichkolwiek
|
ABSOLUTNE i w pełni przenośne (zweryfikowane: 206 `bytes 0-1023` BEZ jakichkolwiek
|
||||||
nagłówków, bez Referera, bez tokena — serwowane jako `image/png`, w środku TS),
|
nagłówków, bez Referera, bez tokena, serwowane jako `image/png`, w środku TS),
|
||||||
więc telefon ciągnie je bezpośrednio z CDN. Przez VPS idzie tylko manifest.
|
więc telefon ciągnie je bezpośrednio z CDN. Przez VPS idzie tylko manifest.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -106,7 +106,7 @@ def fetch_manifest(embed_url: str, *, timeout: float = 30.0) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
|
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
|
||||||
"""Zwraca embed URL z markerem producenta — manifest powstaje dopiero w
|
"""Zwraca embed URL z markerem producenta, manifest powstaje dopiero w
|
||||||
`/proxy/hls` (patrz docstring modułu). Weryfikujemy tu, że manifest realnie
|
`/proxy/hls` (patrz docstring modułu). Weryfikujemy tu, że manifest realnie
|
||||||
da się wyprodukować, żeby nie oddawać martwego źródła."""
|
da się wyprodukować, żeby nie oddawać martwego źródła."""
|
||||||
if not matches(page_url):
|
if not matches(page_url):
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Mixdrop embed hoster — P.A.C.K.E.R. eval → MDCore.wurl direct mp4.
|
"""Mixdrop embed hoster, P.A.C.K.E.R. eval → MDCore.wurl direct mp4.
|
||||||
|
|
||||||
Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120):
|
Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120):
|
||||||
1. Fetch `https://mixdrop.my/e/<id>` → 200 z 95KB body, redirect 301 do
|
1. Fetch `https://mixdrop.my/e/<id>` → 200 z 95KB body, redirect 301 do
|
||||||
|
|
@ -8,7 +8,7 @@ Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120):
|
||||||
3. yt-dlp's `decode_packed_codes()` rozkrywa do ~390 chars JavaScript:
|
3. yt-dlp's `decode_packed_codes()` rozkrywa do ~390 chars JavaScript:
|
||||||
`MDCore.wurl="//a-delivery22.mxcontent.net/v2/<id>.mp4?s=<sig>&e=<exp>&_t=<ts>"`
|
`MDCore.wurl="//a-delivery22.mxcontent.net/v2/<id>.mp4?s=<sig>&e=<exp>&_t=<ts>"`
|
||||||
4. URL na `mxcontent.net` zwraca **direct mp4** (Content-Type: video/mp4,
|
4. URL na `mxcontent.net` zwraca **direct mp4** (Content-Type: video/mp4,
|
||||||
Content-Length: ~485MB) — działa z Hetzner VPS IP, brak token IP-bind.
|
Content-Length: ~485MB), działa z Hetzner VPS IP, brak token IP-bind.
|
||||||
|
|
||||||
`s` to signed token (HMAC?), `e` to expiry timestamp (unix sec), `_t` to
|
`s` to signed token (HMAC?), `e` to expiry timestamp (unix sec), `_t` to
|
||||||
issued timestamp. Token jest valid ~24h od `_t`. Refetching embed page po
|
issued timestamp. Token jest valid ~24h od `_t`. Refetching embed page po
|
||||||
|
|
@ -42,7 +42,7 @@ _DEAD_RE = re.compile(
|
||||||
|
|
||||||
|
|
||||||
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
|
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
|
||||||
# UA + Accept są wymagane — bez nich mixdrop dla VALID video zwraca minimalny
|
# UA + Accept są wymagane, bez nich mixdrop dla VALID video zwraca minimalny
|
||||||
# body bez P.A.C.K.E.R. (sam stream_proxy._refetch_mixdrop_url też tak robi).
|
# body bez P.A.C.K.E.R. (sam stream_proxy._refetch_mixdrop_url też tak robi).
|
||||||
# Brak headerów powodował że extract() na żywym mixdrop ID dostawał shell bez
|
# Brak headerów powodował że extract() na żywym mixdrop ID dostawał shell bez
|
||||||
# packera → no match → None → mobile dostawał czarny ekran.
|
# packera → no match → None → mobile dostawał czarny ekran.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Wszyscy używają tego samego silnika (Vite-built React SPA + AES-CBC encrypted
|
||||||
|
|
||||||
Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI):
|
Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI):
|
||||||
|
|
||||||
1. Embed URL = `https://<sub>.<host>.<tld>/#<hash_id>` — hash fragment to video ID.
|
1. Embed URL = `https://<sub>.<host>.<tld>/#<hash_id>`, hash fragment to video ID.
|
||||||
SPA shell `Loading...` body load'uje `/assets/index-<n>.js` bundle.
|
SPA shell `Loading...` body load'uje `/assets/index-<n>.js` bundle.
|
||||||
|
|
||||||
2. JS fetcha `/api/v1/video?id=<hash_id>&w=<W>&h=<H>&r=` (W,H z window.screen).
|
2. JS fetcha `/api/v1/video?id=<hash_id>&w=<W>&h=<H>&r=` (W,H z window.screen).
|
||||||
|
|
@ -20,15 +20,15 @@ Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI):
|
||||||
- `title`, `poster`, `thumbnail`, ...
|
- `title`, `poster`, `thumbnail`, ...
|
||||||
|
|
||||||
4. `source` URL jest signed z visitor IP. Z VPS fetch zwraca master.m3u8 z signed
|
4. `source` URL jest signed z visitor IP. Z VPS fetch zwraca master.m3u8 z signed
|
||||||
token tied to VPS IP — proxy fetcha segments z tym samym tokenem, działa.
|
token tied to VPS IP, proxy fetcha segments z tym samym tokenem, działa.
|
||||||
CDN port 443 z `verify=False` (self-signed IP cert).
|
CDN port 443 z `verify=False` (self-signed IP cert).
|
||||||
|
|
||||||
5. Wszystkie hostery share te same wartości KEY/IV. Wewnętrzna obfuskacja JS
|
5. Wszystkie hostery share te same wartości KEY/IV. Wewnętrzna obfuskacja JS
|
||||||
maskuje to lookupem `ue(773)`, `ue(686)` itp. — derived bytes są zawsze
|
maskuje to lookupem `ue(773)`, `ue(686)` itp., derived bytes są zawsze
|
||||||
identyczne dla każdej domeny.
|
identyczne dla każdej domeny.
|
||||||
|
|
||||||
Hostery covered (origin counts w DB, 2026-05-15):
|
Hostery covered (origin counts w DB, 2026-05-15):
|
||||||
- embedseek (20271), seekplayer (20271) — mirror sites, dzielą hash_id
|
- embedseek (20271), seekplayer (20271), mirror sites, dzielą hash_id
|
||||||
- rpmplay (15317)
|
- rpmplay (15317)
|
||||||
- upns (14287)
|
- upns (14287)
|
||||||
- player4me (41040)
|
- player4me (41040)
|
||||||
|
|
@ -61,10 +61,10 @@ _IV = b"1234567890oiuytr"
|
||||||
#
|
#
|
||||||
# 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`.
|
# 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`.
|
||||||
# Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne
|
# Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne
|
||||||
# domeny — bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania.
|
# domeny, bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania.
|
||||||
# Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro
|
# Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro
|
||||||
# (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy)
|
# (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy)
|
||||||
# dekodują się tym samym silnikiem. Whitelist jest addytywna — istniejące hosty
|
# dekodują się tym samym silnikiem. Whitelist jest addytywna, istniejące hosty
|
||||||
# matchują się dalej tak samo.
|
# matchują się dalej tak samo.
|
||||||
_HOST_RE = re.compile(
|
_HOST_RE = re.compile(
|
||||||
r"^(?:[a-z0-9]+\.)?"
|
r"^(?:[a-z0-9]+\.)?"
|
||||||
|
|
@ -142,8 +142,8 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
||||||
cf = (data.get("cf") or "").strip()
|
cf = (data.get("cf") or "").strip()
|
||||||
|
|
||||||
# Source: IP-bound m3u8 URL na CDN edge (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8`).
|
# Source: IP-bound m3u8 URL na CDN edge (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8`).
|
||||||
# Token signed dla VPS IP — proxy poda segmenty z tego samego IP, OK.
|
# Token signed dla VPS IP, proxy poda segmenty z tego samego IP, OK.
|
||||||
# CDN servuje cert na IP — fetch wymaga verify=False (stream_proxy.py ma już
|
# CDN servuje cert na IP, fetch wymaga verify=False (stream_proxy.py ma już
|
||||||
# taką gałąź dla IP-host m3u8).
|
# taką gałąź dla IP-host m3u8).
|
||||||
sources: list[StreamSource] = []
|
sources: list[StreamSource] = []
|
||||||
if source:
|
if source:
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@ Pattern (verified 2026-05-15 z residential, live URL `/e/PZqBZp4OomF0Q61`):
|
||||||
'//streamtape.com/get_video' +
|
'//streamtape.com/get_video' +
|
||||||
('<junk>?id=<id>&expires=...&ip=...&token=...').substring(N).substring(M);
|
('<junk>?id=<id>&expires=...&ip=...&token=...').substring(N).substring(M);
|
||||||
|
|
||||||
Junk to 3-4 znaki przed `?` — substring(N).substring(M) je odcina.
|
Junk to 3-4 znaki przed `?`, substring(N).substring(M) je odcina.
|
||||||
|
|
||||||
2. Po sklejeniu fetch `https://streamtape.com/get_video?id=...&token=...` →
|
2. Po sklejeniu fetch `https://streamtape.com/get_video?id=...&token=...` →
|
||||||
302 → `https://<cluster>.tapecontent.net/radosgw/<id>/<signed_path>/<title>.mp4`
|
302 → `https://<cluster>.tapecontent.net/radosgw/<id>/<signed_path>/<title>.mp4`
|
||||||
(direct mp4, video/mp4 ~500MB, brak IP-bind).
|
(direct mp4, video/mp4 ~500MB, brak IP-bind).
|
||||||
|
|
||||||
3. Body czasem zwraca `Video not found! Maybe it got deleted by the creator!`
|
3. Body czasem zwraca `Video not found! Maybe it got deleted by the creator!`
|
||||||
— większość URLów w naszej DB (12k mass-DMCA'd 2026-05-15). Wtedy raise
|
Tak jest dla większości URLów w naszej DB (12k mass-DMCA'd 2026-05-15). Wtedy raise
|
||||||
HosterDead, caller w playback.py oznaczy dead_at.
|
HosterDead, caller w playback.py oznaczy dead_at.
|
||||||
|
|
||||||
Live URL coverage probed 2026-05-15: ~5% URLów żyje, reszta `Video not found`.
|
Live URL coverage probed 2026-05-15: ~5% URLów żyje, reszta `Video not found`.
|
||||||
|
|
@ -33,10 +33,10 @@ from app.extractors._models import HosterDead, StreamSource
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Match: `getElementById('xlink').innerHTML = "<prefix>" + '' + ('<suffix>').substring(N).substring(M);`
|
# Match: `getElementById('xlink').innerHTML = "<prefix>" + '' + ('<suffix>').substring(N).substring(M);`
|
||||||
# Streamtape generuje 4 assignmenty (ideoolink x2 + botlink + robotlink) — 2 są DECOYs
|
# Streamtape generuje 4 assignmenty (ideoolink x2 + botlink + robotlink), 2 są DECOYs
|
||||||
# z połamanym hostname (`.comb`, `.cob`) i tylko botlink/robotlink dają prawdziwy URL.
|
# z połamanym hostname (`.comb`, `.cob`) i tylko botlink/robotlink dają prawdziwy URL.
|
||||||
# Prefix może być fragmentem: `/streamtape.com`, `//streamtape.co`, `//streamtape.com/g`
|
# Prefix może być fragmentem: `/streamtape.com`, `//streamtape.co`, `//streamtape.com/g`
|
||||||
# — `get_video` często jest split między prefix i suffix po slice'ach. Decyzja na
|
# `get_video` często jest split między prefix i suffix po slice'ach. Decyzja na
|
||||||
# podstawie KOMBINOWANEGO output containing exact `streamtape.com/get_video?`.
|
# podstawie KOMBINOWANEGO output containing exact `streamtape.com/get_video?`.
|
||||||
_ASSIGN_RE = re.compile(
|
_ASSIGN_RE = re.compile(
|
||||||
r"document\.getElementById\(['\"](?P<elem>[a-z]+link)['\"]\)\.innerHTML\s*=\s*"
|
r"document\.getElementById\(['\"](?P<elem>[a-z]+link)['\"]\)\.innerHTML\s*=\s*"
|
||||||
|
|
@ -74,7 +74,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
||||||
if _NOT_FOUND_RE.search(r.text):
|
if _NOT_FOUND_RE.search(r.text):
|
||||||
raise HosterDead(f"streamtape {page_url}: Video not found")
|
raise HosterDead(f"streamtape {page_url}: Video not found")
|
||||||
|
|
||||||
# Spróbuj wszystkie 4 assignmenty — pierwszy poprawny URL wygrywa.
|
# Spróbuj wszystkie 4 assignmenty, pierwszy poprawny URL wygrywa.
|
||||||
# `get_video` może być w prefix (residential variant) lub split prefix+suffix
|
# `get_video` może być w prefix (residential variant) lub split prefix+suffix
|
||||||
# (VPS variant gdzie decoy assignmenty produkują `.comb/get_video`).
|
# (VPS variant gdzie decoy assignmenty produkują `.comb/get_video`).
|
||||||
final_url: str | None = None
|
final_url: str | None = None
|
||||||
|
|
@ -91,7 +91,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
||||||
url = "https:/" + combined # `/streamtape.com/...` → `https://streamtape.com/...`
|
url = "https:/" + combined # `/streamtape.com/...` → `https://streamtape.com/...`
|
||||||
else:
|
else:
|
||||||
url = combined
|
url = combined
|
||||||
# Walidacja — odsiewa decoys (`streamtape.comb`, `streamtape.cob`).
|
# Walidacja, odsiewa decoys (`streamtape.comb`, `streamtape.cob`).
|
||||||
if (
|
if (
|
||||||
"streamtape.com/get_video?" in url
|
"streamtape.com/get_video?" in url
|
||||||
and "id=" in url
|
and "id=" in url
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ Pattern (verified 2026-05-15 z VPS Hetzner FI):
|
||||||
|
|
||||||
window.location.href = 'https://<random>.com/e/<id>'
|
window.location.href = 'https://<random>.com/e/<id>'
|
||||||
|
|
||||||
Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.) —
|
Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.),
|
||||||
bierzemy LITERAL `Location` z window.location.href assignment.
|
bierzemy LITERAL `Location` z window.location.href assignment.
|
||||||
|
|
||||||
2. Mirror embed page 137KB zawiera `<script type="application/json">["DROH@$nJjm..."]</script>`
|
2. Mirror embed page 137KB zawiera `<script type="application/json">["DROH@$nJjm..."]</script>`
|
||||||
— pojedyncza zakodowana string z chunkami 4-char rozdzielonymi 7 distinct
|
To pojedyncza zakodowana string z chunkami 4-char rozdzielonymi 7 distinct
|
||||||
2-char delimiterami: `@$`, `^^`, `~@`, `%?`, `*~`, `!!`, `#&`.
|
2-char delimiterami: `@$`, `^^`, `~@`, `%?`, `*~`, `!!`, `#&`.
|
||||||
|
|
||||||
3. Loader `/js/loader.bc4a6543429.js` (83KB obfuscator.io) wczytuje payload przez
|
3. Loader `/js/loader.bc4a6543429.js` (83KB obfuscator.io) wczytuje payload przez
|
||||||
|
|
@ -28,8 +28,8 @@ Pattern (verified 2026-05-15 z VPS Hetzner FI):
|
||||||
|
|
||||||
4. Decoded JSON ma `source` (HLS m3u8 na `cloudwindow-route.com` z signed token)
|
4. Decoded JSON ma `source` (HLS m3u8 na `cloudwindow-route.com` z signed token)
|
||||||
+ `fallback: [{file: ".mp4", type: "mp4", label: "720"}]`. URL signed
|
+ `fallback: [{file: ".mp4", type: "mp4", label: "720"}]`. URL signed
|
||||||
z `i=<visitor IP first 2 octets>` — z VPS dostajemy `i=46.62` (Hetzner).
|
z `i=<visitor IP first 2 octets>`, z VPS dostajemy `i=46.62` (Hetzner).
|
||||||
Token IP-bound do tych pierwszych 2 oktetów (luźne) — proxy działa.
|
Token IP-bound do tych pierwszych 2 oktetów (luźne), proxy działa.
|
||||||
|
|
||||||
5. CDN host losowy ale wzorzec stały. Wymaga Referer = voe.sx (lub mirror) bo
|
5. CDN host losowy ale wzorzec stały. Wymaga Referer = voe.sx (lub mirror) bo
|
||||||
token z `?node=` valida.
|
token z `?node=` valida.
|
||||||
|
|
@ -72,7 +72,7 @@ def _decode_payload(payload: str) -> dict | None:
|
||||||
for sep in _MAGIC_SEPS:
|
for sep in _MAGIC_SEPS:
|
||||||
s = s.replace(sep, "_")
|
s = s.replace(sep, "_")
|
||||||
s = s.replace("_", "")
|
s = s.replace("_", "")
|
||||||
# 1st atob — uses latin-1 to preserve all 256 byte values for shift step.
|
# 1st atob, uses latin-1 to preserve all 256 byte values for shift step.
|
||||||
b = base64.b64decode(s + "=" * (-len(s) % 4)).decode("latin-1")
|
b = base64.b64decode(s + "=" * (-len(s) % 4)).decode("latin-1")
|
||||||
shifted = "".join(chr(ord(c) - 3) for c in b)
|
shifted = "".join(chr(ord(c) - 3) for c in b)
|
||||||
reversed_str = shifted[::-1]
|
reversed_str = shifted[::-1]
|
||||||
|
|
@ -142,7 +142,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
||||||
if isinstance(fallback, dict):
|
if isinstance(fallback, dict):
|
||||||
fallback = [fallback]
|
fallback = [fallback]
|
||||||
|
|
||||||
# Voe CDN URL ma `i=<2-octet IP prefix>` — token loose-bound do IP range.
|
# Voe CDN URL ma `i=<2-octet IP prefix>`, token loose-bound do IP range.
|
||||||
# Proxy z VPS dostaje signed dla `i=46.62`, fetch działa.
|
# Proxy z VPS dostaje signed dla `i=46.62`, fetch działa.
|
||||||
referer = "https://voe.sx/"
|
referer = "https://voe.sx/"
|
||||||
sources: list[StreamSource] = []
|
sources: list[StreamSource] = []
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
"""Wybór najlepszego iframe-hostera z page HTML tube'a typu wrapper.
|
"""Wybór najlepszego iframe-hostera z page HTML tube'a typu wrapper.
|
||||||
|
|
||||||
Tube'y typu siska/perverzija/latestpornvideo nie hostują player'a same — embedują
|
Tube'y typu siska/perverzija/latestpornvideo nie hostują player'a same, embedują
|
||||||
zewnętrznych hosterów (luluvid, doodporn, mixdrop, streamtape, ...). Detail page
|
zewnętrznych hosterów (luluvid, doodporn, mixdrop, streamtape, ...). Detail page
|
||||||
zawiera typowo 2-5 iframes:
|
zawiera typowo 2-5 iframes:
|
||||||
- ad-iframes (willingcease.com, popads, discord, about:blank)
|
- ad-iframes (willingcease.com, popads, discord, about:blank)
|
||||||
- dead hosters (streamtape malware, openload offline)
|
- dead hosters (streamtape malware, openload offline)
|
||||||
- file-hosters (rapidgator, nitroflare — premium walled)
|
- file-hosters (rapidgator, nitroflare, premium walled)
|
||||||
- "fake" hosters typu playmogo (niszowe forki, brak extractora)
|
- "fake" hosters typu playmogo (niszowe forki, brak extractora)
|
||||||
- real KVS hosters (luluvid, doodporn, mixdrop, voe — `extract_stream_from_hoster`
|
- real KVS hosters (luluvid, doodporn, mixdrop, voe, `extract_stream_from_hoster`
|
||||||
radzi sobie z nimi przez KVS markers + yt-dlp generic)
|
radzi sobie z nimi przez KVS markers + yt-dlp generic)
|
||||||
|
|
||||||
`extract_best_iframe()` filtruje śmieci i preferuje hostery known-working z naszej
|
`extract_best_iframe()` filtruje śmieci i preferuje hostery known-working z naszej
|
||||||
|
|
@ -21,9 +21,9 @@ import re
|
||||||
|
|
||||||
from app.extractors.tubes._embed_iframe import DEAD_HOSTER_RE
|
from app.extractors.tubes._embed_iframe import DEAD_HOSTER_RE
|
||||||
|
|
||||||
# Ad / popup iframes — popunder networks + Discord widget + about:blank.
|
# Ad / popup iframes, popunder networks + Discord widget + about:blank.
|
||||||
# Niektóre tube'y embedują kilka takich PRZED prawdziwym player'em żeby
|
# Niektóre tube'y embedują kilka takich PRZED prawdziwym player'em żeby
|
||||||
# zmonetyzować ruch — naszego extractora trzeba przeskoczyć żeby trafić na video.
|
# zmonetyzować ruch, naszego extractora trzeba przeskoczyć żeby trafić na video.
|
||||||
_AD_IFRAME_RE = re.compile(
|
_AD_IFRAME_RE = re.compile(
|
||||||
r"willingcease\.com"
|
r"willingcease\.com"
|
||||||
r"|popads|popunder|adsterra"
|
r"|popads|popunder|adsterra"
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ Mapowanie sitetag → extractor jest w `EXTRACTORS` na końcu. `extract_tags(sit
|
||||||
to public entrypoint.
|
to public entrypoint.
|
||||||
|
|
||||||
UWAGA: nie zwracamy auto-generated word-ngramów (KVS bywa generuje "Tania",
|
UWAGA: nie zwracamy auto-generated word-ngramów (KVS bywa generuje "Tania",
|
||||||
"Tania Amazon", "Amazon Shaft"... — bez wartości semantycznej). Dlatego pornditt
|
"Tania Amazon", "Amazon Shaft"..., bez wartości semantycznej). Dlatego pornditt
|
||||||
NIE jest tu obsłużone — KVS tagi to garbage.
|
NIE jest tu obsłużone, KVS tagi to garbage.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ def _dedupe(items: list[str], cap: int = 20) -> list[str]:
|
||||||
|
|
||||||
def extract_porntrex(html: str) -> list[str]:
|
def extract_porntrex(html: str) -> list[str]:
|
||||||
"""porntrex: <span>Categories:</span><div class="items-holder js-categories"><a class="js-cat" href="/categories/<slug>/">NAME</a>...
|
"""porntrex: <span>Categories:</span><div class="items-holder js-categories"><a class="js-cat" href="/categories/<slug>/">NAME</a>...
|
||||||
`Tags:` block na porntrex zawiera auto-generated n-gramy ("Tania", "L H") — pomijamy.
|
`Tags:` block na porntrex zawiera auto-generated n-gramy ("Tania", "L H"), pomijamy.
|
||||||
"""
|
"""
|
||||||
m = re.search(r'Categories:</span>\s*<div[^>]+>(.*?)</div>', html, re.DOTALL | re.IGNORECASE)
|
m = re.search(r'Categories:</span>\s*<div[^>]+>(.*?)</div>', html, re.DOTALL | re.IGNORECASE)
|
||||||
if not m:
|
if not m:
|
||||||
|
|
@ -60,7 +60,7 @@ def extract_youporn(html: str) -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def extract_xvideos(html: str) -> list[str]:
|
def extract_xvideos(html: str) -> list[str]:
|
||||||
"""xvideos: <a href="/tags/<slug>/">name</a> (slug-style names — przerobimy na Title Case)."""
|
"""xvideos: <a href="/tags/<slug>/">name</a> (slug-style names, przerobimy na Title Case)."""
|
||||||
matches = re.findall(
|
matches = re.findall(
|
||||||
r'<a[^>]+href="[^"]*?/tags/([a-z0-9-]+)/?"[^>]*>',
|
r'<a[^>]+href="[^"]*?/tags/([a-z0-9-]+)/?"[^>]*>',
|
||||||
html, re.IGNORECASE,
|
html, re.IGNORECASE,
|
||||||
|
|
@ -71,7 +71,7 @@ def extract_xvideos(html: str) -> list[str]:
|
||||||
|
|
||||||
def extract_xnxx(html: str) -> list[str]:
|
def extract_xnxx(html: str) -> list[str]:
|
||||||
"""xnxx: tagi w JSON `"video_tags":["ass","deep-throat","rimming",...]` wewnątrz
|
"""xnxx: tagi w JSON `"video_tags":["ass","deep-throat","rimming",...]` wewnątrz
|
||||||
html5player config (NIE w `/tags/` URL-ach jak xvideos — to są różne systemy
|
html5player config (NIE w `/tags/` URL-ach jak xvideos, to są różne systemy
|
||||||
pomimo tego że oba należą do WGCZ Holding).
|
pomimo tego że oba należą do WGCZ Holding).
|
||||||
"""
|
"""
|
||||||
m = re.search(r'"video_tags"\s*:\s*\[([^\]]+)\]', html)
|
m = re.search(r'"video_tags"\s*:\s*\[([^\]]+)\]', html)
|
||||||
|
|
@ -181,7 +181,7 @@ def extract_eporner(html: str) -> list[str]:
|
||||||
def extract_hqporner(html: str) -> list[str]:
|
def extract_hqporner(html: str) -> list[str]:
|
||||||
"""hqporner: tagi siedzą w meta description po "Tags related to this video:".
|
"""hqporner: tagi siedzą w meta description po "Tags related to this video:".
|
||||||
|
|
||||||
Detail page jest "thin" (~26KB) bez tag links w body — wszystko jest tylko
|
Detail page jest "thin" (~26KB) bez tag links w body, wszystko jest tylko
|
||||||
w `<meta name="description">` w formie zdania:
|
w `<meta name="description">` w formie zdania:
|
||||||
`Watch porn video <Title>. Video duration is Xmin Ysec. Tags related to
|
`Watch porn video <Title>. Video duration is Xmin Ysec. Tags related to
|
||||||
this video: handjob, babe, for cash, blowjob, black hair.`
|
this video: handjob, babe, for cash, blowjob, black hair.`
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ jednym z patternów:
|
||||||
`<meta name="twitter:image" content="...">`
|
`<meta name="twitter:image" content="...">`
|
||||||
3. **Schema.org VideoObject LD-JSON**:
|
3. **Schema.org VideoObject LD-JSON**:
|
||||||
`"thumbnailUrl": "https://..."` lub `"thumbnailUrl": ["url1", "url2"]`
|
`"thumbnailUrl": "https://..."` lub `"thumbnailUrl": ["url1", "url2"]`
|
||||||
4. **html5player** (KVS-based — xnxx/xvideos):
|
4. **html5player** (KVS-based, xnxx/xvideos):
|
||||||
`html5player.setThumbUrl('https://thumb-cdn77.xnxx-cdn.com/.../t.jpg')`
|
`html5player.setThumbUrl('https://thumb-cdn77.xnxx-cdn.com/.../t.jpg')`
|
||||||
|
|
||||||
Funkcja zwraca pierwszy znaleziony URL (string), lub None gdy żaden nie pasuje.
|
Funkcja zwraca pierwszy znaleziony URL (string), lub None gdy żaden nie pasuje.
|
||||||
|
|
@ -34,7 +34,7 @@ _KVS_THUMB_RE = re.compile(
|
||||||
# hqporner: stare WordPress bez og:image / schema. Poster `_main.jpg` siedzi w
|
# hqporner: stare WordPress bez og:image / schema. Poster `_main.jpg` siedzi w
|
||||||
# atrybucie onclick (`changeImage('//cdn/_main.jpg', 'cover_<id>')`) lub w preload
|
# atrybucie onclick (`changeImage('//cdn/_main.jpg', 'cover_<id>')`) lub w preload
|
||||||
# array (`preload_<id> = ['//.../_1.jpg', ...]`). Wyciągamy `_main.jpg` z CDN-a
|
# array (`preload_<id> = ['//.../_1.jpg', ...]`). Wyciągamy `_main.jpg` z CDN-a
|
||||||
# fastporndelivery.hqporner.com — to canonical poster, frames `_1..N.jpg` to
|
# fastporndelivery.hqporner.com, to canonical poster, frames `_1..N.jpg` to
|
||||||
# hover animation.
|
# hover animation.
|
||||||
_HQPORNER_THUMB_RE = re.compile(
|
_HQPORNER_THUMB_RE = re.compile(
|
||||||
r"['\"](//[a-z0-9.\-]*hqporner[a-z0-9.\-]*/imgs/[^'\"]+_main\.jpg)['\"]",
|
r"['\"](//[a-z0-9.\-]*hqporner[a-z0-9.\-]*/imgs/[^'\"]+_main\.jpg)['\"]",
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ porndittcom, fpoxxx, hdporn92, sxyland, 0dayxx, ...):
|
||||||
3. Filtruj iframe-y reklamowe (ad domains + `?key=`, `?idzone=` parametry).
|
3. Filtruj iframe-y reklamowe (ad domains + `?key=`, `?idzone=` parametry).
|
||||||
4. Spróbuj `extract_stream_from_hoster` (P.A.C.K.E.R. unpack JWPlayer) → m3u8/mp4.
|
4. Spróbuj `extract_stream_from_hoster` (P.A.C.K.E.R. unpack JWPlayer) → m3u8/mp4.
|
||||||
5. Jeśli nie wyciągnęło URL (CAPTCHA, multi-step JS, anti-bot), fallback do
|
5. Jeśli nie wyciągnęło URL (CAPTCHA, multi-step JS, anti-bot), fallback do
|
||||||
hoster type — mobile otworzy w WebView, hoster JS sam zrobi swoje.
|
hoster type, mobile otworzy w WebView, hoster JS sam zrobi swoje.
|
||||||
|
|
||||||
`referer` dla hoster fetchu to `https://<page_host>/` — komputujemy automatycznie
|
`referer` dla hoster fetchu to `https://<page_host>/`, komputujemy automatycznie
|
||||||
z `page_url`, więc rejestrujemy ten sam extractor pod wieloma sitetag-ami bez
|
z `page_url`, więc rejestrujemy ten sam extractor pod wieloma sitetag-ami bez
|
||||||
osobnych config'ów.
|
osobnych config'ów.
|
||||||
"""
|
"""
|
||||||
|
|
@ -30,7 +30,7 @@ from app.extractors.hoster import extract_stream_from_hoster
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Każdy iframe na stronie — szeroki match. Filtrowanie reklamowych zachodzi niżej.
|
# Każdy iframe na stronie, szeroki match. Filtrowanie reklamowych zachodzi niżej.
|
||||||
# Generic TLD `[a-z]{2,8}` żeby pokryć egzotyczne hosting domains (.yt, .ws, .li,
|
# Generic TLD `[a-z]{2,8}` żeby pokryć egzotyczne hosting domains (.yt, .ws, .li,
|
||||||
# .stream, .pro, .cloud, .live).
|
# .stream, .pro, .cloud, .live).
|
||||||
ANY_IFRAME_RE = re.compile(
|
ANY_IFRAME_RE = re.compile(
|
||||||
|
|
@ -38,11 +38,11 @@ ANY_IFRAME_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Path patterns for "this is a player embed" — covers most hoster URL shapes:
|
# Path patterns for "this is a player embed", covers most hoster URL shapes:
|
||||||
# /e/<id>, /embed/<id>, /video/embed/<id> → StreamWish, doodporn, dood.yt, luluvdo
|
# /e/<id>, /embed/<id>, /video/embed/<id> → StreamWish, doodporn, dood.yt, luluvdo
|
||||||
# /embed-<id>(.html)? → xtapes.porn, niektóre stare hosty
|
# /embed-<id>(.html)? → xtapes.porn, niektóre stare hosty
|
||||||
# /player/<id>(.php)?(?...)? → xtremestream.xyz, niektóre custom hosty
|
# /player/<id>(.php)?(?...)? → xtremestream.xyz, niektóre custom hosty
|
||||||
# /v/<id>, /watch/<id> → emergency catch — niektóre tubes
|
# /v/<id>, /watch/<id> → emergency catch, niektóre tubes
|
||||||
PLAYER_PATH_RE = re.compile(
|
PLAYER_PATH_RE = re.compile(
|
||||||
r'/(?:e|embed|video/embed|v|watch|t|f|d|stream)/[a-zA-Z0-9_\-]+'
|
r'/(?:e|embed|video/embed|v|watch|t|f|d|stream)/[a-zA-Z0-9_\-]+'
|
||||||
r'|/embed-[a-zA-Z0-9_\-]+'
|
r'|/embed-[a-zA-Z0-9_\-]+'
|
||||||
|
|
@ -51,11 +51,11 @@ PLAYER_PATH_RE = re.compile(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bare-slug player path (sdefx.cloud, niektóre playmogo): single path segment after host,
|
# Bare-slug player path (sdefx.cloud, niektóre playmogo): single path segment after host,
|
||||||
# alphanumeric + dash. Żadnego `/folder/file` ani query stringa — to wykrycie hosterów
|
# alphanumeric + dash. Żadnego `/folder/file` ani query stringa, to wykrycie hosterów
|
||||||
# które nie używają standardowego `/e/<id>` patternu.
|
# które nie używają standardowego `/e/<id>` patternu.
|
||||||
BARE_SLUG_PATH_RE = re.compile(r'^/[a-zA-Z0-9_\-]{4,}/?$')
|
BARE_SLUG_PATH_RE = re.compile(r'^/[a-zA-Z0-9_\-]{4,}/?$')
|
||||||
|
|
||||||
# Ad iframes — domeny reklam + parametry (idzone, key=) — odsiewamy nawet jeśli mają
|
# Ad iframes, domeny reklam + parametry (idzone, key=), odsiewamy nawet jeśli mają
|
||||||
# inne path patterns. Lista ze stron które rzeczywiście aggregator tubes serwują.
|
# inne path patterns. Lista ze stron które rzeczywiście aggregator tubes serwują.
|
||||||
AD_DOMAIN_RE = re.compile(
|
AD_DOMAIN_RE = re.compile(
|
||||||
r'(?:willingcease|propellerads|popads|popcash|trafficstars|exoclick|adsterra|'
|
r'(?:willingcease|propellerads|popads|popcash|trafficstars|exoclick|adsterra|'
|
||||||
|
|
@ -70,9 +70,9 @@ AD_DOMAIN_RE = re.compile(
|
||||||
)
|
)
|
||||||
AD_QUERY_RE = re.compile(r'[?&](?:idzone|adkey|key|aff_id|adsrc)=', re.IGNORECASE)
|
AD_QUERY_RE = re.compile(r'[?&](?:idzone|adkey|key|aff_id|adsrc)=', re.IGNORECASE)
|
||||||
|
|
||||||
# Direct stream URLs na page — niektóre aggregator tubes (porn4days, xmoviesforyou)
|
# Direct stream URLs na page, niektóre aggregator tubes (porn4days, xmoviesforyou)
|
||||||
# wstawiają full mp4/m3u8 URL bezpośrednio w HTML/JS (download link, video.js source,
|
# wstawiają full mp4/m3u8 URL bezpośrednio w HTML/JS (download link, video.js source,
|
||||||
# JWPlayer config). Skanujemy je przed iframe processing — direct URL > iframe wymagający
|
# JWPlayer config). Skanujemy je przed iframe processing, direct URL > iframe wymagający
|
||||||
# WebView. Wymagamy że path zawiera quality marker (`<digits>p.mp4`) lub `.m3u8` żeby
|
# WebView. Wymagamy że path zawiera quality marker (`<digits>p.mp4`) lub `.m3u8` żeby
|
||||||
# uniknąć false-positives z thumbnail/preview generation endpoints.
|
# uniknąć false-positives z thumbnail/preview generation endpoints.
|
||||||
_DIRECT_MP4_RE = re.compile(
|
_DIRECT_MP4_RE = re.compile(
|
||||||
|
|
@ -87,10 +87,10 @@ _DIRECT_M3U8_RE = re.compile(
|
||||||
|
|
||||||
# Hosty którym `_DIRECT_*_RE` regex matchuje (URL kończy się .mp4 z quality
|
# Hosty którym `_DIRECT_*_RE` regex matchuje (URL kończy się .mp4 z quality
|
||||||
# marker w path) ALE które NIE serwują direct stream:
|
# marker w path) ALE które NIE serwują direct stream:
|
||||||
# 1. File hosters (rapidgator/k2s/mediafire/...) — file storage, premium auth gate
|
# 1. File hosters (rapidgator/k2s/mediafire/...), file storage, premium auth gate
|
||||||
# Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4` — to download URL,
|
# Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4`, to download URL,
|
||||||
# mobile dostaje login page (HTML), nie video.
|
# mobile dostaje login page (HTML), nie video.
|
||||||
# 2. Embed page'y z mp4-suffix path (playmogo/dood/filemoon /d/<id>/<filename>.mp4) —
|
# 2. Embed page'y z mp4-suffix path (playmogo/dood/filemoon /d/<id>/<filename>.mp4),
|
||||||
# faktycznie HTML embed page, CAPTCHA-walled.
|
# faktycznie HTML embed page, CAPTCHA-walled.
|
||||||
# Bez filter Stage 0.5 zwracał tę URL jako type=mp4 i ExoPlayer dostawał HTML zamiast
|
# Bez filter Stage 0.5 zwracał tę URL jako type=mp4 i ExoPlayer dostawał HTML zamiast
|
||||||
# video → black screen / playback failed.
|
# video → black screen / playback failed.
|
||||||
|
|
@ -106,11 +106,11 @@ _NOT_DIRECT_STREAM_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# JS-defined server URLs — niektóre aggregator tubes (porn4days) trzymają backup
|
# JS-defined server URLs, niektóre aggregator tubes (porn4days) trzymają backup
|
||||||
# hosterów w JavaScript variables, a iframe renderuje tylko pierwszy. Reszta jest
|
# hosterów w JavaScript variables, a iframe renderuje tylko pierwszy. Reszta jest
|
||||||
# dostępna przez clickable "Server 2"/"Server 3" buttons które JS-em swapują src
|
# dostępna przez clickable "Server 2"/"Server 3" buttons które JS-em swapują src
|
||||||
# iframe'a. Pattern: `const SERVER<N>_URL = "https://..."` lub `var server<N> =`.
|
# iframe'a. Pattern: `const SERVER<N>_URL = "https://..."` lub `var server<N> =`.
|
||||||
# Dla nas to są dodatkowe iframe URLs które trzeba zebrać jak inne — extract_stream_from_hoster
|
# Dla nas to są dodatkowe iframe URLs które trzeba zebrać jak inne, extract_stream_from_hoster
|
||||||
# spróbuje je rozpakować, a fallback to type=hoster dla mobile WebView.
|
# spróbuje je rozpakować, a fallback to type=hoster dla mobile WebView.
|
||||||
_JS_SERVER_URL_RE = re.compile(
|
_JS_SERVER_URL_RE = re.compile(
|
||||||
r'(?:const|let|var)?\s*(?:SERVER\d+_URL|server\d+(?:_url)?|src\d+|stream_?\d+|video_?\d+)\s*=\s*'
|
r'(?:const|let|var)?\s*(?:SERVER\d+_URL|server\d+(?:_url)?|src\d+|stream_?\d+|video_?\d+)\s*=\s*'
|
||||||
|
|
@ -118,9 +118,9 @@ _JS_SERVER_URL_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Anchor-href hoster links — xmoviesforyou pattern: scene page nie ma <iframe> playera,
|
# Anchor-href hoster links, xmoviesforyou pattern: scene page nie ma <iframe> playera,
|
||||||
# tylko serię `<a href="https://playmogo.com/d/...">MIXDROP</a>` download buttons.
|
# tylko serię `<a href="https://playmogo.com/d/...">MIXDROP</a>` download buttons.
|
||||||
# Match na anchor wskazujący na typowe hoster domeny — daje fallback gdy brak iframe.
|
# Match na anchor wskazujący na typowe hoster domeny, daje fallback gdy brak iframe.
|
||||||
_ANCHOR_HOSTER_RE = re.compile(
|
_ANCHOR_HOSTER_RE = re.compile(
|
||||||
r'<a\s+[^>]*href=["\'](?P<url>https?://(?:'
|
r'<a\s+[^>]*href=["\'](?P<url>https?://(?:'
|
||||||
r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|'
|
r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|'
|
||||||
|
|
@ -131,10 +131,10 @@ _ANCHOR_HOSTER_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Iframe src ukryty w escape'owanym JS string literal — porndish pattern:
|
# Iframe src ukryty w escape'owanym JS string literal, porndish pattern:
|
||||||
# `const doodstreamContent = "<iframe ... src=\"https://playmogo.com/e/xyz\" ...></iframe>";`
|
# `const doodstreamContent = "<iframe ... src=\"https://playmogo.com/e/xyz\" ...></iframe>";`
|
||||||
# Server-side widzimy tylko pusty `<div id="iframeHolder">` (iframe wstrzykuje się JS-em
|
# Server-side widzimy tylko pusty `<div id="iframeHolder">` (iframe wstrzykuje się JS-em
|
||||||
# po kliknięciu "Video Player 1" button). Match na `src=\"<url>\"` w raw HTML source —
|
# po kliknięciu "Video Player 1" button). Match na `src=\"<url>\"` w raw HTML source,
|
||||||
# `\"` to backslash-quote z JSON-style escape'owania, `\/` to backslash-slash.
|
# `\"` to backslash-quote z JSON-style escape'owania, `\/` to backslash-slash.
|
||||||
# Po match'u czyścimy `\/` → `/` w captured URL.
|
# Po match'u czyścimy `\/` → `/` w captured URL.
|
||||||
_JS_ESCAPED_IFRAME_SRC_RE = re.compile(
|
_JS_ESCAPED_IFRAME_SRC_RE = re.compile(
|
||||||
|
|
@ -151,18 +151,18 @@ _DATA_EMBED_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Blacklista hosterów — wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie:
|
# Blacklista hosterów, wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie:
|
||||||
#
|
#
|
||||||
# 1) Dead — w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran.
|
# 1) Dead, w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran.
|
||||||
# xtapes.porn → 301 → camcaps.to → 403 (zmigrowane 2026-04, infrastruktura zniknęła).
|
# xtapes.porn → 301 → camcaps.to → 403 (zmigrowane 2026-04, infrastruktura zniknęła).
|
||||||
#
|
#
|
||||||
# 2) Malware — działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders
|
# 2) Malware, działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders
|
||||||
# przez chain reklamowy. yt-dlp odmawia ich ekstrakcji (oznaczone jako piracy), więc
|
# przez chain reklamowy. yt-dlp odmawia ich ekstrakcji (oznaczone jako piracy), więc
|
||||||
# nie ma mode'u "direct mp4 do ExoPlayera bez WebView" — jedyna opcja to WebView,
|
# nie ma mode'u "direct mp4 do ExoPlayera bez WebView", jedyna opcja to WebView,
|
||||||
# a WebView pozwala JS-owi hostera ściągnąć user execu plik. Bezpieczniej wyciąć je
|
# a WebView pozwala JS-owi hostera ściągnąć user execu plik. Bezpieczniej wyciąć je
|
||||||
# całkowicie i pokazać user'owi page_url aggregator tube'a (rzadziej malicious niż
|
# całkowicie i pokazać user'owi page_url aggregator tube'a (rzadziej malicious niż
|
||||||
# sam hoster). User nadal może tam wybrać alt player jeśli istnieje.
|
# sam hoster). User nadal może tam wybrać alt player jeśli istnieje.
|
||||||
# streamtape.com — notorious dla drive-by .reg downloads (zgłoszone 2026-05-07
|
# streamtape.com, notorious dla drive-by .reg downloads (zgłoszone 2026-05-07
|
||||||
# porn4days.pw → streamtape.com/e/<id> → popup + ściąganie .reg).
|
# porn4days.pw → streamtape.com/e/<id> → popup + ściąganie .reg).
|
||||||
# Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html`
|
# Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html`
|
||||||
# w path nie matchował, tylko prawdziwy hostname (`https://filemoon.to/...` lub
|
# w path nie matchował, tylko prawdziwy hostname (`https://filemoon.to/...` lub
|
||||||
|
|
@ -177,7 +177,7 @@ DEAD_HOSTER_RE = re.compile(
|
||||||
r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware
|
r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware
|
||||||
r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors
|
r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors
|
||||||
r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019)
|
r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019)
|
||||||
# filemoon — NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend"
|
# filemoon, NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend"
|
||||||
# zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie
|
# zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie
|
||||||
# za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22:
|
# za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22:
|
||||||
# POST /api/videos/<code>/embed/playback {"fingerprint":{}} → AES-256-GCM →
|
# POST /api/videos/<code>/embed/playback {"fingerprint":{}} → AES-256-GCM →
|
||||||
|
|
@ -188,12 +188,12 @@ DEAD_HOSTER_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CAPTCHA-walled hosterzy — DoodStream variants serwują 5KB Cloudflare Turnstile
|
# CAPTCHA-walled hosterzy, DoodStream variants serwują 5KB Cloudflare Turnstile
|
||||||
# challenge response na server-side requests z VPS IP. Na phone (T-Mobile/PLAY)
|
# challenge response na server-side requests z VPS IP. Na phone (T-Mobile/PLAY)
|
||||||
# czasem przechodzi, mobile-side resolver (mobile/src/lib/doodstream.ts) ma szansę.
|
# czasem przechodzi, mobile-side resolver (mobile/src/lib/doodstream.ts) ma szansę.
|
||||||
# ALE: gdy scene ma alt-hostera (np. luluvid, filemoon), tamten zazwyczaj nie ma
|
# ALE: gdy scene ma alt-hostera (np. luluvid, filemoon), tamten zazwyczaj nie ma
|
||||||
# CAPTCHA gate → ExoPlayer odpala bezpośrednio. Sortujemy więc DoodStream NA KONIEC
|
# CAPTCHA gate → ExoPlayer odpala bezpośrednio. Sortujemy więc DoodStream NA KONIEC
|
||||||
# listy — Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują
|
# listy, Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują
|
||||||
# najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts.
|
# najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts.
|
||||||
CAPTCHA_HOSTER_RE = re.compile(
|
CAPTCHA_HOSTER_RE = re.compile(
|
||||||
r'(?:'
|
r'(?:'
|
||||||
|
|
@ -203,7 +203,7 @@ CAPTCHA_HOSTER_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# IP-BOUND CDN URLs — stream URLs które bindują się do requester IP (VPS resolve
|
# IP-BOUND CDN URLs, stream URLs które bindują się do requester IP (VPS resolve
|
||||||
# → mobile 403). Stage 1 server-side ekstrakta `extract_stream_from_hoster` zwraca
|
# → mobile 403). Stage 1 server-side ekstrakta `extract_stream_from_hoster` zwraca
|
||||||
# tę URL, ale mobile direct nie pobierze. Lepiej dropować mp4/m3u8 wynik i upaść
|
# tę URL, ale mobile direct nie pobierze. Lepiej dropować mp4/m3u8 wynik i upaść
|
||||||
# na hoster fallback (mobile WebView wyciągnie URL z phone IP/session).
|
# na hoster fallback (mobile WebView wyciągnie URL z phone IP/session).
|
||||||
|
|
@ -215,14 +215,14 @@ _IP_BOUND_CDN_RE = re.compile(
|
||||||
r"premilkyway\.com" # latestpornvideo
|
r"premilkyway\.com" # latestpornvideo
|
||||||
r"|tnmr\.org" # mypornerleak (legacy CDN)
|
r"|tnmr\.org" # mypornerleak (legacy CDN)
|
||||||
r"|acek-cdn\.com" # mypornerleak (current CDN, shared KVS infra)
|
r"|acek-cdn\.com" # mypornerleak (current CDN, shared KVS infra)
|
||||||
# xtremestream.xyz (perverzija) — `player/xs1.php?data=` to NIE direct mp4 tylko
|
# xtremestream.xyz (perverzija), `player/xs1.php?data=` to NIE direct mp4 tylko
|
||||||
# IP-bound player endpoint (403 cross-IP z VPS). Stage 1 zwracał go jako type=mp4
|
# IP-bound player endpoint (403 cross-IP z VPS). Stage 1 zwracał go jako type=mp4
|
||||||
# → natywny player ładował HTML-player w nieskończoność (reports 06-24 "perverzija
|
# → natywny player ładował HTML-player w nieskończoność (reports 06-24 "perverzija
|
||||||
# nie działa"/"loading w nieskończoność"). Skip → hoster fallback: WebView ładuje
|
# nie działa"/"loading w nieskończoność"). Skip → hoster fallback: WebView ładuje
|
||||||
# index.php playera z residential IP telefonu, xs1.php gra w jego sesji.
|
# index.php playera z residential IP telefonu, xs1.php gra w jego sesji.
|
||||||
r"|xtremestream\.[a-z]{2,8}"
|
r"|xtremestream\.[a-z]{2,8}"
|
||||||
# URL signature shared across these CDNs: `/hls2/<XX>/<scene_id>/.../master.m3u8?t=<token>&s=<ts>&e=<exp>&srv=<srv>&asn=`
|
# URL signature shared across these CDNs: `/hls2/<XX>/<scene_id>/.../master.m3u8?t=<token>&s=<ts>&e=<exp>&srv=<srv>&asn=`
|
||||||
# — `asn` query param = Autonomous System Number bind. Generic match jako safety net.
|
# `asn` query param = Autonomous System Number bind. Generic match jako safety net.
|
||||||
r")\b",
|
r")\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
@ -243,10 +243,10 @@ def _is_player_iframe(url: str) -> bool:
|
||||||
return False
|
return False
|
||||||
if DEAD_HOSTER_RE.search(url):
|
if DEAD_HOSTER_RE.search(url):
|
||||||
return False
|
return False
|
||||||
# Player path — `/e/`, `/embed/`, `/video/embed/`
|
# Player path, `/e/`, `/embed/`, `/video/embed/`
|
||||||
if PLAYER_PATH_RE.search(url):
|
if PLAYER_PATH_RE.search(url):
|
||||||
return True
|
return True
|
||||||
# Bare slug — `<host>/<id>` (no folder, no query) — sdefx.cloud-style.
|
# Bare slug, `<host>/<id>` (no folder, no query), sdefx.cloud-style.
|
||||||
parsed = urlparse(url if url.startswith("http") else "https:" + url.lstrip("/"))
|
parsed = urlparse(url if url.startswith("http") else "https:" + url.lstrip("/"))
|
||||||
if BARE_SLUG_PATH_RE.match(parsed.path) and not parsed.query:
|
if BARE_SLUG_PATH_RE.match(parsed.path) and not parsed.query:
|
||||||
return True
|
return True
|
||||||
|
|
@ -279,13 +279,13 @@ def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSou
|
||||||
seen.add(url)
|
seen.add(url)
|
||||||
if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url):
|
if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url):
|
||||||
continue
|
continue
|
||||||
# Filter pseudo-direct URLs: file hosters (rapidgator/k2s — premium auth gate)
|
# Filter pseudo-direct URLs: file hosters (rapidgator/k2s, premium auth gate)
|
||||||
# i embed pages z .mp4 suffix (playmogo/dood /d/ — to HTML, nie video).
|
# i embed pages z .mp4 suffix (playmogo/dood /d/ to HTML, nie video).
|
||||||
if _NOT_DIRECT_STREAM_RE.search(url):
|
if _NOT_DIRECT_STREAM_RE.search(url):
|
||||||
continue
|
continue
|
||||||
# KVS false-positives (report c156e4b7/8db71220, watchporn przez _embed_iframe):
|
# KVS false-positives (report c156e4b7/8db71220, watchporn przez _embed_iframe):
|
||||||
# preview_/videos_screenshots to krótkie trailery (404), a surowy get_file/ bez
|
# preview_/videos_screenshots to krótkie trailery (404), a surowy get_file/ bez
|
||||||
# podpisanego query-tokena daje 403 — kt_player dokłada token z license_code (patrz
|
# podpisanego query-tokena daje 403, kt_player dokłada token z license_code (patrz
|
||||||
# natywny watchporn.extract). Żadne z nich to pełne wideo → pomijamy.
|
# natywny watchporn.extract). Żadne z nich to pełne wideo → pomijamy.
|
||||||
low = url.lower()
|
low = url.lower()
|
||||||
if "videos_screenshots/" in low or "/preview_" in low:
|
if "videos_screenshots/" in low or "/preview_" in low:
|
||||||
|
|
@ -328,7 +328,7 @@ def extract(
|
||||||
# Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou
|
# Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou
|
||||||
# native players). Wymagamy quality marker w path (`<N>p.mp4`) żeby uniknąć
|
# native players). Wymagamy quality marker w path (`<N>p.mp4`) żeby uniknąć
|
||||||
# thumbnail-preview false-positives. Jeśli znaleziono → zwracamy, skipping iframe
|
# thumbnail-preview false-positives. Jeśli znaleziono → zwracamy, skipping iframe
|
||||||
# processing — direct stream URL > WebView fallback.
|
# processing, direct stream URL > WebView fallback.
|
||||||
direct_sources = _extract_direct_stream_urls(page_html, page_url)
|
direct_sources = _extract_direct_stream_urls(page_html, page_url)
|
||||||
if direct_sources:
|
if direct_sources:
|
||||||
log.info("embed_iframe: found %d direct stream URLs on %s",
|
log.info("embed_iframe: found %d direct stream URLs on %s",
|
||||||
|
|
@ -336,7 +336,7 @@ def extract(
|
||||||
return direct_sources
|
return direct_sources
|
||||||
|
|
||||||
# Znajdź WSZYSTKIE iframe-y które wyglądają na player. Wcześniej braliśmy tylko
|
# Znajdź WSZYSTKIE iframe-y które wyglądają na player. Wcześniej braliśmy tylko
|
||||||
# pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie —
|
# pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie,
|
||||||
# gdy pierwszy ma CF challenge / ad-heavy player (playmogo), drugi (luluvid)
|
# gdy pierwszy ma CF challenge / ad-heavy player (playmogo), drugi (luluvid)
|
||||||
# może być cleaner. Dedupe po URL żeby nie dublować tego samego playera.
|
# może być cleaner. Dedupe po URL żeby nie dublować tego samego playera.
|
||||||
# Trzymamy też raw_iframes (pre-filter) żeby odróżnić "page nie ma iframe-a" od
|
# Trzymamy też raw_iframes (pre-filter) żeby odróżnić "page nie ma iframe-a" od
|
||||||
|
|
@ -364,7 +364,7 @@ def extract(
|
||||||
# JS-hidden backup servers (porn4days SERVER<N>_URL pattern). Niektóre tube'y
|
# JS-hidden backup servers (porn4days SERVER<N>_URL pattern). Niektóre tube'y
|
||||||
# renderują tylko 1 iframe a backup hosterów trzymają w `const SERVER2_URL = "..."`
|
# renderują tylko 1 iframe a backup hosterów trzymają w `const SERVER2_URL = "..."`
|
||||||
# JS variable + clickable "Server 2" button. Bez tego ekstraktor widzi tylko
|
# JS variable + clickable "Server 2" button. Bez tego ekstraktor widzi tylko
|
||||||
# SERVER1 (najczęściej iceyfile/streamtape) — gdy ten 404 / malware-blocked,
|
# SERVER1 (najczęściej iceyfile/streamtape), gdy ten 404 / malware-blocked,
|
||||||
# cały scrape ginie. Backupy mogą być na czystych hosterach (turbovidhls/veev.to).
|
# cały scrape ginie. Backupy mogą być na czystych hosterach (turbovidhls/veev.to).
|
||||||
for m in _JS_SERVER_URL_RE.finditer(page_html):
|
for m in _JS_SERVER_URL_RE.finditer(page_html):
|
||||||
candidate = m.group("url").strip()
|
candidate = m.group("url").strip()
|
||||||
|
|
@ -416,7 +416,7 @@ def extract(
|
||||||
iframe_urls.append(candidate)
|
iframe_urls.append(candidate)
|
||||||
|
|
||||||
# Escape'owane iframe src w JS string literals (porndish pattern). Iframe wstrzykuje
|
# Escape'owane iframe src w JS string literals (porndish pattern). Iframe wstrzykuje
|
||||||
# się do DOM po kliknięciu user'a — server-side HTML widzimy tylko `<div id="iframeHolder">`,
|
# się do DOM po kliknięciu user'a, server-side HTML widzimy tylko `<div id="iframeHolder">`,
|
||||||
# a iframe URL jest w `const doodstreamContent = "<iframe ... src=\"https:\/\/...\" ...>"`.
|
# a iframe URL jest w `const doodstreamContent = "<iframe ... src=\"https:\/\/...\" ...>"`.
|
||||||
# Po match'u czyścimy backslash escape: `\/` → `/`, `\\\"` → `"` (jeśli)
|
# Po match'u czyścimy backslash escape: `\/` → `/`, `\\\"` → `"` (jeśli)
|
||||||
for m in _JS_ESCAPED_IFRAME_SRC_RE.finditer(page_html):
|
for m in _JS_ESCAPED_IFRAME_SRC_RE.finditer(page_html):
|
||||||
|
|
@ -435,17 +435,17 @@ def extract(
|
||||||
iframe_urls.append(candidate)
|
iframe_urls.append(candidate)
|
||||||
|
|
||||||
# Stable-sort: CAPTCHA-walled hosty (DoodStream variants) NA KONIEC. Zachowujemy
|
# Stable-sort: CAPTCHA-walled hosty (DoodStream variants) NA KONIEC. Zachowujemy
|
||||||
# DOM order wśród non-CAPTCHA — preferred player tube'a (zazwyczaj pierwszy) zostaje.
|
# DOM order wśród non-CAPTCHA, preferred player tube'a (zazwyczaj pierwszy) zostaje.
|
||||||
# Powód: siska/xmoviesforyou wstawiają playmogo (dood) jako pierwszy iframe, luluvid
|
# Powód: siska/xmoviesforyou wstawiają playmogo (dood) jako pierwszy iframe, luluvid
|
||||||
# jako drugi — bez sortu mobile bierze playmogo → CAPTCHA → czarny ekran. Z sortem
|
# jako drugi, bez sortu mobile bierze playmogo → CAPTCHA → czarny ekran. Z sortem
|
||||||
# luluvid (lub inny clean hoster) ląduje na pozycji 1, ExoPlayer ma szansę.
|
# luluvid (lub inny clean hoster) ląduje na pozycji 1, ExoPlayer ma szansę.
|
||||||
iframe_urls.sort(key=_hoster_priority)
|
iframe_urls.sort(key=_hoster_priority)
|
||||||
|
|
||||||
if not iframe_urls:
|
if not iframe_urls:
|
||||||
# Wszystkie iframe-y na stronie zostały odsiane (DEAD_HOSTER_RE — np. streamtape,
|
# Wszystkie iframe-y na stronie zostały odsiane (DEAD_HOSTER_RE, np. streamtape,
|
||||||
# xtapes.porn). Page-as-hoster nie pomoże bo WebView na aggregator page i tak
|
# xtapes.porn). Page-as-hoster nie pomoże bo WebView na aggregator page i tak
|
||||||
# załaduje ten sam (zablacklistowany) hoster. Lepiej zwrócić None żeby mobile
|
# załaduje ten sam (zablacklistowany) hoster. Lepiej zwrócić None żeby mobile
|
||||||
# dostał 501 i pokazał error — user wybierze inną źródło, nie dostanie malware.
|
# dostał 501 i pokazał error, user wybierze inną źródło, nie dostanie malware.
|
||||||
if raw_iframes_count > 0:
|
if raw_iframes_count > 0:
|
||||||
log.info(
|
log.info(
|
||||||
"embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None",
|
"embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None",
|
||||||
|
|
@ -454,7 +454,7 @@ def extract(
|
||||||
return None
|
return None
|
||||||
# Brak iframe-ów w ogóle (JS-only render np. mypornerleak's muliframe.js,
|
# Brak iframe-ów w ogóle (JS-only render np. mypornerleak's muliframe.js,
|
||||||
# CloudFlare JS challenge na xxxfree.watch, login-walled tubes). Page-as-hoster
|
# CloudFlare JS challenge na xxxfree.watch, login-walled tubes). Page-as-hoster
|
||||||
# ma sens — WebView przejdzie CF challenge, wyrenderuje JS, da user'owi player.
|
# ma sens, WebView przejdzie CF challenge, wyrenderuje JS, da user'owi player.
|
||||||
log.info("embed_iframe: no iframes in %s — page-as-hoster fallback", page_url)
|
log.info("embed_iframe: no iframes in %s — page-as-hoster fallback", page_url)
|
||||||
page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
|
page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
|
||||||
return [StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")]
|
return [StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")]
|
||||||
|
|
@ -464,9 +464,9 @@ def extract(
|
||||||
|
|
||||||
# Stage 0: yt-dlp generic na PAGE URL (nie iframe). Niektóre tube'y (pornditt,
|
# Stage 0: yt-dlp generic na PAGE URL (nie iframe). Niektóre tube'y (pornditt,
|
||||||
# latestleaks) mają KVS player config (`kt_player(`, `license_code:`) wprost na
|
# latestleaks) mają KVS player config (`kt_player(`, `license_code:`) wprost na
|
||||||
# scene page'u — embed iframe to czysta CSS shell. yt-dlp generic potrafi
|
# scene page'u, embed iframe to czysta CSS shell. yt-dlp generic potrafi
|
||||||
# zdeszyfrować KVS URL z page'a, więc wolimy to niż WebView fallback.
|
# zdeszyfrować KVS URL z page'a, więc wolimy to niż WebView fallback.
|
||||||
# Referer = pełny page URL (nie host root) — KVS get_file/ URL jest signed
|
# Referer = pełny page URL (nie host root), KVS get_file/ URL jest signed
|
||||||
# względem konkretnej page'a, host root daje 410.
|
# względem konkretnej page'a, host root daje 410.
|
||||||
if all(marker in page_html for marker in ("kt_player(", "license_code")):
|
if all(marker in page_html for marker in ("kt_player(", "license_code")):
|
||||||
from app.extractors.hoster import _try_ytdlp_hoster
|
from app.extractors.hoster import _try_ytdlp_hoster
|
||||||
|
|
@ -477,8 +477,8 @@ def extract(
|
||||||
|
|
||||||
# Stage 1: spróbuj wyekstraktować direct video URL z każdego iframe'a po kolei.
|
# Stage 1: spróbuj wyekstraktować direct video URL z każdego iframe'a po kolei.
|
||||||
# Direct mp4/m3u8 idzie PIERWSZY w wyniku (ExoPlayer natywnie >> WebView), ale
|
# Direct mp4/m3u8 idzie PIERWSZY w wyniku (ExoPlayer natywnie >> WebView), ale
|
||||||
# NIE pomijamy reszty iframe — dodajemy je jako hoster fallback. Powód: niektóre
|
# NIE pomijamy reszty iframe, dodajemy je jako hoster fallback. Powód: niektóre
|
||||||
# CDN-y file storage (vidnest.live, iceyfile) blokują Hetzner ASN — Stage 1 wyciąga
|
# CDN-y file storage (vidnest.live, iceyfile) blokują Hetzner ASN, Stage 1 wyciąga
|
||||||
# URL z embed page, ale ani VPS proxy ani mobile go nie pobierze (No route to host
|
# URL z embed page, ale ani VPS proxy ani mobile go nie pobierze (No route to host
|
||||||
# albo 403). Bez iframe fallbacku mobile dostaje 503 i utyka. Z fallbackiem chain:
|
# albo 403). Bez iframe fallbacku mobile dostaje 503 i utyka. Z fallbackiem chain:
|
||||||
# direct → proxy → iframe1 (WebView) → iframe2 (WebView) → page (WebView).
|
# direct → proxy → iframe1 (WebView) → iframe2 (WebView) → page (WebView).
|
||||||
|
|
@ -491,7 +491,7 @@ def extract(
|
||||||
log.warning("embed_iframe: extract failed for %s: %s", iframe_url, e)
|
log.warning("embed_iframe: extract failed for %s: %s", iframe_url, e)
|
||||||
continue
|
continue
|
||||||
if stream_url:
|
if stream_url:
|
||||||
# IP-bound CDN check — skip Stage 1 result, force fallback na hoster
|
# IP-bound CDN check, skip Stage 1 result, force fallback na hoster
|
||||||
# (mobile WebView pobiera embed page z phone IP). Critical dla public
|
# (mobile WebView pobiera embed page z phone IP). Critical dla public
|
||||||
# release: premilkyway/tnmr.org bind token do requester IP.
|
# release: premilkyway/tnmr.org bind token do requester IP.
|
||||||
if _IP_BOUND_CDN_RE.search(stream_url):
|
if _IP_BOUND_CDN_RE.search(stream_url):
|
||||||
|
|
@ -516,13 +516,13 @@ def extract(
|
||||||
sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)"))
|
sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)"))
|
||||||
return sources
|
return sources
|
||||||
|
|
||||||
# Stage 2: wszystkie iframe-y zwróć jako hoster sources — mobile dostaje listę
|
# Stage 2: wszystkie iframe-y zwróć jako hoster sources, mobile dostaje listę
|
||||||
# alternatyw, użytkownik może switchować gdy pierwszy ma overlay/CF challenge.
|
# alternatyw, użytkownik może switchować gdy pierwszy ma overlay/CF challenge.
|
||||||
# Pierwszy w liście ma być najczystszy — ale bez wiedzy a priori które są ad-heavy
|
# Pierwszy w liście ma być najczystszy, ale bez wiedzy a priori które są ad-heavy
|
||||||
# zostawiamy kolejność DOM (zwykle authoring tube wstawia preferred player jako
|
# zostawiamy kolejność DOM (zwykle authoring tube wstawia preferred player jako
|
||||||
# pierwszy, a drugi jako backup). Quality ustawiamy na nazwę hosta iframe'a żeby
|
# pierwszy, a drugi jako backup). Quality ustawiamy na nazwę hosta iframe'a żeby
|
||||||
# PlaybackQualityModal pokazał użytkownikowi rozróżnialne opcje (np. "playmogo"
|
# PlaybackQualityModal pokazał użytkownikowi rozróżnialne opcje (np. "playmogo"
|
||||||
# vs "luluvid"). Na końcu page_url jako safety-net — gdy wszystkie iframe'y są
|
# vs "luluvid"). Na końcu page_url jako safety-net, gdy wszystkie iframe'y są
|
||||||
# martwe (np. sxyland → xtapes.porn 301→camcaps.to 403), mobile WebView otworzy
|
# martwe (np. sxyland → xtapes.porn 301→camcaps.to 403), mobile WebView otworzy
|
||||||
# główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player.
|
# główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player.
|
||||||
sources = []
|
sources = []
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"""Współdzielony resolver dla KVS (kt_player) tube'ów z `function/0/` + `license_code`.
|
"""Współdzielony resolver dla KVS (kt_player) tube'ów z `function/0/` + `license_code`.
|
||||||
|
|
||||||
Wzorzec (yespornvip, pornditt, ... — wszystkie KVS na tym samym silniku):
|
Wzorzec (yespornvip, pornditt, ..., wszystkie KVS na tym samym silniku):
|
||||||
flashvars: `video_url` / `video_alt_url` / `video_alt_url2` = `function/0/https://<host>/
|
flashvars: `video_url` / `video_alt_url` / `video_alt_url2` = `function/0/https://<host>/
|
||||||
get_file/<srv>/<HASH>/.../<id>.mp4/` (480/720/1080p) + `license_code: '$...'`.
|
get_file/<srv>/<HASH>/.../<id>.mp4/` (480/720/1080p) + `license_code: '$...'`.
|
||||||
kt_player dekoduje HASH (permutacja pierwszych 32 znaków algorytmem license_code —
|
kt_player dekoduje HASH (permutacja pierwszych 32 znaków algorytmem license_code,
|
||||||
algo zgodny z yt-dlp KVS `_kvs_get_real_url`, zweryfikowany 2026-05-31 że odtwarza
|
algo zgodny z yt-dlp KVS `_kvs_get_real_url`, zweryfikowany 2026-05-31 że odtwarza
|
||||||
output kt_player).
|
output kt_player).
|
||||||
|
|
||||||
Zdekodowany `get_file` 302-redirectuje do CDN (cdn5.yesporn.vip / twa.tgprn.com / ...)
|
Zdekodowany `get_file` 302-redirectuje do CDN (cdn5.yesporn.vip / twa.tgprn.com / ...)
|
||||||
który serwuje wideo (206). Finalny URL jest **time-bound signed, NIE IP/cookie-bound** —
|
który serwuje wideo (206). Finalny URL jest **time-bound signed, NIE IP/cookie-bound**,
|
||||||
portable cross-IP. get_file token jest session-bound, więc 302 MUSI być rozwiązany w tej
|
portable cross-IP. get_file token jest session-bound, więc 302 MUSI być rozwiązany w tej
|
||||||
samej sesji curl_cffi co fetch strony. Oddajemy finalny portable CDN url per jakość →
|
samej sesji curl_cffi co fetch strony. Oddajemy finalny portable CDN url per jakość →
|
||||||
mobile gra direct natywnie, multi-quality, zero WebView/reklam/proxy.
|
mobile gra direct natywnie, multi-quality, zero WebView/reklam/proxy.
|
||||||
|
|
@ -29,9 +29,9 @@ from app.extractors._models import StreamSource
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Łapie OBA formaty flashvars:
|
# Łapie OBA formaty flashvars:
|
||||||
# - `function/0/https://.../get_file/<HASH>/...` (yespornvip/pornditt/freshporno —
|
# - `function/0/https://.../get_file/<HASH>/...` (yespornvip/pornditt/freshporno,
|
||||||
# zakodowany, dekodowany przez real_url)
|
# zakodowany, dekodowany przez real_url)
|
||||||
# - `https://.../get_file/...` (porn00 — już zdekodowany plain get_file; real_url
|
# - `https://.../get_file/...` (porn00, już zdekodowany plain get_file; real_url
|
||||||
# przepuszcza go bez zmian)
|
# przepuszcza go bez zmian)
|
||||||
# W obu przypadkach finał to get_file → 302 → CDN (follow in-session).
|
# W obu przypadkach finał to get_file → 302 → CDN (follow in-session).
|
||||||
_URL_RE = re.compile(
|
_URL_RE = re.compile(
|
||||||
|
|
@ -49,7 +49,7 @@ _HASH_LENGTH = 32
|
||||||
|
|
||||||
# get_file 302-follow timeout. Zdrowy CDN odpowiada <1s; gdy wideo zostało usunięte
|
# get_file 302-follow timeout. Zdrowy CDN odpowiada <1s; gdy wideo zostało usunięte
|
||||||
# z CDN (strona istnieje, ale get_file stalluje) request wisi do timeoutu. Trzymamy
|
# z CDN (strona istnieje, ale get_file stalluje) request wisi do timeoutu. Trzymamy
|
||||||
# go NISKO i osobno od page-fetch timeoutu — bug 6ec1960e 2026-06-02: yesporn.vip
|
# go NISKO i osobno od page-fetch timeoutu, bug 6ec1960e 2026-06-02: yesporn.vip
|
||||||
# przeszedł na cdn4/remote_control.php i dla martwych scen wszystkie 3 jakości
|
# przeszedł na cdn4/remote_control.php i dla martwych scen wszystkie 3 jakości
|
||||||
# wisiały po 60s = 180s → mobile "resolving w nieskończoność".
|
# wisiały po 60s = 180s → mobile "resolving w nieskończoność".
|
||||||
_GETFILE_TIMEOUT = 10.0
|
_GETFILE_TIMEOUT = 10.0
|
||||||
|
|
@ -117,7 +117,7 @@ def resolve_kvs(page_url: str, *, base_url: str, timeout: float = 60.0) -> list[
|
||||||
"""Fetch KVS page → decode function/0 get_file (per jakość) → follow 302 → portable CDN.
|
"""Fetch KVS page → decode function/0 get_file (per jakość) → follow 302 → portable CDN.
|
||||||
|
|
||||||
Zwraca StreamSource'y posortowane malejąco po jakości, lub None gdy nic nie wyszło.
|
Zwraca StreamSource'y posortowane malejąco po jakości, lub None gdy nic nie wyszło.
|
||||||
base_url: scheme+host hosta (np. 'https://yesporn.vip') — do Referera i logów.
|
base_url: scheme+host hosta (np. 'https://yesporn.vip'), do Referera i logów.
|
||||||
"""
|
"""
|
||||||
if not _HAS_CURL_CFFI:
|
if not _HAS_CURL_CFFI:
|
||||||
log.info("kvs: curl_cffi unavailable — cannot resolve %s", page_url)
|
log.info("kvs: curl_cffi unavailable — cannot resolve %s", page_url)
|
||||||
|
|
@ -150,7 +150,7 @@ def resolve_kvs(page_url: str, *, base_url: str, timeout: float = 60.0) -> list[
|
||||||
|
|
||||||
# get_file timeout NISKI (osobny od page-fetch) + early-break: gdy 2 pierwsze
|
# get_file timeout NISKI (osobny od page-fetch) + early-break: gdy 2 pierwsze
|
||||||
# jakości nie rozwiążą się (przy 0 dotychczasowych wyników) scena jest martwa na
|
# jakości nie rozwiążą się (przy 0 dotychczasowych wyników) scena jest martwa na
|
||||||
# CDN — nie ma sensu czekać na trzecią (oszczędza kolejne _GETFILE_TIMEOUT).
|
# CDN, nie ma sensu czekać na trzecią (oszczędza kolejne _GETFILE_TIMEOUT).
|
||||||
gf_timeout = min(timeout, _GETFILE_TIMEOUT)
|
gf_timeout = min(timeout, _GETFILE_TIMEOUT)
|
||||||
seen_dec: set[str] = set()
|
seen_dec: set[str] = set()
|
||||||
result: list[StreamSource] = []
|
result: list[StreamSource] = []
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue