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:
|
||||
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła.
|
||||
TPDB ma `image`/`poster`/`background.large`. StashDB raw nie zawiera image
|
||||
(osobny query do StashDB potrzebny — tu zwracamy None)."""
|
||||
(osobny query do StashDB potrzebny, tu zwracamy None)."""
|
||||
if kind == SourceKind.tpdb:
|
||||
for k in ("image", "poster"):
|
||||
v = raw.get(k)
|
||||
|
|
@ -42,7 +42,7 @@ def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
|
|||
if isinstance(v, str) and v.startswith("http"):
|
||||
return v
|
||||
elif kind == SourceKind.stashdb:
|
||||
# StashDB scene response includes images via separate query — nie trzymamy
|
||||
# StashDB scene response includes images via separate query, nie trzymamy
|
||||
# tego w raw obecnie. TODO: dorzucić mirror do `paths.screenshot` przy ingest.
|
||||
paths = raw.get("paths")
|
||||
if isinstance(paths, dict):
|
||||
|
|
@ -142,7 +142,7 @@ def list_candidates(
|
|||
|
||||
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
|
||||
# używa statycznego do listy + animowanego po hold-to-preview). Wybieramy najpierw
|
||||
# napotkany niepusty URL — kolejność rzędów playback_sources nie jest gwarantowana,
|
||||
# napotkany niepusty URL, kolejność rzędów playback_sources nie jest gwarantowana,
|
||||
# ale dla triage to wystarcza.
|
||||
thumbs: dict[uuid.UUID, str] = {}
|
||||
animated_thumbs: dict[uuid.UUID, str] = {}
|
||||
|
|
@ -292,7 +292,7 @@ def _fmt_bytes(b: int) -> str:
|
|||
def bandwidth_stats() -> BandwidthStats:
|
||||
"""Per-CDN VPS proxy bytes-out + Hetzner traffic stats.
|
||||
|
||||
Critical dla public release — pokazuje gdzie VPS bandwidth wycieka. Pozwala
|
||||
Critical dla public release, pokazuje gdzie VPS bandwidth wycieka. Pozwala
|
||||
spotted Mixdrop / bandwidth-heavy CDN-y przed Hetzner overage charge.
|
||||
"""
|
||||
from app.api.stream_proxy import get_bandwidth_stats
|
||||
|
|
@ -308,7 +308,7 @@ def bandwidth_stats() -> BandwidthStats:
|
|||
s_24h = get_bandwidth_stats(24)
|
||||
s_7d = get_bandwidth_stats(168)
|
||||
|
||||
# Hetzner stats — load from cache file (written by check_hetzner_traffic.py cron).
|
||||
# Hetzner stats, load from cache file (written by check_hetzner_traffic.py cron).
|
||||
hetzner_data = None
|
||||
settings = get_settings()
|
||||
if settings.hetzner_api_token and settings.hetzner_server_id:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
|
||||
|
||||
Endpointy:
|
||||
GET /ui/ — lista pending (filter status)
|
||||
GET /ui/candidate/{id} — side-by-side scen
|
||||
POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject)
|
||||
GET /ui/ lista pending (filter status)
|
||||
GET /ui/candidate/{id} side-by-side scen
|
||||
POST /ui/candidate/{id}/resolve, htmx form submit (action=merge_keep_left|merge_keep_right|reject)
|
||||
zwraca fragment HTML z potwierdzeniem
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -197,9 +197,9 @@ def resolve_form(
|
|||
|
||||
|
||||
def mount_static(app) -> None: # pragma: no cover - dev convenience
|
||||
# APK MIME type — bez tego Android Browser nie traktuje pliku jako instalable APK
|
||||
# APK MIME type, bez tego Android Browser nie traktuje pliku jako instalable APK
|
||||
# (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest
|
||||
# idempotentna na poziomie procesu — bezpiecznie wywoływać przy każdym startup.
|
||||
# idempotentna na poziomie procesu, bezpiecznie wywoływać przy każdym startup.
|
||||
import mimetypes
|
||||
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
|
||||
if _STATIC_DIR.exists():
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""Blacklists — globalnie ukryte performerki/studia/tagi.
|
||||
"""Blacklists, globalnie ukryte performerki/studia/tagi.
|
||||
|
||||
Sceny które MAJĄ blacklisted entity wypadają z każdego /scenes (pełna lista, search,
|
||||
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
|
||||
|
||||
Endpointy:
|
||||
GET /blacklist — wszystkie 3 listy w jednym response
|
||||
POST /blacklist/{kind}/{entity_id} — dodaj (idempotent)
|
||||
DELETE /blacklist/{kind}/{entity_id} — usuń
|
||||
GET /blacklist wszystkie 3 listy w jednym response
|
||||
POST /blacklist/{kind}/{entity_id} dodaj (idempotent)
|
||||
DELETE /blacklist/{kind}/{entity_id} usuń
|
||||
|
||||
`kind` ∈ {performer, studio, tag}.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""Bug reports — mobile FAB → POST /bug-reports → admin lista przez admin_html.
|
||||
"""Bug reports, mobile FAB → POST /bug-reports → admin lista przez admin_html.
|
||||
|
||||
POST nie wymaga obecnego scene_id (user może raportować z FavoritesScreen,
|
||||
SearchScreen itp.). Screenshot opcjonalny — niektóre ekrany nie warto kapturować.
|
||||
SearchScreen itp.). Screenshot opcjonalny, niektóre ekrany nie warto kapturować.
|
||||
|
||||
Limit body 1.5MB (FastAPI default jest hojny, ale dla rozsądku ograniczamy).
|
||||
Screenshot to PNG/JPEG z react-native-view-shot, base64 — typowe rozmiary:
|
||||
Screenshot to PNG/JPEG z react-native-view-shot, base64, typowe rozmiary:
|
||||
- mały ekran scene-list: ~200-400KB
|
||||
- duży scene-detail z thumbnail: ~600KB-1MB
|
||||
"""
|
||||
|
|
@ -92,7 +92,7 @@ def create_bug_report(
|
|||
scene_id = payload.scene_id
|
||||
elif session.get(Movie, payload.scene_id) is not None:
|
||||
movie_id = payload.scene_id
|
||||
# else: ID nie istnieje już nigdzie (deleted) — drop oba na null
|
||||
# else: ID nie istnieje już nigdzie (deleted), drop oba na null
|
||||
|
||||
br = BugReport(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -211,7 +211,7 @@ def get_bug_report_screenshot(
|
|||
bug_id: uuid.UUID,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> dict[str, str | None]:
|
||||
"""Zwraca base64-encoded screenshot (jeśli jest) — admin UI go renderuje."""
|
||||
"""Zwraca base64-encoded screenshot (jeśli jest), admin UI go renderuje."""
|
||||
br = session.get(BugReport, bug_id)
|
||||
if br is None:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ nowi użytkownicy widzieli/nadpisywali ulubione, watched-badge i blacklisty Jana
|
|||
nagłówku `X-Device-Id`; backend scope'uje wszystkie tabele stanu po `device_id`.
|
||||
|
||||
Stare wiersze (sprzed migracji) mają `device_id = LEGACY_DEVICE`. Klient bez nagłówka
|
||||
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE — czyli dostaje dawną
|
||||
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE, czyli dostaje dawną
|
||||
współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy`
|
||||
przepina LEGACY rows na konkretne device (Jan robi to raz po update).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ Każdy update wgrany przez `scripts/publish_update.py` ląduje w
|
|||
`app/static/expo-updates/<runtime>/<update_id>/`. Plik
|
||||
`app/static/expo-updates/<runtime>/current.json` wskazuje aktywny update_id.
|
||||
|
||||
Endpointy SĄ PUBLICZNE (no auth) — Expo Updates SDK nie wstrzykuje X-API-Key.
|
||||
Endpointy SĄ PUBLICZNE (no auth), Expo Updates SDK nie wstrzykuje X-API-Key.
|
||||
Bezpieczeństwo opiera się na TLS pinningu (mobile ufa tylko naszej self-signed
|
||||
cert SPKI z network_security_config) — ktoś bez tego pinu nie podstawi MITM
|
||||
cert SPKI z network_security_config), ktoś bez tego pinu nie podstawi MITM
|
||||
manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -35,7 +35,7 @@ def get_manifest(
|
|||
expo_platform: str | None = Header(default=None, alias="expo-platform"),
|
||||
) -> Response:
|
||||
"""Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0)
|
||||
+ platform (default android — i tak tylko Android wspieramy).
|
||||
+ platform (default android, i tak tylko Android wspieramy).
|
||||
|
||||
204 No Content gdy nie ma update'u dla tego runtime'u → klient nadal odpala
|
||||
embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest
|
||||
|
|
@ -87,7 +87,7 @@ def get_asset(
|
|||
) -> Response:
|
||||
"""Serwuje pojedynczy asset (JS bundle, image, font) z update directory.
|
||||
|
||||
`asset` to relative path względem `static/expo-updates/<runtime>/` —
|
||||
`asset` to relative path względem `static/expo-updates/<runtime>/`,
|
||||
zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub
|
||||
`<update_id>/assets/<hash>`. Path traversal blocked przez resolve+is_relative.
|
||||
"""
|
||||
|
|
@ -103,7 +103,7 @@ def get_asset(
|
|||
raise HTTPException(status_code=400, detail="invalid asset path")
|
||||
if not target.exists() or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="asset not found")
|
||||
# Content type — bundle to text/javascript, reszta autodetect przez FileResponse.
|
||||
# Content type, bundle to text/javascript, reszta autodetect przez FileResponse.
|
||||
media_type = None
|
||||
if target.suffix in (".js", ".bundle"):
|
||||
media_type = "application/javascript"
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
"""Favorites — ulubione performerki + studia + liczenie nowych scen.
|
||||
"""Favorites, ulubione performerki + studia + liczenie nowych scen.
|
||||
|
||||
Single-user (brak users), więc API zwraca/operuje na global zbiorze. Multi-user
|
||||
można dodać dorzuceniem `user_id` query/header bez breaking change.
|
||||
|
||||
Endpointy (performers — `/favorites/...` zostawione żeby nie łamać starego mobile):
|
||||
GET /favorites — lista ulubionych performerek
|
||||
POST /favorites/{performer_id} — dodaj (idempotent)
|
||||
DELETE /favorites/{performer_id} — usuń
|
||||
POST /favorites/{performer_id}/seen — mark-as-seen (zeruje badge)
|
||||
Endpointy (performers, `/favorites/...` zostawione żeby nie łamać starego mobile):
|
||||
GET /favorites lista ulubionych performerek
|
||||
POST /favorites/{performer_id} dodaj (idempotent)
|
||||
DELETE /favorites/{performer_id} usuń
|
||||
POST /favorites/{performer_id}/seen mark-as-seen (zeruje badge)
|
||||
|
||||
Endpointy (studios):
|
||||
GET /favorites/studios — lista ulubionych studiów
|
||||
POST /favorites/studios/{studio_id} — dodaj
|
||||
DELETE /favorites/studios/{studio_id} — usuń
|
||||
POST /favorites/studios/{studio_id}/seen — mark-as-seen
|
||||
GET /favorites/studios lista ulubionych studiów
|
||||
POST /favorites/studios/{studio_id} dodaj
|
||||
DELETE /favorites/studios/{studio_id} usuń
|
||||
POST /favorites/studios/{studio_id}/seen, mark-as-seen
|
||||
|
||||
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
|
||||
- dla performerki: ScenePerformer.performer_id = X
|
||||
|
|
@ -61,7 +61,7 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
|||
tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy
|
||||
playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem
|
||||
release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca
|
||||
{group_id: n} — bez streamowania N*200 wierszy do Pythona.
|
||||
{group_id: n}, bez streamowania N*200 wierszy do Pythona.
|
||||
|
||||
kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers.
|
||||
kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują
|
||||
|
|
@ -134,7 +134,7 @@ def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
|||
|
||||
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
|
||||
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
|
||||
top-N ani odsiewu stub/backfill — te pojęcia dotyczą scen.
|
||||
top-N ani odsiewu stub/backfill, te pojęcia dotyczą scen.
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ class FavoriteOut(BaseModel):
|
|||
class FavoriteListOut(BaseModel):
|
||||
items: list[FavoriteOut]
|
||||
total: int
|
||||
new_total: int # suma new_count po wszystkich — dla badge w toolbar
|
||||
new_total: int # suma new_count po wszystkich, dla badge w toolbar
|
||||
|
||||
|
||||
@router.get("", response_model=FavoriteListOut)
|
||||
|
|
@ -216,7 +216,7 @@ def list_favorites(
|
|||
return FavoriteListOut(items=[], total=0, new_total=0)
|
||||
|
||||
# scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez
|
||||
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
|
||||
# _job_refresh_taxonomy_counts), ta sama definicja co przed (sceny z żywym
|
||||
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
||||
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
||||
new_counts = _merged_new_counts(session, device_id, kind="performer")
|
||||
|
|
@ -274,7 +274,7 @@ def remove_favorite(
|
|||
) -> None:
|
||||
fav = session.get(FavoritePerformer, (device_id, performer_id))
|
||||
if fav is None:
|
||||
# idempotent — brak ulubionego = nie ma nic do usunięcia, success
|
||||
# idempotent, brak ulubionego = nie ma nic do usunięcia, success
|
||||
return
|
||||
session.delete(fav)
|
||||
session.commit()
|
||||
|
|
@ -416,7 +416,7 @@ def mark_studio_seen(
|
|||
|
||||
# ── Favorite movies ────────────────────────────────────────────────────────
|
||||
# Movies nie mają child scenes per-favorite (jak performerki/studia), więc
|
||||
# `last_seen_at` nie jest tu używany do NEW count — tylko jako tracking ostatniego
|
||||
# `last_seen_at` nie jest tu używany do NEW count, tylko jako tracking ostatniego
|
||||
# wglądu przez usera. Mobile używa NEW badge w liście /movies przez OSOBNY
|
||||
# globalny last_seen z AsyncStorage (client-side, brak backendowego state).
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
|
||||
`POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty)
|
||||
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki — kolejne
|
||||
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki, kolejne
|
||||
wywołania nic nie znajdą (legacy już puste). Jeśli na keep-device istnieje już wiersz
|
||||
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem
|
||||
kasujemy resztki legacy).
|
||||
|
|
@ -22,7 +22,7 @@ from app.db import get_session
|
|||
|
||||
router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)])
|
||||
|
||||
# (tabela, kolumna-encji) — wszystkie tabele device-scoped (migracja 0022).
|
||||
# (tabela, kolumna-encji), wszystkie tabele device-scoped (migracja 0022).
|
||||
_TABLES: list[tuple[str, str]] = [
|
||||
("favorite_performers", "performer_id"),
|
||||
("favorite_studios", "studio_id"),
|
||||
|
|
@ -59,7 +59,7 @@ def import_from_device(
|
|||
) -> ImportOut:
|
||||
"""Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego
|
||||
urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu
|
||||
transferu — jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
|
||||
transferu, jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
|
||||
|
||||
KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które
|
||||
urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid()."""
|
||||
|
|
@ -114,7 +114,7 @@ def adopt_legacy(
|
|||
).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
|
||||
)
|
||||
moved[table] = res.rowcount or 0
|
||||
# Resztki legacy (encje które device już miał) — skasuj, żeby nie wisiały.
|
||||
# Resztki legacy (encje które device już miał), skasuj, żeby nie wisiały.
|
||||
session.execute(
|
||||
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""GET /movies — lista i szczegóły filmów."""
|
||||
"""GET /movies, lista i szczegóły filmów."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
|
@ -138,8 +138,8 @@ def list_movies(
|
|||
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
|
||||
|
||||
|
||||
# Movie playback origin policy — module-level (kiedyś było inline per-request
|
||||
# definition, code-review #19 — perf hit + dorosły kod).
|
||||
# Movie playback origin policy, module-level (kiedyś było inline per-request
|
||||
# definition, code-review #19, perf hit + dorosły kod).
|
||||
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
|
||||
# random per origin).
|
||||
_MOVIE_PREFERRED_ORIGINS = (
|
||||
|
|
@ -150,9 +150,9 @@ _MOVIE_PREFERRED_ORIGINS = (
|
|||
"streamporn",
|
||||
"pandamovies",
|
||||
)
|
||||
# File hosters które NIGDY nie dadzą się stream-extract bez premium account —
|
||||
# File hosters które NIGDY nie dadzą się stream-extract bez premium account,
|
||||
# odfiltrowywane całkowicie (zaśmiecały listę watch options, bug-report
|
||||
# 2026-05-15). Streamtape przywrócony 2026-05-15 — ma dedicated extractor,
|
||||
# 2026-05-15). Streamtape przywrócony 2026-05-15, ma dedicated extractor,
|
||||
# ~5% URLów żyje.
|
||||
_MOVIE_DROP_ORIGINS = frozenset({
|
||||
"mangoporn:rapidgator",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve — rozwiązuje stream URL.
|
||||
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve, rozwiązuje stream URL.
|
||||
|
||||
Mobile apka woła ten endpoint na klik "Watch" — backend ekstraktuje świeży
|
||||
Mobile apka woła ten endpoint na klik "Watch", backend ekstraktuje świeży
|
||||
stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez
|
||||
Linking.openURL → Android player chooser (MX Player / VLC / browser).
|
||||
|
||||
Stream URLs są podpisane/expire (zwykle ~kilka godzin) — nie cache'ujemy ich
|
||||
Stream URLs są podpisane/expire (zwykle ~kilka godzin), nie cache'ujemy ich
|
||||
w DB, tylko resolve on-demand. Logika ekstrakcji per-tube w `app.extractors`.
|
||||
|
||||
**Dead-link detection**: gdy hoster embed page mówi "Video deleted/not found",
|
||||
oznaczamy `PlaybackSource.dead_at = now()` — API dalej go nie listuje, mobile
|
||||
oznaczamy `PlaybackSource.dead_at = now()`, API dalej go nie listuje, mobile
|
||||
nie pokaże martwego buttonu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -39,23 +39,23 @@ log = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)])
|
||||
|
||||
# CDN-domain allowlist dla mobile direct fetch — token IS time-bound (nie IP-bound),
|
||||
# CDN-domain allowlist dla mobile direct fetch, token IS time-bound (nie IP-bound),
|
||||
# zweryfikowane cross-IP curl test 2026-05-18. Mobile ExoPlayer pobiera manifest+segments
|
||||
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
|
||||
#
|
||||
# Verified time-bound:
|
||||
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding) — signed token w path + exp_time
|
||||
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube) — validfrom+validto+hash
|
||||
# - privatehost.com (pornhat CDN) — sign + exp_time, brak Referer requirement
|
||||
# - sxyprn.com — signed path
|
||||
# - eporner.com CDN — IP literal w path ale CDN go ignoruje
|
||||
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding), signed token w path + exp_time
|
||||
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube), validfrom+validto+hash
|
||||
# - privatehost.com (pornhat CDN), sign + exp_time, brak Referer requirement
|
||||
# - sxyprn.com, signed path
|
||||
# - eporner.com CDN, IP literal w path ale CDN go ignoruje
|
||||
#
|
||||
# NIE w allowlist (IP-bound, wymagają proxy):
|
||||
# - premilkyway.com (latestpornvideo) — 403 cross-IP
|
||||
# - tnmr.org (mypornerleak) — 403 cross-IP
|
||||
# - porntrex.com/get_file — single-use token (410 po reuse)
|
||||
# - freshporno.org/get_file — cv= signed token IP-bound
|
||||
# - sn.porn-xp.com, porn00.org — force_proxy explicit
|
||||
# - premilkyway.com (latestpornvideo), 403 cross-IP
|
||||
# - tnmr.org (mypornerleak), 403 cross-IP
|
||||
# - porntrex.com/get_file, single-use token (410 po reuse)
|
||||
# - freshporno.org/get_file, cv= signed token IP-bound
|
||||
# - sn.porn-xp.com, porn00.org, force_proxy explicit
|
||||
_TIME_BOUND_CDN_RE = re.compile(
|
||||
r"\b(?:"
|
||||
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
|
||||
|
|
@ -65,10 +65,10 @@ _TIME_BOUND_CDN_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# IP-BOUND CDN signature — token bind do requester IP, cross-IP fetch = 403.
|
||||
# IP-BOUND CDN signature, token bind do requester IP, cross-IP fetch = 403.
|
||||
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
|
||||
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
|
||||
# latestpornvideo) — wszystkie używają tego samego CDN pool.
|
||||
# latestpornvideo), wszystkie używają tego samego CDN pool.
|
||||
_IP_BOUND_CDN_RE = re.compile(
|
||||
r"\b(?:"
|
||||
r"premilkyway\.com" # latestpornvideo
|
||||
|
|
@ -82,17 +82,17 @@ _IP_BOUND_CDN_RE = re.compile(
|
|||
class StreamLink(BaseModel):
|
||||
"""Pojedynczy variant stream URL (różne quality / kontener).
|
||||
|
||||
`stream_url` = URL do video file (mp4/m3u8/webm) — proxy-wrapped URL przez backend
|
||||
`stream_url` = URL do video file (mp4/m3u8/webm), proxy-wrapped URL przez backend
|
||||
VPS (`/proxy/{token}/play.ext`). Bezpieczny fallback gdy CDN binduje URL do IP
|
||||
extractora (np. fpo.xxx z kt_remote_ips cookie). Bandwidth idzie przez VPS.
|
||||
|
||||
`direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z
|
||||
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
|
||||
poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`.
|
||||
Mobile próbuje direct PIERWSZY — gdy CDN zwróci 403/410 (IP-bound), spada na
|
||||
Mobile próbuje direct PIERWSZY, gdy CDN zwróci 403/410 (IP-bound), spada na
|
||||
`stream_url` (proxy). Daje 0 bandwidth na VPS-ie dla większości scen.
|
||||
|
||||
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn) — mobile
|
||||
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn), mobile
|
||||
otwiera w WebView. Type: 'hoster'.
|
||||
"""
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ def resolve_movie_playback(
|
|||
playback_id: uuid.UUID,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> ResolveOut:
|
||||
"""Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`.
|
||||
"""Movies playback resolve, analog do `/scenes/{id}/playback/{pb}/resolve`.
|
||||
|
||||
Origin patterns:
|
||||
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
|
||||
|
|
@ -150,7 +150,7 @@ def resolve_movie_playback(
|
|||
if pb.origin == "paradisehill":
|
||||
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
|
||||
# Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
|
||||
# bez auth — 200 OK z plain User-Agent + Referer.
|
||||
# bez auth, 200 OK z plain User-Agent + Referer.
|
||||
# Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
|
||||
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
|
||||
# nie ma sposobu przejść do następnego.
|
||||
|
|
@ -162,7 +162,7 @@ def resolve_movie_playback(
|
|||
parts = []
|
||||
if parts:
|
||||
for url, label in parts:
|
||||
# NIE proxifikujemy tutaj — outer `_proxify_link` poniżej (linia 247) opakuje
|
||||
# NIE proxifikujemy tutaj, outer `_proxify_link` poniżej (linia 247) opakuje
|
||||
# wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
|
||||
links.append(
|
||||
StreamLink(
|
||||
|
|
@ -174,7 +174,7 @@ def resolve_movie_playback(
|
|||
)
|
||||
)
|
||||
else:
|
||||
# Fallback: brak videoList (np. login-only movie) — WebView na całość.
|
||||
# Fallback: brak videoList (np. login-only movie), WebView na całość.
|
||||
links = [
|
||||
StreamLink(
|
||||
stream_url=None,
|
||||
|
|
@ -191,7 +191,7 @@ def resolve_movie_playback(
|
|||
try:
|
||||
stream = extract_stream_from_hoster(target, referer=referer)
|
||||
except HosterDead as e:
|
||||
# Hoster wprost mówi "video deleted" — oznacz dead, NIE proponuj
|
||||
# Hoster wprost mówi "video deleted", oznacz dead, NIE proponuj
|
||||
# embed fallback (mobile ExoPlayer dostałby 404 HTML page i
|
||||
# próbowałby zapisać jako .bin file; bug-report 2026-05-16
|
||||
# "streamtape ściąga hurtowo pliki .bin").
|
||||
|
|
@ -203,7 +203,7 @@ def resolve_movie_playback(
|
|||
except Exception as e:
|
||||
log.warning("movie hoster extract failed for %s: %s", target, e)
|
||||
if stream and _IP_BOUND_CDN_RE.search(stream):
|
||||
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.) — token bind do VPS IP,
|
||||
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.), token bind do VPS IP,
|
||||
# mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView).
|
||||
log.info(
|
||||
"movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
|
||||
|
|
@ -225,11 +225,11 @@ def resolve_movie_playback(
|
|||
type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
|
||||
raw_meta: dict = {"origin": pb.origin, "host": target}
|
||||
# seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/
|
||||
# embedseek/upns — ~322k sources) zwraca master.m3u8 na raw-IP CDN
|
||||
# embedseek/upns, ~322k sources) zwraca master.m3u8 na raw-IP CDN
|
||||
# (185.237.x/203.188.x/45.156.x `/v4/<tok>/<exp>/pp/<hash>/master.m3u8`).
|
||||
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
|
||||
# manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
|
||||
# VALID (verify=True OK — IP-SAN, nie self-signed jak głosił stary docstring).
|
||||
# VALID (verify=True OK, IP-SAN, nie self-signed jak głosił stary docstring).
|
||||
# Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer
|
||||
# gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink).
|
||||
# Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
|
||||
|
|
@ -246,7 +246,7 @@ def resolve_movie_playback(
|
|||
raw=raw_meta,
|
||||
)
|
||||
)
|
||||
# Zawsze dorzucamy embed jako fallback — mobile WebView może wyłapać URL z JS-a
|
||||
# Zawsze dorzucamy embed jako fallback, mobile WebView może wyłapać URL z JS-a
|
||||
if pb.embed_url:
|
||||
links.append(
|
||||
StreamLink(
|
||||
|
|
@ -297,7 +297,7 @@ def mark_playback_dead(
|
|||
request: Request,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> None:
|
||||
"""User-triggered mark dead — long-press na playback chip w mobile.
|
||||
"""User-triggered mark dead, long-press na playback chip w mobile.
|
||||
|
||||
Bug-report 2026-05-12 (dd17c709): "Eporner to nie temporary. Więc długie
|
||||
przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
|
||||
|
|
@ -362,7 +362,7 @@ def resolve_playback(
|
|||
|
||||
sitetag: str | None = None
|
||||
if pb.origin.startswith("pornapp:"):
|
||||
# Legacy origin format — pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
|
||||
# Legacy origin format, pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
|
||||
sitetag = pb.origin.split(":", 1)[1]
|
||||
elif pb.origin.startswith("tube:"):
|
||||
sitetag = pb.origin.split(":", 1)[1]
|
||||
|
|
@ -382,7 +382,7 @@ def resolve_playback(
|
|||
log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
|
||||
raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
|
||||
except TubePageError as e:
|
||||
# Tube page is gone (404/410) — mark dead, propagate as 410. Inne 5xx → 502.
|
||||
# Tube page is gone (404/410), mark dead, propagate as 410. Inne 5xx → 502.
|
||||
if e.status_code in (404, 410):
|
||||
reason = f"tube page {e.status_code} {pb.page_url}"
|
||||
pb.dead_at = datetime.now(UTC)
|
||||
|
|
@ -397,7 +397,7 @@ def resolve_playback(
|
|||
) from e
|
||||
|
||||
if not sources:
|
||||
# Extractor None — TRANSIENT failure (network glitch, tube chwilowy 503,
|
||||
# Extractor None, TRANSIENT failure (network glitch, tube chwilowy 503,
|
||||
# ad-network response zmieniony, race condition). NIE oznaczamy `dead_at`
|
||||
# bo wcześniej powodowało false-positive permanent dead dla freshporno scen
|
||||
# które działały przy następnym attempt (bug-report 2026-05-12).
|
||||
|
|
@ -410,9 +410,9 @@ def resolve_playback(
|
|||
"extractor None for playback %s (origin=%s) — transient, not marking dead",
|
||||
pb.id, pb.origin,
|
||||
)
|
||||
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film" — ten kod
|
||||
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film", ten kod
|
||||
# jest dla permanent removal. 503 = transient, user może retry.
|
||||
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py) —
|
||||
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py),
|
||||
# bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case).
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
|
|
@ -446,7 +446,7 @@ DEFAULT_PLAYER_UA = (
|
|||
def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
||||
"""Wzbogaca StreamLink o:
|
||||
- `stream_url`: proxy URL (fallback gdy direct fails)
|
||||
- `direct_url`: surowy CDN URL (preferowany — 0 VPS bandwidth)
|
||||
- `direct_url`: surowy CDN URL (preferowany, 0 VPS bandwidth)
|
||||
- `headers`: Referer + User-Agent dla direct fetch
|
||||
Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
|
||||
"""
|
||||
|
|
@ -462,21 +462,21 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
|||
use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
|
||||
# force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów
|
||||
# gdzie token IS bound do VPS IP (porn00 v-acctoken, pornxp sv.porn-xp.com
|
||||
# signed path) — mobile direct ZAWSZE 403, więc nie ma sensu próbować.
|
||||
# signed path), mobile direct ZAWSZE 403, więc nie ma sensu próbować.
|
||||
# Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy).
|
||||
force_proxy = bool(link.raw and link.raw.get("force_proxy"))
|
||||
# mobile_direct_ok=True (extractor flag) → m3u8 może iść direct do mobile bo
|
||||
# CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
|
||||
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
|
||||
mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok"))
|
||||
# Auto-detect time-bound CDN po domain — bez per-extractor flag setting.
|
||||
# Auto-detect time-bound CDN po domain, bez per-extractor flag setting.
|
||||
# Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/
|
||||
# youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP.
|
||||
if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url):
|
||||
mobile_direct_ok = True
|
||||
refetch_url = (link.raw or {}).get("refetch_url")
|
||||
refetch_hoster = (link.raw or {}).get("refetch_hoster")
|
||||
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL — `/proxy/hls`
|
||||
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL, `/proxy/hls`
|
||||
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
|
||||
manifest_producer = (link.raw or {}).get("manifest_producer")
|
||||
token = make_token(
|
||||
|
|
@ -498,15 +498,15 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
|||
else:
|
||||
ext = "mp4"
|
||||
proxied = f"/proxy/{token}/play.{ext}"
|
||||
# `direct_url`: surowy CDN URL — mobile próbuje go PIERWSZY (0 VPS bandwidth).
|
||||
# `direct_url`: surowy CDN URL, mobile próbuje go PIERWSZY (0 VPS bandwidth).
|
||||
# ALE: dla type=m3u8/hls/mpd manifest URL musi być rewritowany żeby segmenty/keys
|
||||
# też leciały przez proxy (inne IP może też mieć rate limit/token issues), plus
|
||||
# ExoPlayer wybiera extractor po URL extension — `.mp4` w direct URL pornhat
|
||||
# ExoPlayer wybiera extractor po URL extension, `.mp4` w direct URL pornhat
|
||||
# → Mp4Extractor → fail bo content to HLS. Dla m3u8/mpd zwracamy proxied JAKO
|
||||
# direct (mobile używa go bezpośrednio, 1 hop przez VPS ale to jedyny sposób
|
||||
# żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource).
|
||||
# Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez
|
||||
# proxy — bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
|
||||
# proxy, bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
|
||||
# → extra round-trip + ExoPlayer "no extractors" przed retry.
|
||||
# mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token
|
||||
# (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Playback telemetry — POST /playback-events (fire-and-forget z apki).
|
||||
"""Playback telemetry, POST /playback-events (fire-and-forget z apki).
|
||||
|
||||
Apka po każdej próbie odtwarzania woła to z origin sceny + wynikiem (success/error).
|
||||
Sygnał zasila health/szybkość w rankingu źródeł (run_source_stats agreguje okno 7d).
|
||||
Best-effort: apka NIE czeka i ignoruje błąd — telemetria nie może psuć playbacku.
|
||||
Best-effort: apka NIE czeka i ignoruje błąd, telemetria nie może psuć playbacku.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -22,12 +22,12 @@ router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[
|
|||
|
||||
|
||||
class PlaybackEventIn(BaseModel):
|
||||
# 'tube:<sitetag>' — zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
|
||||
# 'tube:<sitetag>', zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
|
||||
origin: str = Field(min_length=1, max_length=64)
|
||||
status: Literal["success", "error"]
|
||||
scene_id: uuid.UUID | None = None
|
||||
error_kind: str | None = Field(default=None, max_length=64)
|
||||
# Surowa treść błędu z native playera (ExoPlayer message) — diagnostyka realnych
|
||||
# Surowa treść błędu z native playera (ExoPlayer message), diagnostyka realnych
|
||||
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
|
||||
error_detail: str | None = Field(default=None, max_length=512)
|
||||
ttff_ms: int | None = Field(default=None, ge=0, le=600_000)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""Saved searches — zapisane słowa kluczowe per urządzenie.
|
||||
"""Saved searches, zapisane słowa kluczowe per urządzenie.
|
||||
|
||||
User-report mobilism: sceny słabo opisane → user chce zapisywać często używane
|
||||
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
|
||||
|
||||
Endpointy:
|
||||
GET /saved-searches — lista zapisów tego urządzenia (najnowsze pierwsze)
|
||||
POST /saved-searches — dodaj {query} (idempotent na device+query)
|
||||
DELETE /saved-searches/{id} — usuń
|
||||
GET /saved-searches lista zapisów tego urządzenia (najnowsze pierwsze)
|
||||
POST /saved-searches dodaj {query} (idempotent na device+query)
|
||||
DELETE /saved-searches/{id} usuń
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ router = APIRouter(
|
|||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
|
||||
# Limit zapisów per urządzenie — to wygodny skrót, nie nieograniczona kolekcja.
|
||||
# Limit zapisów per urządzenie, to wygodny skrót, nie nieograniczona kolekcja.
|
||||
MAX_PER_DEVICE = 50
|
||||
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ def add_saved_search(
|
|||
session: Annotated[Session, Depends(get_session)],
|
||||
device_id: Annotated[str, Depends(get_device_id)],
|
||||
) -> SavedSearch:
|
||||
# Idempotent — ten sam (device, query) zwraca istniejący wiersz.
|
||||
# Idempotent, ten sam (device, query) zwraca istniejący wiersz.
|
||||
existing = session.execute(
|
||||
select(SavedSearch).where(
|
||||
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""Scene favorites — ulubione sceny (single-user, równolegle do /favorites/performers).
|
||||
"""Scene favorites, ulubione sceny (single-user, równolegle do /favorites/performers).
|
||||
|
||||
Endpointy:
|
||||
GET /scene-favorites — lista ulubionych scen (pełen SceneOut)
|
||||
POST /scene-favorites/{scene_id} — dodaj (idempotent)
|
||||
DELETE /scene-favorites/{scene_id} — usuń
|
||||
GET /scene-favorites lista ulubionych scen (pełen SceneOut)
|
||||
POST /scene-favorites/{scene_id} dodaj (idempotent)
|
||||
DELETE /scene-favorites/{scene_id} usuń
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""GET /scenes — lista i szczegóły scen z bazy kanonicznej."""
|
||||
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
|
@ -70,7 +70,7 @@ def _default_scene_count(session: Session) -> int:
|
|||
return total
|
||||
|
||||
|
||||
# JAV vertical — osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
|
||||
# JAV vertical, osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
|
||||
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
|
||||
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
|
||||
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
|
||||
|
|
@ -89,11 +89,11 @@ def _jav_source_exists():
|
|||
|
||||
# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to
|
||||
# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane
|
||||
# per-row — przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
|
||||
# per-row, przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
|
||||
# puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu).
|
||||
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
|
||||
# wracają. Patrz reference_scenes_list_perf / task #22.
|
||||
# Cache per device_id (blacklisty są teraz device-scoped — bug 2026-06-08).
|
||||
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
|
||||
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
|
||||
_BLACKLIST_EMPTY_TTL = 300.0
|
||||
|
||||
|
|
@ -283,7 +283,7 @@ def list_scenes(
|
|||
if len(tag_slug_list) > _MAX_TAGS:
|
||||
raise HTTPException(status_code=422, detail=f"too many tag filters (max {_MAX_TAGS})")
|
||||
# AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny
|
||||
# exists() — zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
|
||||
# exists(), zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
|
||||
#
|
||||
# PERF (2026-06-07): resolvujemy slug→tag_id w aplikacji i filtrujemy po LITERALNYM
|
||||
# tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze
|
||||
|
|
@ -337,7 +337,7 @@ def list_scenes(
|
|||
base = base.where(~live_playback_exists())
|
||||
|
||||
if origin:
|
||||
# Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'.
|
||||
# Substring match na origin, 'hqporner' złapie 'tube:hqpornercom'.
|
||||
base = base.where(
|
||||
exists(
|
||||
select(1).where(
|
||||
|
|
@ -350,14 +350,14 @@ def list_scenes(
|
|||
|
||||
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
|
||||
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
|
||||
# bramy nie stosujemy — jawnie chce ten origin, więc pokazujemy (regresja "No videos"
|
||||
# bramy nie stosujemy, jawnie chce ten origin, więc pokazujemy (regresja "No videos"
|
||||
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
|
||||
if jav:
|
||||
base = base.where(_jav_source_exists())
|
||||
elif origin is None:
|
||||
base = base.where(~_jav_source_exists())
|
||||
|
||||
# Blacklisty device (performer/studio/tag) — globalne wykluczenia, współdzielone z
|
||||
# Blacklisty device (performer/studio/tag), globalne wykluczenia, współdzielone z
|
||||
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
|
||||
for _bl_clause in blacklist_clauses(session, device_id):
|
||||
base = base.where(_bl_clause)
|
||||
|
|
@ -373,7 +373,7 @@ def list_scenes(
|
|||
base = base.where(Scene.release_date >= cutoff)
|
||||
|
||||
if min_quality_p is not None:
|
||||
# PlaybackSource.quality to wolny string — szukamy liczb w prefixie ('1080p',
|
||||
# PlaybackSource.quality to wolny string, szukamy liczb w prefixie ('1080p',
|
||||
# '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy
|
||||
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160.
|
||||
from sqlalchemy import Integer, cast, or_
|
||||
|
|
@ -416,13 +416,13 @@ def list_scenes(
|
|||
# dominującym kosztem (~4s na has_playback / min_duration / duży tag) i plan
|
||||
# był NIESTABILNY (literal LIMIT + count-nad-PK pomogły w części przypadków,
|
||||
# ale planer i tak czasem skanuje cały zbiór zamiast urwać). Mobile paginuje
|
||||
# po `has_more` (per_page+1 fetch), NIE po `total` — `total` to tylko licznik
|
||||
# po `has_more` (per_page+1 fetch), NIE po `total`, `total` to tylko licznik
|
||||
# "N+" w UI. Wyprowadzamy go z has_more PO fetchu (patrz niżej): dolna granica
|
||||
# + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy.
|
||||
total_capped = False
|
||||
total: int | None = _default_scene_count(session) if _is_pure_default else None
|
||||
|
||||
# Sort: KAŻDY tryb kończy się `Scene.id.desc()` — unikalny PK jako ostateczny
|
||||
# Sort: KAŻDY tryb kończy się `Scene.id.desc()`, unikalny PK jako ostateczny
|
||||
# tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy
|
||||
# ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek,
|
||||
# więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu
|
||||
|
|
@ -436,7 +436,7 @@ def list_scenes(
|
|||
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
|
||||
)
|
||||
elif sort == "studio":
|
||||
# Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze.
|
||||
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze.
|
||||
ordered = (
|
||||
base.outerjoin(Studio, Studio.id == Scene.studio_id)
|
||||
.order_by(
|
||||
|
|
@ -451,10 +451,10 @@ def list_scenes(
|
|||
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
|
||||
)
|
||||
|
||||
# Fetch per_page+1 — obecność (per_page+1)-szego wiersza = jest kolejna strona.
|
||||
# Fetch per_page+1, obecność (per_page+1)-szego wiersza = jest kolejna strona.
|
||||
# To źródło prawdy dla paginacji (mobile getNextPageParam), niezależne od bounded
|
||||
# `total`. Nadmiarowy wiersz odcinamy przed serializacją.
|
||||
# LIMIT/OFFSET literalne (NIE bound-param) — patrz wyżej: sparametryzowany LIMIT
|
||||
# LIMIT/OFFSET literalne (NIE bound-param), patrz wyżej: sparametryzowany LIMIT
|
||||
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
|
||||
# zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to
|
||||
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
|
||||
|
|
@ -506,14 +506,14 @@ def similar_scenes(
|
|||
device_id: Annotated[str, Depends(get_device_id)],
|
||||
limit: int = Query(default=12, ge=1, le=30),
|
||||
) -> SceneListOut:
|
||||
"""Sceny podobne do danej — ranking po WSPÓLNYCH performerach (mocny sygnał) i
|
||||
"""Sceny podobne do danej, ranking po WSPÓLNYCH performerach (mocny sygnał) i
|
||||
tagach (słabszy). Zwraca tylko grywalne sceny (żywy playback), zgodne z bramą JAV
|
||||
(JAV↔JAV, nie-JAV↔nie-JAV), z pominięciem blacklist/stubów/samej siebie.
|
||||
|
||||
Wydajność: performerzy są selektywni, więc pula kandydatów z nich jest mała (cap
|
||||
_CAND_CAP). Tagi liczymy TYLKO w obrębie tej puli (tanio). Fill z tagów (bounded
|
||||
_TAG_FILL_CAP, HAVING >=2 wspólne) odpala się gdy scena ma mało/zero performerów
|
||||
(anonimowe tube sceny) — inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
|
||||
(anonimowe tube sceny), inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
|
||||
wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id."""
|
||||
scene = session.get(Scene, scene_id)
|
||||
if scene is None:
|
||||
|
|
@ -527,7 +527,7 @@ def similar_scenes(
|
|||
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
|
||||
).scalars().all()
|
||||
# Odsiej ŚMIECIOWYCH performerów: kategorie/studia podszywające się pod performera
|
||||
# (over-attribution z luźnego tube-searcha — np. "Big Tits", "Brazzers", "Ball Licking").
|
||||
# (over-attribution z luźnego tube-searcha, np. "Big Tits", "Brazzers", "Ball Licking").
|
||||
# Sygnał: slug pokrywa się z tagiem I performer NIE ma kanonicznego refa (tpdb/stashdb).
|
||||
# Prawdziwi performerzy z tym samym tagiem (tuby tagują sceny nazwiskiem) mają kanoniczny
|
||||
# ref → zostają. Bez tego similar klastrował się po "Ball Licking" zamiast po treści.
|
||||
|
|
@ -584,7 +584,7 @@ def similar_scenes(
|
|||
.group_by(SceneTag.scene_id)
|
||||
).all()
|
||||
tag_shared = {r[0]: r[1] for r in rows}
|
||||
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny) — inaczej pomijamy,
|
||||
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny), inaczej pomijamy,
|
||||
# bo szeroki tag skanuje setki tysięcy scen.
|
||||
if tag_ids and len(cand_ids) < limit * 4:
|
||||
rows = session.execute(
|
||||
|
|
@ -642,7 +642,7 @@ _SXYPRN_POST_RE = re.compile(r"sxyprn\.com/post/([0-9a-f]{6,40})", re.IGNORECASE
|
|||
|
||||
def _sxyprn_thumb_url(page_url: str | None) -> str | None:
|
||||
"""Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera
|
||||
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL — token żyje ~1h,
|
||||
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL, token żyje ~1h,
|
||||
więc poster resolvujemy przy serwowaniu (bug 2026-06-10)."""
|
||||
if not page_url:
|
||||
return None
|
||||
|
|
@ -655,7 +655,7 @@ _YOUPORN_WATCH_RE = re.compile(r"youporn\.com/watch/(\d+)", re.IGNORECASE)
|
|||
|
||||
def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
|
||||
"""Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny
|
||||
on-demand endpoint `/proxy/youporn-thumb/<video_id>` — zapisany podpis wygasa i CDN
|
||||
on-demand endpoint `/proxy/youporn-thumb/<video_id>`, zapisany podpis wygasa i CDN
|
||||
oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg`
|
||||
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
|
||||
if not page_url or not thumb or "hash=" not in thumb:
|
||||
|
|
@ -667,7 +667,7 @@ def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
|
|||
def _is_rotting_thumb(url: str) -> bool:
|
||||
"""sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po
|
||||
~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze
|
||||
slim-thumbnaila — używamy tylko gdy scena nie ma żadnej innej miniaturki."""
|
||||
slim-thumbnaila, używamy tylko gdy scena nie ma żadnej innej miniaturki."""
|
||||
if "trafficdeposit.com" in url:
|
||||
return True
|
||||
# imgs.xiaoshenke.net (fullporner) było tu wpisane 2026-08-02, bo host oddawał 200
|
||||
|
|
@ -678,12 +678,12 @@ def _is_rotting_thumb(url: str) -> bool:
|
|||
# rotting-thumb jest ZEROWANY, więc sceny mające tylko fullporner traciły miniaturę
|
||||
# całkowicie („Jakiś problem z miniaturkami z fullporner"). Dotyczyło 53 tys. źródeł.
|
||||
# Gdyby atrapy wróciły, filtruj po ROZMIARZE odpowiedzi w image-proxy, nie po hoście.
|
||||
# tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze
|
||||
# tn.sextu.com (fullvideosporn), CDN odrzuca ruch z IP datacenter (429), więc nasze
|
||||
# image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy
|
||||
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
|
||||
if "tn.sextu.com" in url:
|
||||
return True
|
||||
# fastpic.org (galaxyporn i inne re-uploadery) — hosting zrywa połączenie zamiast
|
||||
# fastpic.org (galaxyporn i inne re-uploadery), hosting zrywa połączenie zamiast
|
||||
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
|
||||
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
|
||||
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
|
||||
|
|
@ -693,14 +693,14 @@ def _is_rotting_thumb(url: str) -> bool:
|
|||
def _needs_proxy(url: str) -> bool:
|
||||
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
|
||||
Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img,
|
||||
inne sxyprn/eporner CDN-y) — expo-image nie wysyła Referera.
|
||||
inne sxyprn/eporner CDN-y), expo-image nie wysyła Referera.
|
||||
Self-hosted lub backend-internal URL-e (zaczynające się od `/`) skipujemy."""
|
||||
return url.startswith("http") and not url.startswith("/proxy/")
|
||||
|
||||
|
||||
def _wrap_image_proxy(url: str, referer: str) -> str:
|
||||
"""Wraps a thumbnail URL through /proxy/img/{token}/img.jpg. Klient nie musi
|
||||
znać sekretu Referer — backend wstawi sam. Long TTL (30d) bo thumby
|
||||
znać sekretu Referer, backend wstawi sam. Long TTL (30d) bo thumby
|
||||
są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache."""
|
||||
from app.api.stream_proxy import make_token
|
||||
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
|
||||
|
|
@ -717,7 +717,7 @@ def _build_scenes_out_batch(
|
|||
) -> list[SceneOut]:
|
||||
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
|
||||
|
||||
Eliminuje N+1 z `_build_scene_out` w listach scen — `/scenes?per_page=24` szło
|
||||
Eliminuje N+1 z `_build_scene_out` w listach scen, `/scenes?per_page=24` szło
|
||||
z ~9.6s do <500ms. Pojedyncza scena (`/scenes/{id}`) nadal używa `_build_scene_out`
|
||||
bo overhead na batch nie ma sensu dla N=1.
|
||||
|
||||
|
|
@ -760,7 +760,7 @@ def _build_scenes_out_batch(
|
|||
)
|
||||
)
|
||||
|
||||
# 3) Tags + 4) External refs — kafelek listy ich nie używa; w light mode pomijamy
|
||||
# 3) Tags + 4) External refs, kafelek listy ich nie używa; w light mode pomijamy
|
||||
# (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}).
|
||||
tags_by_scene: dict = defaultdict(list)
|
||||
refs_by_scene: dict = defaultdict(list)
|
||||
|
|
@ -788,7 +788,7 @@ def _build_scenes_out_batch(
|
|||
)
|
||||
)
|
||||
|
||||
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę) — kafelek
|
||||
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę), kafelek
|
||||
# czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url.
|
||||
pb_by_scene: dict = defaultdict(list)
|
||||
if light:
|
||||
|
|
@ -806,7 +806,7 @@ def _build_scenes_out_batch(
|
|||
.order_by(PlaybackSource.origin.asc())
|
||||
).all()
|
||||
# Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). De-prioritize
|
||||
# sxyprn/trafficdeposit thumbnaile — są podpisane czasowo i ROTUJĄ (asset 404 po
|
||||
# sxyprn/trafficdeposit thumbnaile, są podpisane czasowo i ROTUJĄ (asset 404 po
|
||||
# ~tygodniach, nie da się re-signować; bug 2026-06-10). Wolimy miniaturkę z innego
|
||||
# źródła gdy istnieje; sxyprn bierzemy tylko gdy nic innego nie ma (świeże jeszcze
|
||||
# działają, martwe → mobile pokazuje placeholder zamiast broken-image).
|
||||
|
|
@ -847,7 +847,7 @@ def _build_scenes_out_batch(
|
|||
if a_url and _needs_proxy(a_url):
|
||||
a_url = _wrap_image_proxy(a_url, ref)
|
||||
# id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek
|
||||
# (SceneDetail re-fetchuje pełne źródła) — dummy sentinel.
|
||||
# (SceneDetail re-fetchuje pełne źródła), dummy sentinel.
|
||||
pb_by_scene[sid].append(
|
||||
PlaybackSourceOut(
|
||||
id=uuid.UUID(int=0), origin="", page_url="",
|
||||
|
|
@ -999,7 +999,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
|||
)
|
||||
# Collapse źródła dzielące ten sam origin (hoster). Zmergowana scena często agreguje
|
||||
# kilka uploadów z JEDNEGO tube'a (re-enkody / wersje 4K: bug-report aa79a995 "2 linki,
|
||||
# oba do porntrex" = ta sama scena std+4K) — w UI to nierozróżnialne linki do tego
|
||||
# oba do porntrex" = ta sama scena std+4K), w UI to nierozróżnialne linki do tego
|
||||
# samego hostera (resolvują tym samym extractorem). Zostawiamy jeden najlepszy per
|
||||
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
|
||||
# pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at).
|
||||
|
|
@ -1022,7 +1022,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
|||
for p in playback_rows:
|
||||
out = PlaybackSourceOut.model_validate(p)
|
||||
# sxyprn: podmień martwy trafficdeposit URL na żywy on-demand resolver
|
||||
# (/proxy/sxyprn-thumb/<id>) — TAK JAK lista (_build_scenes_out_batch). Bez tego
|
||||
# (/proxy/sxyprn-thumb/<id>), TAK JAK lista (_build_scenes_out_batch). Bez tego
|
||||
# detal sceny pokazywał martwą miniaturę sxyprn mimo że lista działała, i "refresh
|
||||
# thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22.
|
||||
sxy = _sxyprn_thumb_url(p.page_url)
|
||||
|
|
@ -1034,7 +1034,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
|||
elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url):
|
||||
out.thumbnail_url = None
|
||||
# Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera
|
||||
# (hqporner — fastporndelivery zwraca 403 bez Referer headera, expo-image
|
||||
# (hqporner, fastporndelivery zwraca 403 bez Referer headera, expo-image
|
||||
# nie wysyła go domyślnie). Token ma 30-dniowy TTL bo thumby są stabilne.
|
||||
if out.thumbnail_url and _needs_proxy(out.thumbnail_url):
|
||||
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
|
||||
|
|
@ -1090,7 +1090,7 @@ def remove_tag_from_scene(
|
|||
) -> None:
|
||||
"""Usuwa relację scene↔tag (np. user uznał że tag jest błędny dla tej sceny).
|
||||
|
||||
Idempotent: brak relacji = success. Nie kasuje samego Tag-a — inne sceny mogą
|
||||
Idempotent: brak relacji = success. Nie kasuje samego Tag-a, inne sceny mogą
|
||||
z niego korzystać. Sam tag zostaje w słowniku tagów.
|
||||
"""
|
||||
rel = session.execute(
|
||||
|
|
@ -1142,7 +1142,7 @@ def hide_scene(
|
|||
) -> SceneHideOut:
|
||||
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
|
||||
jako dead → scena wypada z list (has_playback=false). Odwracalne w DB (dead_at).
|
||||
Nie kasujemy wiersza sceny — zachowujemy refs/dedup, tylko znika z UI."""
|
||||
Nie kasujemy wiersza sceny, zachowujemy refs/dedup, tylko znika z UI."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models.playback_source import PlaybackSource
|
||||
|
|
@ -1273,7 +1273,7 @@ def enrich_tags_from_tube(
|
|||
# Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie.
|
||||
# Używamy PostgreSQL INSERT ... ON CONFLICT DO NOTHING zamiast ORM session.add()
|
||||
# bo `resolve_tag` robi session.flush() w pętli, emitując pending SceneTag INSERT
|
||||
# z poprzednich iteracji — gdy 2 concurrent enrich-tags collide na tym samym
|
||||
# z poprzednich iteracji, gdy 2 concurrent enrich-tags collide na tym samym
|
||||
# (scene_id, tag_id), drugi flush dostaje UniqueViolation (GOON-H, 4 events
|
||||
# w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently.
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
|
@ -1309,11 +1309,11 @@ def enrich_duration_from_tube(
|
|||
scene_id: uuid.UUID,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> EnrichDurationOut:
|
||||
"""Wyciąga duration z dowolnego tube playback_source — wszystkie znane tube'y
|
||||
"""Wyciąga duration z dowolnego tube playback_source, wszystkie znane tube'y
|
||||
udostępniają duration na detail page (og:video:duration lub LD-JSON ISO 8601).
|
||||
|
||||
Mobile wywołuje to przy otwarciu SceneDetail gdy scene.duration_sec jest null
|
||||
AND ma tube source. Dla dedupu duration to najsilniejszy single signal — bez
|
||||
AND ma tube source. Dla dedupu duration to najsilniejszy single signal, bez
|
||||
niego sceny z weak title-only score są capowane na 0.85 (review queue).
|
||||
|
||||
Idempotent: zwraca aktualne duration_sec jeśli już ustawione.
|
||||
|
|
@ -1379,7 +1379,7 @@ def enrich_studio_from_tube(
|
|||
"""Wyciąga studio (DVD/series) z pornhat scene page'a.
|
||||
|
||||
Pornhat ma `class="info-video js-ajax-dvd" data-setup='{"title": "Adult Time", ...}'`
|
||||
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern — na razie
|
||||
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern, na razie
|
||||
tylko pornhat (najczystsze studio metadata wśród free tubes).
|
||||
"""
|
||||
import json as _json
|
||||
|
|
@ -1476,7 +1476,7 @@ def enrich_thumbnail_from_tube(
|
|||
Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration).
|
||||
|
||||
`force=true` (przycisk "Refresh thumbnail" na SceneDetail, zgłoszenie d3376a71):
|
||||
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a — dla zepsutych/stałych
|
||||
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a, dla zepsutych/stałych
|
||||
(rotting sxyprn/trafficdeposit, błędna grafika)."""
|
||||
from app.extractors._fetch import browser_get
|
||||
from app.extractors._models import TubePageError
|
||||
|
|
@ -1497,7 +1497,7 @@ def enrich_thumbnail_from_tube(
|
|||
|
||||
sources_with_thumb = [s for s in sources if s.thumbnail_url]
|
||||
if sources_with_thumb and not force:
|
||||
# już mamy — idempotent return (force=true pomija, żeby odświeżyć).
|
||||
# już mamy, idempotent return (force=true pomija, żeby odświeżyć).
|
||||
return EnrichThumbOut(
|
||||
scene_id=scene_id,
|
||||
thumbnail_url=sources_with_thumb[0].thumbnail_url,
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class SceneOut(BaseModel):
|
|||
external_refs: list[ExternalRefOut] = []
|
||||
playback_sources: list[PlaybackSourceOut] = []
|
||||
# Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia
|
||||
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen — gdy
|
||||
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen, gdy
|
||||
# `created_at > last_seen_at` (favorite) → badge.
|
||||
created_at: datetime | None = None
|
||||
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
|
||||
|
|
@ -87,7 +87,7 @@ class SceneListOut(BaseModel):
|
|||
page: int
|
||||
per_page: int
|
||||
# has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe),
|
||||
# NIE z `total` — bo dla filtrowanych list `total` jest bounded ("1000+") żeby
|
||||
# NIE z `total`, bo dla filtrowanych list `total` jest bounded ("1000+") żeby
|
||||
# uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total.
|
||||
has_more: bool = False
|
||||
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Publiczna, crawlowalna powierzchnia SEO — programmatic entity pages.
|
||||
"""Publiczna, crawlowalna powierzchnia SEO, programmatic entity pages.
|
||||
|
||||
Jedyny publiczny router HTML poza /static i healthchecks — NIE wymaga api key,
|
||||
Jedyny publiczny router HTML poza /static i healthchecks, NIE wymaga api key,
|
||||
bo Googlebot/użytkownik musi dotrzeć bez tokenu. Cel: łapać nawigacyjny long-tail
|
||||
(nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje,
|
||||
a który realnie generuje organiczny ruch → pobranie apki.
|
||||
|
|
@ -8,14 +8,14 @@ a który realnie generuje organiczny ruch → pobranie apki.
|
|||
Zasady (świadome, nie przypadkowe):
|
||||
|
||||
* **LINK-OUT only.** Strony renderują metadane i odsyłają do źródeł (`page_url`).
|
||||
NIE eksponujemy `stream_url`/`embed_url` — to zostaje value-add apki i trzyma
|
||||
NIE eksponujemy `stream_url`/`embed_url`, to zostaje value-add apki i trzyma
|
||||
legalny profil "agregatora/wyszukiwarki" (dowozimy ruch tubom, nie re-streamujemy).
|
||||
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
|
||||
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
|
||||
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
|
||||
* **Blacklist respektowany** — te same wykluczenia performer/studio/tag co w /scenes.
|
||||
* **Blacklist respektowany** te same wykluczenia performer/studio/tag co w /scenes.
|
||||
Nie publikujemy SEO-stron dla zblacklistowanej treści.
|
||||
* **Anti-thin-page** — strona encji powstaje tylko gdy ma realną zawartość
|
||||
* **Anti-thin-page** strona encji powstaje tylko gdy ma realną zawartość
|
||||
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
|
||||
nie pusty doorway (Google karze masowe cienkie strony).
|
||||
"""
|
||||
|
|
@ -49,7 +49,7 @@ from app.models.tag import Tag
|
|||
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||
|
||||
# Analityka — wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony.
|
||||
# Analityka, wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony.
|
||||
# Puste = tag się nie renderuje (zero third-party requestów, zachowanie bez zmian).
|
||||
# Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie.
|
||||
templates.env.globals["gtm_id"] = os.environ.get("GOON_GTM_ID", "")
|
||||
|
|
@ -58,7 +58,7 @@ templates.env.globals["gsc_verify"] = os.environ.get("GOON_GSC_VERIFY", "")
|
|||
|
||||
# Limit URL-i na pojedynczy plik sitemap (spec: max 50k). Trzymamy z zapasem.
|
||||
_SITEMAP_PAGE = 25_000
|
||||
# Ile scen renderujemy na stronie encji (performer/studio) — pełna lista 1000+ scen
|
||||
# Ile scen renderujemy na stronie encji (performer/studio), pełna lista 1000+ scen
|
||||
# to thin/slow; bierzemy najświeższe N, reszta i tak wpada przez sitemap scen.
|
||||
_SCENES_PER_ENTITY = 120
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ def _live_playback_exists():
|
|||
def _not_blacklisted():
|
||||
"""Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag).
|
||||
|
||||
Te same reguły co w GET /scenes — żeby SEO nie publikowało tego, co katalog ukrywa.
|
||||
Te same reguły co w GET /scenes, żeby SEO nie publikowało tego, co katalog ukrywa.
|
||||
"""
|
||||
return [
|
||||
~exists(
|
||||
|
|
@ -109,7 +109,7 @@ def _not_blacklisted():
|
|||
|
||||
|
||||
def _indexable_scenes():
|
||||
"""SELECT Scene żywych, nie-zblacklistowanych scen — baza pod listy i sitemap."""
|
||||
"""SELECT Scene żywych, nie-zblacklistowanych scen, baza pod listy i sitemap."""
|
||||
stmt = select(Scene).where(_live_playback_exists())
|
||||
for cond in _not_blacklisted():
|
||||
stmt = stmt.where(cond)
|
||||
|
|
@ -120,7 +120,7 @@ def _performer_indexable_exists():
|
|||
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
|
||||
|
||||
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
|
||||
`performer_page` — inaczej sitemap zgłaszałby URL-e dające 404.
|
||||
`performer_page`, inaczej sitemap zgłaszałby URL-e dające 404.
|
||||
"""
|
||||
sub = (
|
||||
select(1)
|
||||
|
|
@ -180,7 +180,7 @@ def performer_page(
|
|||
if performer is None:
|
||||
raise HTTPException(status_code=404, detail="performer not found")
|
||||
|
||||
# Sceny tej osoby — żywe, nie-blacklisted, najświeższe pierwsze.
|
||||
# Sceny tej osoby, żywe, nie-blacklisted, najświeższe pierwsze.
|
||||
scenes = (
|
||||
session.execute(
|
||||
_indexable_scenes()
|
||||
|
|
@ -283,7 +283,7 @@ def scene_page(
|
|||
if scene is None:
|
||||
raise HTTPException(status_code=404, detail="scene not found")
|
||||
|
||||
# Źródła — żywe, deduplikowane po origin (pokazujemy 1 link per tube).
|
||||
# Źródła, żywe, deduplikowane po origin (pokazujemy 1 link per tube).
|
||||
sources_raw = (
|
||||
session.execute(
|
||||
select(PlaybackSource)
|
||||
|
|
@ -354,7 +354,7 @@ def landing(
|
|||
request: Request,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> HTMLResponse:
|
||||
"""Strona główna — crawl-entry. Najświeższe indeksowalne sceny + CTA."""
|
||||
"""Strona główna, crawl-entry. Najświeższe indeksowalne sceny + CTA."""
|
||||
scenes = (
|
||||
session.execute(
|
||||
_indexable_scenes()
|
||||
|
|
@ -386,8 +386,8 @@ def landing(
|
|||
|
||||
@router.get("/get", response_class=HTMLResponse)
|
||||
def get_app(request: Request) -> HTMLResponse:
|
||||
"""Paid-traffic landing page — odchudzona pod konwersję instalacji APK.
|
||||
noindex (patrz get.html) — nie ma konkurować w SERP z entity-stronami."""
|
||||
"""Paid-traffic landing page, odchudzona pod konwersję instalacji APK.
|
||||
noindex (patrz get.html), nie ma konkurować w SERP z entity-stronami."""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"seo/get.html",
|
||||
|
|
@ -411,7 +411,7 @@ def page_2257(request: Request) -> HTMLResponse:
|
|||
def gsc_site_verification(fname: str) -> PlainTextResponse:
|
||||
"""Plik weryfikacyjny Google Search Console (metoda 'Plik HTML').
|
||||
|
||||
Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc' — bez '.html'). Google
|
||||
Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc', bez '.html'). Google
|
||||
pobiera /<token>.html i oczekuje body 'google-site-verification: <token>.html'.
|
||||
Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest
|
||||
jednosegmentowym *.html w rootcie).
|
||||
|
|
@ -440,7 +440,7 @@ def _count(session: Session, stmt) -> int:
|
|||
|
||||
@router.get("/sitemap.xml")
|
||||
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
|
||||
"""Sitemap index — listuje paginowane pod-sitemapy per typ encji."""
|
||||
"""Sitemap index, listuje paginowane pod-sitemapy per typ encji."""
|
||||
n_perf = _count(
|
||||
session,
|
||||
select(Performer.id).where(_performer_indexable_exists()),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""GET /sources — lista tube źródeł dla feature "Sites" (mobile top-level tab).
|
||||
"""GET /sources, lista tube źródeł dla feature "Sites" (mobile top-level tab).
|
||||
|
||||
Bug-report 2026-05-24 (ea6f05f9, Scenes screen): user chce wybrać "pages"
|
||||
obok Scenes i Movies — widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
|
||||
obok Scenes i Movies, widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
|
||||
najnowsze sceny z konkretnego źródła.
|
||||
|
||||
Endpoint enumeruje distinct `playback_sources.origin` z ŻYWYCH playback_sources
|
||||
(`dead_at IS NULL`), tylko origins zaczynające się od 'tube:' (kanoniczne źródła
|
||||
typu `canonical:tpdb_trailer` są pomijane — to nie są "scrapowane strony" w sensie
|
||||
typu `canonical:tpdb_trailer` są pomijane, to nie są "scrapowane strony" w sensie
|
||||
intencji feature'a).
|
||||
|
||||
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
|
||||
|
|
@ -42,7 +42,7 @@ class SourceRating(BaseModel):
|
|||
richness: int
|
||||
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
|
||||
health: int | None
|
||||
"""0-5: czy realnie gra (telemetria odtwarzania) — 0=offline. None gdy brak danych."""
|
||||
"""0-5: czy realnie gra (telemetria odtwarzania), 0=offline. None gdy brak danych."""
|
||||
health_basis: str | None = None
|
||||
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
|
||||
components: dict | None = None
|
||||
|
|
@ -51,28 +51,28 @@ class SourceRating(BaseModel):
|
|||
|
||||
class SourceOut(BaseModel):
|
||||
origin: str
|
||||
"""Raw origin string z DB — np. 'tube:hqpornercom'. Używany jako parametr
|
||||
"""Raw origin string z DB, np. 'tube:hqpornercom'. Używany jako parametr
|
||||
`origin=` filtra w GET /scenes (substring match)."""
|
||||
|
||||
sitetag: str
|
||||
"""Origin bez prefiksu 'tube:' — np. 'hqpornercom'. Stabilne ID tube'a (zgodne
|
||||
"""Origin bez prefiksu 'tube:', np. 'hqpornercom'. Stabilne ID tube'a (zgodne
|
||||
z `BaseDirectTubeScraper.sitetag`)."""
|
||||
|
||||
display_name: str
|
||||
"""Czytelna nazwa do UI — np. 'hqporner.com'. Wyprowadzona z sitetag przez
|
||||
"""Czytelna nazwa do UI, np. 'hqporner.com'. Wyprowadzona z sitetag przez
|
||||
`_sitetag_to_display`. Tylko presentation; logikę trzymamy na sitetag/origin."""
|
||||
|
||||
scene_count: int
|
||||
"""Liczba ŻYWYCH playback_sources (dead_at IS NULL) per origin. Approx scenes
|
||||
coverage — scena może mieć wiele sources tego samego origin (różne page_url),
|
||||
coverage, scena może mieć wiele sources tego samego origin (różne page_url),
|
||||
więc trochę zawyża rzeczywistą scene-distinct count, ale dla orientacji OK."""
|
||||
|
||||
last_scraped_at: datetime | None
|
||||
"""MAX(last_seen_at) — najświeższy scrape dla tego origin. Pozwala mobile pokazać
|
||||
"""MAX(last_seen_at), najświeższy scrape dla tego origin. Pozwala mobile pokazać
|
||||
'scrapowane Xh temu' i sortować świeżość."""
|
||||
|
||||
rating: SourceRating | None = None
|
||||
"""Ocena 0-5★ (freshness/richness/health) z source_stats — None gdy jeszcze
|
||||
"""Ocena 0-5★ (freshness/richness/health) z source_stats, None gdy jeszcze
|
||||
nie policzona (job source-stats leci co kilka h)."""
|
||||
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ def list_sources(
|
|||
) -> SourceListOut:
|
||||
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
|
||||
|
||||
Filter: `origin LIKE 'tube:%'` (drop canonical:* — TPDB trailery to inna semantyka).
|
||||
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
|
||||
"""
|
||||
rows = session.execute(
|
||||
select(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Stream proxy — pomost VPS↔phone dla podpisanych URL-i CDN-ów.
|
||||
"""Stream proxy, pomost VPS↔phone dla podpisanych URL-i CDN-ów.
|
||||
|
||||
Wiele hosterów (luluvids/medixiru/cdnvids/bigcdn) bindą podpisany URL do IP klienta
|
||||
który fetchował embed page. Gdy backend ekstraktuje URL z VPS-a, signature
|
||||
weryfikuje VPS IP — telefon dostaje 403. Player na phonie kieruje requesty
|
||||
weryfikuje VPS IP, telefon dostaje 403. Player na phonie kieruje requesty
|
||||
*przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature
|
||||
poprawnie i serwuje content.
|
||||
|
||||
|
|
@ -41,8 +41,8 @@ router = APIRouter(prefix="/proxy", tags=["proxy"])
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# In-memory bandwidth counter — bytes-out per CDN domain per hour bucket.
|
||||
# Restart api resetuje counter (akceptowalne — to operational metric, nie billing).
|
||||
# In-memory bandwidth counter, bytes-out per CDN domain per hour bucket.
|
||||
# Restart api resetuje counter (akceptowalne, to operational metric, nie billing).
|
||||
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
|
@ -90,7 +90,7 @@ TOKEN_TTL_SEC = 4 * 60 * 60 # 4h
|
|||
|
||||
|
||||
# URL-level redirect cache: target_url -> (final_resolved_url, expires_ts).
|
||||
# Mobile ExoPlayer robi range-requesty per seek/preload — każdy hituje proxy z tym
|
||||
# Mobile ExoPlayer robi range-requesty per seek/preload, każdy hituje proxy z tym
|
||||
# samym tokenem, proxy GET-uje target_url. Dla `porntrex.com/get_file/...` (a także
|
||||
# fpoxxx, freshporno) URL jest **single-use**: pierwszy GET → 302 → CDN URL (time-bound),
|
||||
# drugi GET → 410. Bez cache: drugi range = 410 → ExoPlayer fail → mobile fallback do
|
||||
|
|
@ -170,10 +170,10 @@ def make_token(
|
|||
|
||||
`refresh`: URL embed page do refetch gdy `url` zwraca 4xx. Proxy odbierze
|
||||
fresh stream URL z embed (np. mixdrop MDCore.wurl) gdy oryginalny token expired.
|
||||
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.) — proxy
|
||||
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.), proxy
|
||||
dispatch do dedicated re-extract logic.
|
||||
`impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot
|
||||
detection — mxcontent, cloudflare-protected).
|
||||
detection, mxcontent, cloudflare-protected).
|
||||
`stable_bucket_sec`: kwantyzuje czas bazowy expiry do okna N sekund, więc token
|
||||
(→ proxied URL) jest IDENTYCZNY dla wszystkich requestów w oknie. Bez tego `e`
|
||||
zawiera bieżący timestamp → URL inny przy każdym fetchu listy → expo-image cache
|
||||
|
|
@ -190,7 +190,7 @@ def make_token(
|
|||
if impersonate:
|
||||
payload["i"] = 1
|
||||
if producer:
|
||||
# Hoster oddający TREŚĆ manifestu (nie URL) — `/proxy/hls` zawoła producenta
|
||||
# Hoster oddający TREŚĆ manifestu (nie URL), `/proxy/hls` zawoła producenta
|
||||
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
|
||||
payload["mp"] = producer
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
|
|
@ -220,7 +220,7 @@ def parse_token(token: str) -> dict:
|
|||
|
||||
def _ascii_safe_url(url: str) -> str:
|
||||
"""Encode non-ASCII chars w URL path/query, zachowując reserved chars dla URI.
|
||||
httpx wymaga ASCII headers — Referer z polskim/cyrillic/unicode (np. hqporner
|
||||
httpx wymaga ASCII headers, Referer z polskim/cyrillic/unicode (np. hqporner
|
||||
`Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote`
|
||||
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
|
||||
enkoduje znaki spoza ASCII."""
|
||||
|
|
@ -255,8 +255,8 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
|
|||
"""Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy.
|
||||
|
||||
HLS manifest ma:
|
||||
- linie URI (segmenty .ts / sub-playlisty .m3u8) — relatywne lub absolute
|
||||
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"` — też potrzebują rewrite
|
||||
- linie URI (segmenty .ts / sub-playlisty .m3u8), relatywne lub absolute
|
||||
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"`, też potrzebują rewrite
|
||||
Każdy URL → token + /proxy/{token}/<basename>.<ext>.
|
||||
"""
|
||||
out: list[str] = []
|
||||
|
|
@ -284,7 +284,7 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
|
|||
|
||||
|
||||
def _absolutize_m3u8(content: str, base_url: str) -> str:
|
||||
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN —
|
||||
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN,
|
||||
NIE przez proxy. Dla direct-HLS passthrough (`/proxy/hls/...`): telefon ciągnie je
|
||||
bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin),
|
||||
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
|
||||
|
|
@ -310,7 +310,7 @@ def sign_url(
|
|||
referer: str | None = Query(default=None),
|
||||
) -> dict:
|
||||
"""Pomocniczy endpoint dla mobile do uzyskania świeżego tokena (np. po expiry).
|
||||
Normalnie /resolve zwraca już proxy URL — to fallback."""
|
||||
Normalnie /resolve zwraca już proxy URL, to fallback."""
|
||||
return {"token": make_token(url, referer), "expires_in": TOKEN_TTL_SEC}
|
||||
|
||||
|
||||
|
|
@ -320,11 +320,11 @@ async def proxy_image(
|
|||
_basename: str,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
"""Image proxy — używany dla thumbnaili z CDN-ów wymagających Referera
|
||||
"""Image proxy, używany dla thumbnaili z CDN-ów wymagających Referera
|
||||
(hqporner i inne porn-app sourcy). Mobile expo-image nie wysyła Referera
|
||||
domyślnie, CDN zwraca 403. Backend dodaje Referer i streamuje obrazek.
|
||||
|
||||
Cache-Control: public,max-age=86400 — thumby są stabilne, klient może cachować."""
|
||||
Cache-Control: public,max-age=86400, thumby są stabilne, klient może cachować."""
|
||||
payload = parse_token(token)
|
||||
target = payload["u"]
|
||||
referer = payload["r"] or None
|
||||
|
|
@ -334,18 +334,18 @@ async def proxy_image(
|
|||
try:
|
||||
r = await client.get(target, headers=headers)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
||||
# CDN connect/timeout — transient (np. Cloudflare 523 origin unreachable
|
||||
# CDN connect/timeout, transient (np. Cloudflare 523 origin unreachable
|
||||
# gdy upstream host jest off). Log INFO + 503, mobile renderuje placeholder.
|
||||
# Bez tego Sentry dostawał setki ERROR-ów (GOON-D/6) z każdym broken
|
||||
# tube'em — spam-szumiło real-issues.
|
||||
# tube'em, spam-szumiło real-issues.
|
||||
log.info("img proxy connect/timeout for %s: %s", target, e)
|
||||
return Response(content=b"", status_code=503, media_type="image/jpeg")
|
||||
except Exception as e:
|
||||
log.warning("img proxy fetch failed for %s: %s", target, e)
|
||||
raise HTTPException(status_code=502, detail=f"img fetch failed: {e}") from e
|
||||
if r.status_code >= 400:
|
||||
# Upstream 4xx/5xx dla thumba — degraded zamiast raise (placeholder w mobile).
|
||||
# GOON-5 (Cloudflare 523) i GOON-D — bezsensowny noise w Sentry, lepiej
|
||||
# Upstream 4xx/5xx dla thumba, degraded zamiast raise (placeholder w mobile).
|
||||
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
|
||||
# info log + 502 pass-through bez exception.
|
||||
log.info("img proxy upstream %d for %s", r.status_code, target)
|
||||
return Response(
|
||||
|
|
@ -362,7 +362,7 @@ async def proxy_image(
|
|||
|
||||
|
||||
# sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token
|
||||
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować — resolvujemy bieżący
|
||||
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować, resolvujemy bieżący
|
||||
# og:image ze strony /post/<id> przy serwowaniu. Cache resolved poster URL ~40min
|
||||
# (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/<id> → cache'uje bajty na
|
||||
# stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post.
|
||||
|
|
@ -376,10 +376,10 @@ _SXYPRN_PID_RE = re.compile(r"^[0-9a-f]{6,40}$")
|
|||
|
||||
# youporn on-demand thumbnail (bug-report bebf8c92 2026-07-25 "miniaturki z youporn
|
||||
# nie działają"). youporn przeszedł dla części katalogu na imgproxy z PODPISANYM URL-em
|
||||
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`) — taki URL
|
||||
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`), taki URL
|
||||
# wygasa, więc zapisany w bazie po czasie zwraca 410 (audit 2026-07-26: 8666 z 62930
|
||||
# źródeł youporn). Reszta katalogu ma stare statyczne `.../original/N.jpg`, które działają
|
||||
# bezterminowo — tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
|
||||
# bezterminowo, tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
|
||||
# świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/<video_id>`.
|
||||
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
|
||||
_YOUPORN_POSTER_TTL = 1800
|
||||
|
|
@ -417,7 +417,7 @@ async def youporn_thumb(video_id: str) -> Response:
|
|||
)
|
||||
if not m:
|
||||
return Response(content=b"", status_code=404, media_type="image/jpeg")
|
||||
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&`) — bez unescape
|
||||
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&`), bez unescape
|
||||
# podpis w query jest zepsuty i CDN oddaje 403.
|
||||
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
|
||||
if poster.startswith("//"):
|
||||
|
|
@ -492,7 +492,7 @@ async def sxyprn_thumb(post_id: str) -> Response:
|
|||
async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None:
|
||||
"""Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl.
|
||||
Cookies persist w session, użytkowane potem do mp4 GET (same-session bind).
|
||||
UA + Accept wymagane — bez tego mixdrop zwraca minimalny body (bez packera).
|
||||
UA + Accept wymagane, bez tego mixdrop zwraca minimalny body (bez packera).
|
||||
"""
|
||||
import re
|
||||
from yt_dlp.utils import decode_packed_codes
|
||||
|
|
@ -622,7 +622,7 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
|||
|
||||
Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e
|
||||
(variant/segmenty) zostają ABSOLUTNE na CDN → telefon ciągnie je DIRECT (phone IP,
|
||||
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty —
|
||||
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty,
|
||||
zweryfikowane cross-IP 2026-06-11). Przez VPS leci tylko ~1KB manifestu, nie segmenty.
|
||||
Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound
|
||||
hostery dalej idą pełnym `/proxy/`."""
|
||||
|
|
@ -674,7 +674,7 @@ async def proxy_stream(
|
|||
refetch_hoster = payload.get("rh")
|
||||
|
||||
# Jeśli ten target był już wcześniej follow-redirect-ed, użyj cached final URL.
|
||||
# Powód: porntrex `get_file/` 410 po reuse — patrz `_REDIRECT_CACHE` docstring.
|
||||
# Powód: porntrex `get_file/` 410 po reuse, patrz `_REDIRECT_CACHE` docstring.
|
||||
cached_target = _redirect_cache_get(original_target)
|
||||
target = cached_target or original_target
|
||||
|
||||
|
|
@ -684,10 +684,10 @@ async def proxy_stream(
|
|||
if range_h:
|
||||
headers["Range"] = range_h
|
||||
|
||||
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny — proxy zwraca pełne odpowiedzi
|
||||
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny, proxy zwraca pełne odpowiedzi
|
||||
|
||||
# Hostery które wymagają Chrome JA3 fingerprint (mxcontent / cloudflare-protected
|
||||
# CDNs) — od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
|
||||
# CDNs), od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
|
||||
# Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py).
|
||||
if use_impersonate:
|
||||
return await _curl_cffi_stream(
|
||||
|
|
@ -707,7 +707,7 @@ async def proxy_stream(
|
|||
|
||||
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
||||
try:
|
||||
# Sprobój streaming send PIERWSZY — sprawdź content-type po headers,
|
||||
# Sprobój streaming send PIERWSZY, sprawdź content-type po headers,
|
||||
# potem decyzja: rewrite manifest vs stream binary.
|
||||
upstream = await client.send(
|
||||
client.build_request(method, target, headers=headers),
|
||||
|
|
@ -719,7 +719,7 @@ async def proxy_stream(
|
|||
ups_headers = dict(upstream.headers)
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410) —
|
||||
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410),
|
||||
# invaliduj cache i daj mobile retry przez fresh /resolve. Bez tego stale
|
||||
# cache trzymałby martwy CDN URL przez 30 min (TTL).
|
||||
if cached_target is not None and status in (401, 403, 404, 410):
|
||||
|
|
@ -728,7 +728,7 @@ async def proxy_stream(
|
|||
|
||||
# Pierwszy successful pass dla single-use targets (np. porntrex get_file):
|
||||
# cache resolved final URL (po follow_redirects). Następne range-requesty
|
||||
# pójdą direct w CDN URL — get_file nie dostaje drugiego hita.
|
||||
# pójdą direct w CDN URL, get_file nie dostaje drugiego hita.
|
||||
if cached_target is None:
|
||||
final_url = str(upstream.url)
|
||||
if final_url != original_target:
|
||||
|
|
@ -741,7 +741,7 @@ async def proxy_stream(
|
|||
or "application/x-mpegurl" in ct
|
||||
)
|
||||
if is_m3u8:
|
||||
# Manifest content — buffer fully, rewrite, return as m3u8.
|
||||
# Manifest content, buffer fully, rewrite, return as m3u8.
|
||||
body = await upstream.aread()
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
|
@ -769,7 +769,7 @@ async def proxy_stream(
|
|||
yield chunk
|
||||
except httpx.TransportError as e:
|
||||
# Upstream CDN zerwał połączenie w trakcie streamu (peer closed before
|
||||
# full body — RemoteProtocolError, ReadError, timeout). Klient dostaje
|
||||
# full body, RemoteProtocolError, ReadError, timeout). Klient dostaje
|
||||
# uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji.
|
||||
# Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija
|
||||
# outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11).
|
||||
|
|
@ -792,7 +792,7 @@ async def proxy_stream(
|
|||
await client.aclose()
|
||||
raise
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
||||
# CDN connect failure / timeout — transient, log na INFO (nie ERROR do Sentry).
|
||||
# CDN connect failure / timeout, transient, log na INFO (nie ERROR do Sentry).
|
||||
# Zwracamy 503 zamiast 502 + Retry-After, mobile może retry-ować bez panic.
|
||||
await client.aclose()
|
||||
log.info("proxy connect/timeout for %s: %s", target, e)
|
||||
|
|
@ -818,10 +818,10 @@ def _upstream_error_response(
|
|||
Rationale per status:
|
||||
- **429 Too Many Requests**: CDN rate-limit (np. fpo.xxx gdy proxy hammeruje
|
||||
get_file/). Pass-through 429 + Retry-After żeby mobile zrobiło backoff.
|
||||
Log INFO (nie ERROR) — to expected behavior CDN-a, nie nasz bug.
|
||||
Log INFO (nie ERROR), to expected behavior CDN-a, nie nasz bug.
|
||||
- **404/410**: video deleted/expired token. Pass-through żeby player wiedział.
|
||||
- **5xx upstream**: pochodzi z CDN-a, nie z naszego kodu. Log INFO.
|
||||
- **inne 4xx**: 502 (i Sentry warn) — może być nasza wina (bad referer itp.).
|
||||
- **inne 4xx**: 502 (i Sentry warn), może być nasza wina (bad referer itp.).
|
||||
"""
|
||||
retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After")
|
||||
if status == 429:
|
||||
|
|
@ -845,7 +845,7 @@ def _upstream_error_response(
|
|||
media_type="text/plain",
|
||||
)
|
||||
if 500 <= status < 600:
|
||||
# CDN-side error (np. Cloudflare 523 — origin unreachable). Pass-through
|
||||
# CDN-side error (np. Cloudflare 523, origin unreachable). Pass-through
|
||||
# 502 ale log INFO bo to nie nasza wina.
|
||||
log.info("proxy upstream %d for %s", status, target)
|
||||
return Response(
|
||||
|
|
@ -854,5 +854,5 @@ def _upstream_error_response(
|
|||
headers={"Retry-After": "5"},
|
||||
media_type="text/plain",
|
||||
)
|
||||
# 4xx other (403 itp.) — raise żeby Sentry zarejestrował (może bug naszego kodu)
|
||||
# 4xx other (403 itp.), raise żeby Sentry zarejestrował (może bug naszego kodu)
|
||||
raise HTTPException(status_code=502, detail=f"upstream {status}")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""GET /tags, /performers, /studios — listy taxonomies do filtrów na mobile.
|
||||
"""GET /tags, /performers, /studios, listy taxonomies do filtrów na mobile.
|
||||
|
||||
Każdy endpoint wspiera:
|
||||
- q: substring search po name_normalized (trgm fallback ilike)
|
||||
|
|
@ -107,7 +107,7 @@ def list_tags(
|
|||
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
|
||||
|
||||
if for_movies:
|
||||
# Movie tag count — zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
|
||||
# Movie tag count, zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
|
||||
# Tag-bez-żadnego-movie zwraca 0 (LEFT OUTER JOIN przez coalesce). Movies są
|
||||
# małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji.
|
||||
_movie_live = exists().where(
|
||||
|
|
@ -146,7 +146,7 @@ def list_tags(
|
|||
]
|
||||
return TagListOut(items=items, total=total, page=page, per_page=per_page)
|
||||
|
||||
# Scenes path — czyta zdenormalizowany Tag.scene_count (refresh w tle przez
|
||||
# Scenes path, czyta zdenormalizowany Tag.scene_count (refresh w tle przez
|
||||
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
|
||||
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
|
||||
base = select(Tag)
|
||||
|
|
@ -236,13 +236,13 @@ def list_studios(
|
|||
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
|
||||
),
|
||||
) -> StudioListOut:
|
||||
from app.models.scene import Scene # lokalny import — Scene FK do Studio
|
||||
from app.models.scene import Scene # lokalny import, Scene FK do Studio
|
||||
|
||||
if order not in ("name", "scene_count", "popular"):
|
||||
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
|
||||
|
||||
if for_movies:
|
||||
# Movies małe (~41k) — live aggregat OK. Scenes path niżej = denormalizacja.
|
||||
# Movies małe (~41k), live aggregat OK. Scenes path niżej = denormalizacja.
|
||||
_movie_live = exists().where(
|
||||
and_(
|
||||
MoviePlaybackSource.movie_id == Movie.id,
|
||||
|
|
@ -280,7 +280,7 @@ def list_studios(
|
|||
]
|
||||
return StudioListOut(items=items, total=total, page=page, per_page=per_page)
|
||||
|
||||
# Scenes path — czyta zdenormalizowany Studio.scene_count (refresh w tle).
|
||||
# Scenes path, czyta zdenormalizowany Studio.scene_count (refresh w tle).
|
||||
# Wcześniej agregat 1.69M scenes + EXISTS playback per-request. Patrz migracja 0019.
|
||||
base = select(Studio)
|
||||
if q:
|
||||
|
|
@ -340,13 +340,13 @@ class PerformerRescrapeOut(BaseModel):
|
|||
# przy partial commits. 45s wall-clock + 50 scen max = ~12 fetches × 3s budgetowo.
|
||||
# Większe rescrape'y user może odpalać wielokrotnie (idempotent dzięki has_thumb/
|
||||
# tag_count check).
|
||||
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s — 5s margin na response build
|
||||
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s, 5s margin na response build
|
||||
_RESCRAPE_MAX_SCENES = 50
|
||||
# Re-fetch tagów dla scen z < N tagami. Niektórzy performerzy mają legit 1-2 tagi
|
||||
# (niche), no harm w sprawdzeniu pierwszy raz; powtarzane wywołania są idempotent
|
||||
# bo INSERT ... ON CONFLICT DO NOTHING.
|
||||
_TAG_RESCRAPE_THRESHOLD = 3
|
||||
# Mainstream tubes priority dla tagów — bogate metadane.
|
||||
# Mainstream tubes priority dla tagów, bogate metadane.
|
||||
_TAG_PRIORITY = [
|
||||
"xhamstercom", "porntrexcom", "epornercom", "youporncom",
|
||||
"xvideoscom", "xnxxcom",
|
||||
|
|
@ -360,7 +360,7 @@ def performer_top_tags(
|
|||
limit: int = Query(default=20, ge=1, le=50),
|
||||
) -> TagListOut:
|
||||
"""Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips,
|
||||
bug-report 1a4bf258 — zastępują dev-only przycisk Re-scrape). Liczymy tylko po
|
||||
bug-report 1a4bf258, zastępują dev-only przycisk Re-scrape). Liczymy tylko po
|
||||
scenach z żywym playbackiem (to co user realnie widzi na liście). scene_id index
|
||||
pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request."""
|
||||
live = exists().where(
|
||||
|
|
@ -418,7 +418,7 @@ def rescrape_performer_scenes(
|
|||
if perf is None:
|
||||
raise HTTPException(status_code=404, detail="performer not found")
|
||||
|
||||
# 1) ID-only query — sceny ze ≥1 alive tube playback.
|
||||
# 1) ID-only query, sceny ze ≥1 alive tube playback.
|
||||
scene_ids = session.execute(
|
||||
select(Scene.id)
|
||||
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
|
||||
|
|
@ -468,7 +468,7 @@ def rescrape_performer_scenes(
|
|||
capped = False
|
||||
cap_reason: str | None = None
|
||||
started = _time.monotonic()
|
||||
# Narrow exception set — łapiemy TYLKO oczekiwane network/parse failures.
|
||||
# Narrow exception set, łapiemy TYLKO oczekiwane network/parse failures.
|
||||
# `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion.
|
||||
NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError)
|
||||
|
||||
|
|
@ -486,7 +486,7 @@ def rescrape_performer_scenes(
|
|||
has_thumb = any(s.thumbnail_url for s in sources)
|
||||
existing_tag_count = tag_counts.get(scene_id, 0)
|
||||
|
||||
# SAVEPOINT — fail isolation. Pojedyncza scena z FK violation w SceneTag
|
||||
# SAVEPOINT, fail isolation. Pojedyncza scena z FK violation w SceneTag
|
||||
# insert nie odpaliłby outer transaction; bez nested rollback całe N scen
|
||||
# po niej miałoby PendingRollbackError.
|
||||
sp = session.begin_nested()
|
||||
|
|
@ -504,7 +504,7 @@ def rescrape_performer_scenes(
|
|||
thumb = extract_thumbnail_url(r.text)
|
||||
if thumb:
|
||||
# Update tylko źródła z którego pochodzi thumb (single playback).
|
||||
# Wcześniej apply'owalismy do wszystkich siblings — wrong-CDN
|
||||
# Wcześniej apply'owalismy do wszystkich siblings, wrong-CDN
|
||||
# cross-attribution (np. xhamster thumb na porntrex entry).
|
||||
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
|
||||
# find()), więc 1 wystarczy.
|
||||
|
|
@ -586,7 +586,7 @@ def refresh_performer(
|
|||
performer_id: uuid.UUID,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> PerformerRefreshOut:
|
||||
"""On-demand search across all tubes dla pojedynczego performera. Synchronous —
|
||||
"""On-demand search across all tubes dla pojedynczego performera. Synchronous,
|
||||
blokujemy aż search skończy. Mobile pokazuje spinner.
|
||||
|
||||
Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style
|
||||
|
|
@ -606,7 +606,7 @@ def refresh_performer(
|
|||
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
|
||||
)
|
||||
|
||||
# Lazy import — performer_driven ma ciężki connector tree
|
||||
# Lazy import, performer_driven ma ciężki connector tree
|
||||
from app.scheduler.performer_driven import run_performer_driven
|
||||
|
||||
# NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes).
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Watch history + continue watching.
|
||||
|
||||
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
|
||||
- Klik Watch (position_sec=0) — wciąga scenę do recent watch
|
||||
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT) — z faktyczną pozycją
|
||||
- Klik Watch (position_sec=0), wciąga scenę do recent watch
|
||||
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT), z faktyczną pozycją
|
||||
|
||||
Continue watching rail na home: GET /watch/recent?limit=10 → top scen po last_played_at,
|
||||
filtruje dead-finished (>=95% lub flag finished). Mobile pokazuje progress bar
|
||||
|
|
@ -55,7 +55,7 @@ def upsert_progress(
|
|||
if session.get(Scene, scene_id) is None:
|
||||
raise HTTPException(status_code=404, detail="scene not found")
|
||||
|
||||
# PG upsert — eliminuje race condition gdy mobile wysyła progress równolegle
|
||||
# PG upsert, eliminuje race condition gdy mobile wysyła progress równolegle
|
||||
# (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add →
|
||||
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
|
@ -139,7 +139,7 @@ def upsert_movie_progress(
|
|||
session: Annotated[Session, Depends(get_session)],
|
||||
device_id: Annotated[str, Depends(get_device_id)],
|
||||
) -> MovieProgressOut:
|
||||
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26 —
|
||||
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26,
|
||||
"przydałoby się oznaczenie filmów już obejrzanych")."""
|
||||
if session.get(Movie, movie_id) is None:
|
||||
raise HTTPException(status_code=404, detail="movie not found")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""API key authentication.
|
||||
|
||||
Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer <key>`.
|
||||
Gdy `settings.api_keys` jest puste — auth jest wyłączony (dev mode).
|
||||
Gdy `settings.api_keys` jest puste, auth jest wyłączony (dev mode).
|
||||
|
||||
Dodatkowo (anti-tamper): gdy `ALLOWED_APP_SIG_HASH` jest ustawione, każdy request
|
||||
musi zawierać `X-App-Signature` z SHA256 (hex) signing certu APK. Mismatch → 403.
|
||||
|
|
@ -30,7 +30,7 @@ def require_api_key(
|
|||
)
|
||||
|
||||
if not settings.auth_enabled:
|
||||
return # local/dev — wszystko otwarte
|
||||
return # local/dev, wszystko otwarte
|
||||
|
||||
candidate: str | None = None
|
||||
if x_api_key:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class Settings(BaseSettings):
|
|||
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
||||
|
||||
|
||||
# Sentry observability — pusty DSN = init no-op (devel/local). Cloud free tier
|
||||
# Sentry observability, pusty DSN = init no-op (devel/local). Cloud free tier
|
||||
# 5k errors/mies wystarczy dla 1-user app.
|
||||
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
|
||||
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
|
||||
|
|
@ -48,12 +48,12 @@ class Settings(BaseSettings):
|
|||
title_token_set_min: int = 88
|
||||
date_window_days: int = 7
|
||||
|
||||
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source —
|
||||
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source,
|
||||
# to permanentne orphany (free tubes nie hostują), ~56% ingestu TPDB/StashDB.
|
||||
# False = wciągaj jak dawniej. Tube'y z clip-store studiem NIE są skipowane (mają playback).
|
||||
skip_clip_store: bool = Field(default=True, validation_alias="GOON_SKIP_CLIP_STORE")
|
||||
|
||||
# Minimalny duration sceny z tube/scraper przy ingescie — <N s = trailer/teaser/preview.
|
||||
# Minimalny duration sceny z tube/scraper przy ingescie, <N s = trailer/teaser/preview.
|
||||
# 0 = wyłączony. Nieznany duration nie jest wycinany. NIE dotyczy canonical (TPDB/StashDB).
|
||||
min_ingest_duration_sec: int = Field(default=180, validation_alias="GOON_MIN_INGEST_DURATION_SEC")
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ class Settings(BaseSettings):
|
|||
sched_performer_continuous_refresh_days: int = Field(
|
||||
default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS"
|
||||
)
|
||||
# Movie ingest — paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
|
||||
# Movie ingest, paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
|
||||
# pandamovies). Każdy connector zapisuje swój `Source` i robi delta od ostatniego
|
||||
# successful run. Set to 0 to disable. Domyślnie 24h: movie sites rosną wolniej
|
||||
# niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej.
|
||||
|
|
@ -92,31 +92,31 @@ class Settings(BaseSettings):
|
|||
sched_browse_latest_max_pages: int = Field(
|
||||
default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES"
|
||||
)
|
||||
# Deep-crawl (Faza 2a) — pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
|
||||
# Deep-crawl (Faza 2a), pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
|
||||
# top-N. Round-robin po tube'ach, wznawialny kursor (app/_state/deepcrawl_state.json).
|
||||
# 0 = wyłączony. 60 stron/run × ~31 scen ≈ 1860 scen/run (~22 min, hard-timeout 1h).
|
||||
sched_deep_crawl_hours: int = Field(default=1, validation_alias="GOON_SCHED_DEEP_CRAWL_HOURS")
|
||||
deep_crawl_pages_per_run: int = Field(default=60, validation_alias="GOON_DEEP_CRAWL_PAGES_PER_RUN")
|
||||
deepcrawl_state_path: str = Field(default="", validation_alias="GOON_DEEPCRAWL_STATE_PATH")
|
||||
# Bulk-dedup performers safety net — auto-merge duplikatów które resolver-time
|
||||
# Bulk-dedup performers safety net, auto-merge duplikatów które resolver-time
|
||||
# scoring pominął. 12h cadence: leci 2x dziennie (po porannym browse-latest run).
|
||||
sched_bulk_dedup_hours: int = Field(
|
||||
default=12, validation_alias="GOON_SCHED_BULK_DEDUP_HOURS"
|
||||
)
|
||||
# Thumb-asset dedup — scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
|
||||
# Thumb-asset dedup, scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
|
||||
# ten sam asset-id miniatury + długość). bulk_dedup tego nie łapie (brak phash/tytuł).
|
||||
# Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off.
|
||||
sched_thumb_dedup_hours: int = Field(
|
||||
default=12, validation_alias="GOON_SCHED_THUMB_DEDUP_HOURS"
|
||||
)
|
||||
# Title+duration dedup — scala missing-merge dupy (ten sam performer + identyczny
|
||||
# Title+duration dedup, scala missing-merge dupy (ten sam performer + identyczny
|
||||
# znormalizowany tytuł + długość co do sekundy), których bulk_dedup nie łapie (tube
|
||||
# re-scrape / cross-tube np. porn00 vs xnxx, reports 28fe8181/32df33b1). Odrastają
|
||||
# przy re-ingeście, stąd cyklicznie. 12h, playback-only (to co user widzi). 0 = off.
|
||||
sched_title_dedup_hours: int = Field(
|
||||
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
|
||||
)
|
||||
# Blacklista zdegenerowanych phashy — wartości dzielonych przez ≥10 scen (zaślepka,
|
||||
# Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka,
|
||||
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
|
||||
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
|
||||
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
|
||||
|
|
@ -124,18 +124,18 @@ class Settings(BaseSettings):
|
|||
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
|
||||
)
|
||||
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
|
||||
# w ogóle — streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
|
||||
# w ogóle, streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
|
||||
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
|
||||
ingest_watchdog_movie_max_age_hours: int = Field(
|
||||
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
|
||||
)
|
||||
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
|
||||
# Śmieciowi performerzy, kategorie i studia podszywające się pod osoby („Creampie",
|
||||
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
|
||||
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
|
||||
sched_junk_performers_hours: int = Field(
|
||||
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
|
||||
)
|
||||
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
|
||||
# Ingest freshness watchdog, alert do Sentry gdy aktywny tube (origin
|
||||
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
|
||||
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
|
||||
# widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence
|
||||
|
|
@ -143,19 +143,19 @@ class Settings(BaseSettings):
|
|||
sched_ingest_watchdog_hours: int = Field(
|
||||
default=6, validation_alias="GOON_SCHED_INGEST_WATCHDOG_HOURS"
|
||||
)
|
||||
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS) — crawlowane raz dziennie z
|
||||
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS), crawlowane raz dziennie z
|
||||
# listingu, więc 48h ciszy = anomalia.
|
||||
ingest_watchdog_max_age_hours: int = Field(
|
||||
default=48, validation_alias="GOON_INGEST_WATCHDOG_MAX_AGE_HOURS"
|
||||
)
|
||||
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS) — kadencja jest
|
||||
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS), kadencja jest
|
||||
# nierówna (continuous queue ~30d refresh per performer, ingest orphan-heavy), więc
|
||||
# 48h dawałoby false-positivy. 7d (168h): healthy search-tuby obserwowane <6h świeżości
|
||||
# (continuous tick hituje wszystkie tuby per performer), zamrożone ≥73h → ~28× margines.
|
||||
ingest_watchdog_search_max_age_hours: int = Field(
|
||||
default=168, validation_alias="GOON_INGEST_WATCHDOG_SEARCH_MAX_AGE_HOURS"
|
||||
)
|
||||
# TPDB movie enrichment — wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
|
||||
# TPDB movie enrichment, wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
|
||||
# reżyserem, plus kanoniczny TPDB UUID do dedupu mirrorów. paradisehill (primary)
|
||||
# prawie nie ma obsady, więc to domyka największą lukę zakładki movies. Batch
|
||||
# najświeższych GRYWALNYCH filmów bez obsady/studia per run; 6h cadence. 0 = off.
|
||||
|
|
@ -165,20 +165,20 @@ class Settings(BaseSettings):
|
|||
tpdb_movie_enrich_batch: int = Field(
|
||||
default=200, validation_alias="GOON_TPDB_MOVIE_ENRICH_BATCH"
|
||||
)
|
||||
# Taxonomy scene_count refresh — przelicza denormalizowane liczniki scen na
|
||||
# Taxonomy scene_count refresh, przelicza denormalizowane liczniki scen na
|
||||
# tags/performers/studios (hot-path /tags|/performers|/studios|/favorites czyta
|
||||
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence —
|
||||
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence,
|
||||
# counts do tego stale, dla sortu "popular" + badge "(N)" bez znaczenia. 0 = off.
|
||||
sched_taxonomy_counts_hours: int = Field(
|
||||
default=3, validation_alias="GOON_SCHED_TAXONOMY_COUNTS_HOURS"
|
||||
)
|
||||
|
||||
# Hetzner Cloud bandwidth monitor — read-only API token (Security → API Tokens
|
||||
# Hetzner Cloud bandwidth monitor, read-only API token (Security → API Tokens
|
||||
# w panelu Hetzner Cloud). Bez tokenu monitor wyłączony (warning w log).
|
||||
# Free traffic per server: CX22=20TB, CPX21=20TB itd. Overage = €1/TB.
|
||||
hetzner_api_token: str | None = Field(default=None, validation_alias="HETZNER_API_TOKEN")
|
||||
hetzner_server_id: int | None = Field(default=None, validation_alias="HETZNER_SERVER_ID")
|
||||
# Alert thresholds (% of included_traffic) — Sentry severity levels.
|
||||
# Alert thresholds (% of included_traffic), Sentry severity levels.
|
||||
hetzner_alert_info_pct: int = Field(default=50, validation_alias="HETZNER_ALERT_INFO_PCT")
|
||||
hetzner_alert_warning_pct: int = Field(default=80, validation_alias="HETZNER_ALERT_WARNING_PCT")
|
||||
hetzner_alert_error_pct: int = Field(default=95, validation_alias="HETZNER_ALERT_ERROR_PCT")
|
||||
|
|
@ -188,14 +188,14 @@ class Settings(BaseSettings):
|
|||
default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS"
|
||||
)
|
||||
|
||||
# Source ranking (Sites screen) — przelicz source_stats (freshness/richness/health
|
||||
# Source ranking (Sites screen), przelicz source_stats (freshness/richness/health
|
||||
# per origin). 0/None = wyłączone. Domyślnie 6h (richness to ciężki agregat po
|
||||
# ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli).
|
||||
sched_source_stats_hours: int = Field(
|
||||
default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS"
|
||||
)
|
||||
|
||||
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB) —
|
||||
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB),
|
||||
# używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare
|
||||
# 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN
|
||||
# IP-bound). Format env: `host:port:user:pass` (panel Bright Data). Pusty = brak.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""Connector registry helpers.
|
||||
|
||||
Lazy factories — importy connectorów wykonują się dopiero w `get_movie_connectors()`
|
||||
Lazy factories, importy connectorów wykonują się dopiero w `get_movie_connectors()`
|
||||
żeby uniknąć circular imports (modeles/db). Każdy entry: `(name, class)` w porządku
|
||||
ingestu (primary FIRST, mirrory potem — `resolve_movie` wtedy ma do czego dokleić
|
||||
ingestu (primary FIRST, mirrory potem, `resolve_movie` wtedy ma do czego dokleić
|
||||
mirror playback sources).
|
||||
|
||||
## Jak dodać nowe movie site
|
||||
|
||||
1. Napisz subclass `DooplayConnector` w `app/connectors/dooplay.py` (jeśli site używa
|
||||
dooplay/PsyPlay WP theme) — wystarczy `name` + `base_url`. Jeśli inny theme,
|
||||
dooplay/PsyPlay WP theme), wystarczy `name` + `base_url`. Jeśli inny theme,
|
||||
napisz osobny connector implementujący `BaseMovieConnector.fetch_movies()`.
|
||||
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
|
||||
3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie
|
||||
|
|
@ -31,7 +31,7 @@ from __future__ import annotations
|
|||
def get_movie_connectors() -> list[tuple[str, type]]:
|
||||
"""Zwraca listę (name, ConnectorCls) tuples w kolejności ingestu.
|
||||
|
||||
Lazy import — uniknięcie circular import bo connectory zaczepiają db/models.
|
||||
Lazy import, uniknięcie circular import bo connectory zaczepiają db/models.
|
||||
"""
|
||||
from app.connectors.dooplay import (
|
||||
MangopornConnector,
|
||||
|
|
@ -43,10 +43,10 @@ def get_movie_connectors() -> list[tuple[str, type]]:
|
|||
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
|
||||
|
||||
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
|
||||
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content —
|
||||
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content,
|
||||
# 72 nowych 2026-05-28; streamporn/pandamovies zwracają stale 0 new), na końcu
|
||||
# streamporn + pandamovies. Powód reorderu (2026-05-30): gdy streamporn wiesza
|
||||
# się intermittentnie, mangoporn musi zdążyć przed nim — patrz per-connector
|
||||
# się intermittentnie, mangoporn musi zdążyć przed nim, patrz per-connector
|
||||
# timeout w _job_movie_ingest.
|
||||
# streamporn.nl padł (0 new od 2026-05-23) → zastąpiony żywym streamporn.vip
|
||||
# (StreampornVipConnector, ocena 2026-07-02). Stara klasa .nl zostaje w dooplay.py
|
||||
|
|
@ -56,15 +56,15 @@ def get_movie_connectors() -> list[tuple[str, type]]:
|
|||
("mangoporn", MangopornConnector),
|
||||
("streampornvip", StreampornVipConnector),
|
||||
("pandamovies", PandamoviesConnector),
|
||||
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD,
|
||||
# freeomovie.to (bestia theme, nie dooplay), mirror: świeże filmy DVD,
|
||||
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
|
||||
("freeomovie", FreeoMovieConnector),
|
||||
# yourdailypornmovies.ws (własny motyw WP, nie dooplay) — ocena 2026-07-28.
|
||||
# yourdailypornmovies.ws (własny motyw WP, nie dooplay), ocena 2026-07-28.
|
||||
# SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
|
||||
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
|
||||
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
|
||||
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
|
||||
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
|
||||
# gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed.
|
||||
# gotowe moduły hosterów), vidara.to nieznana, leci jako zwykły embed.
|
||||
("yourdailypornmovies", YourDailyPornMoviesConnector),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class RawScene(BaseModel):
|
|||
(np. 'tpdb', 'stashdb')."""
|
||||
|
||||
raw: dict[str, Any] = Field(default_factory=dict)
|
||||
"""Oryginalny payload z API — leci do external_records.raw."""
|
||||
"""Oryginalny payload z API, leci do external_records.raw."""
|
||||
|
||||
|
||||
class BaseConnector(abc.ABC):
|
||||
|
|
@ -114,15 +114,15 @@ class BaseConnector(abc.ABC):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movies — odrębny encja od scen, ale ten sam wzorzec connectorów
|
||||
# Movies, odrębny encja od scen, ale ten sam wzorzec connectorów
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RawMovieChapter(BaseModel):
|
||||
"""Pojedynczy rozdział filmu (movies czasem dzielą się na "Part 1/2/3" itp.).
|
||||
|
||||
Identyfikatory chaptera nie są kanonizowane między źródłami — są lokalne dla movie,
|
||||
Identyfikatory chaptera nie są kanonizowane między źródłami, są lokalne dla movie,
|
||||
indeksowane przez `chapter_index`. Może linkować do separate scene (jeśli ta scena
|
||||
znana z TPDB/StashDB) — tym zajmuje się normalizator wyżej."""
|
||||
znana z TPDB/StashDB), tym zajmuje się normalizator wyżej."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ class RawMovieChapter(BaseModel):
|
|||
|
||||
|
||||
class RawMovie(BaseModel):
|
||||
"""Surowy film z connectora — odpowiednik RawScene dla movies.
|
||||
"""Surowy film z connectora, odpowiednik RawScene dla movies.
|
||||
|
||||
Performers / studio / tags reusable z RawPerformer / RawStudio / RawTag (te same
|
||||
typy w obu pipelinach). Playback sources to lista mirrorów odtwarzania (paradisehill
|
||||
|
|
@ -170,7 +170,7 @@ class BaseMovieConnector(abc.ABC):
|
|||
"""Connector dla source'a movies (paradisehill, psyplay, wp_movies).
|
||||
|
||||
Symetrycznie do BaseConnector ale yielduje RawMovie. Każde źródło zna własną
|
||||
paginację i format ID — konwerter wyżej (resolver) dba o dedup między źródłami.
|
||||
paginację i format ID, konwerter wyżej (resolver) dba o dedup między źródłami.
|
||||
"""
|
||||
|
||||
kind: SourceKind
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
"""Direct tube scrapers.
|
||||
|
||||
Każdy scraper hit'uje tube bezpośrednio HTTPm — różne tube'y to różne rate limit
|
||||
Każdy scraper hit'uje tube bezpośrednio HTTPm, różne tube'y to różne rate limit
|
||||
budgets, więc mogą iść równolegle. Wszystkie feedują sceny do tej samej
|
||||
`Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver
|
||||
mergeuje idempotentnie po tym kluczu.
|
||||
|
||||
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"` — myląca pozostałość po usuniętym
|
||||
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA —
|
||||
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"`, myląca pozostałość po usuniętym
|
||||
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA,
|
||||
to nasze własne direct-scrapery tubów). Przemianowana na `"tube-scraper"`; wiersz
|
||||
`sources` zaktualizowany w DB (UPDATE name) więc cała historia ingest_runs została.
|
||||
|
||||
Search-based ścieżka (per performer name); category browse'ng przez `categoriesUrl`.
|
||||
|
||||
UWAGA — speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
|
||||
UWAGA, speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
|
||||
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
|
||||
porndittcom, perverzija, fpoxxx, ...) ma URL templates + regex'y oparte na typowych
|
||||
WordPress conventions. Wymagają post-deploy verification — gdy któryś nie zwraca
|
||||
WordPress conventions. Wymagają post-deploy verification, gdy któryś nie zwraca
|
||||
wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku.
|
||||
"""
|
||||
# Umbrella Source.name dla wszystkich direct-scraperów (deep-crawl, browse-latest,
|
||||
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące — nie ma zależności
|
||||
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące, nie ma zależności
|
||||
# od zewnętrznego porn-app API).
|
||||
SCRAPER_SOURCE_NAME = "tube-scraper"
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
|
|||
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
|
||||
from app.connectors.direct_scrapers.eporner import EpornerScraper
|
||||
from app.connectors.direct_scrapers.fpoxxx import FpoxxxScraper
|
||||
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401 — kept for backref; disabled
|
||||
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401, kept for backref; disabled
|
||||
from app.connectors.direct_scrapers.hqporner import HQPornerScraper
|
||||
from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper
|
||||
from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper
|
||||
|
|
@ -55,21 +55,21 @@ from app.connectors.direct_scrapers.xhamster import XHamsterScraper
|
|||
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
|
||||
from app.connectors.direct_scrapers.xnxx import XnxxScraper
|
||||
from app.connectors.direct_scrapers.xvideos import XVideosScraper
|
||||
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401 — kept for backref; delisted
|
||||
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401, kept for backref; delisted
|
||||
from app.connectors.direct_scrapers.youporn import YouPornScraper
|
||||
|
||||
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
||||
# Existing 4 (verified, in production)
|
||||
HQPornerScraper,
|
||||
# HDPorn92Scraper — wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
|
||||
# HDPorn92Scraper, wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
|
||||
# (tylko happyleafmotion ads), JS hijackuje wszystkie kliki → `go.rmishe.com/smartpop/...`
|
||||
# popunder redirect. Mobile WebView page-as-hoster pokazuje ad redirect zamiast video.
|
||||
# 33,598 playback_sources mass-marked dead, 27,374 solo-orphan scenes deleted.
|
||||
SxyLandScraper,
|
||||
# ZeroDayXXScraper (0dayxx) — USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
|
||||
# ZeroDayXXScraper (0dayxx), USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
|
||||
# factory (0.1% canonical), zastępujemy lepszymi źródłami. Dane/pliki/extractor skasowane.
|
||||
# Mainstream (URL templates well-known)
|
||||
# PornHub + RedTube — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
|
||||
# PornHub + RedTube, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
|
||||
# 2026-05-12 (0.4% canonical match), zamrożone dane skasowane z DB, pliki scraperów
|
||||
# i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio.
|
||||
XVideosScraper,
|
||||
|
|
@ -78,60 +78,60 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
|||
YouPornScraper,
|
||||
PornTrexScraper,
|
||||
EpornerScraper,
|
||||
# Aggregators (WordPress-like ?s= search; speculative — verify post-deploy)
|
||||
# XMoviesForYouScraper — wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
|
||||
# streamtape (DEAD_HOSTER_RE — malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
|
||||
# Aggregators (WordPress-like ?s= search; speculative, verify post-deploy)
|
||||
# XMoviesForYouScraper, wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
|
||||
# streamtape (DEAD_HOSTER_RE, malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
|
||||
# Mixdrop zrebrandował na m1xdrop.bz, yt-dlp out-of-date, packer/JS extract = fail.
|
||||
# Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w
|
||||
# jadx). 1,321 solo-orphan scen.
|
||||
# WatchPornScraper — RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
|
||||
# WatchPornScraper, RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
|
||||
# Site przebudowany na KVS (DoodStream-CAPTCHA z 2026-05-12 zniknął); browse
|
||||
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
|
||||
# SiskaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
|
||||
# bo search siski zepsuty site-side — `?s=` ignoruje query). Patrz siska.py.
|
||||
# Porn4DaysScraper — wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
|
||||
# SiskaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
|
||||
# bo search siski zepsuty site-side, `?s=` ignoruje query). Patrz siska.py.
|
||||
# Porn4DaysScraper, wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
|
||||
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
|
||||
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
|
||||
# solo-orphan scen.
|
||||
# PornDishScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
|
||||
# PornDishScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
|
||||
# watchdog GOON-16: search `?s=` zamarzł 2026-05-07). WordPress → browse przez WP REST
|
||||
# API (/wp-json/wp/v2/posts) jak perverzija: tytuł/data/thumb/studio(category)/tagi.
|
||||
# XxxFreeWatchScraper — wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
|
||||
# XxxFreeWatchScraper, wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
|
||||
# Cloudflare 403 z VPS IP, mobile WebView teoretycznie działa ale 0/790 scen miało jakikolwiek
|
||||
# match do TPDB/StashDB. Pure orphan factory. Solo scenes deleted, scraper disabled.
|
||||
# LatestPornVideoScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||
# LatestPornVideoScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||
# user 1da0375e: search-driven nie brał feedu "latest" → stary zestaw w apce).
|
||||
# LatestLeaksScraper — wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
|
||||
# LatestLeaksScraper, wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
|
||||
# canonical match. Slug-concat tytuły, brak studio/duration/date signali. Solo orphany
|
||||
# usunięte (~15k scen).
|
||||
MyPornerLeakScraper,
|
||||
# Added 2026-05-12 (theporndude survey): jeden z 14 free tubes na liście który
|
||||
# zwraca consistent search results. KVS engine, slug-aware scene URLs. Mostly
|
||||
# orphan ingest (auto-screenshots, no canonical phash match — sprawdzone), ale
|
||||
# orphan ingest (auto-screenshots, no canonical phash match, sprawdzone), ale
|
||||
# może łapać sceny popularnych performerów których jeszcze nie mamy w TPDB.
|
||||
# PornHat (pornhatcom) + PornDitt (porndittcom) — USUNIĘTE CAŁKOWICIE 2026-06-22
|
||||
# PornHat (pornhatcom) + PornDitt (porndittcom), USUNIĘTE CAŁKOWICIE 2026-06-22
|
||||
# (user request). Orphan factories (0.2% / weak-signal canonical match), zastępujemy
|
||||
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
|
||||
# Special
|
||||
SxyPrnScraper,
|
||||
# PerverzijaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||
# PerverzijaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||
# user request). Search `?s=` → 429, homepage JS-renderowane; browse przez WP REST API
|
||||
# (/wp-json/wp/v2/posts) daje tytuł/datę/thumb/studio(category)/tagi. Playback embed-iframe.
|
||||
# FpoxxxScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||
# FpoxxxScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
|
||||
# user request). fpo.xxx to KVS, nie WordPress → search `?s=` zwracał 0; browse z
|
||||
# `/new-<n>/` daje listing tile (tytuł/thumb/duration). Playback i tak phone-side (KVS).
|
||||
]
|
||||
|
||||
# Browse-mode scrapers — iterują `latest-vids` listing zamiast search-by-performer.
|
||||
# Browse-mode scrapers, iterują `latest-vids` listing zamiast search-by-performer.
|
||||
# Phash thumbnail fingerprint (waga 0.40 w composite scoring) auto-mergeuje do
|
||||
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
|
||||
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
|
||||
# `app/scheduler/browse_latest.py`.
|
||||
#
|
||||
# **Pilot results (2026-05-12):**
|
||||
# - ShyfapScraper: 0/23 match (0%) — robi własne thumbnails ≠ canonical
|
||||
# - ShyfapScraper: 0/23 match (0%), robi własne thumbnails ≠ canonical
|
||||
# (phash Hamming 12-16). Plus rebranduje tytuły. **Wyłączony.**
|
||||
# - FreshpornoScraper: 39/59 match (66%) — hot-linkuje studio thumbnaile
|
||||
# - FreshpornoScraper: 39/59 match (66%), hot-linkuje studio thumbnaile
|
||||
# (phash Hamming 0). Oryginalne tytuły + channels=studio 1:1. **Aktywny.**
|
||||
from app.connectors.direct_scrapers.freshporno import FreshpornoScraper # noqa: E402
|
||||
from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402
|
||||
|
|
@ -154,62 +154,62 @@ from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper
|
|||
ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
||||
PerverzijaScraper,
|
||||
PornDishScraper,
|
||||
# WatchPornScraper — re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
|
||||
# WatchPornScraper, re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
|
||||
# DoodStream-CAPTCHA zniknął). Browse /latest-updates/: og:title, JSON-LD duration/
|
||||
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
|
||||
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
|
||||
WatchPornScraper,
|
||||
# YoupervScraper — dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
|
||||
# YoupervScraper, dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
|
||||
# hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy
|
||||
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
|
||||
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
|
||||
YoupervScraper,
|
||||
# GalaxyPornScraper — dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
|
||||
# GalaxyPornScraper, dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
|
||||
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
|
||||
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
|
||||
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
|
||||
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
|
||||
GalaxyPornScraper,
|
||||
# PornBusyScraper — dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
|
||||
# PornBusyScraper, dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
|
||||
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
|
||||
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
|
||||
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
|
||||
PornBusyScraper,
|
||||
# PornMikeScraper — dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
|
||||
# PornMikeScraper, dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
|
||||
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
|
||||
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
|
||||
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
|
||||
# UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez
|
||||
# UWAGA: to klipy 5-12 min, nie pełne sceny, ~80% katalogu to jednak studia bez
|
||||
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
|
||||
PornMikeScraper,
|
||||
# KPorner8Scraper — dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
|
||||
# KPorner8Scraper, dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
|
||||
# tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny
|
||||
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
|
||||
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
|
||||
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
|
||||
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
|
||||
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
|
||||
# Pilot: seen 62, new 25, updated 37, 60% dopina się do scen, które już mamy.
|
||||
KPorner8Scraper,
|
||||
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
|
||||
# FullPornerScraper, dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
|
||||
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
||||
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
||||
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO — za to
|
||||
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO, za to
|
||||
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
|
||||
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
|
||||
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
|
||||
# bez performera scaliły się 0/23. phash liczony celowo — to jedyny sygnał dla
|
||||
# bez performera scaliły się 0/23. phash liczony celowo, to jedyny sygnał dla
|
||||
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
|
||||
FullPornerScraper,
|
||||
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
|
||||
# SexuScraper (`sexu.py`), ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
|
||||
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
|
||||
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
|
||||
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
|
||||
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
|
||||
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
|
||||
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
|
||||
# performera. Scraper i extractor zostają w repo — gdyby serwis kiedyś zaczął wrzucać
|
||||
# performera. Scraper i extractor zostają w repo, gdyby serwis kiedyś zaczął wrzucać
|
||||
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
|
||||
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
|
||||
# FullVideosPornScraper, dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
|
||||
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
|
||||
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
|
||||
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
|
||||
|
|
@ -224,105 +224,105 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
|||
XnxxBrowseScraper,
|
||||
FreshpornoScraper,
|
||||
FpoxxxScraper,
|
||||
# LatestPornVideoScraper — browse od 2026-06-22 (user 1da0375e: search-driven
|
||||
# LatestPornVideoScraper, browse od 2026-06-22 (user 1da0375e: search-driven
|
||||
# nie brał feedu "latest"). Listing card: tytuł (z embedded "<Studio> YY MM DD"),
|
||||
# thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma
|
||||
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
|
||||
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
|
||||
LatestPornVideoScraper,
|
||||
# SiskaScraper — re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
|
||||
# SiskaScraper, re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
|
||||
# zepsuty site-side (`?s=` ignoruje query), więc latest-browse z `/page/<n>/`.
|
||||
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
|
||||
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
|
||||
SiskaScraper,
|
||||
# PornXPScraper — pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
|
||||
# PornXPScraper, pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
|
||||
# release_date 100%, duration 100%, stream_url 100%, phash 100%. Najlepsze
|
||||
# sygnały spośród browse-mode scraperów. Stream direct mp4 (sv.porn-xp.com)
|
||||
# 360/720 quality. Release year z `Released: <year>` na detail.
|
||||
PornXPScraper,
|
||||
# Porn00Scraper — pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
|
||||
# Porn00Scraper, pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
|
||||
# date (0%) ALE performer 100%, duration 100%, stream_url 100% (KVS video_alt_url
|
||||
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
|
||||
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
|
||||
Porn00Scraper,
|
||||
# PornDoeScraper — dołączony 2026-05-21 (theporndude audit). Każda scena ma
|
||||
# PornDoeScraper, dołączony 2026-05-21 (theporndude audit). Każda scena ma
|
||||
# kompletny JSON-LD VideoObject: title + uploadDate + duration + named studio
|
||||
# (producer/publisher) + named performers (actor[]) + thumbnail. Najbogatsze
|
||||
# strukturalne metadane spośród browse scraperów — composite fuzzy match ma
|
||||
# strukturalne metadane spośród browse scraperów, composite fuzzy match ma
|
||||
# komplet sygnałów. Phash hit-rate niski (własne crop-thumbnaile), studio +
|
||||
# performer + date + duration nadrabiają.
|
||||
PornDoeScraper,
|
||||
# YesPornVipScraper — dołączony 2026-05-27 (user audit). JSON-LD VideoObject
|
||||
# YesPornVipScraper, dołączony 2026-05-27 (user audit). JSON-LD VideoObject
|
||||
# + `<meta property="video:duration|release_date|tag">` per scena (Goon ma
|
||||
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
|
||||
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
|
||||
# 941k organic monthly (SE Ranking, comparable z porndoe 731k / porntrex 790k).
|
||||
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen) — wysokie expected
|
||||
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen), wysokie expected
|
||||
# canonical match dla studio scenes. Korekta: theporndude scorecard rank 26
|
||||
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** — pdude.link
|
||||
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** pdude.link
|
||||
# redirect do porndudecams affiliate. Prawdziwa kanoniczna domena to TLD `.vip`.
|
||||
YesPornVipScraper,
|
||||
# FullmoviesScraper + HDPornGGScraper — dołączone 2026-06-01. KVS engine (sponsor_groups
|
||||
# FullmoviesScraper + HDPornGGScraper, dołączone 2026-06-01. KVS engine (sponsor_groups
|
||||
# stack, `/videos/<slug>/` + `/latest-updates/`). Studio teraz z PREFIKSU tytułu
|
||||
# ("Studio - Scene") — sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
|
||||
# ("Studio - Scene"), sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
|
||||
# zawsze Brazzers (mis-attribution, dlatego nigdy nie były włączone). Niosą paysite studio
|
||||
# content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy.
|
||||
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
|
||||
FullmoviesScraper,
|
||||
HDPornGGScraper,
|
||||
# EpornerApiScraper — dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
|
||||
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner — odrzucone), ALE eporner ma
|
||||
# EpornerApiScraper, dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
|
||||
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner, odrzucone), ALE eporner ma
|
||||
# publiczne JSON API (api/v2/video/search): 1 call = 100 filmów z title+length_sec+
|
||||
# keywords+added+thumb. ~100k filmów, deep-crawl przez crawl_page() (API, bez detail-fetch).
|
||||
EpornerApiScraper,
|
||||
# XVideosBrowseScraper — dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
|
||||
# XVideosBrowseScraper, dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
|
||||
# + page-parse /models/ (performerzy) + /tags/. Sample: median ~10.5min, 93% ≥3min.
|
||||
# Mega-katalog ~13M → deep_crawl._PAGE_CAP["xvideoscom"]=1800 (~50k najnowszych), nie
|
||||
# full-crawl. (youporn pominięty — JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
|
||||
# full-crawl. (youporn pominięty, JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
|
||||
XVideosBrowseScraper,
|
||||
# HQFapScraper — PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
|
||||
# HQFapScraper, PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
|
||||
# biblioteka CDN serwowała `/upload/videos/video_down.mp4` stub; PRZYWRÓCONY 2026-07-07
|
||||
# bo strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP), user request.
|
||||
# HQFapScraper — ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
|
||||
# HQFapScraper, ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
|
||||
# Cloudflare z JS-challenge na /watch/: 403 z VPS, z residential IP przy pełnych
|
||||
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy — czyli blokada nie jest
|
||||
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy, czyli blokada nie jest
|
||||
# po IP, tylko wymaga wykonania JS. WebView też nie przechodzi automatycznie (user
|
||||
# widzi challenge: zgłoszenia 60576a84 / 244131ba / 92e9133e). Telemetria: ZERO prób
|
||||
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS —
|
||||
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS,
|
||||
# nieproporcjonalne, decyzja: usunąć (jak 4k69 i pierwsze usunięcie hqfap 2026-06-25).
|
||||
# HQPornerBrowseScraper — dołączony 2026-07-26. hqporner był TYLKO w
|
||||
# HQPornerBrowseScraper, dołączony 2026-07-26. hqporner był TYLKO w
|
||||
# ALL_DIRECT_SCRAPERS (search po performerze), więc świeże sceny wchodziły wyłącznie
|
||||
# przy trafieniu na znanego performera → 0 nowych scen w 7 dni (user-report). Browse
|
||||
# (listing / + ?p=N) daje ingest niezależny od stanu performerów. external_id ten sam
|
||||
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
|
||||
HQPornerBrowseScraper,
|
||||
# FourK69Scraper — USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
|
||||
# JavflixScraper — JAV vertical (osobna sekcja). origin tube:javflix jest w
|
||||
# FourK69Scraper, USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
|
||||
# JavflixScraper, JAV vertical (osobna sekcja). origin tube:javflix jest w
|
||||
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
|
||||
JavflixScraper,
|
||||
# VjavScraper — JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
|
||||
# VjavScraper, JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
|
||||
# TXXX network: sitemap id-walk (newest=max id) + JSON metadata API. Stream
|
||||
# videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10.
|
||||
VjavScraper,
|
||||
# SupjavScraper — JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
|
||||
# SupjavScraper, JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
|
||||
# CF-blokuje VPS → browse homepage przez Bright Data proxy. Embed-aggregator:
|
||||
# data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10.
|
||||
SupjavScraper,
|
||||
# NepornScraper — dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
|
||||
# NepornScraper, dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
|
||||
# porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration
|
||||
# meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa
|
||||
# "- HardX Update - ..." — fuzzy match po tytule). Resolve server-side _kvs,
|
||||
# "- HardX Update - ...", fuzzy match po tytule). Resolve server-side _kvs,
|
||||
# finalny remote_control.php portable cross-IP.
|
||||
NepornScraper,
|
||||
# SuperpornScraper — dołączony 2026-06-10 (user request). superporn blokuje VPS IP
|
||||
# SuperpornScraper, dołączony 2026-06-10 (user request). superporn blokuje VPS IP
|
||||
# twardym CF 403 (każda impersonacja TLS), więc ingest HTML idzie przez Bright Data
|
||||
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy — `_proxy` w
|
||||
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy, `_proxy` w
|
||||
# _browse_base. JSON-LD (title+desc+uploadDate+thumb+duration) + chipy pornstar/
|
||||
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
|
||||
# Bez proxy: scraper no-op (pusty iterator).
|
||||
SuperpornScraper,
|
||||
# porntrex/hqporner/youporn — NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
|
||||
# ShyfapScraper — wyłączony 2026-05-12 (pilot fail, 0% match — orphan factory).
|
||||
# porntrex/hqporner/youporn, NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
|
||||
# ShyfapScraper, wyłączony 2026-05-12 (pilot fail, 0% match, orphan factory).
|
||||
]
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"""BaseBrowseScraper — latest-vids browse mode (vs search-by-performer).
|
||||
"""BaseBrowseScraper, latest-vids browse mode (vs search-by-performer).
|
||||
|
||||
Wzorzec: tube'y typu shyfap/freshporno/porn00/fullmovies/pornxp mają bogatą
|
||||
metadata (title, studio, performers, tags, duration, release_date, description)
|
||||
na detail page'u — wystarczy do canonical fuzzy match w resolverze. Browse mode
|
||||
na detail page'u, wystarczy do canonical fuzzy match w resolverze. Browse mode
|
||||
iteruje "latest" page (sorted by upload date) i fetchuje detail per scene.
|
||||
|
||||
Różnica vs `BaseSearchScraper`:
|
||||
- **search**: tube wyszukuje sceny po performer name (dla performer-driven
|
||||
backfill). Wymaga znanego performera.
|
||||
- **browse**: tube listuje newest scenes (latest-vids endpoint). Nie wymaga
|
||||
żadnego query — chodzi o świeże sceny independent of performer state.
|
||||
żadnego query, chodzi o świeże sceny independent of performer state.
|
||||
|
||||
Browse jest komplementarny do search:
|
||||
- search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube)
|
||||
|
|
@ -67,15 +67,15 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
|
|||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
||||
"""Parsuj scene detail HTML → RawScene z metadata.
|
||||
|
||||
Zwraca None gdy scena niedostępna / parse fail — caller pominie ten URL,
|
||||
Zwraca None gdy scena niedostępna / parse fail, caller pominie ten URL,
|
||||
nie aborti całe browse."""
|
||||
|
||||
def crawl_page(self, page: int) -> list[RawScene] | None:
|
||||
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
|
||||
(top-N) i deep_crawl (kursor). Zwraca:
|
||||
None — transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
|
||||
[] — pusty listing = koniec katalogu (caller: exhausted),
|
||||
[...] — sceny z tej strony.
|
||||
None , transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
|
||||
[] pusty listing = koniec katalogu (caller: exhausted),
|
||||
[...], sceny z tej strony.
|
||||
|
||||
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
|
||||
(call API zamiast listing→detail). HTML browse subclasses dostarczają
|
||||
|
|
@ -120,7 +120,7 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
|
|||
break
|
||||
yield from scenes
|
||||
|
||||
# Stub `search()` — BaseDirectTubeScraper wymaga implementacji. Dla browse-only
|
||||
# Stub `search()`, BaseDirectTubeScraper wymaga implementacji. Dla browse-only
|
||||
# tubes nie supportujemy performer-driven search; zwracamy pusty iterator. Tube'y
|
||||
# które chcą *oba* tryby mogą override'ować search() osobno.
|
||||
def search(
|
||||
|
|
@ -148,7 +148,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
|
|||
Format pasuje do `SceneFingerprint.value` w DB (TPDB/StashDB importują ten sam
|
||||
8x8 phash). Resolver Path 3 `find_by_phash_within` matchuje Hamming ≤5 (default).
|
||||
|
||||
Wymaga lazy importu `imagehash`/`PIL` — żeby moduł browse_base importował się
|
||||
Wymaga lazy importu `imagehash`/`PIL`, żeby moduł browse_base importował się
|
||||
nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver
|
||||
spadnie do composite scoring, jak gdyby fingerprintu nie było).
|
||||
"""
|
||||
|
|
@ -169,7 +169,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
|
|||
return None
|
||||
img = Image.open(io.BytesIO(r.content))
|
||||
# phash domyślnie hash_size=8 → 64-bit hash → 16 hex chars. Mode 'L' (greyscale)
|
||||
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame —
|
||||
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame,
|
||||
# convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey.
|
||||
return str(imagehash.phash(img.convert("RGB")))
|
||||
except Exception as e:
|
||||
|
|
@ -183,7 +183,7 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
|
|||
Standardowy helper dla scraperów które używają OpenGraph / ya:ovs / itp.
|
||||
Cache compiled regex w module-scope dict (te same selectory powtarzają się).
|
||||
|
||||
NB: separate patterns dla `"..."` i `'...'` content quote — wcześniej jeden
|
||||
NB: separate patterns dla `"..."` i `'...'` content quote, wcześniej jeden
|
||||
`[^"\']*` regex tnął title po wewnętrznym apostrofie (np. `<meta content="She's So Insatiable">`
|
||||
→ `She`, bug-report 2026-05-20). Teraz matchujemy dokładnie ten sam quote co opening.
|
||||
"""
|
||||
|
|
@ -191,8 +191,8 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
|
|||
if key not in _META_RE_CACHE:
|
||||
attr = "property" if property else "name"
|
||||
val = re.escape(property or name or "")
|
||||
# double-quoted content (HTML standard) — preferred
|
||||
# Pattern: <meta property="X" content="...inner..." > — inner allows apostrophes
|
||||
# double-quoted content (HTML standard), preferred
|
||||
# Pattern: <meta property="X" content="...inner..." > , inner allows apostrophes
|
||||
_META_RE_CACHE[key] = re.compile(
|
||||
rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"'
|
||||
rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""BasePlayTubeScraper — wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
|
||||
"""BasePlayTubeScraper, wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
|
||||
|
||||
Platforma rozpoznawalna po: `/watch/<slug>_<id>.html`, sitemap index
|
||||
`/sitemaps/videos/sitemap-N.xml` (z `<lastmod>`), JSON-LD VideoObject na detail
|
||||
|
|
@ -9,9 +9,9 @@ Listing NIE paginuje się GET-em (PlayTube doładowuje AJAX-em `aj/load-more/`),
|
|||
więc crawl_page buduje katalog z sitemapów (sort lastmod desc = newest first)
|
||||
i tnie na strony po `_PAGE_SIZE`. Działa dla browse_latest (pages 1-5) i
|
||||
deep_crawl (kursor do końca katalogu). Minus: sitemap laguje ~dobę za
|
||||
najświeższymi uploadami — akceptowalne przy dziennym harmonogramie.
|
||||
najświeższymi uploadami, akceptowalne przy dziennym harmonogramie.
|
||||
|
||||
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()` —
|
||||
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()`,
|
||||
PlayTube nie ma strukturalnego pola studio na scenie, studio siedzi w
|
||||
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ def _extract_video_object(html: str) -> dict | None:
|
|||
|
||||
|
||||
class BasePlayTubeScraper(BaseBrowseScraper):
|
||||
base_url: str # np. "https://hqfap.com" — subclass ustawia
|
||||
base_url: str # np. "https://hqfap.com", subclass ustawia
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
|
@ -123,9 +123,9 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
|||
def _pick_studio(self, category_names: list[str]) -> str | None:
|
||||
return None
|
||||
|
||||
# crawl_page override (jak EpornerApiScraper) — listing nie jest stronicowalny
|
||||
# crawl_page override (jak EpornerApiScraper), listing nie jest stronicowalny
|
||||
# przez GET, źródłem paginacji jest sitemap. _listing_url/_extract_scene_urls
|
||||
# nieużywane, ale abstrakcyjne — dostarczamy no-op implementacje.
|
||||
# nieużywane, ale abstrakcyjne, dostarczamy no-op implementacje.
|
||||
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
|
||||
return f"{self.base_url}/sitemap.xml"
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
|||
sm = browser_get(sm_url, timeout=self._timeout)
|
||||
sm.raise_for_status()
|
||||
except Exception as e:
|
||||
# Brak jednego sitemapa ≠ fail całości — reszta katalogu wystarczy.
|
||||
# Brak jednego sitemapa ≠ fail całości, reszta katalogu wystarczy.
|
||||
log.warning("%s: sitemap fetch failed %s: %s", self.sitetag, sm_url, e)
|
||||
continue
|
||||
for block in _URL_BLOCK_RE.findall(sm.text):
|
||||
|
|
@ -227,7 +227,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
|||
thumbnail_url = video.get("thumbnailUrl") or None
|
||||
|
||||
# Pille: pornstar → performer; category → studio (hook `_pick_studio`)
|
||||
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy — gwiazdki to nie dane.
|
||||
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy, gwiazdki to nie dane.
|
||||
performers: list[RawPerformer] = []
|
||||
category_names: list[str] = []
|
||||
seen_perf: set[str] = set()
|
||||
|
|
@ -275,7 +275,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
|||
if ph:
|
||||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||
|
||||
# Stream: JSON-LD contentUrl wygasa (token time-bound) — NIE zapisujemy
|
||||
# Stream: JSON-LD contentUrl wygasa (token time-bound), NIE zapisujemy
|
||||
# stream_url; extractor per-sitetag resolvuje świeży on-demand.
|
||||
playback_sources = [
|
||||
RawPlaybackSource(
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
"""BaseSearchScraper — shared search-page HTML scraping logika.
|
||||
"""BaseSearchScraper, shared search-page HTML scraping logika.
|
||||
|
||||
Wzorzec stosowany przez wszystkie tube'y discovery scrapers:
|
||||
1. Build search URL z `_search_url_template` (formatowane query+page).
|
||||
2. Fetch HTML curl_cffi.
|
||||
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
|
||||
opcjonalnie `slug` lub `id` jako tytuł source).
|
||||
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query) —
|
||||
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query),
|
||||
fuzzy search tube'ów często zwraca niezwiązane wyniki.
|
||||
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
|
||||
|
||||
Subclass override:
|
||||
- `sitetag: str` — np. "pornhubcom"
|
||||
- `_search_url_template: str` — z `{query}` i `{page}` placeholderami
|
||||
- `_scene_url_re: re.Pattern[str]` — regex z named group `url` (scene URL)
|
||||
- `_title_from_match(match) -> str` — opcjonalny override (default: derive z URL slug)
|
||||
- `_token_filter_text(match) -> str` — co testować na query tokens (default: cała URL)
|
||||
- `sitetag: str`, np. "pornhubcom"
|
||||
- `_search_url_template: str`, z `{query}` i `{page}` placeholderami
|
||||
- `_scene_url_re: re.Pattern[str]`, regex z named group `url` (scene URL)
|
||||
- `_title_from_match(match) -> str`, opcjonalny override (default: derive z URL slug)
|
||||
- `_token_filter_text(match) -> str`, co testować na query tokens (default: cała URL)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ from app.extractors import browser_get
|
|||
|
||||
# Image src extraction: matches src, data-src, data-original, data-lazy-src, data-lazy
|
||||
# (lazy-load lib variants). Wymaga rozszerzenia obrazka żeby ograniczyć false positives
|
||||
# (sprite icons, spinners) — JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
|
||||
# (sprite icons, spinners), JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
|
||||
# dla scene thumbnails.
|
||||
_IMG_SRC_RE = re.compile(
|
||||
r'<img[^>]+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']'
|
||||
|
|
@ -53,7 +53,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
|||
|
||||
#: Regex matchujący scene URL w search HTML. Wymagana grupa `url` (full scene URL),
|
||||
#: opcjonalna grupa `slug` (do title derivation gdy dostępny w URL).
|
||||
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder — subclass override
|
||||
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder, subclass override
|
||||
|
||||
#: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby
|
||||
#: nie matchowały niezwiązanych slugów).
|
||||
|
|
@ -74,12 +74,12 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
|||
})
|
||||
|
||||
#: Window (chars) wokół scene URL match, w którym szukamy `<img>` jako thumbnail.
|
||||
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>` —
|
||||
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>`,
|
||||
#: ±800 chars łapie ten pattern niezawodnie.
|
||||
_thumbnail_window: int = 800
|
||||
|
||||
def _scene_url_from_match(self, m: re.Match[str]) -> str:
|
||||
"""Domyślnie group(1) — subclass override gdy regex używa named groups inaczej."""
|
||||
"""Domyślnie group(1), subclass override gdy regex używa named groups inaczej."""
|
||||
try:
|
||||
return m.group("url")
|
||||
except IndexError:
|
||||
|
|
@ -103,17 +103,17 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
|||
|
||||
def _format_query_for_url(self, query: str) -> str:
|
||||
"""Default: URL-encode (spaces → `+`). Subclass override gdy tube wymaga
|
||||
innego formatu — np. KVS-style sites użyją slug (spaces → `-`).
|
||||
innego formatu, np. KVS-style sites użyją slug (spaces → `-`).
|
||||
"""
|
||||
return urllib.parse.quote_plus(query.strip())
|
||||
|
||||
def _fetch_scene_metadata(
|
||||
self, scene_url: str
|
||||
) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None:
|
||||
"""Optional hook — subclass może override żeby fetch'ować scene detail page
|
||||
"""Optional hook, subclass może override żeby fetch'ować scene detail page
|
||||
i wyciągnąć studio/performerów/tagi. Default zwraca None (skip detail fetch).
|
||||
|
||||
Wywoływane PER SCENE w `search()` — dodaje +1 HTTP request per match. Subclass
|
||||
Wywoływane PER SCENE w `search()`, dodaje +1 HTTP request per match. Subclass
|
||||
powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata.
|
||||
|
||||
Returns: (studio, performers, tags). Każde może być None / pusta lista.
|
||||
|
|
@ -153,7 +153,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
|||
if scene_url.startswith("//"):
|
||||
scene_url = "https:" + scene_url
|
||||
elif scene_url.startswith("/"):
|
||||
# Relative URL — prefix host z search URL.
|
||||
# Relative URL, prefix host z search URL.
|
||||
base = urllib.parse.urlparse(url)
|
||||
scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
|
||||
if scene_url in seen:
|
||||
|
|
@ -165,11 +165,11 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
|||
if slug_lower in self._nav_slug_blacklist:
|
||||
continue
|
||||
# Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()`
|
||||
# przepuszczał scenę gdy choć jeden token był w slug — dla performera
|
||||
# przepuszczał scenę gdy choć jeden token był w slug, dla performera
|
||||
# "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug
|
||||
# (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
|
||||
# bo `any("ava" in slug)` =True. User reports: scena "ava devine
|
||||
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()` — slug musi
|
||||
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()`, slug musi
|
||||
# zawierać każdy ≥3-char token z imienia performera.
|
||||
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
|
||||
continue
|
||||
|
|
@ -178,7 +178,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
|||
|
||||
# Thumbnail: search ±N chars around scene_url match for nearest <img src=>.
|
||||
# Większość tubes ma `<a href="<scene>"><img src="<thumb>"></a>` lub flat
|
||||
# `<img src=><a href=>` — window 800 obejmuje oba.
|
||||
# `<img src=><a href=>`, window 800 obejmuje oba.
|
||||
window_start = max(0, m.start() - self._thumbnail_window)
|
||||
window_end = min(len(r.text), m.end() + self._thumbnail_window)
|
||||
window_html = r.text[window_start:window_end]
|
||||
|
|
@ -193,7 +193,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
|||
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
|
||||
|
||||
# Opcjonalny metadata fetch (studio/dodatkowi performerzy/tagi). Default
|
||||
# zwraca None — większość tube'ów ma tylko search HTML bez metadata.
|
||||
# zwraca None, większość tube'ów ma tylko search HTML bez metadata.
|
||||
# PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach.
|
||||
studio: RawStudio | None = None
|
||||
extra_performers: list[RawPerformer] = []
|
||||
|
|
@ -212,7 +212,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
|||
description = meta[3]
|
||||
|
||||
# Performer z query zawsze obecny (driver scraping). Extra performers
|
||||
# z detail page dorzucamy — dedupe po slug/name w resolverze.
|
||||
# z detail page dorzucamy, dedupe po slug/name w resolverze.
|
||||
all_performers = [RawPerformer(name=query.strip()), *extra_performers]
|
||||
|
||||
yield RawScene(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""BaseDirectTubeScraper — kontrakt dla bezpośrednich scraperów tube'ów."""
|
||||
"""BaseDirectTubeScraper, kontrakt dla bezpośrednich scraperów tube'ów."""
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
|
|
@ -13,7 +13,7 @@ class BaseDirectTubeScraper(abc.ABC):
|
|||
żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
|
||||
|
||||
sitetag: str
|
||||
"""Stabilny ID tube'a — używany w external_id `f"{sitetag}:{url}"`. Zgodny
|
||||
"""Stabilny ID tube'a, używany w external_id `f"{sitetag}:{url}"`. Zgodny
|
||||
z porn-app sitetag (hqpornercom, sxylandcom, itp.)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""eporner.com — direct HTML scrape search results.
|
||||
"""eporner.com, direct HTML scrape search results.
|
||||
|
||||
Search: `https://www.eporner.com/search/<q>/<page>/` (1-indexed pages).
|
||||
Scene URL: `https://www.eporner.com/hd-porn/<id>/<slug>/` lub `/video-<id>/<slug>/`.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""eporner.com — deep-crawl przez oficjalne JSON API (api/v2/video/search).
|
||||
"""eporner.com, deep-crawl przez oficjalne JSON API (api/v2/video/search).
|
||||
|
||||
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów) — ALE eporner ma
|
||||
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów), ALE eporner ma
|
||||
publiczne API zwracające KOMPLETNĄ metadatę w jednym callu: `title`, `length_sec`
|
||||
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
|
||||
`order=latest`, ~100/stronę → ~1000 szybkich calli (BEZ detail-fetch). To czyni
|
||||
eporner idealnym SSR-bogatym źródłem deep-crawla (analiza 2026-06-03: porntrex/hqporner
|
||||
odrzucone — KVS bez SSR duration; eporner-API je zastępuje).
|
||||
odrzucone, KVS bez SSR duration; eporner-API je zastępuje).
|
||||
|
||||
Override `crawl_page()` (API flow). HTML-owe _listing_url/_extract/_parse to stuby
|
||||
(BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co
|
||||
|
|
@ -113,7 +113,7 @@ class EpornerApiScraper(BaseBrowseScraper):
|
|||
)
|
||||
return out
|
||||
|
||||
# HTML stuby — nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
|
||||
# HTML stuby, nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
|
||||
def _listing_url(self, page: int) -> str: # pragma: no cover
|
||||
raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""fpo.xxx — latest-vids browse scraper (KVS engine).
|
||||
"""fpo.xxx, latest-vids browse scraper (KVS engine).
|
||||
|
||||
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP —
|
||||
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP,
|
||||
search zwracał 0 (regex slug-URL nie pasował do `/video/<id>/`). Przerobione na
|
||||
BROWSE (latest z `/new-<n>/`), 2026-06-22 (user request: ożywić zamrożone tuby).
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ Listing tile (`/new-<n>/`):
|
|||
→ tytuł, miniatura, duration, URL sceny. Performerów/tagów listing nie ma czysto
|
||||
(tytuł bywa JAV-code "Imai Kaho-RKI-602 ..."), więc puste → dorabia canonical-merge.
|
||||
|
||||
Playback: KVS (kt_player + license_code na detail page) — token IP-bound, resolve
|
||||
Playback: KVS (kt_player + license_code na detail page), token IP-bound, resolve
|
||||
PO STRONIE TELEFONU (fpoxxxResolver.ts / WebView fallback, extractor `fpoxxx`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""freshporno.org — latest-vids browse scraper.
|
||||
"""freshporno.org, latest-vids browse scraper.
|
||||
|
||||
Pilot #2 (po shyfap fail). Hipoteza: freshporno zachowuje oryginalne studio titles
|
||||
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
|
||||
|
|
@ -9,7 +9,7 @@ URL patterns:
|
|||
- Scene: `/videos/<slug>/`
|
||||
|
||||
Listing: świadomie `/latest-updates/` zamiast roota `/`. Root jest KVS-owym
|
||||
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request — serwer
|
||||
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request, serwer
|
||||
rotuje tam blok "featured" i na zimnej sesji potrafi podać stary zestaw zamiast
|
||||
najnowszych (zaobserwowane 2026-06-15: 3 dni browse-latest skipowało wszystko bo
|
||||
root podawał sceny z 20 maja; freshporno.org report). `/latest-updates/` to jawny
|
||||
|
|
@ -108,7 +108,7 @@ class FreshpornoScraper(BaseBrowseScraper):
|
|||
# Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body
|
||||
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
|
||||
studio: RawStudio | None = None
|
||||
# Skipnij nav linki ze stringiem "Channels" jako anchor text — bierzemy specific channel
|
||||
# Skipnij nav linki ze stringiem "Channels" jako anchor text, bierzemy specific channel
|
||||
for m in _CHANNEL_LINK_RE.finditer(detail_html):
|
||||
slug, name = m.group(1), m.group(2).strip()
|
||||
if name.lower() in ("channels", ""):
|
||||
|
|
@ -120,7 +120,7 @@ class FreshpornoScraper(BaseBrowseScraper):
|
|||
)
|
||||
break
|
||||
|
||||
# Performers — wszyscy `/models/<slug>/`
|
||||
# Performers, wszyscy `/models/<slug>/`
|
||||
performers: list[RawPerformer] = []
|
||||
seen_perf: set[str] = set()
|
||||
for m in _MODEL_LINK_RE.finditer(detail_html):
|
||||
|
|
@ -154,7 +154,7 @@ class FreshpornoScraper(BaseBrowseScraper):
|
|||
)
|
||||
|
||||
# Phash z thumbnail. Wiemy że freshporno używa internal screenshots (preview.mp4.jpg)
|
||||
# więc to też może nie matchować canonical phashy — ale test pokaże.
|
||||
# więc to też może nie matchować canonical phashy, ale test pokaże.
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
if thumbnail_url:
|
||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||
|
|
@ -170,8 +170,8 @@ class FreshpornoScraper(BaseBrowseScraper):
|
|||
)
|
||||
]
|
||||
|
||||
# Release date — freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
|
||||
# To data wrzucenia na freshporno, NIE oryginalna release_date studio — ale dla
|
||||
# Release date, freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
|
||||
# To data wrzucenia na freshporno, NIE oryginalna release_date studio, ale dla
|
||||
# świeżych scen (uploaded niedługo po publikacji) różnica ≤ 3-7 dni, mieści się w
|
||||
# `date_window_days=7` w resolverze. Bez tego pola scene NULL → match score 0 →
|
||||
# duplicate scene zamiast freshporno PS dodane do TPDB canonical (bug-report
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""fullmovies.xxx — latest-vids browse scraper.
|
||||
"""fullmovies.xxx, latest-vids browse scraper.
|
||||
|
||||
Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`,
|
||||
`/networks/<slug>/`, `/models/<slug>/`, `/tags/<slug>/`. og:image to `img.fullmovies.xxx/...`
|
||||
— **prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
|
||||
**prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ class FullmoviesScraper(BaseBrowseScraper):
|
|||
|
||||
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
|
||||
# Sidebar listuje WSZYSTKIE sieci → `_NETWORK_LINK_RE.finditer().first()` zawsze
|
||||
# zwracał pierwszą z listy (Brazzers) dla każdej sceny — mis-attribution. Tytuł
|
||||
# zwracał pierwszą z listy (Brazzers) dla każdej sceny, mis-attribution. Tytuł
|
||||
# po oczyszczeniu ma format "Studio - Opis" (np. "Fake Hostel - ...").
|
||||
studio: RawStudio | None = None
|
||||
if " - " in title:
|
||||
|
|
@ -104,7 +104,7 @@ class FullmoviesScraper(BaseBrowseScraper):
|
|||
tags.append(RawTag(external_id=f"fullmoviesxxx:tag:{slug}", name=name, slug=slug))
|
||||
|
||||
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
|
||||
# canonical — auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
|
||||
# canonical, auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
|
||||
# thumbnaile). Matching trzyma się na title+performer+duration (seed: 92% tagged),
|
||||
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""fullporner.com — browse scraper. Dodany 2026-07-27.
|
||||
"""fullporner.com, browse scraper. Dodany 2026-07-27.
|
||||
|
||||
Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s
|
||||
na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu
|
||||
|
|
@ -11,17 +11,17 @@ Listing jest ściśle chronologiczny, mimo że nagłówek mówi „Featured Vide
|
|||
**Metadane są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
|
||||
mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu
|
||||
studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
|
||||
– Dylan Moore – Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
|
||||
- Dylan Moore - Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
|
||||
(dokładna, nie względna) i długość co do sekundy.
|
||||
|
||||
Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które
|
||||
już mamy, przez `composite_auto` — composite radzi sobie mimo złych tytułów, bo waży
|
||||
już mamy, przez `composite_auto`, composite radzi sobie mimo złych tytułów, bo waży
|
||||
performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po
|
||||
odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash
|
||||
miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać.
|
||||
|
||||
**Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23
|
||||
przypadków — bez performera composite nie ma na czym pracować, a studia tu nie ma
|
||||
przypadków, bez performera composite nie ma na czym pracować, a studia tu nie ma
|
||||
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
|
||||
tylko sceny z obsadą.
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ _THUMB = "https://imgs.xiaoshenke.net/thumb/{}.jpg"
|
|||
|
||||
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
|
||||
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
|
||||
# `<span class="create">1785081937</span></div><div>36:33</div>` — data i długość
|
||||
# `<span class="create">1785081937</span></div><div>36:33</div>`, data i długość
|
||||
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
|
||||
_INFO_RE = re.compile(
|
||||
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
|
||||
|
|
@ -64,7 +64,7 @@ _PERF_BLOCK_RE = re.compile(r"<b>\s*Pornstars?:\s*</b>(.*?)</p>", re.IGNORECASE
|
|||
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
|
||||
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
|
||||
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
|
||||
# `<iframe src="//xiaoshenke.net/video/4458723/14">` — id wspak, /14 = maska jakości.
|
||||
# `<iframe src="//xiaoshenke.net/video/4458723/14">`, id wspak, /14 = maska jakości.
|
||||
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
|
||||
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ class FullPornerScraper(BaseBrowseScraper):
|
|||
pb = _PERF_BLOCK_RE.search(detail_html)
|
||||
if pb:
|
||||
for name in _PERF_RE.findall(pb.group(1)):
|
||||
# Strona pisze performerów z małej („melissa straton") — do wyświetlania
|
||||
# Strona pisze performerów z małej („melissa straton"), do wyświetlania
|
||||
# podnosimy, dopasowanie i tak idzie po slugu.
|
||||
name = html_mod.unescape(name).strip().title()
|
||||
slug = slugify(name)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ.
|
||||
"""fullvideosporn.com (= sextu.com pod nową marką), browse scraper z BRAMKĄ NA OBSADĘ.
|
||||
|
||||
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
|
||||
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
|
||||
|
|
@ -6,15 +6,15 @@ pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
|
|||
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
|
||||
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
|
||||
Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
|
||||
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem — wtedy sygnał jest dobry: 88-89%
|
||||
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem, wtedy sygnał jest dobry: 88-89%
|
||||
nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
|
||||
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
|
||||
|
||||
Pozostałe pułapki, wszystkie obsłużone niżej:
|
||||
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** — te bywają
|
||||
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** te bywają
|
||||
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
|
||||
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
|
||||
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** — na całej stronie
|
||||
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** na całej stronie
|
||||
jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
|
||||
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
|
||||
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
|
||||
|
|
@ -65,7 +65,7 @@ _UNIT_DAYS = {
|
|||
|
||||
def _submitted_to_date(detail_html: str):
|
||||
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
|
||||
to jest data uploadu na tube (nie premiery studia) — traktujemy jak inne tuby."""
|
||||
to jest data uploadu na tube (nie premiery studia), traktujemy jak inne tuby."""
|
||||
m = _SUBMITTED_RE.search(detail_html)
|
||||
if not m:
|
||||
return None
|
||||
|
|
@ -127,7 +127,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
|
|||
return out
|
||||
|
||||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
||||
# Sekcje metadanych — każda w swoim <h3 class="item">.
|
||||
# Sekcje metadanych, każda w swoim <h3 class="item">.
|
||||
performers: list[RawPerformer] = []
|
||||
tags: list[RawTag] = []
|
||||
studio: RawStudio | None = None
|
||||
|
|
@ -162,7 +162,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
|
|||
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
|
||||
)
|
||||
|
||||
# BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to
|
||||
# BRAMKA: bez obsady nie ingestujemy (patrz docstring, 2/3 katalogu to
|
||||
# spunowane tytuły bez performera, które weszłyby jako puste orphany).
|
||||
if not performers:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
"""galaxyporn.net — browse scraper (WordPress retrotube). Dodany 2026-07-26.
|
||||
"""galaxyporn.net, browse scraper (WordPress retrotube). Dodany 2026-07-26.
|
||||
|
||||
Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings,
|
||||
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem — 92% performerów
|
||||
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem, 92% performerów
|
||||
z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny.
|
||||
|
||||
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
|
||||
|
||||
Metadane:
|
||||
- obsada: `<div id="video-actors">` — czysta, BEZ pollution (na całej stronie sceny
|
||||
- obsada: `<div id="video-actors">`, czysta, BEZ pollution (na całej stronie sceny
|
||||
są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami)
|
||||
- studio + data: prefiks tytułu `Studio YY MM DD Performer – Title` albo `[Studio]`
|
||||
- studio + data: prefiks tytułu `Studio YY MM DD Performer - Title` albo `[Studio]`
|
||||
- tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio)
|
||||
- thumbnail: `data-main-thumb` / og:image
|
||||
|
||||
**Duration — uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
|
||||
**Duration, uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
|
||||
payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje
|
||||
`duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby
|
||||
NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen).
|
||||
Dlatego liczymy ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na
|
||||
scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od
|
||||
realnej (2226 s vs ~2276 s na próbce, ~2%) — akceptowalne, bo alternatywą jest brak.
|
||||
realnej (2226 s vs ~2276 s na próbce, ~2%), akceptowalne, bo alternatywą jest brak.
|
||||
|
||||
Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro /
|
||||
sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący
|
||||
|
|
@ -58,7 +58,7 @@ _TAGS_BLOCK_RE = re.compile(r'<div class="tags-list">(.*?)</div>', re.IGNORECASE
|
|||
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
|
||||
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
|
||||
|
||||
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch – Swapping Off The Pervy Maid`
|
||||
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch - Swapping Off The Pervy Maid`
|
||||
_TITLE_STUDIO_DATE_RE = re.compile(r"^(?P<studio>[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P<y>\d{2})\s+(?P<m>\d{2})\s+(?P<d>\d{2})\s+(?P<rest>.+)$")
|
||||
# `[Onlyfans] Some Title` / `[Darkkotv] …`
|
||||
_TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$")
|
||||
|
|
@ -105,7 +105,7 @@ def _resolve_duration(iframe_url: str, *, timeout: float, attempts: int = 3) ->
|
|||
data = json.loads(_decrypt(r.text))
|
||||
thumb = (data.get("thumbnail") or "").strip()
|
||||
if not thumb:
|
||||
return None # payload OK, ale brak miniatur — retry nie pomoże
|
||||
return None # payload OK, ale brak miniatur, retry nie pomoże
|
||||
vtt_url = host + thumb if thumb.startswith("/") else thumb
|
||||
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
|
||||
if vr.status_code != 200:
|
||||
|
|
@ -145,7 +145,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
|
|||
if not title:
|
||||
return None
|
||||
|
||||
# Obsada — blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
|
||||
# Obsada, blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
|
||||
# ma dokładnie tyle linków /actor/ ile realnych aktorów).
|
||||
performers: list[RawPerformer] = []
|
||||
seen_p: set[str] = set()
|
||||
|
|
@ -186,7 +186,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
|
|||
slug=slugify(studio_name),
|
||||
)
|
||||
|
||||
# Tagi — bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
|
||||
# Tagi, bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
|
||||
tags: list[RawTag] = []
|
||||
seen_t: set[str] = set()
|
||||
studio_slug = slugify(studio_name) if studio_name else ""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""HDPorn92Scraper — direct HTML scrape hdporn92.com search.
|
||||
"""HDPorn92Scraper, direct HTML scrape hdporn92.com search.
|
||||
|
||||
Search: `https://hdporn92.com/page/<n>/?s=<query>`. Scene URL format:
|
||||
`https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""hdporn.gg — latest-vids browse scraper.
|
||||
"""hdporn.gg, latest-vids browse scraper.
|
||||
|
||||
Engine podobny do freshporno: `/videos/<slug>/` URL, `/networks/<slug>/` = studio,
|
||||
`/models/<slug>/` = performer, `/tags/<slug>/` = tag.
|
||||
|
||||
Quirk: og:image to internal CDN `img.hdporn.gg/...` — przed merging do prod
|
||||
Quirk: og:image to internal CDN `img.hdporn.gg/...`, przed merging do prod
|
||||
sprawdzamy phash distance (gate-keeper: jeśli Hamming >5 dla >70% scen → orphan
|
||||
factory, wyłącz; analogia do shyfap).
|
||||
"""
|
||||
|
|
@ -63,9 +63,9 @@ class HDPornGGScraper(BaseBrowseScraper):
|
|||
title = meta_content(detail_html, property="og:title")
|
||||
if not title:
|
||||
return None
|
||||
# og:title typowo zawiera ": Free HD Porn" suffix — usuń
|
||||
# og:title typowo zawiera ": Free HD Porn" suffix, usuń
|
||||
title = re.sub(r":\s*Free HD Porn\s*$", "", title, flags=re.IGNORECASE).strip()
|
||||
# I "Brazzers - " prefix często też w title — zostaw, bo studio name w title
|
||||
# I "Brazzers - " prefix często też w title, zostaw, bo studio name w title
|
||||
# to silny sygnał dla fuzzy match.
|
||||
|
||||
description = meta_content(detail_html, property="og:description")
|
||||
|
|
@ -112,7 +112,7 @@ class HDPornGGScraper(BaseBrowseScraper):
|
|||
)
|
||||
|
||||
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
|
||||
# canonical — auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
|
||||
# canonical, auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
|
||||
# Matching trzyma się na title+performer+duration (seed: 92% tagged), więc download
|
||||
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""hqfap.com — latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
|
||||
"""hqfap.com, latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
|
||||
|
||||
Dołączony 2026-06-10 (user request), usunięty 2026-06-25 (cała biblioteka CDN padła
|
||||
na stub), przywrócony 2026-07-07 (strona wróciła na CDN vstor.top z realnymi plikami,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""HQPornerScraper — direct HTML scrape hqporner search page.
|
||||
"""HQPornerScraper, direct HTML scrape hqporner search page.
|
||||
|
||||
Search URL: `https://hqporner.com/?q=<query>&p=<page>`. Static HTML zwraca ~50
|
||||
linków `/hdporn/<id>-<slug>.html` per strona. Tytuł deducimy ze slug'a (porn-app
|
||||
data API zwraca dokładniejszy ale wymaga round-trip — dla MVP slug-derived OK,
|
||||
data API zwraca dokładniejszy ale wymaga round-trip, dla MVP slug-derived OK,
|
||||
resolver i tak je sciagnie z TPDB merge).
|
||||
|
||||
Search fuzzy: hqporner zwraca "Lola Noir" gdy szukamy "Noir" itp. Dlatego
|
||||
filtrujemy wyniki po tym czy slug zawiera query (lub jego token) — analogicznie
|
||||
filtrujemy wyniki po tym czy slug zawiera query (lub jego token), analogicznie
|
||||
jak `fetch_scenes_for_search` w pornapp connectorze.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -55,7 +55,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
|
|||
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
|
||||
query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3}
|
||||
# Człon <3 znaki ("Lady Vi", "Mia Li", "Ai Uehara") wypada z `query_tokens`, więc
|
||||
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'" —
|
||||
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'",
|
||||
# czyli KAŻDA scena z tym słowem (audit 2026-07-28: Lady Vi miała 116 przypisań
|
||||
# z tube, z czego 115 tylko przez „lady", realnie o niej 1). Gdy nazwa ma taki
|
||||
# człon, wymagamy dodatkowo CAŁEJ nazwy sklejonej bez separatorów (slug
|
||||
|
|
@ -92,7 +92,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
|
|||
page_url=scene_url,
|
||||
)
|
||||
],
|
||||
# Wymuszamy hint performera = query — search per performer name znaczy
|
||||
# Wymuszamy hint performera = query, search per performer name znaczy
|
||||
# że scena prawie na pewno o nim. Resolver dorobi ScenePerformer link.
|
||||
performers=[RawPerformer(name=query.strip())],
|
||||
raw={
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""HQPornerBrowseScraper — latest-vids browse dla hqporner.com.
|
||||
"""HQPornerBrowseScraper, latest-vids browse dla hqporner.com.
|
||||
|
||||
Dlaczego: hqporner był TYLKO w `ALL_DIRECT_SCRAPERS` (search po nazwisku performera),
|
||||
więc nowe sceny wchodziły wyłącznie wtedy, gdy worker akurat szukał pasującego
|
||||
|
|
@ -17,7 +17,7 @@ Detail (`/hdporn/<id>-<slug>.html`) ma komplet metadanych:
|
|||
`external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`.
|
||||
Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami.
|
||||
|
||||
Miniaturki fastporndelivery wymagają Referera (bez niego 403) — backend i tak owija je
|
||||
Miniaturki fastporndelivery wymagają Referera (bez niego 403), backend i tak owija je
|
||||
w `/proxy/img/`, a phash liczymy z jawnym refererem.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -134,7 +134,7 @@ class HQPornerBrowseScraper(BaseBrowseScraper):
|
|||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||
|
||||
return RawScene(
|
||||
# Ten sam format co HQPornerScraper.search — inaczej duplikat sceny.
|
||||
# Ten sam format co HQPornerScraper.search, inaczej duplikat sceny.
|
||||
external_id=f"{self.sitetag}:{scene_url}",
|
||||
title=title,
|
||||
duration_sec=duration_sec,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""javflix.cc — JAV browse scraper (WordPress, English-subbed JAV).
|
||||
"""javflix.cc, JAV browse scraper (WordPress, English-subbed JAV).
|
||||
|
||||
Osobna pula JAV (kody typu BKD-368, tytuły azjatyckie), origin `tube:javflix`.
|
||||
NIE deduplikuje się z zachodnim katalogiem — świadomie orphan vertical (sekcja JAV).
|
||||
NIE deduplikuje się z zachodnim katalogiem, świadomie orphan vertical (sekcja JAV).
|
||||
|
||||
Struktura (RE 2026-07-10):
|
||||
- listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/)
|
||||
|
|
@ -76,7 +76,7 @@ class JavflixScraper(BaseBrowseScraper):
|
|||
up = _itemprop(detail_html, "uploadDate")
|
||||
release_date = _parse_iso_date(up) if up else None
|
||||
|
||||
# JAV code (BKD-368) ze sluga — kanoniczny identyfikator, dodajemy jako tag
|
||||
# JAV code (BKD-368) ze sluga, kanoniczny identyfikator, dodajemy jako tag
|
||||
# (searchable) bo javflix nie ma osobnego pola kodu.
|
||||
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
|
||||
cm = _CODE_RE.match(slug)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""8kporner.com — browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
|
||||
"""8kporner.com, browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
|
||||
|
||||
Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane
|
||||
pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny
|
||||
|
|
@ -9,8 +9,8 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
|
|||
1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` → 403 również
|
||||
PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc
|
||||
`_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos&
|
||||
page=latest` (20). To jednocześnie SUFIT — deep crawl jest niemożliwy.
|
||||
2. **Paginacja nie działa** — `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
|
||||
page=latest` (20). To jednocześnie SUFIT, deep crawl jest niemożliwy.
|
||||
2. **Paginacja nie działa** `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
|
||||
co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`).
|
||||
3. **Forma `/watch/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id=
|
||||
<id>` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny
|
||||
|
|
@ -21,22 +21,22 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
|
|||
strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie
|
||||
ma żadnego performera i weszłaby jako puste orphany.
|
||||
|
||||
Bramka się broni — z 117 performerów wciągniętych pilotem **113 (97%) ma ref
|
||||
Bramka się broni, z 117 performerów wciągniętych pilotem **113 (97%) ma ref
|
||||
tpdb/stashdb**, najwyżej ze wszystkich źródeł dodanych w tej rundzie. Pilotowy ingest:
|
||||
`seen 62, new 25, updated 37, errors 0` — czyli 60% scen przypięło się do czegoś, co
|
||||
`seen 62, new 25, updated 37, errors 0`, czyli 60% scen przypięło się do czegoś, co
|
||||
już mamy (to nie jest fabryka orphanów, tylko dodatkowe, lepsze jakościowo źródło).
|
||||
|
||||
**Studio.** Scena NIE linkuje studia osobno — na stronie sceny wszystko jest pillem
|
||||
**Studio.** Scena NIE linkuje studia osobno, na stronie sceny wszystko jest pillem
|
||||
`/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami.
|
||||
Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi studiami.
|
||||
Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy
|
||||
to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej
|
||||
powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio.
|
||||
Trafień jest mało (1 na 62 w pilocie) — większość scen po prostu nie ma pilla studia,
|
||||
Trafień jest mało (1 na 62 w pilocie), większość scen po prostu nie ma pilla studia,
|
||||
ale kosztuje to jeden fetch na run, więc zostaje.
|
||||
|
||||
Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
|
||||
canonical — dopasowanie musi opierać się na tytule, obsadzie i długości.
|
||||
canonical, dopasowanie musi opierać się na tytule, obsadzie i długości.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ _SCENE_HREF_RE = re.compile(
|
|||
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
|
||||
_STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE)
|
||||
|
||||
# Pozycje z `/studios`, które są normalnymi gatunkami — jako studio dałyby fałszywkę
|
||||
# Pozycje z `/studios`, które są normalnymi gatunkami, jako studio dałyby fałszywkę
|
||||
# na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki
|
||||
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
|
||||
_AMBIGUOUS_STUDIOS = {
|
||||
|
|
@ -119,7 +119,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
|||
|
||||
def _load_catalog(self) -> list[str] | None:
|
||||
"""Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz
|
||||
docstring), więc to jedyne dostępne źródło URL-i — i zarazem sufit katalogu."""
|
||||
docstring), więc to jedyne dostępne źródło URL-i, i zarazem sufit katalogu."""
|
||||
if self._catalog is not None:
|
||||
return self._catalog
|
||||
urls: list[str] = []
|
||||
|
|
@ -145,7 +145,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
|||
|
||||
def crawl_page(self, page: int) -> list[RawScene] | None:
|
||||
"""Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/`
|
||||
daje 403) i z pauzą — CF tnie przy szybszym tempie."""
|
||||
daje 403) i z pauzą, CF tnie przy szybszym tempie."""
|
||||
catalog = self._load_catalog()
|
||||
if catalog is None:
|
||||
return None
|
||||
|
|
@ -174,7 +174,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
|||
scene = super()._parse_detail(scene_url, detail_html)
|
||||
if scene is None:
|
||||
return None
|
||||
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą) — bez niej
|
||||
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą), bez niej
|
||||
# większość katalogu wpadłaby jako puste orphany.
|
||||
if not scene.performers:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""latestleaks.co — direct HTML scrape.
|
||||
"""latestleaks.co, direct HTML scrape.
|
||||
|
||||
Search: `https://latestleaks.co/page/<n>/?s=<q>`.
|
||||
Scene URL: `https://latestleaks.co/<slug>/`.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""latestpornvideo.com — latest-vids browse scraper.
|
||||
"""latestpornvideo.com, latest-vids browse scraper.
|
||||
|
||||
Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem
|
||||
(user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których
|
||||
|
|
@ -6,7 +6,7 @@ akurat szukamy → feed strony "latest" nigdy nie wpada, w apce widać stary zes
|
|||
a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z
|
||||
`/page/<n>/`, page 1 = `/`), 2026-06-22.
|
||||
|
||||
Listing card (zero detail-fetchy — detail page nie ma performerów ani duration):
|
||||
Listing card (zero detail-fetchy, detail page nie ma performerów ani duration):
|
||||
<article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ...">
|
||||
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
|
||||
data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg"
|
||||
|
|
@ -51,7 +51,7 @@ _CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")
|
|||
_THUMB_NAME_RE = re.compile(r"/([A-Za-z0-9][A-Za-z0-9-]*?)-(\d{4})-(\d{2})-(\d{2})-", re.IGNORECASE)
|
||||
# Tytuł: `<Studio> YY MM DD <rest>` (np. "MySexMobile 20 10 23 Abella Danger").
|
||||
_TITLE_DATE_RE = re.compile(r"^(.*?)\s*\b(\d{2})\s+(\d{2})\s+(\d{2})\b")
|
||||
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos" — to nie jest tag.
|
||||
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos", to nie jest tag.
|
||||
_CAT_SKIP = {"latest-porn-videos", "uncategorized", ""}
|
||||
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ class LatestPornVideoScraper(BaseBrowseScraper):
|
|||
)
|
||||
|
||||
# Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos".
|
||||
# `tag-*` POMIJAMY — to mieszanka fragmentów imion performerów i gatunków,
|
||||
# `tag-*` POMIJAMY, to mieszanka fragmentów imion performerów i gatunków,
|
||||
# bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum.
|
||||
tags: list[RawTag] = []
|
||||
seen_tag: set[str] = set()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""mypornerleak.com — performer-page listing scrape (search-based, performer-driven).
|
||||
"""mypornerleak.com, performer-page listing scrape (search-based, performer-driven).
|
||||
|
||||
2026-06-16 fix (zamrożony od 05-07): treść serwowana jest pod subdomeną
|
||||
`w8.mypornerleak.com` (load-balancer wN.), a stary regex szukał scen na gołym
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
listingu i sceny, więc fetchujemy `mypornerleak.com/actor/<slug>/` a linki scen
|
||||
normalizujemy wN.→ kanoniczny (stabilny page_url/dedup).
|
||||
|
||||
Wszystko z LISTINGU (bez detail-fetcha) — karta `<article>` koduje metadane:
|
||||
Wszystko z LISTINGU (bez detail-fetcha), karta `<article>` koduje metadane:
|
||||
- klasa `actors-<slug>` (multi) → performerzy; `category-<slug>` (multi) → tagi
|
||||
- `<a href title="...">` → URL sceny + czysty tytuł
|
||||
- `<span class="duration">MM:SS|HH:MM:SS`
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""mypornerleak.com — latest BROWSE scraper via WordPress REST API, obok search scrapera.
|
||||
"""mypornerleak.com, latest BROWSE scraper via WordPress REST API, obok search scrapera.
|
||||
|
||||
MyPornerLeakScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada
|
||||
świeżość wprost z WP REST (`/wp-json/wp/v2/posts?_embed=1`). W odróżnieniu od
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
"""neporn.com — latest-vids browse scraper (KVS engine).
|
||||
"""neporn.com, latest-vids browse scraper (KVS engine).
|
||||
|
||||
Dołączony 2026-06-10 (user request). Paysite re-upload (HardX, DAP itp.),
|
||||
~40k+ scen po numeracji video id.
|
||||
|
||||
Sygnały per scena:
|
||||
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl
|
||||
(uwaga: bywa zmanglowany "https:https://..." — normalizujemy)
|
||||
(uwaga: bywa zmanglowany "https:https://...", normalizujemy)
|
||||
- `<meta property="video:duration">` = sekundy wprost
|
||||
- Performerzy: `<a class="link" href="https://neporn.com/models/<slug>/">`
|
||||
(nazwa w spanie z ikoną — bierzemy slug i tytułujemy). Świeże uploady NIE mają
|
||||
jeszcze linków modelek — fallback: flashvars `video_tags` (na świeżych scenach
|
||||
(nazwa w spanie z ikoną, bierzemy slug i tytułujemy). Świeże uploady NIE mają
|
||||
jeszcze linków modelek, fallback: flashvars `video_tags` (na świeżych scenach
|
||||
to czyste nazwiska, np. "emily willis, gianna dior"); bierzemy wpisy 2-3 słowa
|
||||
obecne w tytule i bez generycznych fraz (stoplist), bo starsze sceny mają tam
|
||||
też szum ("deep throat", "natural tits", "hd porn").
|
||||
- Kategorie: linki `/categories/<slug>/` w bloku info (tagi z `/tags/` to szum
|
||||
typu "hd porn", "2020" — pomijamy)
|
||||
typu "hd porn", "2020", pomijamy)
|
||||
|
||||
Studio: brak strukturalnego pola — tytuł bywa "- HardX Update - ..." ale format
|
||||
Studio: brak strukturalnego pola, tytuł bywa "- HardX Update - ..." ale format
|
||||
niespójny, zostawiamy fuzzy matchowi po tytule.
|
||||
|
||||
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
|
||||
|
|
@ -111,7 +111,7 @@ class NepornScraper(BaseBrowseScraper):
|
|||
description = (video.get("description") or "").strip() or None
|
||||
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
|
||||
if thumbnail_url:
|
||||
# JSON-LD emituje "https:https://cdn..." — utnij zdublowany scheme.
|
||||
# JSON-LD emituje "https:https://cdn...", utnij zdublowany scheme.
|
||||
thumbnail_url = re.sub(r"^https?:(?=https?://)", "", thumbnail_url)
|
||||
|
||||
performers: list[RawPerformer] = []
|
||||
|
|
@ -128,7 +128,7 @@ class NepornScraper(BaseBrowseScraper):
|
|||
)
|
||||
)
|
||||
# Fallback dla świeżych uploadów bez linków modelek: nazwiska z flashvars
|
||||
# video_tags — 2-3 słowa, obecne w tytule, bez generycznych fraz.
|
||||
# video_tags, 2-3 słowa, obecne w tytule, bez generycznych fraz.
|
||||
title_cf = title.casefold()
|
||||
tags_m = _VIDEO_TAGS_RE.search(detail_html)
|
||||
for entry in (tags_m.group(1).split(",") if tags_m else []):
|
||||
|
|
@ -160,7 +160,7 @@ class NepornScraper(BaseBrowseScraper):
|
|||
seen_tag.add(slug)
|
||||
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
|
||||
|
||||
# Phash z KVS screenshotów — niski hit-rate oczekiwany (jak porn00),
|
||||
# Phash z KVS screenshotów, niski hit-rate oczekiwany (jak porn00),
|
||||
# graceful miss → composite scoring (performer+title+duration).
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
if thumbnail_url:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""perverzija.com — latest browse scraper via WordPress REST API.
|
||||
"""perverzija.com, latest browse scraper via WordPress REST API.
|
||||
|
||||
Historia: dawniej search scraper (`?s=`), ale 2026-06 perverzija rate-limituje search
|
||||
(429) a homepage jest JS-renderowane (brak linków postów w surowym HTML) → search
|
||||
|
|
@ -8,7 +8,7 @@ na stronę. VPS dociera (curl_cffi bypassuje JA3; 200 nie 403). Przerobione na b
|
|||
2026-06-22 (user request).
|
||||
|
||||
Z REST `?_embed=1` bierzemy: tytuł, datę, miniaturę (featured_media), STUDIO
|
||||
(taksonomia `category` — np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
|
||||
(taksonomia `category`, np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
|
||||
(`post_tag`). Performerów REST nie wystawia (custom taksonomia `stars` bez show_in_rest)
|
||||
→ puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł
|
||||
i tak ma nazwiska).
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
"""porn00.org — latest-vids browse scraper.
|
||||
"""porn00.org, latest-vids browse scraper.
|
||||
|
||||
URL patterns:
|
||||
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
|
||||
- Scene: `/video/<slug>/`
|
||||
- Performer: `/<slug>/` (np. `/august-skye/`) — w sekcji "Pornstars:" na detail
|
||||
- Performer: `/<slug>/` (np. `/august-skye/`), w sekcji "Pornstars:" na detail
|
||||
- Categories: `/category-name/<slug>/`
|
||||
|
||||
Sygnały dostępne:
|
||||
- Title (listing card + h1 + og:title)
|
||||
- Performer(s) (z sekcji "Pornstars:" na detail page — pojedynczy slug per link)
|
||||
- Categories (z sekcji "Categories:" — `/category-name/<slug>/`)
|
||||
- Performer(s) (z sekcji "Pornstars:" na detail page, pojedynczy slug per link)
|
||||
- Categories (z sekcji "Categories:", `/category-name/<slug>/`)
|
||||
- Duration (listing card `<div class="duration">MM:SS</div>`)
|
||||
- Direct mp4 (KVS engine — `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
|
||||
- Direct mp4 (KVS engine, `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
|
||||
- Thumbnail (own CDN `/contents/videos_screenshots/.../1.jpg`)
|
||||
|
||||
BRAK:
|
||||
|
|
@ -78,7 +78,7 @@ _CATEGORY_LINK_RE = re.compile(
|
|||
)
|
||||
|
||||
# Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`.
|
||||
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp. — bierzemy
|
||||
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp., bierzemy
|
||||
# wszystko do najbliższego `'` lub `"`.
|
||||
_VIDEO_URL_RE = re.compile(
|
||||
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
|
||||
|
|
@ -107,7 +107,7 @@ class Porn00Scraper(BaseBrowseScraper):
|
|||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Cache listing card meta — duration + thumb + title. Detail page nie ma
|
||||
# Cache listing card meta, duration + thumb + title. Detail page nie ma
|
||||
# tych pól w meta (brak og:duration), więc listing jest source of truth.
|
||||
self._listing_cache: dict[str, dict] = {}
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ class Porn00Scraper(BaseBrowseScraper):
|
|||
# Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180.
|
||||
thumb = meta_content(detail_html, property="og:image") or meta.get("thumb")
|
||||
|
||||
# Performers — porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
|
||||
# Performers, porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
|
||||
# `/category-name/`). Wszystkie linki tego pattern to performerzy.
|
||||
performers: list[RawPerformer] = []
|
||||
seen_perf: set[str] = set()
|
||||
|
|
@ -177,14 +177,14 @@ class Porn00Scraper(BaseBrowseScraper):
|
|||
)
|
||||
)
|
||||
|
||||
# Direct mp4 z KVS flashvars — preferujemy 720p (video_alt_url) nad 360p (video_url).
|
||||
# Direct mp4 z KVS flashvars, preferujemy 720p (video_alt_url) nad 360p (video_url).
|
||||
stream_url: str | None = None
|
||||
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
|
||||
stream_url = vm.group(1)
|
||||
elif (vm := _VIDEO_URL_RE.search(detail_html)):
|
||||
stream_url = vm.group(1)
|
||||
|
||||
# Phash — porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
|
||||
# Phash, porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
|
||||
# więc canonical phash match raczej fail. Próbujemy mimo to.
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
if thumb:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""porn4days.pw — direct HTML scrape.
|
||||
"""porn4days.pw, direct HTML scrape.
|
||||
|
||||
Search: `https://porn4days.pw/page/<n>/?s=<q>`.
|
||||
Scene URL: `https://porn4days.pw/<slug>/`.
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
"""pornbusy.com — browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
|
||||
"""pornbusy.com, browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
|
||||
|
||||
Ripy studyjne (~60%) + JAV z kodami (~30%). Ocena orphan-risk LOW z pomiarem:
|
||||
80% scen z próbki 100 ma performera, który u nas MA już ref tpdb/stashdb, a 21%
|
||||
tytułów mocno matchuje sceny które już mamy (czyli się scalą, nie zorphanują).
|
||||
|
||||
**Paginacja homepage jest ZEPSUTA** — `/page/2/` i `/page/3/` zwracają ten sam
|
||||
**Paginacja homepage jest ZEPSUTA** `/page/2/` i `/page/3/` zwracają ten sam
|
||||
zestaw co strona 1 (zweryfikowane). Dlatego listing bierzemy z SITEMAPY:
|
||||
`sitemap_index.xml` → 10× `post-sitemapN.xml` po ~500 URL-i, każdy z `<lastmod>`,
|
||||
posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE`
|
||||
(wzorzec jak `_playtube.BasePlayTubeScraper`).
|
||||
|
||||
Metadane — każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
|
||||
Metadane, każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
|
||||
tytułu-slug:
|
||||
- title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description
|
||||
- obsada: `<div id="video-actors">` — CZYSTA, bez pollution (na stronie sceny jest
|
||||
- obsada: `<div id="video-actors">`, CZYSTA, bez pollution (na stronie sceny jest
|
||||
dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related)
|
||||
- kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`)
|
||||
- studio: **brak** — pornbusy nie ma pola studia ani prefiksu w tytule
|
||||
- studio: **brak** pornbusy nie ma pola studia ani prefiksu w tytule
|
||||
|
||||
Playback: `itemprop="embedURL"` → extractor `pornbusycom` (loadvid / seekplayer /
|
||||
zpi.cx ≈ 69% katalogu).
|
||||
|
|
@ -96,7 +96,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
|||
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
|
||||
self._catalog: list[str] | None = None
|
||||
|
||||
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta) —
|
||||
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta),
|
||||
# paginację robi sitemap w `crawl_page`. Te dwie metody są abstrakcyjne w bazie.
|
||||
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
|
||||
return _SITEMAP_INDEX
|
||||
|
|
@ -131,7 +131,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
|||
if not loc:
|
||||
continue
|
||||
url = loc.group(1)
|
||||
# Pomijamy strony taksonomii/nav — sceny to `/<slug>/` jednosegmentowe.
|
||||
# Pomijamy strony taksonomii/nav, sceny to `/<slug>/` jednosegmentowe.
|
||||
if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")):
|
||||
continue
|
||||
lm = _LASTMOD_RE.search(block)
|
||||
|
|
@ -189,7 +189,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
|||
dsm = _DESC_RE.search(detail_html)
|
||||
description = html.unescape(dsm.group(1)).strip() if dsm else None
|
||||
if description and description.strip().lower() == title.strip().lower():
|
||||
description = None # opis bywa kopią tytułu — nie duplikujemy
|
||||
description = None # opis bywa kopią tytułu, nie duplikujemy
|
||||
|
||||
performers: list[RawPerformer] = []
|
||||
seen_p: set[str] = set()
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"""porndish.com — latest browse scraper via WordPress REST API.
|
||||
"""porndish.com, latest browse scraper via WordPress REST API.
|
||||
|
||||
Historia: dawniej search scraper (`?s=`), zamarzł 2026-05-07 (search przestał dawać
|
||||
nowe sceny — 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
|
||||
nowe sceny, 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
|
||||
dociera, więc czysty kanał to REST API: `/wp-json/wp/v2/posts?_embed=1` daje
|
||||
ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24
|
||||
(ten sam wzorzec co perverzija).
|
||||
|
||||
Z REST `_embed`: tytuł, data, miniatura (featured_media), STUDIO (taksonomia
|
||||
`category` — np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag` — porndish
|
||||
`category`, np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag`, porndish
|
||||
miesza w nich performerów z gatunkami, bierzemy jak jest; canonical-merge i tak
|
||||
dorabia performerów z TPDB/StashDB, a tytuł ma nazwiska). Performerów osobno nie
|
||||
wyciągamy (post_tag ich nie rozdziela od gatunków bez listy known-performers).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""porndoe.com — latest-vids browse scraper.
|
||||
"""porndoe.com, latest-vids browse scraper.
|
||||
|
||||
Dołączony 2026-05-21 (theporndude audit). Jedyny verified high-value candidate
|
||||
z 172 tube'ów na theporndude.com/top-porn-tube-sites + /full-porn-movies-sites.
|
||||
|
|
@ -11,7 +11,7 @@ Czemu wart: każda scena ma kompletny **JSON-LD VideoObject** schema:
|
|||
|
||||
To wystarczy do composite fuzzy match w resolverze (studio + performer Jaccard +
|
||||
date proximity + title token-set + duration). Phash hit-rate niski (porndoe robi
|
||||
własne crop-thumbnaile 390x219, nie hot-linkuje studio art) — ale rich metadata
|
||||
własne crop-thumbnaile 390x219, nie hot-linkuje studio art), ale rich metadata
|
||||
nadrabia, jak pornxp/porn00.
|
||||
|
||||
URL patterns:
|
||||
|
|
@ -21,7 +21,7 @@ URL patterns:
|
|||
- Performer: `/pornstars-profile/<slug>`
|
||||
- Tags/categories: `/categories/<slug>`
|
||||
|
||||
Playback: stream URL NIE jest inline w SSR HTML — player JS init dopiero po user
|
||||
Playback: stream URL NIE jest inline w SSR HTML, player JS init dopiero po user
|
||||
"Play" click. Dajemy playback_source z page_url + origin `tube:porndoecom`;
|
||||
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
|
||||
INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z
|
||||
|
|
@ -51,7 +51,7 @@ log = logging.getLogger(__name__)
|
|||
|
||||
_BASE = "https://porndoe.com"
|
||||
|
||||
# Scene listing — `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
|
||||
# Scene listing, `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
|
||||
_SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE)
|
||||
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ _TAG_LINK_RE = re.compile(
|
|||
r'href="/category/\d+/([a-z0-9\-]+)"[^>]*>([^<]+)</a>', re.IGNORECASE
|
||||
)
|
||||
|
||||
# ISO 8601 duration — porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
|
||||
# ISO 8601 duration, porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
|
||||
_ISO_DUR_RE = re.compile(
|
||||
r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE
|
||||
)
|
||||
|
|
@ -237,7 +237,7 @@ class PornDoeScraper(BaseBrowseScraper):
|
|||
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
||||
)
|
||||
|
||||
# Phash z thumbnail (porndoe robi własne crop-thumbnaile — niski hit-rate
|
||||
# Phash z thumbnail (porndoe robi własne crop-thumbnaile, niski hit-rate
|
||||
# oczekiwany, ale graceful: brak match → resolver spada do composite scoring).
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
if thumbnail_url:
|
||||
|
|
@ -245,7 +245,7 @@ class PornDoeScraper(BaseBrowseScraper):
|
|||
if ph:
|
||||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||
|
||||
# Playback — page_url do scena strony. Stream JS-rendered, więc extractor
|
||||
# Playback, page_url do scena strony. Stream JS-rendered, więc extractor
|
||||
# `porndoecom` → `_vps_blocked_fallback.extract` (mobile WebView scrape).
|
||||
playback_sources = [
|
||||
RawPlaybackSource(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""pornmike.com — browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
|
||||
"""pornmike.com, browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
|
||||
|
||||
Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia
|
||||
u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan).
|
||||
|
|
@ -6,7 +6,7 @@ Pomiar: 82% performerów ma u nas ref tpdb/stashdb, 19-20/20 kanałów znamy jak
|
|||
|
||||
Metadane w CAŁOŚCI z JSON-LD `@graph` → `ItemPage.mainEntity` (VideoObject): name,
|
||||
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
|
||||
`og:`, zero itemprop — więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
|
||||
`og:`, zero itemprop, więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
|
||||
jest przepisany pod SEO, patrz niżej).
|
||||
|
||||
**Świadome decyzje:**
|
||||
|
|
@ -21,7 +21,7 @@ jest przepisany pod SEO, patrz niżej).
|
|||
zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości.
|
||||
- Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2`
|
||||
jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok
|
||||
sidebara (stąd 62 linki, realnie 52 sceny) — dedup po external_id go pochłania.
|
||||
sidebara (stąd 62 linki, realnie 52 sceny), dedup po external_id go pochłania.
|
||||
- Ingest tylko EN (`/videos/`), pomijamy niemiecki mirror (`/de/filme/`).
|
||||
|
||||
**Uwaga jakościowa:** to KLIPY 5-12 min (mediana ~487 s) wobec 1800-2400 s w tubach,
|
||||
|
|
@ -29,7 +29,7 @@ które przyjęliśmy. Dla ~20% katalogu (Tushy/Blacked Raw/Milfy/Anilos) będą
|
|||
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
|
||||
czyli realnie nowa podaż.
|
||||
|
||||
Playback: `<source>` direct mp4 (twincdn) — extractor `pornmike`, bez tokenu, bez
|
||||
Playback: `<source>` direct mp4 (twincdn), extractor `pornmike`, bez tokenu, bez
|
||||
Referera, przenośny cross-IP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""PornTrex.com — direct HTML scrape search results.
|
||||
"""PornTrex.com, direct HTML scrape search results.
|
||||
|
||||
Search: `https://www.porntrex.com/search/<q>/` (single page, brak ?page=).
|
||||
Scene URL: `https://www.porntrex.com/video/<id>/<slug>/`
|
||||
|
||||
Porntrex pagination niespójne między widokami — używamy `?from=<offset>` gdy page>1.
|
||||
Porntrex pagination niespójne między widokami, używamy `?from=<offset>` gdy page>1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""porntrex.com — latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
|
||||
"""porntrex.com, latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
|
||||
|
||||
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS — daje
|
||||
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS, daje
|
||||
pokrycie back-catalogu performerów. Ten browse dokłada gwarancję świeżości wprost
|
||||
z feedu `/latest-updates/<n>/` (próg watchdog 48h zamiast 168h, nie zależy od kolejki
|
||||
performerów). Wzorzec jak xvideos (search + browse równolegle).
|
||||
|
|
@ -9,7 +9,7 @@ KVS listing tile:
|
|||
<div ... data-item-id="<id>"><a href="https://www.porntrex.com/video/<id>/<slug>">
|
||||
<img data-src="//ptx.cdntrex.com/contents/.../300x168/1.jpg" alt="<Tytuł>">
|
||||
<div class="duration">MM:SS</div>
|
||||
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable) — bez zmian.
|
||||
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable), bez zmian.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""pornxp.ph — latest-vids browse scraper.
|
||||
"""pornxp.ph, latest-vids browse scraper.
|
||||
|
||||
URL patterns:
|
||||
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
|
||||
URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs
|
||||
`/videos/94528971837`) — **`data-id` (np. `94528971`) jest stable** i tego
|
||||
`/videos/94528971837`), **`data-id` (np. `94528971`) jest stable** i tego
|
||||
używamy dla external_id zamiast całego URL.
|
||||
- Detail: `/videos/<id_with_suffix>`.
|
||||
- Tags: `/tags/<URL-encoded-name>`. Trzy kategorie wnioskowane heurystyką
|
||||
|
|
@ -15,10 +15,10 @@ Rich signals (perfekt dla canonical match scoring):
|
|||
- Performers (z tags w `<div class="tags">`, Capital + space + Capital)
|
||||
- Release year (regex `Released:` na detail page bodyText)
|
||||
- Duration (`<div class="item_dur">MM:SS</div>` listing card)
|
||||
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`) — no hoster
|
||||
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`), no hoster
|
||||
- Animated preview (`data-preview="//t.porn-xp.com/.../<id>.mp4"`)
|
||||
|
||||
Thumbnail: `<img class="item_img" src="/<id>.jpg">` — relatywny, pornxp's own CDN.
|
||||
Thumbnail: `<img class="item_img" src="/<id>.jpg">`, relatywny, pornxp's own CDN.
|
||||
Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -45,10 +45,10 @@ log = logging.getLogger(__name__)
|
|||
|
||||
_BASE = "https://pornxp.ph"
|
||||
|
||||
# Listing card — DOTALL bo HTML cards są wieloliniowe.
|
||||
# Listing card, DOTALL bo HTML cards są wieloliniowe.
|
||||
# Wariant 1 (eager): `<img class="item_img" src="/<id>.jpg">`
|
||||
# Wariant 2 (lazy): `<img class="item_img lazy" src="/images/fluid_spinner.svg" data-src="/<id>.jpg">`
|
||||
# Łapiemy obie warianty — w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
|
||||
# Łapiemy obie warianty, w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
|
||||
_LISTING_CARD_RE = re.compile(
|
||||
r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
|
||||
r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>'
|
||||
|
|
@ -61,7 +61,7 @@ _LISTING_CARD_RE = re.compile(
|
|||
_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
|
||||
_IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', re.IGNORECASE)
|
||||
|
||||
# Detail page — tags wrapper. Sometimes <div class="tags">, sometimes inline.
|
||||
# Detail page, tags wrapper. Sometimes <div class="tags">, sometimes inline.
|
||||
# Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie.
|
||||
_DETAIL_TAGS_BLOCK_RE = re.compile(
|
||||
r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL,
|
||||
|
|
@ -71,8 +71,8 @@ _TAG_LINK_RE = re.compile(
|
|||
)
|
||||
_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE)
|
||||
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
|
||||
# Direct mp4/m3u8 sources — preferujemy 720 nad 360. Format często protocol-relative:
|
||||
# `<source src="//sv.porn-xp.com/.../720.mp4">` — normalize do `https://...` w consumerze.
|
||||
# Direct mp4/m3u8 sources, preferujemy 720 nad 360. Format często protocol-relative:
|
||||
# `<source src="//sv.porn-xp.com/.../720.mp4">`, normalize do `https://...` w consumerze.
|
||||
_SOURCE_RE = re.compile(
|
||||
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
|
||||
re.IGNORECASE,
|
||||
|
|
@ -99,11 +99,11 @@ def _classify_tag(name: str) -> str:
|
|||
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
|
||||
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
|
||||
- Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`)
|
||||
- Tag/category: pozostałe — lowercase single word LUB Cap single word
|
||||
- Tag/category: pozostałe, lowercase single word LUB Cap single word
|
||||
(`oral`, `Lesbians`, `Incest`, `BBC`)
|
||||
|
||||
Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag.
|
||||
To akceptowalne — composite score scoring tags ma niższą wagę niż studio match,
|
||||
To akceptowalne, composite score scoring tags ma niższą wagę niż studio match,
|
||||
więc fallback z 1+ performer match wystarczy.
|
||||
"""
|
||||
name = name.strip()
|
||||
|
|
@ -136,7 +136,7 @@ class PornXPScraper(BaseBrowseScraper):
|
|||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Cache listing card metadata per scene URL — populated w `_extract_scene_urls`,
|
||||
# Cache listing card metadata per scene URL, populated w `_extract_scene_urls`,
|
||||
# consumed w `_parse_detail`. Detail page sam nie ma `<div class="item_dur">`
|
||||
# ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde
|
||||
# _extract_scene_urls override'uje).
|
||||
|
|
@ -188,12 +188,12 @@ class PornXPScraper(BaseBrowseScraper):
|
|||
return out
|
||||
|
||||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
||||
# Listing-card meta (preferowane — detail page nie ma duration/thumb)
|
||||
# Listing-card meta (preferowane, detail page nie ma duration/thumb)
|
||||
meta = self._listing_cache.get(scene_url, {})
|
||||
data_id = meta.get("data_id")
|
||||
if not data_id:
|
||||
# URL nie pasuje do listingu (random suffix mismatch po pagination redo).
|
||||
# Wyciągnij data-id z URL: /videos/<id>... — pierwsze 8-10 cyfr.
|
||||
# Wyciągnij data-id z URL: /videos/<id>..., pierwsze 8-10 cyfr.
|
||||
id_match = re.search(r"/videos/(\d{6,12})", scene_url)
|
||||
data_id = id_match.group(1) if id_match else None
|
||||
|
||||
|
|
@ -207,8 +207,8 @@ class PornXPScraper(BaseBrowseScraper):
|
|||
duration_sec = meta.get("duration_sec")
|
||||
thumb = meta.get("thumb")
|
||||
|
||||
# Release year — `Released: 2016`. RawScene ma `release_date` (typu `date`),
|
||||
# nie samo year — wpisujemy Jan 1 jako placeholder żeby resolver miał year
|
||||
# Release year, `Released: 2016`. RawScene ma `release_date` (typu `date`),
|
||||
# nie samo year, wpisujemy Jan 1 jako placeholder żeby resolver miał year
|
||||
# signal (date proximity scoring tylko sprawdza year w composite).
|
||||
release_date: date | None = None
|
||||
if (m := _RELEASED_RE.search(detail_html)):
|
||||
|
|
@ -251,7 +251,7 @@ class PornXPScraper(BaseBrowseScraper):
|
|||
tags.append(RawTag(external_id=ext_id, name=display, slug=slug))
|
||||
|
||||
# Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`.
|
||||
# URL-e są protocol-relative — normalize do `https:`. Preferujemy 720 nad 360.
|
||||
# URL-e są protocol-relative, normalize do `https:`. Preferujemy 720 nad 360.
|
||||
def _norm(u: str) -> str:
|
||||
return "https:" + u if u.startswith("//") else u
|
||||
|
||||
|
|
@ -264,7 +264,7 @@ class PornXPScraper(BaseBrowseScraper):
|
|||
break
|
||||
stream_url = stream_url or all_sources[0]
|
||||
|
||||
# Phash z thumbnail (pornxp własny CDN — expected niski match rate, ale
|
||||
# Phash z thumbnail (pornxp własny CDN, expected niski match rate, ale
|
||||
# try). Reseter ścieżek do canonical odbędzie się głównie przez
|
||||
# studio+performer+year+title scoring.
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
|
|
@ -275,7 +275,7 @@ class PornXPScraper(BaseBrowseScraper):
|
|||
|
||||
# Normalize page_url: pornxp homepage serwuje random URL suffix per request
|
||||
# (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource
|
||||
# unique key to `(origin, page_url)` — bez normalize generujemy 3x duplikaty
|
||||
# unique key to `(origin, page_url)`, bez normalize generujemy 3x duplikaty
|
||||
# na każdym scrape run. Canonical URL = `/videos/<data_id>`.
|
||||
canonical_url = (
|
||||
f"{_BASE}/videos/{data_id}" if data_id else scene_url
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""sexu.com — browse scraper. Dodany 2026-07-27.
|
||||
"""sexu.com, browse scraper. Dodany 2026-07-27.
|
||||
|
||||
Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen:
|
||||
obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X,
|
||||
|
|
@ -15,7 +15,7 @@ odrzuciłaby dobre dane.
|
|||
1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych
|
||||
tagów sceny tylko 5 (reszta to sidebar „popularne wyszukiwania"). Bez zawężenia
|
||||
do bloku „Tags:" wpisalibyśmy każdej scenie te same 20 śmieci.
|
||||
2. **W bloku tagów siedzą też linki `/search?q=`** — to fraz-y pod SEO („pleases",
|
||||
2. **W bloku tagów siedzą też linki `/search?q=`** to fraz-y pod SEO („pleases",
|
||||
„hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
|
||||
|
||||
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
|
||||
|
|
@ -25,7 +25,7 @@ bo performer w tabeli studios to zanieczyszczenie, nie dana.
|
|||
Data jest względna („7 hours ago", „yesterday"), więc liczymy ją wstecz od teraz.
|
||||
To i tak data uploadu na tube, nie premiery studia.
|
||||
|
||||
Playback: `POST /api/video-info` (extractor `sexu`) — wymaga Bright Data, bo
|
||||
Playback: `POST /api/video-info` (extractor `sexu`), wymaga Bright Data, bo
|
||||
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -67,7 +67,7 @@ _BLOCK_END_RE = re.compile(r"_container|player-tags__title")
|
|||
|
||||
|
||||
def _block(detail_html: str, label: str) -> str:
|
||||
"""Fragment HTML należący do bloku `<label>:` — patrz pułapka 1 w docstringu."""
|
||||
"""Fragment HTML należący do bloku `<label>:`, patrz pułapka 1 w docstringu."""
|
||||
start = detail_html.find(f'player-tags__title">{label}:')
|
||||
if start < 0:
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""shyfap.net — latest-vids browse scraper.
|
||||
"""shyfap.net, latest-vids browse scraper.
|
||||
|
||||
Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail
|
||||
page'u (meta tags + body links): title, studio, performers, tags, duration,
|
||||
description, upload_date, embed_url.
|
||||
|
||||
Pierwszy pilot scrapera browse-mode (2026-05-12) — weryfikacja czy detail-page
|
||||
Pierwszy pilot scrapera browse-mode (2026-05-12), weryfikacja czy detail-page
|
||||
metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00,
|
||||
fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ _PORNSTAR_LINK_RE = re.compile(
|
|||
_TAG_LINK_RE = re.compile(
|
||||
r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE
|
||||
)
|
||||
# /video/<slug>_v<id>/ — id z URL używamy jako stable internal ID (np. w external_id),
|
||||
# /video/<slug>_v<id>/ id z URL używamy jako stable internal ID (np. w external_id),
|
||||
# nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL.
|
||||
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
|||
sitetag = "shyfapnet"
|
||||
|
||||
def _listing_url(self, page: int) -> str:
|
||||
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk — sufiks `_1`
|
||||
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk, sufiks `_1`
|
||||
# zawsze, dodatkowy `/N/` dla pagination)
|
||||
if page <= 1:
|
||||
return f"{_BASE}/videos_1/"
|
||||
|
|
@ -108,7 +108,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
|||
if not embed_url and internal_id:
|
||||
embed_url = f"{_BASE}/embed/{internal_id}"
|
||||
|
||||
# Studio — pierwszy `/studio/<slug>_s<id>/` link na stronie
|
||||
# Studio, pierwszy `/studio/<slug>_s<id>/` link na stronie
|
||||
studio: RawStudio | None = None
|
||||
m_studio = _STUDIO_LINK_RE.search(detail_html)
|
||||
if m_studio:
|
||||
|
|
@ -119,7 +119,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
|||
slug=slug,
|
||||
)
|
||||
|
||||
# Performers — wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
|
||||
# Performers, wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
|
||||
performers: list[RawPerformer] = []
|
||||
seen_perf: set[str] = set()
|
||||
for m_p in _PORNSTAR_LINK_RE.finditer(detail_html):
|
||||
|
|
@ -134,7 +134,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
|||
)
|
||||
)
|
||||
|
||||
# Tags — wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
|
||||
# Tags, wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
|
||||
tags: list[RawTag] = []
|
||||
seen_tag: set[str] = set()
|
||||
for m_t in _TAG_LINK_RE.finditer(detail_html):
|
||||
|
|
@ -146,8 +146,8 @@ class ShyfapScraper(BaseBrowseScraper):
|
|||
RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug)
|
||||
)
|
||||
|
||||
# Playback source — embed_url (mobile WebView fallback). Stream extraction
|
||||
# przez app/extractors/__init__.py wymaga osobnego registry entry — dla
|
||||
# Playback source, embed_url (mobile WebView fallback). Stream extraction
|
||||
# przez app/extractors/__init__.py wymaga osobnego registry entry, dla
|
||||
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
|
||||
playback_sources = [
|
||||
RawPlaybackSource(
|
||||
|
|
@ -161,7 +161,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
|||
|
||||
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
|
||||
# Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny.
|
||||
# Niezależne od shyfap title-rebrandingu — bierze się z frame'u sceny.
|
||||
# Niezależne od shyfap title-rebrandingu, bierze się z frame'u sceny.
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
if thumbnail_url:
|
||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""siska.video — latest-vids browse scraper.
|
||||
"""siska.video, latest-vids browse scraper.
|
||||
|
||||
Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła
|
||||
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
"""superporn.com — latest-vids browse scraper (przez Bright Data ISP proxy).
|
||||
"""superporn.com, latest-vids browse scraper (przez Bright Data ISP proxy).
|
||||
|
||||
Dołączony 2026-06-10 (user request). superporn twardo blokuje VPS IP Cloudflarem
|
||||
(403 na KAŻDEJ impersonacji TLS — chrome/safari/firefox), więc ingest HTML idzie
|
||||
(403 na KAŻDEJ impersonacji TLS, chrome/safari/firefox), więc ingest HTML idzie
|
||||
przez Bright Data ISP proxy (`settings.brightdata_proxy_url`). Gdy proxy
|
||||
nieskonfigurowane → scraper no-op (pusty iterator, log warning).
|
||||
|
||||
Proxy używamy TYLKO do scrape HTML. Playback NIE idzie przez proxy: `<source>` mp4
|
||||
(cdnst*.superporn.com) ma token IP-bound do fetchera (403 cross-IP), więc resolve
|
||||
musi nastąpić po stronie telefonu — extractor `superporncom` → `_vps_blocked_fallback`
|
||||
musi nastąpić po stronie telefonu, extractor `superporncom` → `_vps_blocked_fallback`
|
||||
(mobile WebView ładuje stronę z residential IP telefonu, INJECTED_JS bierze video.src).
|
||||
Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa).
|
||||
|
||||
Sygnały (SSR HTML):
|
||||
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl, duration
|
||||
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer — pomijamy).
|
||||
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer, pomijamy).
|
||||
- Performerzy: chip `<a class="chip-link" href=".../pornstar/<slug>"><span>Name</span>`
|
||||
- Kategorie: chip `<a class="chip-link" href="/<slug>"><span>Name</span>` (bez
|
||||
`/pornstar/`) — w bloku `#collapse-categories`.
|
||||
`/pornstar/`), w bloku `#collapse-categories`.
|
||||
- Duration backup: `<video ... data-video-duration="2328">` (sekundy).
|
||||
|
||||
Listing: `?page=N` (newest-first; sitemap lastmod jest z 2024, bezużyteczny).
|
||||
|
|
@ -69,7 +69,7 @@ _ISO_DUR_FULL_RE = re.compile(
|
|||
|
||||
def _clean(text: str) -> str:
|
||||
"""superporn dwukrotnie HTML-koduje encje w JSON-LD (`&#39;` zamiast `'`).
|
||||
Unescape iteracyjnie (max 2) — bez over-decode dla zwykłych nazw."""
|
||||
Unescape iteracyjnie (max 2), bez over-decode dla zwykłych nazw."""
|
||||
for _ in range(2):
|
||||
new = html.unescape(text)
|
||||
if new == text:
|
||||
|
|
@ -95,7 +95,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
|||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Bright Data ISP proxy — bez niego superporn jest nieosiągalny z VPS.
|
||||
# Bright Data ISP proxy, bez niego superporn jest nieosiągalny z VPS.
|
||||
self._proxy = get_settings().brightdata_proxy_url
|
||||
if not self._proxy:
|
||||
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
|
||||
|
|
@ -106,7 +106,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
|||
return f"{_BASE}/?page={page}"
|
||||
|
||||
def crawl_page(self, page: int):
|
||||
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403) — sygnalizuj
|
||||
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403), sygnalizuj
|
||||
# "exhausted" (pusta lista), żeby deep-crawl nie retry'ował w kółko.
|
||||
if not self._proxy:
|
||||
return []
|
||||
|
|
@ -164,7 +164,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
|||
seen_tag.add(slug)
|
||||
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
|
||||
|
||||
# Phash z thumbnaila — proxy NIE potrzebny (img*.superporn.com działa z VPS).
|
||||
# Phash z thumbnaila, proxy NIE potrzebny (img*.superporn.com działa z VPS).
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
if thumbnail_url:
|
||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""supjav.com — JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
|
||||
"""supjav.com, JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
|
||||
|
||||
Osobna pula JAV (sekcja JAV), origin `tube:supjav` w JAV_ORIGINS. supjav agreguje
|
||||
embedy (serwery TV/FST/ST/VOE), gdzie realny hoster jest schowany za `data-link`
|
||||
|
|
@ -10,7 +10,7 @@ Browse (RE 2026-07-10):
|
|||
- karty: `<div class="post"><a href="/<id>.html" title="<title>" rel="bookmark"
|
||||
class="img"><img src=".../images/YYYY/MM/<CODE>.jpg!320x216.jpg" class="thumb">`.
|
||||
- Metadane bierzemy Z KARTY (tytuł/thumb/URL/kod JAV/data z roku-miesiąca ścieżki
|
||||
thumb), NIE z detalu — detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
|
||||
thumb), NIE z detalu, detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
|
||||
per-post fetch byłby wolny i lossy. Studio/tagi (poza kodem) dorabia enrich później.
|
||||
|
||||
supjav blokuje VPS IP twardym CF → listing idzie przez Bright Data ISP proxy z retry
|
||||
|
|
@ -36,7 +36,7 @@ _BASE = "https://supjav.com"
|
|||
# atrybutu nie zawiera surowego "), więc tytuły z `>` (np. `<<3-Day Offer>>`) przechodzą.
|
||||
# Link-obraz karty: <a href="URL" ... title="TITLE"><img src|data-original="THUMB">.
|
||||
# supjav miesza kolejność atrybutów (href/rel/class="img"/title w dwóch wariantach), więc
|
||||
# NIE wymagamy pozycji class="img" — wymóg `>\s*<img` i tak izoluje link-obraz od
|
||||
# NIE wymagamy pozycji class="img", wymóg `>\s*<img` i tak izoluje link-obraz od
|
||||
# link-tekstu (ten drugi po `>` ma tekst, nie <img>). Thumb eager `src=` lub lazy
|
||||
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
|
||||
_CARD_RE = re.compile(
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""sxyland.com — performer-page scrape (search-based, performer-driven).
|
||||
"""sxyland.com, performer-page scrape (search-based, performer-driven).
|
||||
|
||||
2026-06-16 fix (zamrożony od 06-07): sxyland porzucił URL scen `/<numeric_id>/<slug>/`
|
||||
na rzecz `/<slug>/`, więc stary regex (wymagał cyfry w ścieżce) dawał 0. WordPress `?s=`
|
||||
filtruje, ale miesza — czystsze są **strony performera** `/actor/<slug>/`
|
||||
filtruje, ale miesza, czystsze są **strony performera** `/actor/<slug>/`
|
||||
(performer-driven query = nazwa performera → slugify → /actor/<slug>/).
|
||||
|
||||
Bogate metadane (per-scene detail fetch — sxyland to WP tube, taksonomie na scenie):
|
||||
Bogate metadane (per-scene detail fetch, sxyland to WP tube, taksonomie na scenie):
|
||||
- performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`)
|
||||
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
|
||||
- studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio
|
||||
|
|
@ -181,7 +181,7 @@ class SxyLandScraper(BaseDirectTubeScraper):
|
|||
)
|
||||
)
|
||||
|
||||
# Tagi — TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
|
||||
# Tagi, TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
|
||||
tags: list[RawTag] = []
|
||||
seen_tag: set[str] = set()
|
||||
block_m = _TAGS_BLOCK_RE.search(detail)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""sxyprn.com — performer-page scrape (search-based, performer-driven).
|
||||
"""sxyprn.com, performer-page scrape (search-based, performer-driven).
|
||||
|
||||
2026-06-16 fix (zamrożony od 05-07): sxyprn NIE ma free-text searcha. Stary endpoint
|
||||
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
|
||||
|
|
@ -42,19 +42,19 @@ log = logging.getLogger(__name__)
|
|||
_BASE = "https://sxyprn.com"
|
||||
|
||||
# js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera)
|
||||
# + aria-label (połamani performerzy — nieużywane).
|
||||
# + aria-label (połamani performerzy, nieużywane).
|
||||
_CARD_ANCHOR_RE = re.compile(
|
||||
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc
|
||||
# NIE używamy `[^>]*` (łamie się na tym `>`) — leniwe `.*?` do wartości HH:MM:SS.
|
||||
# NIE używamy `[^>]*` (łamie się na tym `>`), leniwe `.*?` do wartości HH:MM:SS.
|
||||
_DURATION_RE = re.compile(
|
||||
r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
_SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE)
|
||||
_THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", re.IGNORECASE)
|
||||
# post_time z tytułem — keyowane po ID, bo leży w post_control PO vid_container.
|
||||
# post_time z tytułem, keyowane po ID, bo leży w post_control PO vid_container.
|
||||
_POSTTIME_RE = re.compile(
|
||||
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
|
||||
re.IGNORECASE,
|
||||
|
|
@ -89,7 +89,7 @@ def _clean_title(raw: str) -> tuple[str, list[str]]:
|
|||
|
||||
class SxyPrnScraper(BaseSearchScraper):
|
||||
sitetag = "sxyprncom"
|
||||
# _search_url_template/_scene_url_re z bazy nieużywane — mamy custom search().
|
||||
# _search_url_template/_scene_url_re z bazy nieużywane, mamy custom search().
|
||||
_card_window = 2600 # od js-pop anchora do post_control z tytułem
|
||||
|
||||
def _performer_path(self, query: str) -> str:
|
||||
|
|
@ -131,7 +131,7 @@ class SxyPrnScraper(BaseSearchScraper):
|
|||
continue
|
||||
title, tags = _clean_title(tm.group(1))
|
||||
title_l = title.lower()
|
||||
# Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule) —
|
||||
# Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule),
|
||||
# chroni przed śmieciem na stronie i przed over-attribution.
|
||||
if query_tokens and not all(tok in title_l for tok in query_tokens):
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"""vjav.com — JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
|
||||
"""vjav.com, JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
|
||||
|
||||
Osobna pula JAV (tytuły azjatyckie + `title_jp`, studia typu JAVHD), origin `tube:vjav`.
|
||||
Świadomie orphan vertical (sekcja JAV) — NIE deduplikuje się z zachodnim katalogiem.
|
||||
Świadomie orphan vertical (sekcja JAV), NIE deduplikuje się z zachodnim katalogiem.
|
||||
|
||||
vjav to SPA (ktk_player + blob) — listing renderuje się JS-owo, więc HTML listingu
|
||||
vjav to SPA (ktk_player + blob), listing renderuje się JS-owo, więc HTML listingu
|
||||
nie ma linków do scen. Zamiast tego:
|
||||
- **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest
|
||||
= najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N =
|
||||
kolejny wycinek PER_PAGE.
|
||||
- **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json`
|
||||
(floor = id//1000*1000) — title (+title_jp), duration, post_date, channel.title
|
||||
(floor = id//1000*1000), title (+title_jp), duration, post_date, channel.title
|
||||
(studio), models{} (performerki), categories{}+tags{}.
|
||||
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
|
||||
get_file HLS). RE 2026-07-10.
|
||||
|
|
@ -62,7 +62,7 @@ def _parse_duration(value: str | None) -> int | None:
|
|||
# --- bramka jakości tytułu ----------------------------------------------------------
|
||||
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
|
||||
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
|
||||
# w apce nieodnajdywalne — nie mają ani tytułu, ani performera, po którym da się trafić.
|
||||
# w apce nieodnajdywalne, nie mają ani tytułu, ani performera, po którym da się trafić.
|
||||
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
|
||||
# bo sprawdzała tylko jeden z czterech wzorców).
|
||||
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
|
||||
|
|
@ -74,7 +74,7 @@ def _has_cjk(text: str) -> bool:
|
|||
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
|
||||
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało
|
||||
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
|
||||
wyszła przy pomiarze pierwszej wersji bramki — bez tego wyjątku poszłoby 708 z 708
|
||||
wyszła przy pomiarze pierwszej wersji bramki, bez tego wyjątku poszłoby 708 z 708
|
||||
tytułów CJK z próbki."""
|
||||
return sum(1 for c in text if " " <= c <= "鿿" or "" <= c <= "") >= 3
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ class VjavScraper(BaseBrowseScraper):
|
|||
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
|
||||
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
|
||||
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
|
||||
# działania, nie optymalizacją — bez klucza scraper się wyłącza.
|
||||
# działania, nie optymalizacją, bez klucza scraper się wyłącza.
|
||||
self._proxy = get_settings().brightdata_proxy_url
|
||||
if not self._proxy:
|
||||
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
|
||||
|
|
@ -130,7 +130,7 @@ class VjavScraper(BaseBrowseScraper):
|
|||
zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
|
||||
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
|
||||
wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na
|
||||
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx —
|
||||
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx,
|
||||
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
|
||||
try:
|
||||
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
|
||||
|
|
@ -238,7 +238,7 @@ class VjavScraper(BaseBrowseScraper):
|
|||
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec
|
||||
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
|
||||
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
|
||||
kończyło CAŁY run — źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
|
||||
kończyło CAŁY run, źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
|
||||
2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`.
|
||||
|
||||
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
|
||||
|
|
@ -268,7 +268,7 @@ class VjavScraper(BaseBrowseScraper):
|
|||
out: list[RawScene] = []
|
||||
for vid in chunk:
|
||||
try:
|
||||
# Proxy także tutaj — API metadanych siedzi na tym samym hoście, więc
|
||||
# Proxy także tutaj, API metadanych siedzi na tym samym hoście, więc
|
||||
# obejmuje je ten sam 429 co sitemap.
|
||||
res = browser_get(
|
||||
self._meta_url(vid),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""watchporn.to — browse scraper (KVS engine). Re-enabled 2026-07-02.
|
||||
"""watchporn.to, browse scraper (KVS engine). Re-enabled 2026-07-02.
|
||||
|
||||
Był search-scraperem (`?s=`), zamarzł, a potem site przebudowano na KVS z nowym
|
||||
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął — teraz KVS
|
||||
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął, teraz KVS
|
||||
flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound).
|
||||
|
||||
Browse `/latest-updates/` → detail page:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""XHamster.com — direct HTML scrape search results.
|
||||
"""XHamster.com, direct HTML scrape search results.
|
||||
|
||||
Search: `https://xhamster.com/search/<q>?page=<n>`
|
||||
Scene URL: `https://xhamster.com/videos/<slug>-<id>`
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""xmoviesforyou.com — direct HTML scrape.
|
||||
"""xmoviesforyou.com, direct HTML scrape.
|
||||
|
||||
Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination).
|
||||
Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""XNXX.com — direct HTML scrape search results.
|
||||
"""XNXX.com, direct HTML scrape search results.
|
||||
|
||||
Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed)
|
||||
Scene URL: `https://www.xnxx.com/video-<id>/<slug>`
|
||||
|
|
@ -12,7 +12,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
|
|||
|
||||
class XnxxScraper(BaseSearchScraper):
|
||||
sitetag = "xnxxcom"
|
||||
# `/<page-1>` — handle override in search() by replacing {page}.
|
||||
# `/<page-1>`, handle override in search() by replacing {page}.
|
||||
_search_url_template = "https://www.xnxx.com/search/{query}/{page}"
|
||||
_scene_url_re = re.compile(
|
||||
r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""xnxx.com — BROWSE scraper (JSON-LD), obok search scrapera.
|
||||
"""xnxx.com, BROWSE scraper (JSON-LD), obok search scrapera.
|
||||
|
||||
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail) — i TYLKO to
|
||||
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail), i TYLKO to
|
||||
bierzemy. Mimo wspólnego silnika z xvideos, xnxx detail NIE wystawia w SSR linków
|
||||
`/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia
|
||||
canonical-merge + istniejący performer-search. XnxxScraper (search) zostaje; browse
|
||||
|
|
@ -8,13 +8,13 @@ dokłada sygnał świeżości. Tytuł z JSON-LD bywa HTML-encoded (`,`/`&e
|
|||
html.unescape.
|
||||
|
||||
Listing: xnxx NIE ma czystego SSR `/new/` (404), ale `/best/<YYYY-MM>/<page>` jest
|
||||
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc — pokrywa świeży
|
||||
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc, pokrywa świeży
|
||||
content (sortowanie best-of-month, nie ściśle chronologiczne, ale dla sygnału
|
||||
świeżości wystarcza; ścisłą chronologię i tak daje performer-search). Homepage
|
||||
xnxx jest JS-renderowany (0 linków w surowym HTML), stąd /best/.
|
||||
|
||||
Playback bez zmian (extractor `xnxxcom`). Phash pominięty (xnxx crop-thumbnaile,
|
||||
0% hit do canonical — jak xvideos).
|
||||
0% hit do canonical, jak xvideos).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ class XnxxBrowseScraper(BaseBrowseScraper):
|
|||
if isinstance(thumbnail_url, list):
|
||||
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
|
||||
|
||||
# performers/tags puste — xnxx detail nie ma ich w SSR (patrz docstring).
|
||||
# performers/tags puste, xnxx detail nie ma ich w SSR (patrz docstring).
|
||||
return RawScene(
|
||||
external_id=f"{self.sitetag}:{scene_url}",
|
||||
title=title,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""XVideos.com — direct HTML scrape search results.
|
||||
"""XVideos.com, direct HTML scrape search results.
|
||||
|
||||
Search: `https://www.xvideos.com/?k=<q>&p=<page-1>` (xvideos używa 0-indexed pages)
|
||||
Scene URL: `https://www.xvideos.com/video<digits>/<slug>`
|
||||
|
|
@ -13,7 +13,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
|
|||
|
||||
class XVideosScraper(BaseSearchScraper):
|
||||
sitetag = "xvideoscom"
|
||||
# 0-indexed page — w base classie computed jako `page=N`, więc override _build_url.
|
||||
# 0-indexed page, w base classie computed jako `page=N`, więc override _build_url.
|
||||
_search_url_template = "https://www.xvideos.com/?k={query}&p={page}"
|
||||
_scene_url_re = re.compile(
|
||||
r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"',
|
||||
|
|
@ -21,10 +21,10 @@ class XVideosScraper(BaseSearchScraper):
|
|||
)
|
||||
|
||||
def search(self, query, *, page=1, limit=None):
|
||||
# XVideos używa 0-indexed pages — `page=1` w API → `&p=0` w URL.
|
||||
# XVideos używa 0-indexed pages, `page=1` w API → `&p=0` w URL.
|
||||
# Override żeby base class fetch'nął zewnętrzny URL z (page-1).
|
||||
# Najprościej: dostosujmy URL w override przed wywołaniem super().search().
|
||||
# Ale super() używa self._search_url_template — robimy clone z poprawionym page.
|
||||
# Ale super() używa self._search_url_template, robimy clone z poprawionym page.
|
||||
original = self._search_url_template
|
||||
self._search_url_template = original.replace("{page}", str(page - 1))
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""xvideos.com — deep-crawl browse scraper (JSON-LD + page-parse).
|
||||
"""xvideos.com, deep-crawl browse scraper (JSON-LD + page-parse).
|
||||
|
||||
xvideos SSR-uje JSON-LD VideoObject (duration, name, uploadDate) ORAZ na detail-stronie
|
||||
linki `/models/<slug>` (performerzy tej sceny) + `/tags/<slug>` (tagi). Sample 2026-06-03
|
||||
(15 scen): median ~10.5min, 93% ≥3min — dobry full-scene content (nie trailery).
|
||||
(15 scen): median ~10.5min, 93% ≥3min, dobry full-scene content (nie trailery).
|
||||
|
||||
Mega-katalog (~13M) → deep_crawl z per-tube page-cap (xvideoscom w deep_crawl._PAGE_CAP),
|
||||
żeby nie monopolizował round-robin ani nie zalał bazy. Listing: /new/<page> (newest).
|
||||
Scene: /video.<hash>/<slug>. Playback: page_url + origin tube:xvideoscom (istniejący
|
||||
extractor `xvideoscom` resolvuje stream mobile-side). Phash pominięty (xvideos robi
|
||||
własne crop-thumbnaile — 0% hit do canonical, jak fullmovies/hdporn).
|
||||
własne crop-thumbnaile, 0% hit do canonical, jak fullmovies/hdporn).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ _JSONLD_RE = re.compile(
|
|||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
# Model anchor: `<a href="/models/<slug>" ...><span ...></span><span class="name">Display Name</span>...`.
|
||||
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora —
|
||||
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora,
|
||||
# poprzedni wzorzec `>([^<]{2,60})</a>` wymagał tekstu zaraz po `>` i NIGDY nie pasował do
|
||||
# obecnego markupu xvideos → wszystkie browse-scraped sceny xvideos lądowały z 0 performerami
|
||||
# (bug-report 2026-06-07 "czemu nie ma aktorów, są na stronie"). `(?:(?!</a>).)*?` trzyma match
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
"""yesporn.vip — latest-vids browse scraper.
|
||||
"""yesporn.vip, latest-vids browse scraper.
|
||||
|
||||
Dołączony 2026-05-27. Identyfikowany przez user audit jako "scraper-of-paysites"
|
||||
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
|
||||
audit pomylił domeny: `yespornvip.com` (z theporndude rankingu) redirectuje przez
|
||||
pdude.link do `porndudecams.com` affiliate spam — kanoniczna domena ma TLD `.vip`.
|
||||
pdude.link do `porndudecams.com` affiliate spam, kanoniczna domena ma TLD `.vip`.
|
||||
|
||||
Czemu wart (parity z porndoe):
|
||||
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
|
||||
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
|
||||
(BunnyCDN: `yesnn.b-cdn.net/contents/videos_screenshots/...`).
|
||||
- **`<meta property="video:duration" content="2340">`** — durations już w sekundach
|
||||
- **`<meta property="video:duration" content="2340">`** durations już w sekundach
|
||||
(fallback gdy ISO-duration parse fail).
|
||||
- **`<meta property="video:release_date">`** — ISO 8601 z timezone, redundant z
|
||||
- **`<meta property="video:release_date">`** ISO 8601 z timezone, redundant z
|
||||
JSON-LD uploadDate ale czystszy format.
|
||||
- **`<meta property="video:tag">`** (multiple) — kanoniczna lista tagów (np.
|
||||
- **`<meta property="video:tag">`** (multiple), kanoniczna lista tagów (np.
|
||||
"Big Ass", "Threesome"). Główne źródło tagów; alternatywnie DOM ma `btn gold`
|
||||
linki ale te miksują performerów/studio z tagami.
|
||||
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
|
||||
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
|
||||
multiple). Slugi mają stable per-type salt (`*-i459s7` dla modeli, `*-7p72tp`
|
||||
dla channels) — zachowują się jak hash z site-version, ale stabilne przez
|
||||
dla channels), zachowują się jak hash z site-version, ale stabilne przez
|
||||
sesje.
|
||||
|
||||
External_id strategia: `yespornvip:<numeric_video_id>` (`/video/69841/...` → `69841`).
|
||||
|
|
@ -31,11 +31,11 @@ URL patterns:
|
|||
- Scene: `/video/<id>/<slug>/` (id numeryczny, slug = title slug + 6-char salt)
|
||||
- Studio: `/channels/<slug>/`
|
||||
- Performer: `/models/<slug>/`
|
||||
- Search: `/search/<query>/` (nie używane w browse-mode — można dorobić jako
|
||||
- Search: `/search/<query>/` (nie używane w browse-mode, można dorobić jako
|
||||
osobny tryb dla performer-driven backfill jeśli będzie potrzeba)
|
||||
|
||||
Playback: download endpoint `/view_video_download.php?id=<id>&format=<480|720|1080>`
|
||||
z `data-attach-session="PHPSESSID"` — wymaga session cookie, więc nie direct mp4
|
||||
z `data-attach-session="PHPSESSID"`, wymaga session cookie, więc nie direct mp4
|
||||
z server-side. Plus jest `embedUrl: /embed/<id>` w JSON-LD. Extractor →
|
||||
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
|
||||
mobile WebView fetcha embed z phone IP, INJECTED_JS scrape'uje `<video>.src`.
|
||||
|
|
@ -66,7 +66,7 @@ log = logging.getLogger(__name__)
|
|||
|
||||
_BASE = "https://yesporn.vip"
|
||||
|
||||
# Listing — scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
|
||||
# Listing, scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
|
||||
# Slug `<slug>` zawiera stable per-type salt (`*-npu57w` dla videos).
|
||||
_SCENE_URL_RE = re.compile(
|
||||
r'href="(https://yesporn\.vip/video/(\d+)/[a-z0-9\-]+/)"',
|
||||
|
|
@ -87,13 +87,13 @@ _PERFORMER_LINK_RE = re.compile(
|
|||
)
|
||||
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
# JSON-LD VideoObject — pełny blok między `<script type="application/ld+json">` tagami.
|
||||
# JSON-LD VideoObject, pełny blok między `<script type="application/ld+json">` tagami.
|
||||
_JSONLD_RE = re.compile(
|
||||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# `<meta property="video:tag" content="Big Ass">` — multiple, jeden tag per meta.
|
||||
# `<meta property="video:tag" content="Big Ass">`, multiple, jeden tag per meta.
|
||||
_META_TAG_RE = re.compile(
|
||||
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
|
||||
re.IGNORECASE,
|
||||
|
|
@ -122,7 +122,7 @@ def _parse_iso_date(value: str | None) -> date | None:
|
|||
"""`2026-05-26T19:23:29Z` / `2026-05-26T19:23:29.EDT` → date."""
|
||||
if not value:
|
||||
return None
|
||||
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate — strip żeby
|
||||
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate, strip żeby
|
||||
# `fromisoformat` nie crash'ował. video:release_date meta ma czysty `Z`.
|
||||
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
|
||||
try:
|
||||
|
|
@ -265,7 +265,7 @@ class YesPornVipScraper(BaseBrowseScraper):
|
|||
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
|
||||
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
|
||||
# w 20-scene dry-run 2026-05-27. Trade-off: stracimy hipotetyczne legit
|
||||
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował) —
|
||||
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował),
|
||||
# akceptowalne bo tags mają wagę tylko 0.05 w composite scoring resolvera.
|
||||
tags: list[RawTag] = []
|
||||
seen_tag: set[str] = set()
|
||||
|
|
@ -283,17 +283,17 @@ class YesPornVipScraper(BaseBrowseScraper):
|
|||
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
||||
)
|
||||
|
||||
# Phash z thumbnailUrl — BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
|
||||
# Phash z thumbnailUrl, BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
|
||||
# Hit-rate vs canonical TPDB/StashDB nieznany do pilot run; graceful: brak
|
||||
# phash → resolver spada do composite scoring (studio + performer + date +
|
||||
# duration + title token-set) — wszystkie dostępne dzięki JSON-LD.
|
||||
# duration + title token-set), wszystkie dostępne dzięki JSON-LD.
|
||||
fingerprints: list[RawFingerprint] = []
|
||||
if thumbnail_url:
|
||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||
if ph:
|
||||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||
|
||||
# Playback — page_url do strony sceny. Direct mp4 (`view_video_download.php`)
|
||||
# Playback, page_url do strony sceny. Direct mp4 (`view_video_download.php`)
|
||||
# wymaga PHPSESSID cookie (data-attach-session attribute), więc nie usable
|
||||
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
|
||||
# mobile WebView z phone IP łapie session natively, INJECTED_JS scrape.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""youperv.com — browse scraper (DataLife Engine). Dodany 2026-07-26.
|
||||
"""youperv.com, browse scraper (DataLife Engine). Dodany 2026-07-26.
|
||||
|
||||
Ripy paysite ze studiami (Brazzers Exxtra, Blacked, Evil Angel, Deeper, Private…),
|
||||
tytuły w formacie `Studio - Performer - Title` (71% próbki 132 tytułów), świeże
|
||||
|
|
@ -8,20 +8,20 @@ co do sekundy + duration = mocny sygnał do canonical match.
|
|||
Listing: homepage (newest) + `/page/N/`, 19 scen/stronę, zero overlapu między
|
||||
stronami. Scene URL: `/<kategoria>/<id>-<slug>.html`.
|
||||
|
||||
**KRYTYCZNE — scoping obsady**: performerzy MUSZĄ być czytani tylko z bloku
|
||||
**KRYTYCZNE, scoping obsady**: performerzy MUSZĄ być czytani tylko z bloku
|
||||
`<div class="fmeta">` … `Related`. Na całej stronie jest 19-26 linków
|
||||
`xfsearch/pornstar/` (blok Related), w samym fmeta 1-2 realnych. Bez scopingu
|
||||
powtórzylibyśmy błąd, przez który odrzuciliśmy xxxfiles (page-wide pollution
|
||||
zaśmiecająca bazę performerów).
|
||||
|
||||
Tytuł zostaje z prefiksem studia (jak hdporngg/porn00) — token_set_ratio i tak
|
||||
Tytuł zostaje z prefiksem studia (jak hdporngg/porn00), token_set_ratio i tak
|
||||
złapie canonical, a prefiks niesie dodatkowy sygnał.
|
||||
|
||||
Playback: direct mp4 `<source>` na files.klubnichka-hd.com, BEZ tokena/expiry, ale
|
||||
CDN ma hotlink-guard na Referer (bez nagłówka 403, z nagłówkiem 206 cross-IP).
|
||||
Rozwiązuje extractor `youpervcom` (VPS-side, mobile gra direct, zero WebView/proxy).
|
||||
|
||||
Głębokość: deep-crawl capowany (`_PAGE_CAP` w deep_crawl.py) — strony ~2100+ to
|
||||
Głębokość: deep-crawl capowany (`_PAGE_CAP` w deep_crawl.py), strony ~2100+ to
|
||||
stara amatorka bez performerów, z martwymi linkami (HTTP 500 na CDN).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""YouPorn.com — direct HTML scrape search results.
|
||||
"""YouPorn.com, direct HTML scrape search results.
|
||||
|
||||
Search: `https://www.youporn.com/search/?query=<q>&page=<n>`
|
||||
Scene URL: `https://www.youporn.com/watch/<id>/<slug>/`
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""youporn.com — latest BROWSE scraper (JSON-LD + page-parse), obok search scrapera.
|
||||
"""youporn.com, latest BROWSE scraper (JSON-LD + page-parse), obok search scrapera.
|
||||
|
||||
YouPornScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada świeżość
|
||||
wprost z `/browse/time/?page=<n>` (newest-first, SSR). Detail page ma JSON-LD
|
||||
VideoObject (name/duration/uploadDate/thumbnail) — i TYLKO to bierzemy.
|
||||
VideoObject (name/duration/uploadDate/thumbnail), i TYLKO to bierzemy.
|
||||
|
||||
UWAGA: performerów/tagów z detail-strony NIE wyciągamy. JSON-LD nie ma pola `actor`,
|
||||
a linki `/pornstar/` i `/category/` na stronie są zaśmiecone sidebarem (popularne
|
||||
pornstars/related) bez czystego scene-scoped kontenera — naiwny regex podpinał te
|
||||
pornstars/related) bez czystego scene-scoped kontenera, naiwny regex podpinał te
|
||||
same 2 pornstars do KAŻDEJ sceny (mass-misattribution). Browse to tylko sygnał
|
||||
świeżości (próg watchdog 48h); performerów/tagi dorabia canonical-merge + istniejący
|
||||
search scraper (performer-driven). Listing SSR (/watch/<id>/); homepage JS-renderowany.
|
||||
|
|
@ -96,7 +96,7 @@ class YouPornBrowseScraper(BaseBrowseScraper):
|
|||
if isinstance(thumbnail_url, list):
|
||||
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
|
||||
|
||||
# performers/tags celowo puste — patrz docstring (sidebar pollution, brak
|
||||
# performers/tags celowo puste, patrz docstring (sidebar pollution, brak
|
||||
# scene-scoped kontenera). Dorabia canonical-merge + search scraper.
|
||||
return RawScene(
|
||||
external_id=f"{self.sitetag}:{scene_url}",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""dooplay (a.k.a. PsyPlay) WordPress theme scraper — generic dla mangoporn/streamporn/pandamovies.
|
||||
"""dooplay (a.k.a. PsyPlay) WordPress theme scraper, generic dla mangoporn/streamporn/pandamovies.
|
||||
|
||||
Te 3 strony to dokładnie ten sam template (theme=dooplay + PsyPlay player plugin),
|
||||
więc parametryzujemy connector po `(base_url, source_name)` i odpalamy 3 instancje.
|
||||
|
|
@ -15,7 +15,7 @@ Detail: `/movies/<slug>/` ma rich meta:
|
|||
- <span class="dt_rating_vgs" itemprop="ratingValue">N</span> rating 0-10
|
||||
- <li ... data-fl-source="<embed_url>"><a href="<embed_link>">Host</a></li> player options
|
||||
|
||||
Player ma multi-host options (DoodStream, LuluStream, RPMShare etc.) — każdy embed
|
||||
Player ma multi-host options (DoodStream, LuluStream, RPMShare etc.), każdy embed
|
||||
URL idzie jako osobny `playback_source` z origin=`{site}:{host}` żeby później mobile
|
||||
mógł wybrać czyim embedem chce odpalić scenę.
|
||||
"""
|
||||
|
|
@ -48,9 +48,9 @@ USER_AGENT = (
|
|||
)
|
||||
|
||||
|
||||
# ---- selektory (theme-agnostic — działa dla dowolnego dooplay) -----------
|
||||
# ---- selektory (theme-agnostic, działa dla dowolnego dooplay) -----------
|
||||
|
||||
# Listing item — dwa wzorce w zależności od witryny:
|
||||
# Listing item, dwa wzorce w zależności od witryny:
|
||||
# 1. mangoporn: zwykłe <a href="https://site/movies/<slug>/"> bez klasy
|
||||
# (theme wyrendurował SEO-friendly URL bezpośrednio w grid)
|
||||
# 2. streamporn/pandamovies: <a class="ml-mask jt" href="<base>/<slug>/">
|
||||
|
|
@ -62,14 +62,14 @@ _LIST_ITEM_RE = re.compile(
|
|||
r'<a\s+href="(?P<url2>https?://[^"]+/movies/[a-z0-9-]+/)"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Tolerantny title — mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
|
||||
# Tolerantny title, mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
|
||||
# (raw PsyPlay theme) używają <h3 itemprop="name">. Łapiemy oba przez itemprop="name".
|
||||
_TITLE_RE = re.compile(
|
||||
r'<h[1-6][^>]*\sitemprop="name"[^>]*>([^<]+)</h[1-6]>'
|
||||
r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów —
|
||||
# dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów,
|
||||
# różne thema dziedziczą podstawowy markup ale customizują URL słowniki.
|
||||
_YEAR_RE = re.compile(
|
||||
r'/(?:year|release-year)/(\d{4})/"\s*rel="tag"', re.IGNORECASE
|
||||
|
|
@ -111,7 +111,7 @@ _GENRE_TAG_RE = re.compile(
|
|||
# Player options: data-fl-source jest oryginalnym embed URL hostera, data-fl-url
|
||||
# to page URL u hostera. Stare theme (mangoporn): `<li class="hosts-buttons-wpx">`.
|
||||
# Nowe theme (pandamovies od ~2026-04): `<div class="Rtable1-cell" data-fl-url=...
|
||||
# data-fl-source=...>`. Trzeba też tolerować order-independent attrs — nowe theme
|
||||
# data-fl-source=...>`. Trzeba też tolerować order-independent attrs, nowe theme
|
||||
# emituje url BEFORE source, stare odwrotnie. Łapiemy oba wzorce dwoma osobnymi
|
||||
# regexami i konsolidujemy w `_iter_player_options`.
|
||||
_PLAYER_OPTION_RE = re.compile(
|
||||
|
|
@ -126,7 +126,7 @@ _PLAYER_OPTION_RE = re.compile(
|
|||
# Attrs są w kolejności url→source, source często pusty (`data-fl-source=""` dla
|
||||
# doodstream/mixdrop/easyvidplayer). Capturujemy CAŁY opening tag w group(1)
|
||||
# żeby data-fl-source należał gwarantowanie do TEGO konkretnego div (wcześniejszy
|
||||
# window-lookback 600 chars mógł pickować poprzedni cell — cross-attribution
|
||||
# window-lookback 600 chars mógł pickować poprzedni cell, cross-attribution
|
||||
# doodstream→mixdrop entry, code-review #14).
|
||||
_PLAYER_OPTION_DIV_RE = re.compile(
|
||||
r'(<div[^>]*\bclass="Rtable1-cell"[^>]*>)\s*'
|
||||
|
|
@ -135,7 +135,7 @@ _PLAYER_OPTION_DIV_RE = re.compile(
|
|||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_DATA_FL_SOURCE_RE = re.compile(r'data-fl-source="([^"]*)"', re.IGNORECASE)
|
||||
# Poster — JSON-LD `thumbnailUrl` jest najbardziej stabilny (każdy dooplay/PsyPlay
|
||||
# Poster, JSON-LD `thumbnailUrl` jest najbardziej stabilny (każdy dooplay/PsyPlay
|
||||
# theme z SEO ma JSON-LD VideoObject schema). Fallback na class="poster" img dla starych
|
||||
# instalacji bez schema. Trzeci fallback: og:image meta tag.
|
||||
_POSTER_JSONLD_RE = re.compile(
|
||||
|
|
@ -168,7 +168,7 @@ class DooplayConnector(BaseMovieConnector):
|
|||
pass
|
||||
|
||||
def _fetch(self, url: str) -> str:
|
||||
"""browser_get z chrome120 impersonation — psyplay sites czasem blokują
|
||||
"""browser_get z chrome120 impersonation, psyplay sites czasem blokują
|
||||
czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to."""
|
||||
if not url.startswith("http"):
|
||||
url = self.base_url.rstrip("/") + url
|
||||
|
|
@ -187,12 +187,12 @@ class DooplayConnector(BaseMovieConnector):
|
|||
)
|
||||
return r.text
|
||||
|
||||
# Bezpiecznik — dooplay listing potrafi mieć tysiące stron (streamporn.nl ma
|
||||
# Bezpiecznik, dooplay listing potrafi mieć tysiące stron (streamporn.nl ma
|
||||
# >2k filmów). Bez tego ingest wisi godzinami, jest killowany przy restartcie
|
||||
# schedulera, blokując kolejne connectory w queue (bug-report 2026-05-28: "od
|
||||
# wczoraj nie ma nowych filmów" — streamporn wisiał od 5-24, blokował
|
||||
# wczoraj nie ma nowych filmów", streamporn wisiał od 5-24, blokował
|
||||
# mangoporn + pandamovies). Listing jest sortowany po dacie uploadu (NIE
|
||||
# release_date filmu — release może być z 2013 a upload z dziś), ale upload
|
||||
# release_date filmu, release może być z 2013 a upload z dziś), ale upload
|
||||
# date nie jest w markupie, więc filtrowanie po `since` przez release_date
|
||||
# nie działa. Pragmatyczny cap stron: 3 dla delta (≈150 nowych pozycji/dzień
|
||||
# to znacznie powyżej realnego upload-rate), 50 dla full ingestu (`since=None`).
|
||||
|
|
@ -335,7 +335,7 @@ def _parse_dooplay_detail(
|
|||
poster_url = candidate
|
||||
break
|
||||
|
||||
# Performers — tylko sekcja "Pornstars" ma /pornstar/<slug>/ linki, dooplay
|
||||
# Performers, tylko sekcja "Pornstars" ma /pornstar/<slug>/ linki, dooplay
|
||||
# filtruje cast w tej sekcji. Jaccard może łapać dubel ale dedup robimy w
|
||||
# resolverze (po performer_id).
|
||||
performers = [
|
||||
|
|
@ -360,14 +360,14 @@ def _parse_dooplay_detail(
|
|||
|
||||
# Playback sources: każdy host (Doodstream/Lulu/RPM/...) jako osobny entry.
|
||||
# Dedup po href żeby ten sam host nie wpadł 2x. Raw landing page (origin=
|
||||
# source_name, bez :host) appendujemy TYLKO gdy nie ma żadnych sub-hosters —
|
||||
# source_name, bez :host) appendujemy TYLKO gdy nie ma żadnych sub-hosters,
|
||||
# inaczej myli usera (otwiera WebView z reklamami zamiast video; bug-report
|
||||
# 2026-05-16: "mangoporn przekierowuje do strony, reklama full screen").
|
||||
playback_sources: list[RawPlaybackSource] = []
|
||||
seen_hrefs: set[str] = set()
|
||||
|
||||
# Hostery file-download (non-streamable) + malware. Mobile player nie potrafi
|
||||
# ich odtworzyć — rapidgator/nitroflare/frdl serwują .zip/.rar/.mp4 do download
|
||||
# ich odtworzyć, rapidgator/nitroflare/frdl serwują .zip/.rar/.mp4 do download
|
||||
# (premium login required), streamtape ma malware drive-by .reg. Skipujemy
|
||||
# przy ingest żeby nie zaśmiecać UI martwym contentem (bug-report 2026-05-18).
|
||||
SKIP_HOSTERS = {"rapidgator", "nitroflare", "nitro", "frdl", "streamtape"}
|
||||
|
|
@ -400,7 +400,7 @@ def _parse_dooplay_detail(
|
|||
_emit_host_entry(m.group("href") or "", (m.group("source") or "").strip() or None)
|
||||
|
||||
# Nowy `<div class="Rtable1-cell">` markup (pandamovies od ~2026-04 + nowe
|
||||
# streamporn instances). data-fl-source jest opcjonalny — capturujemy CAŁY
|
||||
# streamporn instances). data-fl-source jest opcjonalny, capturujemy CAŁY
|
||||
# opening tag w group(1), data-fl-source extract z TEGO tagu (nie z window
|
||||
# lookback po HTMLu, bo to mogło pickować poprzedni cell).
|
||||
for m in _PLAYER_OPTION_DIV_RE.finditer(html):
|
||||
|
|
@ -411,7 +411,7 @@ def _parse_dooplay_detail(
|
|||
_emit_host_entry(href, source)
|
||||
|
||||
if not playback_sources:
|
||||
# Brak sub-hosters znalezionych — fallback do landing page (mobile otworzy
|
||||
# Brak sub-hosters znalezionych, fallback do landing page (mobile otworzy
|
||||
# w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest
|
||||
# niepotrzebnym ad-pageiem.
|
||||
playback_sources.append(
|
||||
|
|
@ -457,13 +457,13 @@ class StreampornVipConnector(DooplayConnector):
|
|||
def _listing_path(self, page: int) -> str:
|
||||
"""Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od
|
||||
2026-07-11 (0 nowych przez 23 dni), ale strona żyła i konektor parsował
|
||||
poprawnie — tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
|
||||
poprawnie, tyle że `/movies/` na tym motywie NIE jest sortowane po dacie i
|
||||
oddaje stary katalog. Zmierzone na tych samych 8 pozycjach: `/movies/` dało
|
||||
roczniki 2012-2015, strona główna wyłącznie 2026 (po 10-11 źródeł playbacku).
|
||||
`?orderby=date` motyw ignoruje (odpowiedź identyczna z domyślną).
|
||||
|
||||
To ta sama klasa błędu co przy freshporno: właściwa strona, zły endpoint
|
||||
listingu — patrz [[reference_kvs_root_rotates_use_latest_updates]].
|
||||
listingu, patrz [[reference_kvs_root_rotates_use_latest_updates]].
|
||||
|
||||
Strony 1 i 2 współdzielą ~44 linki (sidebar/widgety), ale treść główna jest
|
||||
stronicowana normalnie; dedup po URL w `fetch_movies` i tak je odsiewa.
|
||||
|
|
@ -482,7 +482,7 @@ class MangopornConnector(DooplayConnector):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (zduplikowane z paradisehill.py — celowo, żeby connectory były niezależne)
|
||||
# Helpers (zduplikowane z paradisehill.py, celowo, żeby connectory były niezależne)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""freeomovie.to — movie source (WordPress "bestia" theme, NIE dooplay).
|
||||
"""freeomovie.to, movie source (WordPress "bestia" theme, NIE dooplay).
|
||||
|
||||
Dodany 2026-07-02 (ocena). Pełnometrażowe filmy, świeże (auto-poster, dziś), tytuły
|
||||
DVD → title-trigram mirror-attach do canonical (paradisehill/TPDB movies), dokładając
|
||||
|
|
@ -18,7 +18,7 @@ Struktura:
|
|||
doczepia się do canonical po tytule, a TPDB enrichment dokłada obsadę/studio autorytatywnie.
|
||||
|
||||
Playback: TABS hostery → MoviePlaybackSource per host (voe/luluvid/vidhide resolvują się
|
||||
VPS-side; myvidplay = DoodStream clone, phone-side — wymaga myvidplay.com w DOOD_HOSTS).
|
||||
VPS-side; myvidplay = DoodStream clone, phone-side, wymaga myvidplay.com w DOOD_HOSTS).
|
||||
Pomijamy streamtape (martwy malware) + mxdrop (flaky).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -151,7 +151,7 @@ class FreeoMovieConnector(BaseMovieConnector):
|
|||
name = part.strip()
|
||||
if not name or name.lower() == "xxx movies":
|
||||
if name.lower() == "xxx movies":
|
||||
break # dalej idzie obsada/studio — nie tagi
|
||||
break # dalej idzie obsada/studio, nie tagi
|
||||
continue
|
||||
sl = slugify(name)
|
||||
if not sl or sl in seen_tag:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""Paradisehill connector — primary source dla movies (full-length adult films).
|
||||
"""Paradisehill connector, primary source dla movies (full-length adult films).
|
||||
|
||||
Site notes:
|
||||
- Age-gate: wymagany cookie `is18=1` (POST /is18/ zwraca 400 z curla, ale samo dorzucenie
|
||||
cookie do GET-a działa — site jest tolerancyjny).
|
||||
- Listing: `/all/?sort=created_at&page=N` — paginacja po 28 filmów, mikro-data Schema.org Movie.
|
||||
- Detail: `/<hex_id>/` — pełne meta + Video.js playlist (chaptery jako "Part 1/2/3").
|
||||
cookie do GET-a działa, site jest tolerancyjny).
|
||||
- Listing: `/all/?sort=created_at&page=N`, paginacja po 28 filmów, mikro-data Schema.org Movie.
|
||||
- Detail: `/<hex_id>/`, pełne meta + Video.js playlist (chaptery jako "Part 1/2/3").
|
||||
|
||||
Co ekstraktujemy:
|
||||
- Schema.org microdata: name, description, director, datePublished (upload), image, thumbnailUrl
|
||||
|
|
@ -12,7 +12,7 @@ Co ekstraktujemy:
|
|||
- Genres: ze Schema.org `itemprop="genre"` (pierwszy = movie's main genre)
|
||||
- Year: parsowany z description gdy obecny ("This 1999 film..."), bo `datePublished` to upload_date
|
||||
- Chapters: liczba `<li>...Part N</li>` w playliście Video.js
|
||||
- Playback: na MVP `page_url` only — Video.js playlist URL jest dynamicznie ładowany przez JS
|
||||
- Playback: na MVP `page_url` only, Video.js playlist URL jest dynamicznie ładowany przez JS
|
||||
i wymaga login session. Mobile może otworzyć page w WebView (degradacja lepsza niż brak).
|
||||
|
||||
External_id: hex slug z URL-a (np. `259448f6b75ee` z `/259448f6b75ee/`).
|
||||
|
|
@ -49,7 +49,7 @@ LISTING_PATH = "/all/" # ?sort=created_at&page=N
|
|||
SOURCE_NAME = "paradisehill"
|
||||
|
||||
|
||||
# Microdata extraction — Schema.org tagi są stabilne i niezagubione przy lekkich
|
||||
# Microdata extraction, Schema.org tagi są stabilne i niezagubione przy lekkich
|
||||
# zmianach themu (yii2 widget renderuje je inwariantnie).
|
||||
_TITLE_RE = re.compile(
|
||||
r'<h1\s+class="title-inside"\s+itemprop="name">([^<]+)</h1>', re.IGNORECASE
|
||||
|
|
@ -72,7 +72,7 @@ _CHAPTER_RE = re.compile(
|
|||
r'<a\s+href="#"\s+class="js-list-item"\s+data-index="(\d+)">([^<]+)</a>',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# videoList JS array w detail page — może mieć multiple parts (Video.js playlist):
|
||||
# videoList JS array w detail page, może mieć multiple parts (Video.js playlist):
|
||||
# var videoList = [{"sources":[{"src":"...part1.mp4","type":"video/mp4"}]}, ...]
|
||||
# Bez parsowania tego mobile WebView gra tylko pierwszy part, kolejne pomija.
|
||||
# Bug-reports `c5693926`/`418270e4` 2026-05-21 ("ładuje tylko 1 z 4 części").
|
||||
|
|
@ -155,7 +155,7 @@ class ParadisehillConnector(BaseMovieConnector):
|
|||
|
||||
`since`: stop gdy datePublished < since. `limit`: stop po N filmach.
|
||||
Aktualnie 28 movies/page; site rośnie ~5/dzień, więc pełen crawl to ~tysiące
|
||||
stron — w prod używamy `since` żeby zobaczyć tylko delta od poprzedniego runa.
|
||||
stron, w prod używamy `since` żeby zobaczyć tylko delta od poprzedniego runa.
|
||||
"""
|
||||
seen = 0
|
||||
page = 1
|
||||
|
|
@ -179,14 +179,14 @@ class ParadisehillConnector(BaseMovieConnector):
|
|||
if movie is None:
|
||||
continue
|
||||
|
||||
# `since` filter — datePublished (= data uploadu na paradisehill) poniżej
|
||||
# `since` filter, datePublished (= data uploadu na paradisehill) poniżej
|
||||
# progu = stop crawla (listing chronologiczny).
|
||||
#
|
||||
# UWAGA: release_date to DATA (bez godziny). Wcześniej combine()→00:00
|
||||
# porównywane z TIMESTAMPEM `since` ucinało crawl na PIERWSZYM filmie z dnia
|
||||
# == since (midnight < since o dowolnej porze dnia) → uploady tego samego dnia
|
||||
# systematycznie ginęły, bo movie-ingest jest dzienny (seen=0-2/run mimo
|
||||
# świeżych filmów na froncie strony — bug-report 2026-06-01 "Movies stoją").
|
||||
# świeżych filmów na froncie strony, bug-report 2026-06-01 "Movies stoją").
|
||||
# Fix: porównuj po DACIE z 1-dniowym grace; ponowny fetch świeżych jest tani
|
||||
# (external_records upsert pomija niezmieniony hash).
|
||||
if since is not None and movie.release_date is not None:
|
||||
|
|
@ -243,8 +243,8 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
# Year — najpierw z tytułu, potem z opisu. datePublished to upload date paradisehill
|
||||
# (np. 2026-05) a nie production year (np. 1999) — useless dla year filtering.
|
||||
# Year, najpierw z tytułu, potem z opisu. datePublished to upload date paradisehill
|
||||
# (np. 2026-05) a nie production year (np. 1999), useless dla year filtering.
|
||||
release_year: int | None = None
|
||||
m_yt = _YEAR_IN_TITLE_RE.search(title)
|
||||
if m_yt:
|
||||
|
|
@ -271,9 +271,9 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
|
|||
name=_decode_html(m_studio.group(2).strip()),
|
||||
)
|
||||
|
||||
# Genre — pierwszy itemprop="genre" w samym block-inside (nie w recommendations).
|
||||
# Genre, pierwszy itemprop="genre" w samym block-inside (nie w recommendations).
|
||||
# Recommended films też mają itemprop="genre" więc match limity do block-inside.
|
||||
# Wcześniejszy regex wymagał `</div></div><div class="similar"` — ale paradisehill
|
||||
# Wcześniejszy regex wymagał `</div></div><div class="similar"`, ale paradisehill
|
||||
# czasami ma `</div></noindex>...<div class="similar"` (banner skin z 2026-05-19),
|
||||
# przez co block_match failował → fallback do html[:8000] → 0 tagów. Bug-report
|
||||
# `3c999b27` 2026-05-21 ("Brak kategorii"). Robust: szukaj similar jako stop boundary,
|
||||
|
|
@ -295,7 +295,7 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
|
|||
# Paradisehill miesza dwa szablony per-page:
|
||||
# v1: `itemprop="genre">Female Domination</span>`
|
||||
# v2: `itemprop="genre"><a href="/category/...">All Sex</a></span>` (od 2026-05)
|
||||
# Optional `<a>` wrapper między `itemprop` a tekstem — bez tego v2 dawał empty.
|
||||
# Optional `<a>` wrapper między `itemprop` a tekstem, bez tego v2 dawał empty.
|
||||
for m_genre in re.finditer(
|
||||
r'itemprop="genre"[^>]*>\s*(?:<a[^>]*>)?\s*([^<]+)', block, re.IGNORECASE,
|
||||
):
|
||||
|
|
@ -332,7 +332,7 @@ def _parse_detail(hex_id: str, html: str) -> RawMovie | None:
|
|||
backdrop_url=backdrop_url,
|
||||
url=page_url,
|
||||
studio=studio,
|
||||
performers=[], # Paradisehill rzadko ma cast linki — uzupełnimy przez mirrory.
|
||||
performers=[], # Paradisehill rzadko ma cast linki, uzupełnimy przez mirrory.
|
||||
tags=tags,
|
||||
chapters=chapters,
|
||||
playback_sources=playback_sources,
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ Lista scen: GET /scenes?per_page=200&page=N&date={YYYY-MM-DD} (delta filter)
|
|||
Format: {data: [...], meta: {current_page, last_page, per_page, total}}
|
||||
|
||||
Sceny TPDB zwracają już rozwiniętych performerów (`performers[]`), studio (`site`) i tagi (`tags[]`).
|
||||
W związku z tym pojedyncze GET /scenes wystarcza do MVP — nie musimy uderzać oddzielnie po performera.
|
||||
W związku z tym pojedyncze GET /scenes wystarcza do MVP, nie musimy uderzać oddzielnie po performera.
|
||||
|
||||
Format performera w scenie:
|
||||
- performer.id — ID przypisania performer↔scene (NIE używać do dedup)
|
||||
- performer.name — imię w tej konkretnej scenie (może być alias, np. „Mia M.")
|
||||
- performer.parent.id — kanoniczne UUID performerki w TPDB → external_id
|
||||
- performer.parent.name / .extra.gender / .extra.birthday — kanoniczne metadane
|
||||
- performer.id ID przypisania performer↔scene (NIE używać do dedup)
|
||||
- performer.name imię w tej konkretnej scenie (może być alias, np. „Mia M.")
|
||||
- performer.parent.id, kanoniczne UUID performerki w TPDB → external_id
|
||||
- performer.parent.name / .extra.gender / .extra.birthday, kanoniczne metadane
|
||||
|
||||
Format studia: scene.site = {id, name, slug, parent: {...}, network: {...}}
|
||||
"""
|
||||
|
|
@ -35,7 +35,7 @@ from tenacity import (
|
|||
def _is_retryable_http_error(exc: BaseException) -> bool:
|
||||
"""Retry transport errors + 5xx + 429; NIE retry 4xx (404/422 = permanent).
|
||||
|
||||
401/403 NIE są retryowalne tutaj — TPDB token expiry musiałby być
|
||||
401/403 NIE są retryowalne tutaj, TPDB token expiry musiałby być
|
||||
obsłużony jako auth refresh (TODO gdyby zaczęły się pojawiać). Aktualnie
|
||||
expire'a się raz na rok, więc nie warto kombinować.
|
||||
"""
|
||||
|
|
@ -100,7 +100,7 @@ class TPDBConnector(BaseConnector):
|
|||
def _get(self, client: httpx.Client, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
resp = client.get(path, params=params)
|
||||
if resp.status_code == 429:
|
||||
# let tenacity retry — but raise something it knows
|
||||
# let tenacity retry, but raise something it knows
|
||||
raise httpx.HTTPStatusError("rate limited", request=resp.request, response=resp)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
|
@ -125,13 +125,13 @@ class TPDBConnector(BaseConnector):
|
|||
) -> Iterator[RawScene]:
|
||||
"""Pobiera wszystkie sceny TPDB dla performera o podanym kanonicznym ID.
|
||||
|
||||
TPDB API: GET /performers/<id>/scenes — dedykowany endpoint.
|
||||
TPDB API: GET /performers/<id>/scenes, dedykowany endpoint.
|
||||
(Inne warianty są broken: /scenes?performers[]=<uuid> zwraca zawsze total=0,
|
||||
/scenes?performer_id=<uuid> → 422.)
|
||||
|
||||
404 = performer usunięty z TPDB (np. b959ccbb 2026-05-16 Sentry GOON-N).
|
||||
Wcześniej leciało raise → exception bąbelek do scheduler.performer_driven
|
||||
→ cały run failed. Teraz warn + yield empty — caller widzi 0 scen i
|
||||
→ cały run failed. Teraz warn + yield empty, caller widzi 0 scen i
|
||||
kontynuuje z następnym performer.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -157,11 +157,11 @@ class TPDBConnector(BaseConnector):
|
|||
) -> Iterator[RawScene]:
|
||||
"""Pobiera wszystkie sceny TPDB dla site/studio o podanym ID.
|
||||
|
||||
TPDB API: GET /sites/<id>/scenes — dedykowany endpoint analogiczny
|
||||
TPDB API: GET /sites/<id>/scenes, dedykowany endpoint analogiczny
|
||||
do /performers/<id>/scenes. Bez paginacji limit zwraca total scenes
|
||||
z meta.total (Brazzers=272, Naughty America=631 w czasie pisania).
|
||||
|
||||
404 = site usunięty z TPDB — analogicznie do fetch_scenes_for_performer.
|
||||
404 = site usunięty z TPDB, analogicznie do fetch_scenes_for_performer.
|
||||
"""
|
||||
try:
|
||||
yield from self._paginate_scenes(
|
||||
|
|
@ -215,7 +215,7 @@ class TPDBConnector(BaseConnector):
|
|||
def fetch_movie(self, movie_uuid: str) -> dict[str, Any] | None:
|
||||
"""GET /movies/<uuid> → pojedynczy film (detail). None gdy 404/błąd.
|
||||
|
||||
NB: list endpoint jest już hydrated, więc detail rzadko potrzebny — trzymamy
|
||||
NB: list endpoint jest już hydrated, więc detail rzadko potrzebny, trzymamy
|
||||
dla spójności / gdyby TPDB kiedyś przeniósł część pól tylko do detalu."""
|
||||
with self._client() as client:
|
||||
try:
|
||||
|
|
@ -384,7 +384,7 @@ def _parse_movie(raw: dict[str, Any]) -> RawMovie | None:
|
|||
director = ", ".join(d.get("name") for d in directors if isinstance(d, dict) and d.get("name")) or None
|
||||
|
||||
d = _parse_date(raw.get("date"))
|
||||
# duration bywa 1s-placeholderem na movies — poniżej 60s traktujemy jak brak.
|
||||
# duration bywa 1s-placeholderem na movies, poniżej 60s traktujemy jak brak.
|
||||
dur = raw.get("duration")
|
||||
duration_sec = int(dur) if dur and int(dur) > 60 else None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""yourdailypornmovies.ws — konektor filmów. Dodany 2026-07-28.
|
||||
"""yourdailypornmovies.ws, konektor filmów. Dodany 2026-07-28.
|
||||
|
||||
WordPress z własnym motywem (NIE dooplay), więc nie da się podpiąć pod
|
||||
`DooplayConnector` jak mangoporn/pandamovies/streamporn.
|
||||
|
|
@ -12,7 +12,7 @@ okładki DVD z `imgs1cdn.adultempire.com`, nie re-hosty z pandanetwork.
|
|||
Katalog: min. 8000 pozycji (strona 200 nadal oddaje wyniki, po 40 na stronę), sięga
|
||||
2003 roku, strona 1 ma bieżący rok.
|
||||
|
||||
**Pułapka: `/tag/` w całym HTML to 2177 linków** — to chmura tagów w sidebarze,
|
||||
**Pułapka: `/tag/` w całym HTML to 2177 linków** to chmura tagów w sidebarze,
|
||||
identyczny wzorzec jak przy sexu. Tagi (i obsada) MUSZĄ być czytane z bloków
|
||||
`div.xmll`, inaczej każdy film dostanie te same dwa tysiące śmieci.
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Czego strona nie ma: długości filmu.
|
|||
|
||||
Playback: 3 hostery w iframe. `voe.sx` i `mxdrop.sx` zweryfikowane, że resolwują
|
||||
istniejącymi modułami (`app/extractors/hosters/voe.py` → m3u8, `mixdrop.py` → mp4).
|
||||
`vidara.to` jest nam nieznany — zapisujemy go jako zwykły embed, telefon otworzy go
|
||||
`vidara.to` jest nam nieznany, zapisujemy go jako zwykły embed, telefon otworzy go
|
||||
w WebView, ale nie liczymy na niego.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -60,7 +60,7 @@ _CARD_RE = re.compile(
|
|||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd — patrz pułapka w docstringu.
|
||||
# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd, patrz pułapka w docstringu.
|
||||
_XMLL_RE = re.compile(r'<div class="xmll">.*?</div>', re.DOTALL)
|
||||
_ACTOR_RE = re.compile(r'href="[^"]*/actor/[^"]+"[^>]*>([^<]+)</a>')
|
||||
_TAG_RE = re.compile(r'href="[^"]*/tag/[^"]+"[^>]*>([^<]+)</a>')
|
||||
|
|
@ -143,7 +143,7 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
|
|||
if not title:
|
||||
return None
|
||||
|
||||
# Playback najpierw — bez niego film jest bezużyteczny, nie tworzymy orphana.
|
||||
# Playback najpierw, bez niego film jest bezużyteczny, nie tworzymy orphana.
|
||||
playback: list[RawPlaybackSource] = []
|
||||
seen_host: set[str] = set()
|
||||
for m in _IFRAME_RE.finditer(detail):
|
||||
|
|
@ -219,7 +219,7 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
|
|||
performers=performers,
|
||||
tags=tags,
|
||||
playback_sources=playback,
|
||||
# id produktu Adult Empire — mocniejsza kotwica do dedupu niż tytuł
|
||||
# id produktu Adult Empire, mocniejsza kotwica do dedupu niż tytuł
|
||||
# (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał).
|
||||
raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""TPDB movie enrichment + dedup.
|
||||
|
||||
TPDB `/movies` jest naszym kanonicznym źródłem metadanych filmów: obsada, kategorie
|
||||
(tagi), studio, reżyser, rok — plus stabilny UUID do dedupu. paradisehill (primary
|
||||
(tagi), studio, reżyser, rok, plus stabilny UUID do dedupu. paradisehill (primary
|
||||
movie source) prawie nie ma obsady, więc TPDB wypełnia największą lukę.
|
||||
|
||||
Zasada: TPDB TYLKO wzbogaca ISTNIEJĄCE filmy (z paradisehill/dooplay) i dedupuje,
|
||||
NIGDY nie tworzy nowych — TPDB nie ma playbacku, więc nowy film byłby niegrywalny.
|
||||
NIGDY nie tworzy nowych, TPDB nie ma playbacku, więc nowy film byłby niegrywalny.
|
||||
|
||||
Flow per film:
|
||||
1. skip, jeśli film ma już TPDB movie ref (movie_external_refs jest tylko-movie,
|
||||
|
|
@ -19,7 +19,7 @@ Flow per film:
|
|||
poster/rating (fill-only), przypnij MovieExternalRef(tpdb, uuid).
|
||||
|
||||
Match jest zachowawczy (próg 0.90 + guard roku), bo TPDB search zwraca dużo (np.
|
||||
"Pirates" → gay Knightbreeders przed feature) — bez scoringu wzięlibyśmy zły film.
|
||||
"Pirates" → gay Knightbreeders przed feature), bez scoringu wzięlibyśmy zły film.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ def _best_match(
|
|||
|
||||
|
||||
def _fill_scalar_fields(movie: Movie, norm) -> None:
|
||||
"""Fill-only — nie nadpisujemy pól ustawionych przez primary (paradisehill/mirror)."""
|
||||
"""Fill-only, nie nadpisujemy pól ustawionych przez primary (paradisehill/mirror)."""
|
||||
if norm.director and not movie.director:
|
||||
movie.director = norm.director
|
||||
if norm.release_year and not movie.release_year:
|
||||
|
|
@ -243,7 +243,7 @@ def enrich_movie(
|
|||
movie.studio_id = studio.id
|
||||
|
||||
# Dedup wchodzących performerów po external_id (TPDB potrafi wylistować tego samego
|
||||
# kanonicznego performera 2×: pod aliasem i kanonicznie) — bez tego resolve_performer
|
||||
# kanonicznego performera 2×: pod aliasem i kanonicznie), bez tego resolve_performer
|
||||
# próbuje wstawić performer_external_refs 2× → UniqueViolation (jak w scene_resolver).
|
||||
resolved: list[tuple[uuid.UUID, str | None]] = []
|
||||
seen_perf_keys: set[str] = set()
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ Public API:
|
|||
- `try_extract(sitetag, page_url) -> list[StreamSource] | None`
|
||||
- `StreamSource` (dataclass)
|
||||
- `HosterDead` (exception)
|
||||
- `extract_stream_from_hoster(iframe_url, *, referer)` — generic packer-based hoster extract
|
||||
- `fetch_tube_html(url)` — Chrome TLS fingerprint fetch (curl_cffi)
|
||||
- `browser_get(url)` — low-level
|
||||
- `extract_stream_from_hoster(iframe_url, *, referer)`, generic packer-based hoster extract
|
||||
- `fetch_tube_html(url)`, Chrome TLS fingerprint fetch (curl_cffi)
|
||||
- `browser_get(url)`, low-level
|
||||
|
||||
Architektura: każdy tube ma osobny moduł `app.extractors.tubes.<tube>` który eksportuje
|
||||
`extract(page_url) -> list[StreamSource] | None`. Registry niżej mapuje sitetag →
|
||||
modułowy extractor. `try_extract()` to thin wrapper z exception handlingiem.
|
||||
|
||||
Po removalu porn-app dependency, ten moduł jest jedynym mechanizmem rozwiązywania
|
||||
streamów — playback.py nie wpada już do porn-app /stream API.
|
||||
streamów, playback.py nie wpada już do porn-app /stream API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -63,14 +63,14 @@ log = logging.getLogger(__name__)
|
|||
# (lub po Fazie 2 migracji: `tube:<sitetag>`).
|
||||
#
|
||||
# Mainstream tubes (pornhub/xvideos/xnxx/xhamster/redtube/youporn/porntrex) używają
|
||||
# yt-dlp jako extractor — battle-tested, aktualizowane przez upstream przy zmianach
|
||||
# yt-dlp jako extractor, battle-tested, aktualizowane przez upstream przy zmianach
|
||||
# HTML. Aggregator tubes (xmoviesforyou/watchporn/siska/...) używają generic
|
||||
# embed-iframe extractor (page → /e/<id> iframe → P.A.C.K.E.R. unpack). Custom kod
|
||||
# tylko tam gdzie tube ma niestandardowy schemat (eporner XHR, sxyprn URL transform).
|
||||
_REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||
# hqporner — dedicated extractor zwraca multi-quality `<source>` mp4 URLs
|
||||
# hqporner, dedicated extractor zwraca multi-quality `<source>` mp4 URLs
|
||||
# (bigcdn.cc / hqwo.cc / flyflv) z `force_proxy=True`. CDN URLs IP-bound do
|
||||
# VPS, więc playback.py routuje przez proxy — mobile dostaje quality picker
|
||||
# VPS, więc playback.py routuje przez proxy, mobile dostaje quality picker
|
||||
# + natywny ExoPlayer, bez WebView.
|
||||
# Bug-report e8ddd8d4: WebView fallback (`_vps_blocked_fallback`) ładował
|
||||
# hqporner.com scene page w WebView, ale ta strona ma ad-iframes (adtng,
|
||||
|
|
@ -81,25 +81,25 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
|||
"hqpornercom": hqporner.extract,
|
||||
"epornercom": eporner.extract,
|
||||
"sxyprncom": sxyprn.extract,
|
||||
# Mainstream tubes — yt-dlp
|
||||
# Mainstream tubes, yt-dlp
|
||||
# NB: 2026-05-18 cross-IP test potwierdził że xvideos/xnxx/pornhub/youporn/redtube
|
||||
# CDN URLs są **time-bound** (nie IP-bound) — mobile_direct_ok auto-detect w
|
||||
# CDN URLs są **time-bound** (nie IP-bound), mobile_direct_ok auto-detect w
|
||||
# playback.py daje mobile direct fetch, zero VPS bandwidth.
|
||||
# pornhub + redtube — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Scrapery były
|
||||
# disabled od 2026-05-12 (0.4% canonical match — głównie skrócone amatorskie clipy),
|
||||
# pornhub + redtube, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Scrapery były
|
||||
# disabled od 2026-05-12 (0.4% canonical match, głównie skrócone amatorskie clipy),
|
||||
# zamrożone sceny/źródła skasowane z DB. Brak ekstraktorów → zero resolve.
|
||||
"xvideoscom": _ytdlp.extract,
|
||||
"xnxxcom": _ytdlp.extract,
|
||||
"youporncom": _ytdlp.extract,
|
||||
# porntrex KVS — 2026-05-22 VPS znów dociera (HTTP 200). Dedykowany extractor:
|
||||
# porntrex KVS, 2026-05-22 VPS znów dociera (HTTP 200). Dedykowany extractor:
|
||||
# flashvars `video_url` → `get_file` 302 → CDN time-bound signed URL
|
||||
# (`expires`+`md5`, NIE IP-bound) → mobile gra direct, zero VPS bandwidth.
|
||||
"porntrexcom": porntrex.extract,
|
||||
# fpoxxx — KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 →
|
||||
# `videos3.fpo.xxx/remote_control.php?acctoken=<base64>` — zdekodowany acctoken
|
||||
# fpoxxx, KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 →
|
||||
# `videos3.fpo.xxx/remote_control.php?acctoken=<base64>`, zdekodowany acctoken
|
||||
# zawiera WBITY IP serwera-resolvera → definitywnie IP-bound. WebView only.
|
||||
"fpoxxx": _vps_blocked_fallback.extract,
|
||||
# sxyland — embeduje playmogo.com/e/<id> (= klon DoodStream: doodcdn.io + pass_md5
|
||||
# sxyland, embeduje playmogo.com/e/<id> (= klon DoodStream: doodcdn.io + pass_md5
|
||||
# + niewidzialny CF Turnstile; Chrome-DevTools verify 2026-06-08, bug-report 827a50a1).
|
||||
# Strona sxyland NIE jest Turnstile-gated (VPS curl wyciąga iframe URL z HTML), więc
|
||||
# _embed_iframe wyłuskuje embed playmogo i oddaje jako type='hoster' → mobile
|
||||
|
|
@ -107,49 +107,49 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
|||
# mp4 → autoplay. Wcześniej _vps_blocked_fallback ładował CAŁĄ stronę sxyland w WebView
|
||||
# (ads + klik-to-play + brak autoplay = dokładnie objaw z reportu 827a50a1).
|
||||
"sxylandcom": _embed_iframe.extract,
|
||||
# Aggregator tubes — generic embed-iframe → hoster unpacker
|
||||
# Aggregator tubes, generic embed-iframe → hoster unpacker
|
||||
"latestpornvideocom": latestpornvideo.extract,
|
||||
"xmoviesforyoucom": _embed_iframe.extract,
|
||||
# watchporn — 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął).
|
||||
# watchporn, 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął).
|
||||
# flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound.
|
||||
"watchporn": watchporn.extract,
|
||||
# youperv — fluidplayer, zwykły <source> mp4 na files.klubnichka-hd.com. Bez tokena
|
||||
# youperv, fluidplayer, zwykły <source> mp4 na files.klubnichka-hd.com. Bez tokena
|
||||
# i bez IP-bindingu; CDN pilnuje tylko Referera (cross-IP 206 z VPS) → mobile direct.
|
||||
"youpervcom": youperv.extract,
|
||||
# galaxyporn — iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy,
|
||||
# galaxyporn, iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy,
|
||||
# extractor tylko wyłuskuje iframe. HLS Referer+IP-bound → /proxy/hls.
|
||||
"galaxyporncom": galaxyporn.extract,
|
||||
# pornbusy — dispatch po embedURL: loadvid (POST-manifest przez /proxy/hls),
|
||||
# pornbusy, dispatch po embedURL: loadvid (POST-manifest przez /proxy/hls),
|
||||
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
|
||||
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
|
||||
"pornbusycom": pornbusy.extract,
|
||||
# pornmike — gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry.
|
||||
# pornmike, gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry.
|
||||
# Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView.
|
||||
"pornmike": pornmike.extract,
|
||||
# 8kporner — JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!).
|
||||
# 8kporner, JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!).
|
||||
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
|
||||
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
|
||||
"8kpornercom": kporner8.extract,
|
||||
# fullporner — iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
|
||||
# fullporner, iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
|
||||
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
|
||||
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
|
||||
"fullporner": fullporner.extract,
|
||||
# sexu — POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
|
||||
# Bierzemy HLS `playerData.src`, NIE mp4 `sources` — te mają w tokenie `ip=`
|
||||
# sexu, POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
|
||||
# Bierzemy HLS `playerData.src`, NIE mp4 `sources`, te mają w tokenie `ip=`
|
||||
# requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi
|
||||
# hls_needs_passthrough i manifest idzie przez /proxy/hls, segmenty direct.
|
||||
"sexu": sexu.extract,
|
||||
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
|
||||
# fullvideosporn (= sextu), TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
|
||||
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
|
||||
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
|
||||
"fullvideosporn": fullvideosporn.extract,
|
||||
"siskavideo": _embed_iframe.extract,
|
||||
"porn4dayspw": _embed_iframe.extract,
|
||||
"porndishcom": _embed_iframe.extract,
|
||||
# xxxfreewatch — DELISTED 2026-05-18. 790 solo-orphan scen, 0% match, CF-walled z VPS.
|
||||
# xxxfreewatch, DELISTED 2026-05-18. 790 solo-orphan scen, 0% match, CF-walled z VPS.
|
||||
"latestleaksco": _embed_iframe.extract,
|
||||
"mypornerleakcom": _embed_iframe.extract,
|
||||
# xhamster — 2026-06-08 PRZEPIĘTE z _vps_blocked_fallback na natywny server-side HLS.
|
||||
# xhamster, 2026-06-08 PRZEPIĘTE z _vps_blocked_fallback na natywny server-side HLS.
|
||||
# Re-test (DevTools + cross-IP): VPS pobiera scene page bez CF challenge, master m3u8
|
||||
# w SSR HTML, manifest+segmenty time-bound (portable, nie IP-bound). Mobile gra HLS
|
||||
# direct, multi-quality, zero VPS proxy/WebView/reklam. Patrz tubes/xhamster.py.
|
||||
|
|
@ -158,24 +158,24 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
|||
"xhamstercom": xhamster.extract,
|
||||
# Freshporno KVS (function/0 + license). 2026-06-04 DevTools + cross-IP re-test
|
||||
# NAPRAWIA błąd z #20: finalny cdn4.freshporno.org/remote_control.php jest PORTABLE
|
||||
# (token time-bound nie IP-bound — VPS odtworzył token z residential → 206) ale
|
||||
# (token time-bound nie IP-bound, VPS odtworzył token z residential → 206) ale
|
||||
# wymaga browser-TLS (curl_cffi chrome/ExoPlayer → 206; plain curl → 000). W #20
|
||||
# testowałem plain-curl-em poza sesją → 000 → błędnie „nieosiągalny" → WebView.
|
||||
# Teraz backend-resolve jak yespornvip/pornditt (_kvs używa curl_cffi chrome).
|
||||
# Native, multi-quality, zero proxy/WebView. (zweryfikowane na emulatorze przed deploy)
|
||||
"freshpornoorg": freshporno.extract,
|
||||
# porn00 — KVS (plain get_file + license). 2026-06-04 DevTools + cross-IP re-test
|
||||
# porn00, KVS (plain get_file + license). 2026-06-04 DevTools + cross-IP re-test
|
||||
# NAPRAWIA błąd z #20: finalny fe.porn00.org/...?token=&expires= jest PORTABLE
|
||||
# (token time-bound nie IP-bound — Bright Data residential proxy z innego IP → 206)
|
||||
# (token time-bound nie IP-bound, Bright Data residential proxy z innego IP → 206)
|
||||
# ale wymaga browser-TLS (curl_cffi chrome → 206; plain curl → 403). W #20
|
||||
# testowałem finalny URL plain-curl-em → 403 → błędnie „IP-bound" → WebView.
|
||||
# Teraz backend-resolve przez _kvs (curl_cffi chrome), native multi-quality,
|
||||
# ZERO proxy (wcześniej force_proxy łamał no-proxy). Same mechanizm co freshporno.
|
||||
"porn00org": porn00.extract,
|
||||
# pornxp — `<source> //sr.porn-xp.com/<token>/.../720.mp4` (redirect → xpxp.eu).
|
||||
# pornxp, `<source> //sr.porn-xp.com/<token>/.../720.mp4` (redirect → xpxp.eu).
|
||||
# 2026-06-01 (task #20): 403 cross-IP → token w path IP-bound. WebView only.
|
||||
"pornxpph": _vps_blocked_fallback.extract,
|
||||
# yesporn.vip — KVS engine. VPS znów dociera (HTTP 200, odblokowane jak porntrex),
|
||||
# yesporn.vip, KVS engine. VPS znów dociera (HTTP 200, odblokowane jak porntrex),
|
||||
# więc resolvujemy SERVER-SIDE: dekoduj flashvars `video_url`/alt/alt2 (function/0/ +
|
||||
# license_code, algo kt_player) → follow get_file 302 → portable cdn5 url (time-bound,
|
||||
# NIE IP/cookie-bound, zweryfikowane cross-IP 2026-05-31). Mobile gra direct natywnie,
|
||||
|
|
@ -183,60 +183,60 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
|||
# ad-heavy stronę a scrape łapał preroll-reklamę (bkcdn) zamiast wideo.
|
||||
"yespornvip": yespornvip.extract,
|
||||
# Direct-scraping tubes (mają też search scraper w connectors/direct_scrapers/)
|
||||
# — używają identycznego embed-iframe pattern dla streamingu.
|
||||
# hdporn92com — DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe,
|
||||
# używają identycznego embed-iframe pattern dla streamingu.
|
||||
# hdporn92com, DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe,
|
||||
# JS hijackuje kliki na popunder. Wszystkie playback_sources mass-marked dead.
|
||||
# 0dayxx + pornditt + pornhat — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request): orphan
|
||||
# factories (0–0.2% canonical match), zastępujemy lepszymi źródłami. Dane skasowane.
|
||||
# CF-protected tube — curl_cffi w fetch_tube_html bypassa JA3, embed-iframe pattern.
|
||||
# perverzija — natywny HLS (2026-07-28). Player xtremestream ma w HTML tylko
|
||||
# 0dayxx + pornditt + pornhat, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request): orphan
|
||||
# factories (0-0.2% canonical match), zastępujemy lepszymi źródłami. Dane skasowane.
|
||||
# CF-protected tube, curl_cffi w fetch_tube_html bypassa JA3, embed-iframe pattern.
|
||||
# perverzija, natywny HLS (2026-07-28). Player xtremestream ma w HTML tylko
|
||||
# preroll/midroll (grały w pętli, bug 40ec88cc/92e9133e), a wideo dociąga runtime:
|
||||
# xs1.php?data=<md5>&q=<480|720> zwraca GOTOWY manifest HLS z pełnymi URL-ami
|
||||
# segmentów. Zamiast WebView oddajemy m3u8 wprost.
|
||||
"perverzijacom": perverzija.extract,
|
||||
# Special: WebView-only (Yii2 session-bound player).
|
||||
"paradisehillcc": paradisehill.extract,
|
||||
# PornDoe — dołączony 2026-05-21 (theporndude audit). Stream URL nie inline w
|
||||
# PornDoe, dołączony 2026-05-21 (theporndude audit). Stream URL nie inline w
|
||||
# SSR HTML (player JS init po Play click), więc WebView fallback: mobile pobiera
|
||||
# /watch/<id> z phone IP, player JS dekoduje video.src, INJECTED_JS scrape.
|
||||
# 0 VPS bandwidth — zgodne z pre-public bandwidth/anonimowość priorytet.
|
||||
# 0 VPS bandwidth, zgodne z pre-public bandwidth/anonimowość priorytet.
|
||||
"porndoecom": _vps_blocked_fallback.extract,
|
||||
# fullmovies.xxx + hdporn.gg — BRAKOWAŁO extractora (try_extract→None→"no stream";
|
||||
# fullmovies.xxx + hdporn.gg — ta sama platforma (`<source>/get_file/8512/`).
|
||||
# fullmovies.xxx + hdporn.gg, BRAKOWAŁO extractora (try_extract→None→"no stream";
|
||||
# fullmovies.xxx + hdporn.gg, ta sama platforma (`<source>/get_file/8512/`).
|
||||
# 2026-06-04 (DevTools + cross-IP, naprawia „loading forever" + bug 19866e9e):
|
||||
# get_file binduje fpvcdn do IP FETCHERA + jest stateless + ważny ≥90s, więc
|
||||
# oddajemy get_file NIEZRESOLWOWANY (mobile_direct) — telefon follow-uje 302 →
|
||||
# oddajemy get_file NIEZRESOLWOWANY (mobile_direct), telefon follow-uje 302 →
|
||||
# fpvcdn z IP telefonu → gra. POMIJAMY 4K (time-out 30s na fpvcdn = przyczyna
|
||||
# „loading forever"; 720/480p gra ~1s). Native, multi-quality, ZERO proxy/WebView.
|
||||
# (#19866e9e wcześniej źle: założyłem „get_file 403 IP-bound" testem plain-curl.)
|
||||
"fullmoviesxxx": fullmovies.extract,
|
||||
"hdporngg": hdporngg.extract,
|
||||
# hqfap (PlayTube CMS) — JSON-LD contentUrl = direct mp4. USUNIĘTY 2026-06-25 (cała
|
||||
# hqfap (PlayTube CMS), JSON-LD contentUrl = direct mp4. USUNIĘTY 2026-06-25 (cała
|
||||
# biblioteka serwowała `/upload/videos/video_down.mp4` stub), PRZYWRÓCONY 2026-07-07:
|
||||
# strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP, zweryfikowane),
|
||||
# user request. 4k69 zostaje usunięty (nie sprawdzany ponownie).
|
||||
# 2026-07-28: hqfap wdrożył Cloudflare z JS-challenge na strony /watch/ — 403 zarówno
|
||||
# 2026-07-28: hqfap wdrożył Cloudflare z JS-challenge na strony /watch/ 403 zarówno
|
||||
# z VPS, jak i z residential IP, także przy pełnym komplecie nagłówków przeglądarki
|
||||
# (strona główna dalej 200, więc blokada jest celowana w strony wideo). Phone-side
|
||||
# `resolveHqfapPage` (zwykły fetch) nie ma jak przejść challenge, więc padał i user
|
||||
# lądował na stronie zamiast na wideo (bug-report 92e9133e "Hqfap przekierowuje na
|
||||
# stronę"; telemetria to PRZEMILCZAŁA — zero eventów od 27.07, bo błąd jest przed
|
||||
# stronę"; telemetria to PRZEMILCZAŁA, zero eventów od 27.07, bo błąd jest przed
|
||||
# playerem). WebView (Chrome engine + JS + cookie) challenge przechodzi, więc
|
||||
# przełączamy na tryb WebView; INJECTED_JS i tak wyłuska mp4 i wróci na native player.
|
||||
# "hqfapcom" — ZDJĘTY 2026-08-02, patrz komentarz w direct_scrapers/__init__.py.
|
||||
# javflix (JAV, WordPress) — przyciski serwerów to `<a class="myLink" href="<embed>">`
|
||||
# "hqfapcom", ZDJĘTY 2026-08-02, patrz komentarz w direct_scrapers/__init__.py.
|
||||
# javflix (JAV, WordPress), przyciski serwerów to `<a class="myLink" href="<embed>">`
|
||||
# (streamtape/voe/doodstream/emturbovid). Generyczny _embed_iframe łapie je anchor-hoster
|
||||
# patternem → type='hoster', telefon resolwuje (voe backend, dood/filemoon phone-side).
|
||||
# Wrapper javflix.extract odsiewa placeholder players.mp4.
|
||||
"javflix": javflix.extract,
|
||||
# vjav (JAV, TXXX network) — videofile.php -> get_file HLS (patrz tubes/vjav.py).
|
||||
# vjav (JAV, TXXX network), videofile.php -> get_file HLS (patrz tubes/vjav.py).
|
||||
"vjav": vjav.extract,
|
||||
# supjav (JAV, embed-aggregator) — data-link reverse-hex -> lk1 -> hoster (type=hoster).
|
||||
# supjav (JAV, embed-aggregator), data-link reverse-hex -> lk1 -> hoster (type=hoster).
|
||||
"supjav": supjav.extract,
|
||||
# neporn — KVS function/0 + license (jak freshporno). Server-side _kvs resolve →
|
||||
# neporn, KVS function/0 + license (jak freshporno). Server-side _kvs resolve →
|
||||
# data001.neporn.com/remote_control.php portable (cross-IP 206, 2026-06-10).
|
||||
"neporncom": neporn.extract,
|
||||
# superporn — `<source>` mp4 (cdnst*.superporn.com) token IP-bound do fetchera
|
||||
# superporn, `<source>` mp4 (cdnst*.superporn.com) token IP-bound do fetchera
|
||||
# (403 cross-IP, test 2026-06-10), a sama strona CF-blocked z VPS. Resolve MUSI
|
||||
# być phone-side: WebView ładuje stronę z residential IP telefonu, INJECTED_JS
|
||||
# bierze video.src. Ingest HTML idzie osobno przez Bright Data proxy (scraper).
|
||||
|
|
@ -251,7 +251,7 @@ def try_extract(sitetag: str, page_url: str) -> list[StreamSource] | None:
|
|||
- brak extractora dla tego sitetag
|
||||
- extractor zwrócił None / nie znalazł URL'a
|
||||
|
||||
Raises HosterDead gdy embed page wprost mówi że video deleted/not found —
|
||||
Raises HosterDead gdy embed page wprost mówi że video deleted/not found,
|
||||
caller (playback.py) łapie i oznacza playback_source.dead_at.
|
||||
"""
|
||||
extractor = _REGISTRY.get(sitetag)
|
||||
|
|
@ -273,7 +273,7 @@ def supported_sitetags() -> tuple[str, ...]:
|
|||
|
||||
def is_vps_blocked_fallback(sitetag: str) -> bool:
|
||||
"""True gdy sitetag resolvuje się TYLKO przez WebView fallback (IP-bound CDN /
|
||||
ad-heavy / CAPTCHA — np. fpoxxx, pornxpph). Takie źródła dają gorszy
|
||||
ad-heavy / CAPTCHA, np. fpoxxx, pornxpph). Takie źródła dają gorszy
|
||||
UX (reklamy, czarny ekran) niż natywny KVS/direct resolve, więc UI powinien je
|
||||
rankować NIŻEJ gdy scena ma też natywne źródło (bug-report 2026-06-07: scena
|
||||
pokazywała fpoxxx-WebView przed działającym freshporno bo sort był alfabetyczny)."""
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ _DEFAULT_UA = (
|
|||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
"""Mini response-like object — drop-in dla httpx.Response w naszych use case'ach."""
|
||||
"""Mini response-like object, drop-in dla httpx.Response w naszych use case'ach."""
|
||||
|
||||
status_code: int
|
||||
text: str
|
||||
|
|
@ -60,7 +60,7 @@ def browser_get(
|
|||
) -> FetchResult:
|
||||
"""GET z Chrome TLS fingerprint (curl_cffi). Spada do httpx gdy curl_cffi brak.
|
||||
|
||||
`proxy` (http://user:pass@host:port) — routuje request przez proxy. Używane
|
||||
`proxy` (http://user:pass@host:port), routuje request przez proxy. Używane
|
||||
tylko do ingestu HTML tubów blokujących VPS IP (np. superporn przez Bright Data
|
||||
ISP proxy). NIE dla streamów."""
|
||||
if not _HAS_CURL_CFFI:
|
||||
|
|
@ -78,7 +78,7 @@ def browser_get(
|
|||
impersonate=impersonate,
|
||||
allow_redirects=follow_redirects,
|
||||
proxies={"http": proxy, "https": proxy} if proxy else None,
|
||||
verify=not proxy, # Bright Data MITM CA — curl_cffi nie ma go w bundlu
|
||||
verify=not proxy, # Bright Data MITM CA, curl_cffi nie ma go w bundlu
|
||||
)
|
||||
return FetchResult(status_code=r.status_code, text=r.text, url=str(r.url))
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ def fetch_tube_html(url: str, *, timeout: float = 60.0, max_retries: int = 2) ->
|
|||
(browser_get) żeby ominąć JA3 fingerprint blocks na CF-fronted tube'ach.
|
||||
|
||||
Retry: 5xx i empty body retry max_retries razy z exponential backoff (0.5s, 1s).
|
||||
Dla freshporno itp. które czasem zwracają 503/empty — bez retry user dostawał
|
||||
Dla freshporno itp. które czasem zwracają 503/empty, bez retry user dostawał
|
||||
"extractor None" z transient hiccup.
|
||||
"""
|
||||
import time as _time
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ from typing import Any
|
|||
class StreamSource:
|
||||
"""Pojedynczy resolved stream URL.
|
||||
|
||||
Mapuje na `StreamLink` w playback API (api/playback.py) — `link` → `stream_url`,
|
||||
Mapuje na `StreamLink` w playback API (api/playback.py), `link` → `stream_url`,
|
||||
`quality` → `quality`, `type` → `type`.
|
||||
|
||||
`referer` — opcjonalny override Referera używanego przez stream_proxy. Niektóre
|
||||
`referer`, opcjonalny override Referera używanego przez stream_proxy. Niektóre
|
||||
CDN-y (KVS-style watchporn.to, fpo.xxx itp.) zwracają 410/403 gdy Referer nie
|
||||
pasuje do *embed page'a* (np. proxy używa `Referer: 0dayxx.com` ale CDN expectuje
|
||||
`Referer: watchporn.to`). Gdy None → caller (playback.py) używa `page_url`.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
"""Universal duration extractor for tube pages.
|
||||
|
||||
Direct scrapery (xvideos, xnxx, youporn, porntrex, …) są search-only — pobierają
|
||||
Direct scrapery (xvideos, xnxx, youporn, porntrex, …) są search-only, pobierają
|
||||
listing i wycioskują tylko URL + slug-as-title. Duration pojawia się dopiero na
|
||||
detail page i jest dostępne w jednym z patternów:
|
||||
|
||||
1. **OpenGraph numeric** (youporn, redtube, eporner):
|
||||
`<meta property="og:video:duration" content="992">` — sekundy.
|
||||
`<meta property="og:video:duration" content="992">`, sekundy.
|
||||
2. **OpenGraph ISO 8601** (rzadkie):
|
||||
`<meta property="og:video:duration" content="PT16M32S">`.
|
||||
3. **Schema.org VideoObject LD-JSON** (xvideos, xnxx, KVS-based):
|
||||
`"duration": "PT00H07M10S"` w JSON-LD `<script type="application/ld+json">`.
|
||||
4. **itemprop microdata** (sxyland, 0dayxx, niektóre WordPress):
|
||||
`<meta itemprop="duration" content="P0DT0H21M13S">` — ISO 8601 z opcjonalnym
|
||||
`<meta itemprop="duration" content="P0DT0H21M13S">`, ISO 8601 z opcjonalnym
|
||||
`P<days>D` prefix + opcjonalnym `T` blokiem HMS.
|
||||
|
||||
Funkcja zwraca pierwszy znaleziony match jako int seconds, lub None.
|
||||
|
|
@ -29,7 +29,7 @@ _ITEMPROP_DURATION_RE = re.compile(
|
|||
r'itemprop="duration"[^>]*content="([^"]+)"', re.IGNORECASE
|
||||
)
|
||||
# Hqporner-style meta description: "Video duration is 6min 55sec" lub "1h 23min 5sec".
|
||||
# Generic — pasuje też do innych tube'ów które dorzucają w meta opis duration prozą.
|
||||
# Generic, pasuje też do innych tube'ów które dorzucają w meta opis duration prozą.
|
||||
_META_DESC_DURATION_RE = re.compile(
|
||||
r'(?:duration\s+is\s+|<meta\s+name="description"\s+content="[^"]*duration\s+is\s+)'
|
||||
r'(?:(\d+)\s*h(?:our)?s?)?\s*(?:(\d+)\s*min)?\s*(?:(\d+)\s*sec)?',
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ Hostery embed-page'y stosują JWPlayer + P.A.C.K.E.R. obfuskację:
|
|||
i chowają `sources: [{file: "https://...m3u8"}]` w packed JS.
|
||||
|
||||
Tu jest:
|
||||
- `unpack_packer(js)` — dekoder P.A.C.K.E.R.
|
||||
- `extract_stream_from_hoster(iframe_url, *, referer)` — fetch embed → unpack → m3u8/mp4
|
||||
- `unpack_packer(js)`, dekoder P.A.C.K.E.R.
|
||||
- `extract_stream_from_hoster(iframe_url, *, referer)`, fetch embed → unpack → m3u8/mp4
|
||||
|
||||
Te funkcje są używane przez:
|
||||
1. Per-tube extractors (latestpornvideo, hqporner fallback) — page → embed iframe → tu
|
||||
2. Movies playback (api/playback.py movies_router) — direct hoster URL → tu
|
||||
1. Per-tube extractors (latestpornvideo, hqporner fallback), page → embed iframe → tu
|
||||
2. Movies playback (api/playback.py movies_router), direct hoster URL → tu
|
||||
|
||||
Nie ma już zależności od PornAppClient / porn-app API.
|
||||
"""
|
||||
|
|
@ -25,10 +25,10 @@ from app.extractors._models import HosterDead
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# P.A.C.K.E.R. javascript unpacker — odwraca obfuskację wzorca:
|
||||
# P.A.C.K.E.R. javascript unpacker, odwraca obfuskację wzorca:
|
||||
# eval(function(p,a,c,k,e,d){while(c--)if(k[c])p=p.replace(...);return p}
|
||||
# ('PAYLOAD', BASE, COUNT, 'kw1|kw2|...'.split('|'), 0, {}))
|
||||
# StreamWish, doodporn, mixdrop, filemoon — wszystkie używają tego packera do schowania
|
||||
# StreamWish, doodporn, mixdrop, filemoon, wszystkie używają tego packera do schowania
|
||||
# `sources: [{file: "https://...m3u8"}]` w JWPlayer config.
|
||||
_PACKER_ARGS_RE = re.compile(
|
||||
r"\}\s*\(\s*'((?:\\'|[^'])+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\'|[^'])*)'\s*\.split\('\|'\)",
|
||||
|
|
@ -102,7 +102,7 @@ def _looks_like_ad(url: str) -> bool:
|
|||
|
||||
# Niektóre hostery (doodporn) chowają mp4/m3u8 w słowniku zmiennych i odwołują się do
|
||||
# nich w `sources: [{file: links.hls2}]`. Wtedy regex powyżej nie złapie. Drugi pass
|
||||
# bierze pierwszy `.m3u8|.mp4|.mpd` URL z całego unpacked HTML — heurystyka, ale
|
||||
# bierze pierwszy `.m3u8|.mp4|.mpd` URL z całego unpacked HTML, heurystyka, ale
|
||||
# pierwszy taki URL to zwykle master playlist video.
|
||||
_HOSTER_FALLBACK_URL_RE = re.compile(
|
||||
r'https?://[^\s"\'<>]+\.(?:m3u8|mp4|mpd)(?:\?[^\s"\'<>]*)?',
|
||||
|
|
@ -111,7 +111,7 @@ _HOSTER_FALLBACK_URL_RE = re.compile(
|
|||
|
||||
|
||||
# Sygnatury "video not found" / "deleted" które hostery wstawiają w HTML embed page.
|
||||
# Gdy widzimy te markery, to wiemy że link jest martwy — raise HosterDead, caller w
|
||||
# Gdy widzimy te markery, to wiemy że link jest martwy, raise HosterDead, caller w
|
||||
# playback.py oznaczy playback_source.dead_at.
|
||||
_HOSTER_DEAD_PATTERNS = (
|
||||
"Video not found",
|
||||
|
|
@ -124,20 +124,20 @@ _HOSTER_DEAD_PATTERNS = (
|
|||
)
|
||||
|
||||
|
||||
# KVS (Kernel Video Sharing) player markers — kt_player.js + license_code w HTML.
|
||||
# KVS (Kernel Video Sharing) player markers, kt_player.js + license_code w HTML.
|
||||
# Używają go fpo.xxx, 0day.kim, hdporn92, sxyland, i wiele innych WordPress-based
|
||||
# tubes. KVS encryptuje URL `function/0/<encrypted>` license_code'em — regex fallback
|
||||
# tubes. KVS encryptuje URL `function/0/<encrypted>` license_code'em, regex fallback
|
||||
# (`_HOSTER_FALLBACK_URL_RE`) złapie zamiast tego URL `event_reporting2` (tracking
|
||||
# pixel zwracający 1×1 GIF zamiast video). Jak widzimy markery KVS, idziemy od razu
|
||||
# do yt-dlp którego generic extractor poprawnie deszyfruje URL.
|
||||
_KVS_MARKERS = ("kt_player(", "license_code")
|
||||
|
||||
|
||||
# File hosters / known dead — rapidgator/nitroflare/frdl wymagają premium account
|
||||
# (zwracają HTML z formularzem logowania zamiast video). Zwróć None bez fetch'u —
|
||||
# File hosters / known dead, rapidgator/nitroflare/frdl wymagają premium account
|
||||
# (zwracają HTML z formularzem logowania zamiast video). Zwróć None bez fetch'u,
|
||||
# caller w movies playback dorzuci embed-only fallback i mobile i tak otworzy
|
||||
# WebView (gdzie user może zalogować się premium jeśli chce).
|
||||
# Streamtape USUNIĘTY z blacklistu 2026-05-15 — ma dedicated extractor (innerHTML
|
||||
# Streamtape USUNIĘTY z blacklistu 2026-05-15, ma dedicated extractor (innerHTML
|
||||
# substring decode → /get_video → 302 → tapecontent.net mp4). Większość 12k URLów
|
||||
# w naszej DB jest DMCA-dead ale ~5% żyje.
|
||||
_FILE_HOSTER_RE = re.compile(
|
||||
|
|
@ -169,8 +169,8 @@ def extract_stream_from_hoster(
|
|||
return None
|
||||
# Per-hoster dedicated extractors (specific URL shapes / decode patterns).
|
||||
# Mixdrop: P.A.C.K.E.R. → MDCore.wurl protocol-relative `//host/v2/<id>.mp4?s=...`
|
||||
# — generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme).
|
||||
# `miixdrop` (double-i) to current canonical domain — wszystkie legacy
|
||||
# generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme).
|
||||
# `miixdrop` (double-i) to current canonical domain, wszystkie legacy
|
||||
# `mixdrop.{ag,sb,my,co,...}` + `m1xdrop.bz` 301-ują tam. Bez `miixdrop`
|
||||
# w dispatch URLs already-on-new-domain (upstream tubes które zaktualizowały
|
||||
# embed src) trafiałyby do generic logic, gdzie regex `https?://...\.mp4`
|
||||
|
|
@ -182,7 +182,7 @@ def extract_stream_from_hoster(
|
|||
return sources[0].link
|
||||
# Fall through to generic logic gdyby dedicated zwrócił None.
|
||||
# xtremestream (perverzija): videojs+hls.js player. Embed `/player/index.php?data=<HASH>`
|
||||
# — m3u8 master playlist serwowany przez `/player/xs1.php?data=<HASH>` (ten sam HASH,
|
||||
# m3u8 master playlist serwowany przez `/player/xs1.php?data=<HASH>` (ten sam HASH,
|
||||
# inny endpoint; potwierdzone w inline JS `m3u8_loader_url`). Brak packera/file: w
|
||||
# HTML → generic logic zwracała None → WebView fallback. Trywialna podmiana endpointu.
|
||||
_xtr = re.search(
|
||||
|
|
@ -200,9 +200,9 @@ def extract_stream_from_hoster(
|
|||
sources = streamtape.extract(iframe_url, timeout=timeout)
|
||||
if sources:
|
||||
return sources[0].link
|
||||
return None # streamtape ma własną HosterDead obsługę — generic fallback by się sypał
|
||||
return None # streamtape ma własną HosterDead obsługę, generic fallback by się sypał
|
||||
# Shared SPA+AES-CBC engine: embedseek/seekplayer/rpmplay/upns/player4me/easyvidplayer
|
||||
# — wszystkie używają tego samego silnika (`/api/v1/video` z AES-CBC encrypted
|
||||
# wszystkie używają tego samego silnika (`/api/v1/video` z AES-CBC encrypted
|
||||
# m3u8 source). Razem ~159k playback sources w DB.
|
||||
from app.extractors.hosters import seekplayer_engine
|
||||
if seekplayer_engine.matches(iframe_url):
|
||||
|
|
@ -226,7 +226,7 @@ def extract_stream_from_hoster(
|
|||
if sources:
|
||||
return sources[0].link
|
||||
return None
|
||||
# filemoon "Byse" SPA — server-side resolve jest bezcelowy: stream URL z API
|
||||
# filemoon "Byse" SPA, server-side resolve jest bezcelowy: stream URL z API
|
||||
# jest IP-bound do requestera, więc VPS dostałby URL działający tylko z VPS.
|
||||
# Zwracamy None od razu → _embed_iframe Stage 2 → type='hoster' → mobile
|
||||
# filemoonHoster.ts robi POST /playback + AES-256-GCM z IP użytkownika.
|
||||
|
|
@ -238,7 +238,7 @@ def extract_stream_from_hoster(
|
|||
):
|
||||
log.debug("hoster %s: filemoon SPA → type=hoster (mobile-side resolve)", iframe_url)
|
||||
return None
|
||||
# DoodStream + klony (playmogo/doply/myvidplay/dood.*/doodstream) — protokół pass_md5
|
||||
# DoodStream + klony (playmogo/doply/myvidplay/dood.*/doodstream), protokół pass_md5
|
||||
# + niewidzialny Cloudflare Turnstile wiązany z IP requestera. Server-side resolve
|
||||
# bezcelowy (VPS dostaje Turnstile gate / IP-bound token). Zwracamy None → caller
|
||||
# oddaje embed jako type='hoster' → mobile doodstream.ts robi pass_md5 z IP usera
|
||||
|
|
@ -305,7 +305,7 @@ def extract_stream_from_hoster(
|
|||
if (url := _first_non_ad(_HOSTER_FALLBACK_URL_RE, html, 0)):
|
||||
return url
|
||||
|
||||
# 4) yt-dlp last resort — battle-tested extractory dla streamtape, dood, mixdrop,
|
||||
# 4) yt-dlp last resort, battle-tested extractory dla streamtape, dood, mixdrop,
|
||||
# filemoon, voe, vidoza, etc. Nie używamy go domyślnie (slow + lots of HTTP),
|
||||
# tylko gdy nasze własne metody zawiodły.
|
||||
ytdlp_url = _try_ytdlp_hoster(iframe_url, timeout=timeout)
|
||||
|
|
@ -324,7 +324,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
|
|||
"""yt-dlp wrapper dla hosters których nasz P.A.C.K.E.R. unpacker nie ogarnął.
|
||||
|
||||
yt-dlp ma extractory dla popularnych hosterów (streamtape, dood, mixdrop, filemoon,
|
||||
voe, vidoza, streamwish, ...) — bezpośredni dostęp do `_extract_info`. Te extractory
|
||||
voe, vidoza, streamwish, ...), bezpośredni dostęp do `_extract_info`. Te extractory
|
||||
robią multi-step AJAX / token rotation / regex unpacking dla każdego hostera.
|
||||
|
||||
Catch-all exception handling: jeśli yt-dlp nie ma extractora dla tego hostera lub
|
||||
|
|
@ -366,7 +366,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
|
|||
# "fake video" lub błąd (zgłoszone 2026-05-10 #30c4d3cf perverzija).
|
||||
return any(ext in low for ext in (".m3u8", ".mp4", ".mpd", ".webm", ".ts"))
|
||||
|
||||
# Best video format URL — yt-dlp już rankuje formats, pierwszy w `formats` zwykle jest
|
||||
# Best video format URL, yt-dlp już rankuje formats, pierwszy w `formats` zwykle jest
|
||||
# najlepszy, albo `info["url"]` dla single-format extractorów.
|
||||
formats = info.get("formats") or [info]
|
||||
for fmt in formats:
|
||||
|
|
@ -375,7 +375,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
|
|||
url = fmt.get("url")
|
||||
if _looks_like_video_url(url):
|
||||
return url
|
||||
# Fallback: top-level URL — ale tylko gdy faktycznie wygląda na video.
|
||||
# Fallback: top-level URL, ale tylko gdy faktycznie wygląda na video.
|
||||
top = info.get("url")
|
||||
if _looks_like_video_url(top):
|
||||
return top
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""cdn.loadvid.com — HLS hoster oddający manifest przez POST (nie URL).
|
||||
"""cdn.loadvid.com, HLS hoster oddający manifest przez POST (nie URL).
|
||||
|
||||
Protokół (reverse-engineer 2026-07-27, potwierdzony live):
|
||||
1. GET /videos/play/<hash> → HTML z `<meta name="csrf-token">` + inline
|
||||
|
|
@ -15,7 +15,7 @@ Rozwiązanie: `extract()` zwraca **embed URL** oznaczony `manifest_producer=load
|
|||
`/proxy/hls/<token>/play.m3u8` rozpoznaje ten marker i zamiast GET-a woła
|
||||
`fetch_manifest()`, po czym serwuje manifest telefonowi. Segmenty w manifeście są
|
||||
ABSOLUTNE i w pełni przenośne (zweryfikowane: 206 `bytes 0-1023` BEZ jakichkolwiek
|
||||
nagłówków, bez Referera, bez tokena — serwowane jako `image/png`, w środku TS),
|
||||
nagłówków, bez Referera, bez tokena, serwowane jako `image/png`, w środku TS),
|
||||
więc telefon ciągnie je bezpośrednio z CDN. Przez VPS idzie tylko manifest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -106,7 +106,7 @@ def fetch_manifest(embed_url: str, *, timeout: float = 30.0) -> str | None:
|
|||
|
||||
|
||||
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
|
||||
"""Zwraca embed URL z markerem producenta — manifest powstaje dopiero w
|
||||
"""Zwraca embed URL z markerem producenta, manifest powstaje dopiero w
|
||||
`/proxy/hls` (patrz docstring modułu). Weryfikujemy tu, że manifest realnie
|
||||
da się wyprodukować, żeby nie oddawać martwego źródła."""
|
||||
if not matches(page_url):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Mixdrop embed hoster — P.A.C.K.E.R. eval → MDCore.wurl direct mp4.
|
||||
"""Mixdrop embed hoster, P.A.C.K.E.R. eval → MDCore.wurl direct mp4.
|
||||
|
||||
Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120):
|
||||
1. Fetch `https://mixdrop.my/e/<id>` → 200 z 95KB body, redirect 301 do
|
||||
|
|
@ -8,7 +8,7 @@ Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120):
|
|||
3. yt-dlp's `decode_packed_codes()` rozkrywa do ~390 chars JavaScript:
|
||||
`MDCore.wurl="//a-delivery22.mxcontent.net/v2/<id>.mp4?s=<sig>&e=<exp>&_t=<ts>"`
|
||||
4. URL na `mxcontent.net` zwraca **direct mp4** (Content-Type: video/mp4,
|
||||
Content-Length: ~485MB) — działa z Hetzner VPS IP, brak token IP-bind.
|
||||
Content-Length: ~485MB), działa z Hetzner VPS IP, brak token IP-bind.
|
||||
|
||||
`s` to signed token (HMAC?), `e` to expiry timestamp (unix sec), `_t` to
|
||||
issued timestamp. Token jest valid ~24h od `_t`. Refetching embed page po
|
||||
|
|
@ -42,7 +42,7 @@ _DEAD_RE = re.compile(
|
|||
|
||||
|
||||
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
|
||||
# UA + Accept są wymagane — bez nich mixdrop dla VALID video zwraca minimalny
|
||||
# UA + Accept są wymagane, bez nich mixdrop dla VALID video zwraca minimalny
|
||||
# body bez P.A.C.K.E.R. (sam stream_proxy._refetch_mixdrop_url też tak robi).
|
||||
# Brak headerów powodował że extract() na żywym mixdrop ID dostawał shell bez
|
||||
# packera → no match → None → mobile dostawał czarny ekran.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Wszyscy używają tego samego silnika (Vite-built React SPA + AES-CBC encrypted
|
|||
|
||||
Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI):
|
||||
|
||||
1. Embed URL = `https://<sub>.<host>.<tld>/#<hash_id>` — hash fragment to video ID.
|
||||
1. Embed URL = `https://<sub>.<host>.<tld>/#<hash_id>`, hash fragment to video ID.
|
||||
SPA shell `Loading...` body load'uje `/assets/index-<n>.js` bundle.
|
||||
|
||||
2. JS fetcha `/api/v1/video?id=<hash_id>&w=<W>&h=<H>&r=` (W,H z window.screen).
|
||||
|
|
@ -20,15 +20,15 @@ Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI):
|
|||
- `title`, `poster`, `thumbnail`, ...
|
||||
|
||||
4. `source` URL jest signed z visitor IP. Z VPS fetch zwraca master.m3u8 z signed
|
||||
token tied to VPS IP — proxy fetcha segments z tym samym tokenem, działa.
|
||||
token tied to VPS IP, proxy fetcha segments z tym samym tokenem, działa.
|
||||
CDN port 443 z `verify=False` (self-signed IP cert).
|
||||
|
||||
5. Wszystkie hostery share te same wartości KEY/IV. Wewnętrzna obfuskacja JS
|
||||
maskuje to lookupem `ue(773)`, `ue(686)` itp. — derived bytes są zawsze
|
||||
maskuje to lookupem `ue(773)`, `ue(686)` itp., derived bytes są zawsze
|
||||
identyczne dla każdej domeny.
|
||||
|
||||
Hostery covered (origin counts w DB, 2026-05-15):
|
||||
- embedseek (20271), seekplayer (20271) — mirror sites, dzielą hash_id
|
||||
- embedseek (20271), seekplayer (20271), mirror sites, dzielą hash_id
|
||||
- rpmplay (15317)
|
||||
- upns (14287)
|
||||
- player4me (41040)
|
||||
|
|
@ -61,10 +61,10 @@ _IV = b"1234567890oiuytr"
|
|||
#
|
||||
# 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`.
|
||||
# Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne
|
||||
# domeny — bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania.
|
||||
# domeny, bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania.
|
||||
# Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro
|
||||
# (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy)
|
||||
# dekodują się tym samym silnikiem. Whitelist jest addytywna — istniejące hosty
|
||||
# dekodują się tym samym silnikiem. Whitelist jest addytywna, istniejące hosty
|
||||
# matchują się dalej tak samo.
|
||||
_HOST_RE = re.compile(
|
||||
r"^(?:[a-z0-9]+\.)?"
|
||||
|
|
@ -142,8 +142,8 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
|||
cf = (data.get("cf") or "").strip()
|
||||
|
||||
# Source: IP-bound m3u8 URL na CDN edge (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8`).
|
||||
# Token signed dla VPS IP — proxy poda segmenty z tego samego IP, OK.
|
||||
# CDN servuje cert na IP — fetch wymaga verify=False (stream_proxy.py ma już
|
||||
# Token signed dla VPS IP, proxy poda segmenty z tego samego IP, OK.
|
||||
# CDN servuje cert na IP, fetch wymaga verify=False (stream_proxy.py ma już
|
||||
# taką gałąź dla IP-host m3u8).
|
||||
sources: list[StreamSource] = []
|
||||
if source:
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ Pattern (verified 2026-05-15 z residential, live URL `/e/PZqBZp4OomF0Q61`):
|
|||
'//streamtape.com/get_video' +
|
||||
('<junk>?id=<id>&expires=...&ip=...&token=...').substring(N).substring(M);
|
||||
|
||||
Junk to 3-4 znaki przed `?` — substring(N).substring(M) je odcina.
|
||||
Junk to 3-4 znaki przed `?`, substring(N).substring(M) je odcina.
|
||||
|
||||
2. Po sklejeniu fetch `https://streamtape.com/get_video?id=...&token=...` →
|
||||
302 → `https://<cluster>.tapecontent.net/radosgw/<id>/<signed_path>/<title>.mp4`
|
||||
(direct mp4, video/mp4 ~500MB, brak IP-bind).
|
||||
|
||||
3. Body czasem zwraca `Video not found! Maybe it got deleted by the creator!`
|
||||
— większość URLów w naszej DB (12k mass-DMCA'd 2026-05-15). Wtedy raise
|
||||
Tak jest dla większości URLów w naszej DB (12k mass-DMCA'd 2026-05-15). Wtedy raise
|
||||
HosterDead, caller w playback.py oznaczy dead_at.
|
||||
|
||||
Live URL coverage probed 2026-05-15: ~5% URLów żyje, reszta `Video not found`.
|
||||
|
|
@ -33,10 +33,10 @@ from app.extractors._models import HosterDead, StreamSource
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
# Match: `getElementById('xlink').innerHTML = "<prefix>" + '' + ('<suffix>').substring(N).substring(M);`
|
||||
# Streamtape generuje 4 assignmenty (ideoolink x2 + botlink + robotlink) — 2 są DECOYs
|
||||
# Streamtape generuje 4 assignmenty (ideoolink x2 + botlink + robotlink), 2 są DECOYs
|
||||
# z połamanym hostname (`.comb`, `.cob`) i tylko botlink/robotlink dają prawdziwy URL.
|
||||
# Prefix może być fragmentem: `/streamtape.com`, `//streamtape.co`, `//streamtape.com/g`
|
||||
# — `get_video` często jest split między prefix i suffix po slice'ach. Decyzja na
|
||||
# `get_video` często jest split między prefix i suffix po slice'ach. Decyzja na
|
||||
# podstawie KOMBINOWANEGO output containing exact `streamtape.com/get_video?`.
|
||||
_ASSIGN_RE = re.compile(
|
||||
r"document\.getElementById\(['\"](?P<elem>[a-z]+link)['\"]\)\.innerHTML\s*=\s*"
|
||||
|
|
@ -74,7 +74,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
|||
if _NOT_FOUND_RE.search(r.text):
|
||||
raise HosterDead(f"streamtape {page_url}: Video not found")
|
||||
|
||||
# Spróbuj wszystkie 4 assignmenty — pierwszy poprawny URL wygrywa.
|
||||
# Spróbuj wszystkie 4 assignmenty, pierwszy poprawny URL wygrywa.
|
||||
# `get_video` może być w prefix (residential variant) lub split prefix+suffix
|
||||
# (VPS variant gdzie decoy assignmenty produkują `.comb/get_video`).
|
||||
final_url: str | None = None
|
||||
|
|
@ -91,7 +91,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
|||
url = "https:/" + combined # `/streamtape.com/...` → `https://streamtape.com/...`
|
||||
else:
|
||||
url = combined
|
||||
# Walidacja — odsiewa decoys (`streamtape.comb`, `streamtape.cob`).
|
||||
# Walidacja, odsiewa decoys (`streamtape.comb`, `streamtape.cob`).
|
||||
if (
|
||||
"streamtape.com/get_video?" in url
|
||||
and "id=" in url
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ Pattern (verified 2026-05-15 z VPS Hetzner FI):
|
|||
|
||||
window.location.href = 'https://<random>.com/e/<id>'
|
||||
|
||||
Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.) —
|
||||
Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.),
|
||||
bierzemy LITERAL `Location` z window.location.href assignment.
|
||||
|
||||
2. Mirror embed page 137KB zawiera `<script type="application/json">["DROH@$nJjm..."]</script>`
|
||||
— pojedyncza zakodowana string z chunkami 4-char rozdzielonymi 7 distinct
|
||||
To pojedyncza zakodowana string z chunkami 4-char rozdzielonymi 7 distinct
|
||||
2-char delimiterami: `@$`, `^^`, `~@`, `%?`, `*~`, `!!`, `#&`.
|
||||
|
||||
3. Loader `/js/loader.bc4a6543429.js` (83KB obfuscator.io) wczytuje payload przez
|
||||
|
|
@ -28,8 +28,8 @@ Pattern (verified 2026-05-15 z VPS Hetzner FI):
|
|||
|
||||
4. Decoded JSON ma `source` (HLS m3u8 na `cloudwindow-route.com` z signed token)
|
||||
+ `fallback: [{file: ".mp4", type: "mp4", label: "720"}]`. URL signed
|
||||
z `i=<visitor IP first 2 octets>` — z VPS dostajemy `i=46.62` (Hetzner).
|
||||
Token IP-bound do tych pierwszych 2 oktetów (luźne) — proxy działa.
|
||||
z `i=<visitor IP first 2 octets>`, z VPS dostajemy `i=46.62` (Hetzner).
|
||||
Token IP-bound do tych pierwszych 2 oktetów (luźne), proxy działa.
|
||||
|
||||
5. CDN host losowy ale wzorzec stały. Wymaga Referer = voe.sx (lub mirror) bo
|
||||
token z `?node=` valida.
|
||||
|
|
@ -72,7 +72,7 @@ def _decode_payload(payload: str) -> dict | None:
|
|||
for sep in _MAGIC_SEPS:
|
||||
s = s.replace(sep, "_")
|
||||
s = s.replace("_", "")
|
||||
# 1st atob — uses latin-1 to preserve all 256 byte values for shift step.
|
||||
# 1st atob, uses latin-1 to preserve all 256 byte values for shift step.
|
||||
b = base64.b64decode(s + "=" * (-len(s) % 4)).decode("latin-1")
|
||||
shifted = "".join(chr(ord(c) - 3) for c in b)
|
||||
reversed_str = shifted[::-1]
|
||||
|
|
@ -142,7 +142,7 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
|||
if isinstance(fallback, dict):
|
||||
fallback = [fallback]
|
||||
|
||||
# Voe CDN URL ma `i=<2-octet IP prefix>` — token loose-bound do IP range.
|
||||
# Voe CDN URL ma `i=<2-octet IP prefix>`, token loose-bound do IP range.
|
||||
# Proxy z VPS dostaje signed dla `i=46.62`, fetch działa.
|
||||
referer = "https://voe.sx/"
|
||||
sources: list[StreamSource] = []
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"""Wybór najlepszego iframe-hostera z page HTML tube'a typu wrapper.
|
||||
|
||||
Tube'y typu siska/perverzija/latestpornvideo nie hostują player'a same — embedują
|
||||
Tube'y typu siska/perverzija/latestpornvideo nie hostują player'a same, embedują
|
||||
zewnętrznych hosterów (luluvid, doodporn, mixdrop, streamtape, ...). Detail page
|
||||
zawiera typowo 2-5 iframes:
|
||||
- ad-iframes (willingcease.com, popads, discord, about:blank)
|
||||
- dead hosters (streamtape malware, openload offline)
|
||||
- file-hosters (rapidgator, nitroflare — premium walled)
|
||||
- file-hosters (rapidgator, nitroflare, premium walled)
|
||||
- "fake" hosters typu playmogo (niszowe forki, brak extractora)
|
||||
- real KVS hosters (luluvid, doodporn, mixdrop, voe — `extract_stream_from_hoster`
|
||||
- real KVS hosters (luluvid, doodporn, mixdrop, voe, `extract_stream_from_hoster`
|
||||
radzi sobie z nimi przez KVS markers + yt-dlp generic)
|
||||
|
||||
`extract_best_iframe()` filtruje śmieci i preferuje hostery known-working z naszej
|
||||
|
|
@ -21,9 +21,9 @@ import re
|
|||
|
||||
from app.extractors.tubes._embed_iframe import DEAD_HOSTER_RE
|
||||
|
||||
# Ad / popup iframes — popunder networks + Discord widget + about:blank.
|
||||
# Ad / popup iframes, popunder networks + Discord widget + about:blank.
|
||||
# Niektóre tube'y embedują kilka takich PRZED prawdziwym player'em żeby
|
||||
# zmonetyzować ruch — naszego extractora trzeba przeskoczyć żeby trafić na video.
|
||||
# zmonetyzować ruch, naszego extractora trzeba przeskoczyć żeby trafić na video.
|
||||
_AD_IFRAME_RE = re.compile(
|
||||
r"willingcease\.com"
|
||||
r"|popads|popunder|adsterra"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ Mapowanie sitetag → extractor jest w `EXTRACTORS` na końcu. `extract_tags(sit
|
|||
to public entrypoint.
|
||||
|
||||
UWAGA: nie zwracamy auto-generated word-ngramów (KVS bywa generuje "Tania",
|
||||
"Tania Amazon", "Amazon Shaft"... — bez wartości semantycznej). Dlatego pornditt
|
||||
NIE jest tu obsłużone — KVS tagi to garbage.
|
||||
"Tania Amazon", "Amazon Shaft"..., bez wartości semantycznej). Dlatego pornditt
|
||||
NIE jest tu obsłużone, KVS tagi to garbage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ def _dedupe(items: list[str], cap: int = 20) -> list[str]:
|
|||
|
||||
def extract_porntrex(html: str) -> list[str]:
|
||||
"""porntrex: <span>Categories:</span><div class="items-holder js-categories"><a class="js-cat" href="/categories/<slug>/">NAME</a>...
|
||||
`Tags:` block na porntrex zawiera auto-generated n-gramy ("Tania", "L H") — pomijamy.
|
||||
`Tags:` block na porntrex zawiera auto-generated n-gramy ("Tania", "L H"), pomijamy.
|
||||
"""
|
||||
m = re.search(r'Categories:</span>\s*<div[^>]+>(.*?)</div>', html, re.DOTALL | re.IGNORECASE)
|
||||
if not m:
|
||||
|
|
@ -60,7 +60,7 @@ def extract_youporn(html: str) -> list[str]:
|
|||
|
||||
|
||||
def extract_xvideos(html: str) -> list[str]:
|
||||
"""xvideos: <a href="/tags/<slug>/">name</a> (slug-style names — przerobimy na Title Case)."""
|
||||
"""xvideos: <a href="/tags/<slug>/">name</a> (slug-style names, przerobimy na Title Case)."""
|
||||
matches = re.findall(
|
||||
r'<a[^>]+href="[^"]*?/tags/([a-z0-9-]+)/?"[^>]*>',
|
||||
html, re.IGNORECASE,
|
||||
|
|
@ -71,7 +71,7 @@ def extract_xvideos(html: str) -> list[str]:
|
|||
|
||||
def extract_xnxx(html: str) -> list[str]:
|
||||
"""xnxx: tagi w JSON `"video_tags":["ass","deep-throat","rimming",...]` wewnątrz
|
||||
html5player config (NIE w `/tags/` URL-ach jak xvideos — to są różne systemy
|
||||
html5player config (NIE w `/tags/` URL-ach jak xvideos, to są różne systemy
|
||||
pomimo tego że oba należą do WGCZ Holding).
|
||||
"""
|
||||
m = re.search(r'"video_tags"\s*:\s*\[([^\]]+)\]', html)
|
||||
|
|
@ -181,7 +181,7 @@ def extract_eporner(html: str) -> list[str]:
|
|||
def extract_hqporner(html: str) -> list[str]:
|
||||
"""hqporner: tagi siedzą w meta description po "Tags related to this video:".
|
||||
|
||||
Detail page jest "thin" (~26KB) bez tag links w body — wszystko jest tylko
|
||||
Detail page jest "thin" (~26KB) bez tag links w body, wszystko jest tylko
|
||||
w `<meta name="description">` w formie zdania:
|
||||
`Watch porn video <Title>. Video duration is Xmin Ysec. Tags related to
|
||||
this video: handjob, babe, for cash, blowjob, black hair.`
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ jednym z patternów:
|
|||
`<meta name="twitter:image" content="...">`
|
||||
3. **Schema.org VideoObject LD-JSON**:
|
||||
`"thumbnailUrl": "https://..."` lub `"thumbnailUrl": ["url1", "url2"]`
|
||||
4. **html5player** (KVS-based — xnxx/xvideos):
|
||||
4. **html5player** (KVS-based, xnxx/xvideos):
|
||||
`html5player.setThumbUrl('https://thumb-cdn77.xnxx-cdn.com/.../t.jpg')`
|
||||
|
||||
Funkcja zwraca pierwszy znaleziony URL (string), lub None gdy żaden nie pasuje.
|
||||
|
|
@ -34,7 +34,7 @@ _KVS_THUMB_RE = re.compile(
|
|||
# hqporner: stare WordPress bez og:image / schema. Poster `_main.jpg` siedzi w
|
||||
# atrybucie onclick (`changeImage('//cdn/_main.jpg', 'cover_<id>')`) lub w preload
|
||||
# array (`preload_<id> = ['//.../_1.jpg', ...]`). Wyciągamy `_main.jpg` z CDN-a
|
||||
# fastporndelivery.hqporner.com — to canonical poster, frames `_1..N.jpg` to
|
||||
# fastporndelivery.hqporner.com, to canonical poster, frames `_1..N.jpg` to
|
||||
# hover animation.
|
||||
_HQPORNER_THUMB_RE = re.compile(
|
||||
r"['\"](//[a-z0-9.\-]*hqporner[a-z0-9.\-]*/imgs/[^'\"]+_main\.jpg)['\"]",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ porndittcom, fpoxxx, hdporn92, sxyland, 0dayxx, ...):
|
|||
3. Filtruj iframe-y reklamowe (ad domains + `?key=`, `?idzone=` parametry).
|
||||
4. Spróbuj `extract_stream_from_hoster` (P.A.C.K.E.R. unpack JWPlayer) → m3u8/mp4.
|
||||
5. Jeśli nie wyciągnęło URL (CAPTCHA, multi-step JS, anti-bot), fallback do
|
||||
hoster type — mobile otworzy w WebView, hoster JS sam zrobi swoje.
|
||||
hoster type, mobile otworzy w WebView, hoster JS sam zrobi swoje.
|
||||
|
||||
`referer` dla hoster fetchu to `https://<page_host>/` — komputujemy automatycznie
|
||||
`referer` dla hoster fetchu to `https://<page_host>/`, komputujemy automatycznie
|
||||
z `page_url`, więc rejestrujemy ten sam extractor pod wieloma sitetag-ami bez
|
||||
osobnych config'ów.
|
||||
"""
|
||||
|
|
@ -30,7 +30,7 @@ from app.extractors.hoster import extract_stream_from_hoster
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Każdy iframe na stronie — szeroki match. Filtrowanie reklamowych zachodzi niżej.
|
||||
# Każdy iframe na stronie, szeroki match. Filtrowanie reklamowych zachodzi niżej.
|
||||
# Generic TLD `[a-z]{2,8}` żeby pokryć egzotyczne hosting domains (.yt, .ws, .li,
|
||||
# .stream, .pro, .cloud, .live).
|
||||
ANY_IFRAME_RE = re.compile(
|
||||
|
|
@ -38,11 +38,11 @@ ANY_IFRAME_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Path patterns for "this is a player embed" — covers most hoster URL shapes:
|
||||
# Path patterns for "this is a player embed", covers most hoster URL shapes:
|
||||
# /e/<id>, /embed/<id>, /video/embed/<id> → StreamWish, doodporn, dood.yt, luluvdo
|
||||
# /embed-<id>(.html)? → xtapes.porn, niektóre stare hosty
|
||||
# /player/<id>(.php)?(?...)? → xtremestream.xyz, niektóre custom hosty
|
||||
# /v/<id>, /watch/<id> → emergency catch — niektóre tubes
|
||||
# /v/<id>, /watch/<id> → emergency catch, niektóre tubes
|
||||
PLAYER_PATH_RE = re.compile(
|
||||
r'/(?:e|embed|video/embed|v|watch|t|f|d|stream)/[a-zA-Z0-9_\-]+'
|
||||
r'|/embed-[a-zA-Z0-9_\-]+'
|
||||
|
|
@ -51,11 +51,11 @@ PLAYER_PATH_RE = re.compile(
|
|||
)
|
||||
|
||||
# Bare-slug player path (sdefx.cloud, niektóre playmogo): single path segment after host,
|
||||
# alphanumeric + dash. Żadnego `/folder/file` ani query stringa — to wykrycie hosterów
|
||||
# alphanumeric + dash. Żadnego `/folder/file` ani query stringa, to wykrycie hosterów
|
||||
# które nie używają standardowego `/e/<id>` patternu.
|
||||
BARE_SLUG_PATH_RE = re.compile(r'^/[a-zA-Z0-9_\-]{4,}/?$')
|
||||
|
||||
# Ad iframes — domeny reklam + parametry (idzone, key=) — odsiewamy nawet jeśli mają
|
||||
# Ad iframes, domeny reklam + parametry (idzone, key=), odsiewamy nawet jeśli mają
|
||||
# inne path patterns. Lista ze stron które rzeczywiście aggregator tubes serwują.
|
||||
AD_DOMAIN_RE = re.compile(
|
||||
r'(?:willingcease|propellerads|popads|popcash|trafficstars|exoclick|adsterra|'
|
||||
|
|
@ -70,9 +70,9 @@ AD_DOMAIN_RE = re.compile(
|
|||
)
|
||||
AD_QUERY_RE = re.compile(r'[?&](?:idzone|adkey|key|aff_id|adsrc)=', re.IGNORECASE)
|
||||
|
||||
# Direct stream URLs na page — niektóre aggregator tubes (porn4days, xmoviesforyou)
|
||||
# Direct stream URLs na page, niektóre aggregator tubes (porn4days, xmoviesforyou)
|
||||
# wstawiają full mp4/m3u8 URL bezpośrednio w HTML/JS (download link, video.js source,
|
||||
# JWPlayer config). Skanujemy je przed iframe processing — direct URL > iframe wymagający
|
||||
# JWPlayer config). Skanujemy je przed iframe processing, direct URL > iframe wymagający
|
||||
# WebView. Wymagamy że path zawiera quality marker (`<digits>p.mp4`) lub `.m3u8` żeby
|
||||
# uniknąć false-positives z thumbnail/preview generation endpoints.
|
||||
_DIRECT_MP4_RE = re.compile(
|
||||
|
|
@ -87,10 +87,10 @@ _DIRECT_M3U8_RE = re.compile(
|
|||
|
||||
# Hosty którym `_DIRECT_*_RE` regex matchuje (URL kończy się .mp4 z quality
|
||||
# marker w path) ALE które NIE serwują direct stream:
|
||||
# 1. File hosters (rapidgator/k2s/mediafire/...) — file storage, premium auth gate
|
||||
# Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4` — to download URL,
|
||||
# 1. File hosters (rapidgator/k2s/mediafire/...), file storage, premium auth gate
|
||||
# Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4`, to download URL,
|
||||
# mobile dostaje login page (HTML), nie video.
|
||||
# 2. Embed page'y z mp4-suffix path (playmogo/dood/filemoon /d/<id>/<filename>.mp4) —
|
||||
# 2. Embed page'y z mp4-suffix path (playmogo/dood/filemoon /d/<id>/<filename>.mp4),
|
||||
# faktycznie HTML embed page, CAPTCHA-walled.
|
||||
# Bez filter Stage 0.5 zwracał tę URL jako type=mp4 i ExoPlayer dostawał HTML zamiast
|
||||
# video → black screen / playback failed.
|
||||
|
|
@ -106,11 +106,11 @@ _NOT_DIRECT_STREAM_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# JS-defined server URLs — niektóre aggregator tubes (porn4days) trzymają backup
|
||||
# JS-defined server URLs, niektóre aggregator tubes (porn4days) trzymają backup
|
||||
# hosterów w JavaScript variables, a iframe renderuje tylko pierwszy. Reszta jest
|
||||
# dostępna przez clickable "Server 2"/"Server 3" buttons które JS-em swapują src
|
||||
# iframe'a. Pattern: `const SERVER<N>_URL = "https://..."` lub `var server<N> =`.
|
||||
# Dla nas to są dodatkowe iframe URLs które trzeba zebrać jak inne — extract_stream_from_hoster
|
||||
# Dla nas to są dodatkowe iframe URLs które trzeba zebrać jak inne, extract_stream_from_hoster
|
||||
# spróbuje je rozpakować, a fallback to type=hoster dla mobile WebView.
|
||||
_JS_SERVER_URL_RE = re.compile(
|
||||
r'(?:const|let|var)?\s*(?:SERVER\d+_URL|server\d+(?:_url)?|src\d+|stream_?\d+|video_?\d+)\s*=\s*'
|
||||
|
|
@ -118,9 +118,9 @@ _JS_SERVER_URL_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Anchor-href hoster links — xmoviesforyou pattern: scene page nie ma <iframe> playera,
|
||||
# Anchor-href hoster links, xmoviesforyou pattern: scene page nie ma <iframe> playera,
|
||||
# tylko serię `<a href="https://playmogo.com/d/...">MIXDROP</a>` download buttons.
|
||||
# Match na anchor wskazujący na typowe hoster domeny — daje fallback gdy brak iframe.
|
||||
# Match na anchor wskazujący na typowe hoster domeny, daje fallback gdy brak iframe.
|
||||
_ANCHOR_HOSTER_RE = re.compile(
|
||||
r'<a\s+[^>]*href=["\'](?P<url>https?://(?:'
|
||||
r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|'
|
||||
|
|
@ -131,10 +131,10 @@ _ANCHOR_HOSTER_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Iframe src ukryty w escape'owanym JS string literal — porndish pattern:
|
||||
# Iframe src ukryty w escape'owanym JS string literal, porndish pattern:
|
||||
# `const doodstreamContent = "<iframe ... src=\"https://playmogo.com/e/xyz\" ...></iframe>";`
|
||||
# Server-side widzimy tylko pusty `<div id="iframeHolder">` (iframe wstrzykuje się JS-em
|
||||
# po kliknięciu "Video Player 1" button). Match na `src=\"<url>\"` w raw HTML source —
|
||||
# po kliknięciu "Video Player 1" button). Match na `src=\"<url>\"` w raw HTML source,
|
||||
# `\"` to backslash-quote z JSON-style escape'owania, `\/` to backslash-slash.
|
||||
# Po match'u czyścimy `\/` → `/` w captured URL.
|
||||
_JS_ESCAPED_IFRAME_SRC_RE = re.compile(
|
||||
|
|
@ -151,18 +151,18 @@ _DATA_EMBED_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Blacklista hosterów — wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie:
|
||||
# Blacklista hosterów, wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie:
|
||||
#
|
||||
# 1) Dead — w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran.
|
||||
# 1) Dead, w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran.
|
||||
# xtapes.porn → 301 → camcaps.to → 403 (zmigrowane 2026-04, infrastruktura zniknęła).
|
||||
#
|
||||
# 2) Malware — działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders
|
||||
# 2) Malware, działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders
|
||||
# przez chain reklamowy. yt-dlp odmawia ich ekstrakcji (oznaczone jako piracy), więc
|
||||
# nie ma mode'u "direct mp4 do ExoPlayera bez WebView" — jedyna opcja to WebView,
|
||||
# nie ma mode'u "direct mp4 do ExoPlayera bez WebView", jedyna opcja to WebView,
|
||||
# a WebView pozwala JS-owi hostera ściągnąć user execu plik. Bezpieczniej wyciąć je
|
||||
# całkowicie i pokazać user'owi page_url aggregator tube'a (rzadziej malicious niż
|
||||
# sam hoster). User nadal może tam wybrać alt player jeśli istnieje.
|
||||
# streamtape.com — notorious dla drive-by .reg downloads (zgłoszone 2026-05-07
|
||||
# streamtape.com, notorious dla drive-by .reg downloads (zgłoszone 2026-05-07
|
||||
# porn4days.pw → streamtape.com/e/<id> → popup + ściąganie .reg).
|
||||
# Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html`
|
||||
# w path nie matchował, tylko prawdziwy hostname (`https://filemoon.to/...` lub
|
||||
|
|
@ -177,7 +177,7 @@ DEAD_HOSTER_RE = re.compile(
|
|||
r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware
|
||||
r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors
|
||||
r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019)
|
||||
# filemoon — NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend"
|
||||
# filemoon, NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend"
|
||||
# zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie
|
||||
# za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22:
|
||||
# POST /api/videos/<code>/embed/playback {"fingerprint":{}} → AES-256-GCM →
|
||||
|
|
@ -188,12 +188,12 @@ DEAD_HOSTER_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# CAPTCHA-walled hosterzy — DoodStream variants serwują 5KB Cloudflare Turnstile
|
||||
# CAPTCHA-walled hosterzy, DoodStream variants serwują 5KB Cloudflare Turnstile
|
||||
# challenge response na server-side requests z VPS IP. Na phone (T-Mobile/PLAY)
|
||||
# czasem przechodzi, mobile-side resolver (mobile/src/lib/doodstream.ts) ma szansę.
|
||||
# ALE: gdy scene ma alt-hostera (np. luluvid, filemoon), tamten zazwyczaj nie ma
|
||||
# CAPTCHA gate → ExoPlayer odpala bezpośrednio. Sortujemy więc DoodStream NA KONIEC
|
||||
# listy — Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują
|
||||
# listy, Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują
|
||||
# najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts.
|
||||
CAPTCHA_HOSTER_RE = re.compile(
|
||||
r'(?:'
|
||||
|
|
@ -203,7 +203,7 @@ CAPTCHA_HOSTER_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# IP-BOUND CDN URLs — stream URLs które bindują się do requester IP (VPS resolve
|
||||
# IP-BOUND CDN URLs, stream URLs które bindują się do requester IP (VPS resolve
|
||||
# → mobile 403). Stage 1 server-side ekstrakta `extract_stream_from_hoster` zwraca
|
||||
# tę URL, ale mobile direct nie pobierze. Lepiej dropować mp4/m3u8 wynik i upaść
|
||||
# na hoster fallback (mobile WebView wyciągnie URL z phone IP/session).
|
||||
|
|
@ -215,14 +215,14 @@ _IP_BOUND_CDN_RE = re.compile(
|
|||
r"premilkyway\.com" # latestpornvideo
|
||||
r"|tnmr\.org" # mypornerleak (legacy CDN)
|
||||
r"|acek-cdn\.com" # mypornerleak (current CDN, shared KVS infra)
|
||||
# xtremestream.xyz (perverzija) — `player/xs1.php?data=` to NIE direct mp4 tylko
|
||||
# xtremestream.xyz (perverzija), `player/xs1.php?data=` to NIE direct mp4 tylko
|
||||
# IP-bound player endpoint (403 cross-IP z VPS). Stage 1 zwracał go jako type=mp4
|
||||
# → natywny player ładował HTML-player w nieskończoność (reports 06-24 "perverzija
|
||||
# nie działa"/"loading w nieskończoność"). Skip → hoster fallback: WebView ładuje
|
||||
# index.php playera z residential IP telefonu, xs1.php gra w jego sesji.
|
||||
r"|xtremestream\.[a-z]{2,8}"
|
||||
# URL signature shared across these CDNs: `/hls2/<XX>/<scene_id>/.../master.m3u8?t=<token>&s=<ts>&e=<exp>&srv=<srv>&asn=`
|
||||
# — `asn` query param = Autonomous System Number bind. Generic match jako safety net.
|
||||
# `asn` query param = Autonomous System Number bind. Generic match jako safety net.
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
|
@ -243,10 +243,10 @@ def _is_player_iframe(url: str) -> bool:
|
|||
return False
|
||||
if DEAD_HOSTER_RE.search(url):
|
||||
return False
|
||||
# Player path — `/e/`, `/embed/`, `/video/embed/`
|
||||
# Player path, `/e/`, `/embed/`, `/video/embed/`
|
||||
if PLAYER_PATH_RE.search(url):
|
||||
return True
|
||||
# Bare slug — `<host>/<id>` (no folder, no query) — sdefx.cloud-style.
|
||||
# Bare slug, `<host>/<id>` (no folder, no query), sdefx.cloud-style.
|
||||
parsed = urlparse(url if url.startswith("http") else "https:" + url.lstrip("/"))
|
||||
if BARE_SLUG_PATH_RE.match(parsed.path) and not parsed.query:
|
||||
return True
|
||||
|
|
@ -279,13 +279,13 @@ def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSou
|
|||
seen.add(url)
|
||||
if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url):
|
||||
continue
|
||||
# Filter pseudo-direct URLs: file hosters (rapidgator/k2s — premium auth gate)
|
||||
# i embed pages z .mp4 suffix (playmogo/dood /d/ — to HTML, nie video).
|
||||
# Filter pseudo-direct URLs: file hosters (rapidgator/k2s, premium auth gate)
|
||||
# i embed pages z .mp4 suffix (playmogo/dood /d/ to HTML, nie video).
|
||||
if _NOT_DIRECT_STREAM_RE.search(url):
|
||||
continue
|
||||
# KVS false-positives (report c156e4b7/8db71220, watchporn przez _embed_iframe):
|
||||
# preview_/videos_screenshots to krótkie trailery (404), a surowy get_file/ bez
|
||||
# podpisanego query-tokena daje 403 — kt_player dokłada token z license_code (patrz
|
||||
# podpisanego query-tokena daje 403, kt_player dokłada token z license_code (patrz
|
||||
# natywny watchporn.extract). Żadne z nich to pełne wideo → pomijamy.
|
||||
low = url.lower()
|
||||
if "videos_screenshots/" in low or "/preview_" in low:
|
||||
|
|
@ -328,7 +328,7 @@ def extract(
|
|||
# Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou
|
||||
# native players). Wymagamy quality marker w path (`<N>p.mp4`) żeby uniknąć
|
||||
# thumbnail-preview false-positives. Jeśli znaleziono → zwracamy, skipping iframe
|
||||
# processing — direct stream URL > WebView fallback.
|
||||
# processing, direct stream URL > WebView fallback.
|
||||
direct_sources = _extract_direct_stream_urls(page_html, page_url)
|
||||
if direct_sources:
|
||||
log.info("embed_iframe: found %d direct stream URLs on %s",
|
||||
|
|
@ -336,7 +336,7 @@ def extract(
|
|||
return direct_sources
|
||||
|
||||
# Znajdź WSZYSTKIE iframe-y które wyglądają na player. Wcześniej braliśmy tylko
|
||||
# pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie —
|
||||
# pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie,
|
||||
# gdy pierwszy ma CF challenge / ad-heavy player (playmogo), drugi (luluvid)
|
||||
# może być cleaner. Dedupe po URL żeby nie dublować tego samego playera.
|
||||
# Trzymamy też raw_iframes (pre-filter) żeby odróżnić "page nie ma iframe-a" od
|
||||
|
|
@ -364,7 +364,7 @@ def extract(
|
|||
# JS-hidden backup servers (porn4days SERVER<N>_URL pattern). Niektóre tube'y
|
||||
# renderują tylko 1 iframe a backup hosterów trzymają w `const SERVER2_URL = "..."`
|
||||
# JS variable + clickable "Server 2" button. Bez tego ekstraktor widzi tylko
|
||||
# SERVER1 (najczęściej iceyfile/streamtape) — gdy ten 404 / malware-blocked,
|
||||
# SERVER1 (najczęściej iceyfile/streamtape), gdy ten 404 / malware-blocked,
|
||||
# cały scrape ginie. Backupy mogą być na czystych hosterach (turbovidhls/veev.to).
|
||||
for m in _JS_SERVER_URL_RE.finditer(page_html):
|
||||
candidate = m.group("url").strip()
|
||||
|
|
@ -416,7 +416,7 @@ def extract(
|
|||
iframe_urls.append(candidate)
|
||||
|
||||
# Escape'owane iframe src w JS string literals (porndish pattern). Iframe wstrzykuje
|
||||
# się do DOM po kliknięciu user'a — server-side HTML widzimy tylko `<div id="iframeHolder">`,
|
||||
# się do DOM po kliknięciu user'a, server-side HTML widzimy tylko `<div id="iframeHolder">`,
|
||||
# a iframe URL jest w `const doodstreamContent = "<iframe ... src=\"https:\/\/...\" ...>"`.
|
||||
# Po match'u czyścimy backslash escape: `\/` → `/`, `\\\"` → `"` (jeśli)
|
||||
for m in _JS_ESCAPED_IFRAME_SRC_RE.finditer(page_html):
|
||||
|
|
@ -435,17 +435,17 @@ def extract(
|
|||
iframe_urls.append(candidate)
|
||||
|
||||
# Stable-sort: CAPTCHA-walled hosty (DoodStream variants) NA KONIEC. Zachowujemy
|
||||
# DOM order wśród non-CAPTCHA — preferred player tube'a (zazwyczaj pierwszy) zostaje.
|
||||
# DOM order wśród non-CAPTCHA, preferred player tube'a (zazwyczaj pierwszy) zostaje.
|
||||
# Powód: siska/xmoviesforyou wstawiają playmogo (dood) jako pierwszy iframe, luluvid
|
||||
# jako drugi — bez sortu mobile bierze playmogo → CAPTCHA → czarny ekran. Z sortem
|
||||
# jako drugi, bez sortu mobile bierze playmogo → CAPTCHA → czarny ekran. Z sortem
|
||||
# luluvid (lub inny clean hoster) ląduje na pozycji 1, ExoPlayer ma szansę.
|
||||
iframe_urls.sort(key=_hoster_priority)
|
||||
|
||||
if not iframe_urls:
|
||||
# Wszystkie iframe-y na stronie zostały odsiane (DEAD_HOSTER_RE — np. streamtape,
|
||||
# Wszystkie iframe-y na stronie zostały odsiane (DEAD_HOSTER_RE, np. streamtape,
|
||||
# xtapes.porn). Page-as-hoster nie pomoże bo WebView na aggregator page i tak
|
||||
# załaduje ten sam (zablacklistowany) hoster. Lepiej zwrócić None żeby mobile
|
||||
# dostał 501 i pokazał error — user wybierze inną źródło, nie dostanie malware.
|
||||
# dostał 501 i pokazał error, user wybierze inną źródło, nie dostanie malware.
|
||||
if raw_iframes_count > 0:
|
||||
log.info(
|
||||
"embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None",
|
||||
|
|
@ -454,7 +454,7 @@ def extract(
|
|||
return None
|
||||
# Brak iframe-ów w ogóle (JS-only render np. mypornerleak's muliframe.js,
|
||||
# CloudFlare JS challenge na xxxfree.watch, login-walled tubes). Page-as-hoster
|
||||
# ma sens — WebView przejdzie CF challenge, wyrenderuje JS, da user'owi player.
|
||||
# ma sens, WebView przejdzie CF challenge, wyrenderuje JS, da user'owi player.
|
||||
log.info("embed_iframe: no iframes in %s — page-as-hoster fallback", page_url)
|
||||
page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
|
||||
return [StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")]
|
||||
|
|
@ -464,9 +464,9 @@ def extract(
|
|||
|
||||
# Stage 0: yt-dlp generic na PAGE URL (nie iframe). Niektóre tube'y (pornditt,
|
||||
# latestleaks) mają KVS player config (`kt_player(`, `license_code:`) wprost na
|
||||
# scene page'u — embed iframe to czysta CSS shell. yt-dlp generic potrafi
|
||||
# scene page'u, embed iframe to czysta CSS shell. yt-dlp generic potrafi
|
||||
# zdeszyfrować KVS URL z page'a, więc wolimy to niż WebView fallback.
|
||||
# Referer = pełny page URL (nie host root) — KVS get_file/ URL jest signed
|
||||
# Referer = pełny page URL (nie host root), KVS get_file/ URL jest signed
|
||||
# względem konkretnej page'a, host root daje 410.
|
||||
if all(marker in page_html for marker in ("kt_player(", "license_code")):
|
||||
from app.extractors.hoster import _try_ytdlp_hoster
|
||||
|
|
@ -477,8 +477,8 @@ def extract(
|
|||
|
||||
# Stage 1: spróbuj wyekstraktować direct video URL z każdego iframe'a po kolei.
|
||||
# Direct mp4/m3u8 idzie PIERWSZY w wyniku (ExoPlayer natywnie >> WebView), ale
|
||||
# NIE pomijamy reszty iframe — dodajemy je jako hoster fallback. Powód: niektóre
|
||||
# CDN-y file storage (vidnest.live, iceyfile) blokują Hetzner ASN — Stage 1 wyciąga
|
||||
# NIE pomijamy reszty iframe, dodajemy je jako hoster fallback. Powód: niektóre
|
||||
# CDN-y file storage (vidnest.live, iceyfile) blokują Hetzner ASN, Stage 1 wyciąga
|
||||
# URL z embed page, ale ani VPS proxy ani mobile go nie pobierze (No route to host
|
||||
# albo 403). Bez iframe fallbacku mobile dostaje 503 i utyka. Z fallbackiem chain:
|
||||
# direct → proxy → iframe1 (WebView) → iframe2 (WebView) → page (WebView).
|
||||
|
|
@ -491,7 +491,7 @@ def extract(
|
|||
log.warning("embed_iframe: extract failed for %s: %s", iframe_url, e)
|
||||
continue
|
||||
if stream_url:
|
||||
# IP-bound CDN check — skip Stage 1 result, force fallback na hoster
|
||||
# IP-bound CDN check, skip Stage 1 result, force fallback na hoster
|
||||
# (mobile WebView pobiera embed page z phone IP). Critical dla public
|
||||
# release: premilkyway/tnmr.org bind token do requester IP.
|
||||
if _IP_BOUND_CDN_RE.search(stream_url):
|
||||
|
|
@ -516,13 +516,13 @@ def extract(
|
|||
sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)"))
|
||||
return sources
|
||||
|
||||
# Stage 2: wszystkie iframe-y zwróć jako hoster sources — mobile dostaje listę
|
||||
# Stage 2: wszystkie iframe-y zwróć jako hoster sources, mobile dostaje listę
|
||||
# alternatyw, użytkownik może switchować gdy pierwszy ma overlay/CF challenge.
|
||||
# Pierwszy w liście ma być najczystszy — ale bez wiedzy a priori które są ad-heavy
|
||||
# Pierwszy w liście ma być najczystszy, ale bez wiedzy a priori które są ad-heavy
|
||||
# zostawiamy kolejność DOM (zwykle authoring tube wstawia preferred player jako
|
||||
# pierwszy, a drugi jako backup). Quality ustawiamy na nazwę hosta iframe'a żeby
|
||||
# PlaybackQualityModal pokazał użytkownikowi rozróżnialne opcje (np. "playmogo"
|
||||
# vs "luluvid"). Na końcu page_url jako safety-net — gdy wszystkie iframe'y są
|
||||
# vs "luluvid"). Na końcu page_url jako safety-net, gdy wszystkie iframe'y są
|
||||
# martwe (np. sxyland → xtapes.porn 301→camcaps.to 403), mobile WebView otworzy
|
||||
# główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player.
|
||||
sources = []
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""Współdzielony resolver dla KVS (kt_player) tube'ów z `function/0/` + `license_code`.
|
||||
|
||||
Wzorzec (yespornvip, pornditt, ... — wszystkie KVS na tym samym silniku):
|
||||
Wzorzec (yespornvip, pornditt, ..., wszystkie KVS na tym samym silniku):
|
||||
flashvars: `video_url` / `video_alt_url` / `video_alt_url2` = `function/0/https://<host>/
|
||||
get_file/<srv>/<HASH>/.../<id>.mp4/` (480/720/1080p) + `license_code: '$...'`.
|
||||
kt_player dekoduje HASH (permutacja pierwszych 32 znaków algorytmem license_code —
|
||||
kt_player dekoduje HASH (permutacja pierwszych 32 znaków algorytmem license_code,
|
||||
algo zgodny z yt-dlp KVS `_kvs_get_real_url`, zweryfikowany 2026-05-31 że odtwarza
|
||||
output kt_player).
|
||||
|
||||
Zdekodowany `get_file` 302-redirectuje do CDN (cdn5.yesporn.vip / twa.tgprn.com / ...)
|
||||
który serwuje wideo (206). Finalny URL jest **time-bound signed, NIE IP/cookie-bound** —
|
||||
który serwuje wideo (206). Finalny URL jest **time-bound signed, NIE IP/cookie-bound**,
|
||||
portable cross-IP. get_file token jest session-bound, więc 302 MUSI być rozwiązany w tej
|
||||
samej sesji curl_cffi co fetch strony. Oddajemy finalny portable CDN url per jakość →
|
||||
mobile gra direct natywnie, multi-quality, zero WebView/reklam/proxy.
|
||||
|
|
@ -29,9 +29,9 @@ from app.extractors._models import StreamSource
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
# Łapie OBA formaty flashvars:
|
||||
# - `function/0/https://.../get_file/<HASH>/...` (yespornvip/pornditt/freshporno —
|
||||
# - `function/0/https://.../get_file/<HASH>/...` (yespornvip/pornditt/freshporno,
|
||||
# zakodowany, dekodowany przez real_url)
|
||||
# - `https://.../get_file/...` (porn00 — już zdekodowany plain get_file; real_url
|
||||
# - `https://.../get_file/...` (porn00, już zdekodowany plain get_file; real_url
|
||||
# przepuszcza go bez zmian)
|
||||
# W obu przypadkach finał to get_file → 302 → CDN (follow in-session).
|
||||
_URL_RE = re.compile(
|
||||
|
|
@ -49,7 +49,7 @@ _HASH_LENGTH = 32
|
|||
|
||||
# get_file 302-follow timeout. Zdrowy CDN odpowiada <1s; gdy wideo zostało usunięte
|
||||
# z CDN (strona istnieje, ale get_file stalluje) request wisi do timeoutu. Trzymamy
|
||||
# go NISKO i osobno od page-fetch timeoutu — bug 6ec1960e 2026-06-02: yesporn.vip
|
||||
# go NISKO i osobno od page-fetch timeoutu, bug 6ec1960e 2026-06-02: yesporn.vip
|
||||
# przeszedł na cdn4/remote_control.php i dla martwych scen wszystkie 3 jakości
|
||||
# wisiały po 60s = 180s → mobile "resolving w nieskończoność".
|
||||
_GETFILE_TIMEOUT = 10.0
|
||||
|
|
@ -117,7 +117,7 @@ def resolve_kvs(page_url: str, *, base_url: str, timeout: float = 60.0) -> list[
|
|||
"""Fetch KVS page → decode function/0 get_file (per jakość) → follow 302 → portable CDN.
|
||||
|
||||
Zwraca StreamSource'y posortowane malejąco po jakości, lub None gdy nic nie wyszło.
|
||||
base_url: scheme+host hosta (np. 'https://yesporn.vip') — do Referera i logów.
|
||||
base_url: scheme+host hosta (np. 'https://yesporn.vip'), do Referera i logów.
|
||||
"""
|
||||
if not _HAS_CURL_CFFI:
|
||||
log.info("kvs: curl_cffi unavailable — cannot resolve %s", page_url)
|
||||
|
|
@ -150,7 +150,7 @@ def resolve_kvs(page_url: str, *, base_url: str, timeout: float = 60.0) -> list[
|
|||
|
||||
# get_file timeout NISKI (osobny od page-fetch) + early-break: gdy 2 pierwsze
|
||||
# jakości nie rozwiążą się (przy 0 dotychczasowych wyników) scena jest martwa na
|
||||
# CDN — nie ma sensu czekać na trzecią (oszczędza kolejne _GETFILE_TIMEOUT).
|
||||
# CDN, nie ma sensu czekać na trzecią (oszczędza kolejne _GETFILE_TIMEOUT).
|
||||
gf_timeout = min(timeout, _GETFILE_TIMEOUT)
|
||||
seen_dec: set[str] = set()
|
||||
result: list[StreamSource] = []
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue