goon/app/extractors/hosters/seekplayer_engine.py
goon-foss 2bf9179467 feat(galaxyporn): browse scraper + extractor; widen seekplayer host regex
galaxyporn.net carries paysite rips with a dedicated <div id="video-actors">
cast block (no sidebar pollution), studio + date in the title prefix, healthy
/page/N/ pagination. Measured orphan risk is low: 92% of a 75-performer
sample already have a tpdb/stashdb ref in our DB and 79% of titles match
scenes we already hold.

seekplayer_engine host regex widened to cover seekplays|4meplayer|ezplayer|
seeks|upn and the pro|cloud|one TLDs. This is the same engine (identical
AES key/IV and /api/v1/video endpoint), just newer domains; the whitelist is
additive so existing hosts keep matching (verified, including that
upns.evil.com is still rejected). Unlocks all four galaxyporn player hosts
and the same family on other sites.

Two things the extractor had to get right:
- Call seekplayer_engine directly rather than via extract_stream_from_hoster:
  the wrapper verifies the resulting URL and the hotlink-guarded HLS 403s, so
  it discarded correctly decoded streams.
- Referer must be the PLAYER origin, not galaxyporn.net. With the site referer
  the manifest 403s; with the player origin it returns 200. Verified end to
  end: manifest 200 -> variant 200 (570 segments) -> segment 206.

Duration is absent from the HTML, JSON-LD and player payload, and a NULL
duration would silently hide scenes behind the min_duration_sec filter (the
porntrex incident), so it is computed from the player's thumbnail.vtt last
cue. The player API throttles bursts, hence the retry/backoff: that took
missing durations from 9/21 down to 3/21. The value runs ~2% short of the
true length (one thumbnail interval), which beats having none.

Pilot: 21 scenes/page with studio 21/21 and clean cast (max 3), ingest of
2 pages = 42 seen, 34 new, 8 merged, 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-07-26 18:23:13 +02:00

163 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()
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ż
# taką gałąź dla IP-host m3u8).
sources: list[StreamSource] = []
if source:
sources.append(
StreamSource(
link=source,
quality=None,
type="m3u8",
referer=f"{host}/",
raw={
"proxy_no_verify": True,
"cf_fallback": cf or None,
"engine": "seekplayer",
},
)
)
return sources or None