goon/app/extractors/tubes/_embed_iframe.py
goon-foss ad0284585b Initial commit
Goon — self-hosted aggregator for adult-content scene metadata.

Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites.
Cross-source deduplication via perceptual hash + Levenshtein distance.
FastAPI backend + APScheduler worker + React Native (Expo) mobile client.

FOSS, ad-free, donation-funded. See README for details.
2026-05-20 10:10:22 +02:00

520 lines
27 KiB
Python

"""Generic embed-iframe stream extractor.
Wzorzec stosowany przez większość aggregator tubes (latestpornvideo, xmoviesforyou,
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
porndittcom, fpoxxx, hdporn92, sxyland, 0dayxx, ...):
1. Fetch page HTML
2. Znajdź `<iframe src=...>` wskazujący na hoster (StreamWish/doodporn/luluvdo/
mediafire/sdefx/playmogo/...). Iframe path moze być `/e/<id>`, `/embed/<id>`,
`/video/embed/<id>` LUB goły slug `/<id>` (sdefx.cloud, niektóre playmogo).
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.
`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.
"""
from __future__ import annotations
import logging
import re
from urllib.parse import urlparse
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
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.
# Generic TLD `[a-z]{2,8}` żeby pokryć egzotyczne hosting domains (.yt, .ws, .li,
# .stream, .pro, .cloud, .live).
ANY_IFRAME_RE = re.compile(
r'<iframe[^>]+src=["\'](?P<url>(?:https?:)?(?://)?[a-z0-9.-]+\.[a-z]{2,8}/[^"\']+)',
re.IGNORECASE,
)
# 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
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_\-]+'
r'|/player(?:/[a-zA-Z0-9_/.\-]+)?',
re.IGNORECASE,
)
# 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
# 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ą
# 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|'
r'happyleafmotion|adskeeper|hilltopads|juicyads|trafficjunky|adblade|'
# Cam-widget / smartpop / pop-under sieci embed'owane przez aggregator tubes
# (porndish→mavrtracktor, xmoviesforyou→adtng+bluetrafficstream). Brak ich w
# liście powodował że `raw_iframes_count` rosło → fall-through do "all blacklisted"
# → None zamiast page-as-hoster fallback (WebView na page'a aggregatora).
r'mavrtracktor|adtng|bluetrafficstream|smartpop|chaturbate|stripchat|streamate|'
r'mypornclub|cdntrafficstars|trafficfactory|popcrn|popmyads|adcash)\.[a-z]{2,8}',
re.IGNORECASE,
)
AD_QUERY_RE = re.compile(r'[?&](?:idzone|adkey|key|aff_id|adsrc)=', re.IGNORECASE)
# Direct stream URLs na page — niektóre aggregator tubes (porn4days, xmoviesforyou)
# 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
# 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(
r'(?P<url>https?://[a-zA-Z0-9.\-]+\.[a-z]{2,8}(?:/[^"\'\s<>]+)?/[^"\'\s<>]*?'
r'(?:480p|720p|1080p|2160p|360p|240p|144p)[^"\'\s<>]*\.mp4(?:\?[^"\'\s<>]*)?)',
re.IGNORECASE,
)
_DIRECT_M3U8_RE = re.compile(
r'(?P<url>https?://[a-zA-Z0-9.\-]+\.[a-z]{2,8}/[^"\'\s<>]+?\.m3u8(?:\?[^"\'\s<>]*)?)',
re.IGNORECASE,
)
# 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,
# mobile dostaje login page (HTML), nie video.
# 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.
_NOT_DIRECT_STREAM_RE = re.compile(
r'(?:'
r'rapidgator\.net|rg\.to|k2s\.cc|keep2share|nitroflare|turbobit|hexupload|'
r'1fichier|uploaded\.(?:net|to)|ul\.to|mega\.(?:co\.)?nz|mediafire|fastleech|'
# DoodStream rebrand /d/ download pages (HTML, not mp4)
r'playmogo\.|d0000d|dooood|d0o0d|do0od|do7go|doodstream|doodporn|dood\.(?:la|li|ws|so|to|watch|work|yt|re)|'
# File hide embed pages
r'streamhide|vidhide|filemoon|kerapoxy|moonseries'
r')',
re.IGNORECASE,
)
# 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
# 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*'
r'["\'](?P<url>https?://[^"\']+)["\']',
re.IGNORECASE,
)
# 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.
_ANCHOR_HOSTER_RE = re.compile(
r'<a\s+[^>]*href=["\'](?P<url>https?://(?:'
r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|'
r'filemoon|streamwish|sdefx|veev|turbovidhls|gounlimited|iceyfile|hlswish|'
r'mixdrop|voe|vidoza|mediafire|asnwish|obeywish|streamruby|hqq\.[a-z]+|'
r'feurl|streamhide|krakenfiles|earnvids|jollytuna|peekvids|playerwish'
r')\.[a-z]{2,8}/[^"\']+)["\']',
re.IGNORECASE,
)
# 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 —
# `\"` 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(
r'src=\\"(?P<url>https?:[^"]+?)\\"',
re.IGNORECASE,
)
# Data attribute pattern (mypornerleak's `data-embed="https://cdnstream.top/e/..."`).
# Tube renderuje `<div class="iframeholder" data-embed="...">` + JS które po user click
# wstawia iframe ze stored URL'em. Bez tego nasz ANY_IFRAME_RE widzi tylko pusty
# holder + ad iframes → falls back na page-as-hoster (mobile WebView na aggregator
# stronę = full-screen ad redirect, bug-report f1a01585 2026-05-17).
_DATA_EMBED_RE = re.compile(
r'data-embed=["\'](?P<url>(?:https?:)?(?://)?[a-z0-9.-]+\.[a-z]{2,8}/[^"\']+)',
re.IGNORECASE,
)
# 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.
# 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
# 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,
# 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
# 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
# `cdn.filemoon.to/...`). Bez tego DEAD_HOSTER_RE potencjalnie false-positive
# blacklistował legit URL-e z fragmentami w ścieżce (code-review #17).
DEAD_HOSTER_RE = re.compile(
r'(?:^|//|\.)'
r'(?:'
# camcaps.to dead. xtapes.porn ZNIESIONE 2026-05-15 (Chrome DevTools verify:
# → reelshdd.com/<id>.mp4 z residential IP = działa, tylko VPS blocked).
r'camcaps\.to' # dead
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.* — wszystkie mirrory (filemoon.to/sx/nl/in/ru/co + aliasy
# kerapoxy.cc, lvturbo.com) serwują od ~2026-05 ten sam SPA "Byse Frontend"
# placeholder bez player JS. Globalny shutdown. Siska/perverzija/xmoviesforyou
# mają filemoon jako default embed → wszystkie sceny przez ten path = dead
# iframe (bug-report 16966e77 2026-05-16 "Niby 404 ale graficzne"). Blacklist
# eliminuje próby + wymusza fallback na alt hostera / TubePageError None.
r'|filemoon\.[a-z]{2,4}|kerapoxy\.cc|lvturbo\.com|emturbovid\.com' # dead 2026-05
r')'
r'(?:[:/]|$)', # port, path, lub end-of-string
re.IGNORECASE,
)
# 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ą
# najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts.
CAPTCHA_HOSTER_RE = re.compile(
r'(?:'
r'(?:playmogo|doodstream|doodporn|ds2play|ds2video|d000d|d0o0d|do0od|do7go|dooood|d0000d)\.[a-z]{2,8}'
r'|dood\.(?:la|li|ws|so|to|watch|work|yt|re)'
r')',
re.IGNORECASE,
)
# 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).
#
# Bandwidth cost (public release): te tubes by szli całością przez VPS proxy.
# Skip ich z Stage 1 → mobile WebView → 0 VPS bandwidth.
_IP_BOUND_CDN_RE = re.compile(
r"\b(?:"
r"premilkyway\.com" # latestpornvideo
r"|tnmr\.org" # mypornerleak (legacy CDN)
r"|acek-cdn\.com" # mypornerleak (current CDN, shared KVS infra)
# 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.
r")\b",
re.IGNORECASE,
)
def _hoster_priority(url: str) -> int:
"""Niższa wartość = wcześniej w liście. CAPTCHA-walled hosty (DoodStream variants) na końcu."""
if CAPTCHA_HOSTER_RE.search(url):
return 1
return 0
def _is_player_iframe(url: str) -> bool:
"""Heurystyka: czy iframe wygląda na player a nie reklamę."""
if AD_DOMAIN_RE.search(url):
return False
if AD_QUERY_RE.search(url):
return False
if DEAD_HOSTER_RE.search(url):
return False
# 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.
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
return False
def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSource]:
"""Stage 0.5: scan page for direct mp4/m3u8 URLs (porn4days→iceyfile/gounlimited
download links, xmoviesforyou native player). Returns deduplicated sources
ordered by quality desc.
Returns empty list jeśli żadnych nie znaleziono lub wszystkie są na blacklisted
hosterach (streamtape itp.).
"""
seen: set[str] = set()
sources: list[tuple[int, StreamSource]] = [] # (quality_int, source) for sorting
quality_map = {
"2160p": 2160, "1080p": 1080, "720p": 720,
"480p": 480, "360p": 360, "240p": 240, "144p": 144,
}
page_host = (urlparse(page_url).hostname or "").lstrip("www.")
page_referer = f"https://{page_host}/" if page_host else page_url
for m in _DIRECT_MP4_RE.finditer(page_html):
url = m.group("url")
if url in seen:
continue
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).
if _NOT_DIRECT_STREAM_RE.search(url):
continue
# Quality wykrycie z nazwy pliku
q_int = 0
q_label = "mp4"
for q_str, q_val in quality_map.items():
if q_str in url.lower():
q_int = q_val
q_label = q_str
break
sources.append((q_int, StreamSource(link=url, type="mp4", quality=q_label, referer=page_referer)))
for m in _DIRECT_M3U8_RE.finditer(page_html):
url = m.group("url")
if url in seen:
continue
seen.add(url)
if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url):
continue
if _NOT_DIRECT_STREAM_RE.search(url):
continue
# m3u8 traktujemy jako wyższą jakość (adaptive)
sources.append((10000, StreamSource(link=url, type="m3u8", quality="auto", referer=page_referer)))
sources.sort(key=lambda x: -x[0])
return [s for _, s in sources]
def extract(
page_url: str,
*,
timeout: float = 60.0,
) -> list[StreamSource] | None:
page_html = fetch_tube_html(page_url, timeout=timeout)
# 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.
direct_sources = _extract_direct_stream_urls(page_html, page_url)
if direct_sources:
log.info("embed_iframe: found %d direct stream URLs on %s",
len(direct_sources), page_url)
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 —
# 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
# "page miał iframe ale został zablacklistowany jako malware/dead".
raw_iframes_count = 0
iframe_urls: list[str] = []
seen: set[str] = set()
for m in ANY_IFRAME_RE.finditer(page_html):
candidate = m.group("url").strip()
# Reklamowe iframe-y nie liczą się jako "raw iframe" (są zawsze obecne).
if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
continue
raw_iframes_count += 1
if not _is_player_iframe(candidate):
continue
if candidate.startswith("//"):
candidate = "https:" + candidate
elif not candidate.startswith("http"):
candidate = "https://" + candidate
if candidate in seen:
continue
seen.add(candidate)
iframe_urls.append(candidate)
# 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,
# 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()
if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
continue
if not _is_player_iframe(candidate):
continue
if candidate.startswith("//"):
candidate = "https:" + candidate
if candidate in seen:
continue
seen.add(candidate)
iframe_urls.append(candidate)
# Anchor-href hoster links (xmoviesforyou pattern). Tube page nie ma iframe playera,
# tylko `<a href="https://playmogo.com/d/...">` download buttons. Bez tego
# extractor zwraca tylko page-as-hoster → WebView na catalog page, user musi
# manualnie klikać hoster button. Po wyciągnięciu user dostaje hoster bezpośrednio
# w mobile sources list.
for m in _ANCHOR_HOSTER_RE.finditer(page_html):
candidate = m.group("url").strip()
if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
continue
if not _is_player_iframe(candidate):
continue
if candidate.startswith("//"):
candidate = "https:" + candidate
if candidate in seen:
continue
seen.add(candidate)
iframe_urls.append(candidate)
# Data attribute embed (mypornerleak: `data-embed="https://cdnstream.top/e/..."`).
# Iframe sam wstawia się przez muliframe.js po user click; HTML server-side
# ma tylko placeholder div + data-embed URL.
for m in _DATA_EMBED_RE.finditer(page_html):
candidate = m.group("url").strip()
if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
continue
if not _is_player_iframe(candidate):
continue
if candidate.startswith("//"):
candidate = "https:" + candidate
elif not candidate.startswith("http"):
candidate = "https://" + candidate
if candidate in seen:
continue
seen.add(candidate)
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">`,
# 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):
candidate = m.group("url").strip()
# Unescape JSON-style: \/ → /, \" → "
candidate = candidate.replace("\\/", "/").replace('\\"', '"')
if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
continue
if not _is_player_iframe(candidate):
continue
if candidate.startswith("//"):
candidate = "https:" + candidate
if candidate in seen:
continue
seen.add(candidate)
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.
# Powód: siska/xmoviesforyou wstawiają playmogo (dood) jako pierwszy iframe, luluvid
# 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,
# 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.
if raw_iframes_count > 0:
log.info(
"embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None",
raw_iframes_count, page_url,
)
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.
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)")]
host = urlparse(page_url).hostname or ""
referer = f"https://{host}/" if host else page_url
# 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
# 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
# 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
ytdlp_url = _try_ytdlp_hoster(page_url, timeout=timeout)
if ytdlp_url:
type_hint = "m3u8" if ".m3u8" in ytdlp_url.lower() else "mp4"
return [StreamSource(link=ytdlp_url, type=type_hint, referer=page_url)]
# 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
# 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).
for idx, iframe_url in enumerate(iframe_urls):
try:
stream_url = extract_stream_from_hoster(
iframe_url, referer=referer, timeout=timeout,
)
except Exception as e:
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
# (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):
log.info(
"embed_iframe: stream URL %s is IP-bound CDN — skip Stage 1, fall to hoster",
stream_url[:80],
)
continue
type_hint = "m3u8" if ".m3u8" in stream_url.lower() else "mp4"
iframe_host = urlparse(iframe_url).hostname or ""
stream_referer = f"https://{iframe_host}/" if iframe_host else iframe_url
sources = [StreamSource(link=stream_url, type=type_hint, referer=stream_referer)]
# Dodaj pozostałe iframe-y (z wyłączeniem tego z którego wyciągnięto stream)
# jako hoster fallback. WebView załaduje embed page z własną sesją/IP/cookies,
# może pobrać video gdy VPS proxy blocked.
for u in iframe_urls:
if u == iframe_url:
continue
host_label = (urlparse(u).hostname or "").lstrip("www.").split(".")[0] or None
sources.append(StreamSource(link=u, type="hoster", quality=host_label))
page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
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ę
# 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
# 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ą
# 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 = []
for u in iframe_urls:
host_label = (urlparse(u).hostname or "").lstrip("www.").split(".")[0] or None
sources.append(StreamSource(link=u, type="hoster", quality=host_label))
page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)"))
return sources