filemoon (+ mirrory kerapoxy/lvturbo/emturbovid/bysezoxexe/bysezejataos)
nie umarł — ~2026-05 zrobił rebrand na Vite SPA "Byse Frontend". Stary
P.A.C.K.E.R.-JWPlayer embed zniknął, więc backend uznał go za martwego i
wpisał na DEAD_HOSTER_RE. RE bundla index-ChwZgmXV.js (2026-05-22):
POST /api/videos/<code>/embed/playback body {"fingerprint":{}}
→ {"playback":{"key_parts":[..],"iv":..,"payload":..}}
→ key=concat(b64url(key_parts)); AES-256-GCM(key,iv,payload) → JSON
→ sources[*].url = HLS master.m3u8
Browser-attestation jest opcjonalny — pusty fingerprint wystarcza.
Stream URL jest IP-bound (token wiąże się z IP requestera), więc resolve
musi iść z urządzenia użytkownika (jak doodstream.ts / packerHoster.ts).
- mobile/src/lib/aesGcm.ts — pure-JS AES-256-GCM decrypt (RN/Hermes nie
ma Web Crypto); S-box liczony z GF(2^8), GHASH weryfikuje tag.
Zweryfikowane przeciw cryptography (Python) na 2 payloadach.
- mobile/src/lib/filemoonHoster.ts — resolver: POST playback → decrypt →
pick best source. E2E test: filemoon.to/e + /d + bysezoxexe.com mirror.
- PlayerScreen: filemoon w resolve useEffect obok doodstream/packer.
- backend: filemoon poza DEAD_HOSTER_RE; hoster.py early-return → przelot
jako type='hoster' do mobile resolvera (server-side resolve bezcelowy,
bo URL IP-bound do VPS).
- direct_scrapers: poprawiony błędny komentarz "filemoon shutdown".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
519 lines
26 KiB
Python
519 lines
26 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 — 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 →
|
|
# m3u8. URL jest IP-bound, więc resolver MUSI iść z urządzenia użytkownika:
|
|
# filemoon przelatuje jako type='hoster' → mobile/src/lib/filemoonHoster.ts.
|
|
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
|