Zadanie bylo "podpiac albo usunac". Sprawdzone i NIE DA SIE podpiac.
Fallback za Cloudflare (svb.professionalvisibility.cyou/...cf-master.<ts>.txt)
oddaje 403 w szesciu wariantach:
httpx + Referer hostera httpx + Referer galaxyporn.net
curl_cffi chrome120 z Ref curl_cffi bez Ref (oddaje strone CF)
znacznik czasu biezacy znacznik z linku glownego
I to samo 403 z lacza RESIDENTIAL, wiec to nie blokada datacenter. Adres
jest najpewniej zwiazany z sesja tego samego wywolania /api/v1/video,
a my siegamy po niego osobno i po czasie (znacznik w nazwie pliku bywa
5 dni stary). Podpiecie dokladaloby request, ktory ZAWSZE pada, na
sciezce bledu — czyli opoznienie zamiast ratunku.
Cel, ktoremu mial sluzyc (przezyc padniety nod CDN), jest juz osiagniety
inaczej: ponowny resolve wraca z innym adresem CDN (47547fe).
`proxy_no_verify` ZOSTAJE mimo ze nic go nie czyta: zapisuje realne
ryzyko (cert niepasujacy do golego IP), a jego usuniecie skasowaloby te
informacje. Falszywy komentarz o istniejacej galezi `verify=False` juz
poprawiony.
Wszystkie szesc prob zapisane w docstringu, zeby nikt ich nie powtarzal.
Kontrola po zmianie: extractor oddaje m3u8, proxy 200.
184 lines
7.5 KiB
Python
184 lines
7.5 KiB
Python
"""Common engine extractor for: embedseek, seekplayer, rpmplay, upns, player4me, easyvidplayer.
|
||
|
||
Wszyscy używają tego samego silnika (Vite-built React SPA + AES-CBC encrypted API
|
||
+ HLS-based streaming). Hostname domains different ale shared backend.
|
||
|
||
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.
|
||
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).
|
||
Response: hex-encoded AES-CBC(key=`kiemtienmua911ca`, iv=`1234567890oiuytr`)
|
||
ciphertext, ~5KB. PKCS7 padded.
|
||
|
||
3. Plaintext JSON zawiera:
|
||
- `source`: signed m3u8 URL na CDN edge IP (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8?v=...`)
|
||
- `cf`: Cloudflare-fronted fallback URL (.txt z listą m3u8 paths)
|
||
- `metric.ipAddress`: IP visitora (signed token IP-bound do tego IP)
|
||
- `metric.cfDomain`: CF domain dla fallback
|
||
- `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.
|
||
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
|
||
identyczne dla każdej domeny.
|
||
|
||
Hostery covered (origin counts w DB, 2026-05-15):
|
||
- embedseek (20271), seekplayer (20271), mirror sites, dzielą hash_id
|
||
- rpmplay (15317)
|
||
- upns (14287)
|
||
- player4me (41040)
|
||
- easyvidplayer (47588)
|
||
|
||
Razem ~159k playback sources.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from urllib.parse import urlparse
|
||
|
||
from cryptography.hazmat.primitives import padding
|
||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||
|
||
from app.extractors._fetch import _DEFAULT_UA, browser_get
|
||
from app.extractors._models import HosterDead, StreamSource
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
_KEY = b"kiemtienmua911ca"
|
||
_IV = b"1234567890oiuytr"
|
||
|
||
# Hostname matching: base hosts × subdomains × TLD variants.
|
||
# Examples:
|
||
# my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online,
|
||
# my.upns.online, vip.player4me.vip, p.easyvidplayer.com
|
||
#
|
||
# 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.
|
||
# 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
|
||
# matchują się dalej tak samo.
|
||
_HOST_RE = re.compile(
|
||
r"^(?:[a-z0-9]+\.)?"
|
||
r"(?:embedseek|seekplayer|seekplays|seeks|rpmplay|upns|upn|player4me|4meplayer"
|
||
r"|ezplayer|easyvidplayer)\."
|
||
r"(?:online|vip|com|net|io|me|tv|pro|cloud|one)$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def matches(url: str) -> bool:
|
||
try:
|
||
host = urlparse(url).hostname or ""
|
||
except Exception:
|
||
return False
|
||
return bool(_HOST_RE.match(host))
|
||
|
||
|
||
def _decrypt(hex_str: str) -> str:
|
||
ct = bytes.fromhex(hex_str)
|
||
cipher = Cipher(algorithms.AES(_KEY), modes.CBC(_IV))
|
||
dec = cipher.decryptor()
|
||
pt = dec.update(ct) + dec.finalize()
|
||
unpadder = padding.PKCS7(128).unpadder()
|
||
return (unpadder.update(pt) + unpadder.finalize()).decode("utf-8", errors="replace")
|
||
|
||
|
||
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
|
||
parsed = urlparse(page_url)
|
||
if not parsed.hostname or not _HOST_RE.match(parsed.hostname):
|
||
return None
|
||
# hash_id w `#<id>` fragmencie; gdy klient przekazał bez `#` (np. po nav.replace),
|
||
# spróbujemy też `?id=` query param.
|
||
hash_id = parsed.fragment.strip()
|
||
if not hash_id and parsed.query:
|
||
from urllib.parse import parse_qs
|
||
qs = parse_qs(parsed.query)
|
||
hash_id = (qs.get("id") or [""])[0]
|
||
if not hash_id:
|
||
log.info("seekplayer-engine: no hash_id w %s", page_url)
|
||
return None
|
||
|
||
host = f"{parsed.scheme}://{parsed.hostname}"
|
||
api_url = f"{host}/api/v1/video?id={hash_id}&w=1920&h=1080&r="
|
||
|
||
headers = {
|
||
"User-Agent": _DEFAULT_UA,
|
||
"Accept": "*/*",
|
||
"Referer": f"{host}/",
|
||
}
|
||
r = browser_get(api_url, headers=headers, timeout=timeout)
|
||
if r.status_code in (404, 410):
|
||
raise HosterDead(f"seekplayer-engine {page_url}: HTTP {r.status_code}")
|
||
if r.status_code != 200 or not r.text:
|
||
log.info("seekplayer-engine: api fail %s status=%s", api_url, r.status_code)
|
||
return None
|
||
|
||
try:
|
||
plaintext = _decrypt(r.text)
|
||
except Exception as e:
|
||
log.warning("seekplayer-engine: decrypt fail dla %s: %s", api_url, e)
|
||
return None
|
||
|
||
try:
|
||
data = json.loads(plaintext)
|
||
except Exception as e:
|
||
log.warning("seekplayer-engine: JSON parse fail dla %s: %s", api_url, e)
|
||
return None
|
||
|
||
# Hostery same-engine wracają `{"error": "..."}` gdy video nie istnieje.
|
||
if isinstance(data, dict) and data.get("error"):
|
||
raise HosterDead(f"seekplayer-engine {page_url}: {data['error']}")
|
||
|
||
source = (data.get("source") or "").strip()
|
||
|
||
# Source: m3u8 na CDN edge pod GOŁYM IP (np. `185.237.107.146/v4/<sig>/<exp>/…/master.m3u8`).
|
||
# Token ma ~2,8 h TTL i NIE jest związany z IP (sprawdzone 2026-08-14), a węzły
|
||
# rotują: dwa kolejne resolve tej samej sceny wracają z różnymi adresami. Dlatego
|
||
# padnięcie odtwarzania na tym źródle leczy się ponownym resolve, a nie WebView-em
|
||
# (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen).
|
||
#
|
||
# `proxy_no_verify` NIE JEST przez nic czytane. Wcześniejszy komentarz twierdził, że
|
||
# `stream_proxy.py` ma gałąź `verify=False` dla hostów-IP — nie ma jej, w tym pliku nie
|
||
# występuje słowo `verify` ani razu. W praktyce certyfikaty tych węzłów przechodzą
|
||
# weryfikację (fetch przez proxy oddaje 200), więc flaga jest informacyjna. Zostaje,
|
||
# bo zapisuje realne ryzyko: gdyby któryś nod zaczął serwować cert niepasujący do IP,
|
||
# trzeba będzie tę gałąź dopisać.
|
||
#
|
||
# `cf` (fallback za Cloudflare) CELOWO NIE JEST PRZEKAZYWANY DALEJ. Sprawdzone
|
||
# 2026-08-14, sześć wariantów, wszystkie 403:
|
||
#
|
||
# httpx + Referer hostera · httpx + Referer galaxyporn.net
|
||
# curl_cffi chrome120 z Refererem · curl_cffi bez Referera (oddaje stronę CF)
|
||
# znacznik czasu bieżący · znacznik z linku głównego
|
||
#
|
||
# I to samo 403 z łącza RESIDENTIAL, więc to nie jest blokada datacenter. Adres jest
|
||
# najpewniej związany z sesją tego samego wywołania `/api/v1/video`, a my sięgamy po
|
||
# niego osobno i po czasie (znacznik w nazwie pliku bywa 5 dni stary). Podpięcie go
|
||
# jako zapasowego źródła dokładałoby request, który ZAWSZE pada, na ścieżce błędu.
|
||
#
|
||
# Padnięcie pojedynczego noda leczy ponowny resolve: kolejne wywołania wracają z
|
||
# różnymi adresami CDN (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen, commit 47547fe).
|
||
sources: list[StreamSource] = []
|
||
if source:
|
||
sources.append(
|
||
StreamSource(
|
||
link=source,
|
||
quality=None,
|
||
type="m3u8",
|
||
referer=f"{host}/",
|
||
raw={
|
||
"proxy_no_verify": True,
|
||
"engine": "seekplayer",
|
||
},
|
||
)
|
||
)
|
||
return sources or None
|