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.
94 lines
3.1 KiB
Python
94 lines
3.1 KiB
Python
"""eporner.com — direct stream extractor.
|
||
|
||
Page → wyciągnij `EP.video.player.vid` + `EP.video.player.hash` → XHR
|
||
`/xhr/video/<vid>?hash=<base36>` → JSON z multi-quality mp4 URL'ami (240p–1080p).
|
||
|
||
Bez hasha gvideo.eporner.com URL'e zwracają 403 (są podpisane krótkoterminowymi
|
||
tokenami które XHR zwraca świeże). Algorytm hex→base36 ekwiwalentny temu z
|
||
aplikacji AIO Streamer (`rf1.java`):
|
||
BigInteger(hex[0:8],16).toString(36) + BigInteger(hex[8:16],16).toString(36)
|
||
+ BigInteger(hex[16:24],16).toString(36) + BigInteger(hex[24:32],16).toString(36)
|
||
|
||
URL-e są bound do IP requestera (więc resolver musi pobiegać z VPS-a, nie z
|
||
klienta) i ważne ~kilka godzin.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
|
||
from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html
|
||
from app.extractors._models import StreamSource
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
_VID_RE = re.compile(r"EP\.video\.player\.vid\s*=\s*'([^']+)'")
|
||
_HASH_RE = re.compile(r"EP\.video\.player\.hash\s*=\s*'([0-9a-fA-F]{32})'")
|
||
|
||
|
||
def _hash_to_b36(hex_hash: str) -> str:
|
||
"""Konwertuje 32-znakowy hex hash na base36 w 4 chunkach po 8 znaków."""
|
||
parts = []
|
||
for i in (0, 8, 16, 24):
|
||
n = int(hex_hash[i : i + 8], 16)
|
||
if n == 0:
|
||
parts.append("0")
|
||
continue
|
||
s = ""
|
||
while n > 0:
|
||
d = n % 36
|
||
ch = chr(ord("0") + d) if d < 10 else chr(ord("a") + d - 10)
|
||
s = ch + s
|
||
n //= 36
|
||
parts.append(s)
|
||
return "".join(parts)
|
||
|
||
|
||
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
|
||
html = fetch_tube_html(page_url, timeout=timeout)
|
||
m_vid = _VID_RE.search(html)
|
||
m_hash = _HASH_RE.search(html)
|
||
if not m_vid or not m_hash:
|
||
log.warning("eporner: no vid/hash in %s", page_url)
|
||
return None
|
||
vid = m_vid.group(1)
|
||
hash_b36 = _hash_to_b36(m_hash.group(1))
|
||
xhr_url = (
|
||
f"https://www.eporner.com/xhr/video/{vid}?hash={hash_b36}"
|
||
"&domain=www.eporner.com&pixelRatio=1&playerWidth=0&playerHeight=0"
|
||
"&fallback=false&embed=false&supportedFormats=mp4"
|
||
)
|
||
headers = {
|
||
"User-Agent": _DEFAULT_UA,
|
||
"Referer": page_url,
|
||
"Accept": "*/*",
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
}
|
||
try:
|
||
r = browser_get(xhr_url, headers=headers, timeout=timeout, follow_redirects=True)
|
||
r.raise_for_status()
|
||
data = json.loads(r.text)
|
||
except Exception as e:
|
||
log.warning("eporner xhr fetch %s failed: %s", xhr_url, e)
|
||
return None
|
||
|
||
if not data.get("available", True):
|
||
log.info("eporner xhr %s: available=false", vid)
|
||
return None
|
||
|
||
mp4_dict = (data.get("sources") or {}).get("mp4") or {}
|
||
sources: list[StreamSource] = []
|
||
for label, info in mp4_dict.items():
|
||
if not isinstance(info, dict):
|
||
continue
|
||
src = info.get("src")
|
||
if not src:
|
||
continue
|
||
sources.append(StreamSource(link=src, quality=label, type="mp4"))
|
||
|
||
if not sources:
|
||
log.warning("eporner xhr %s: no mp4 sources in JSON", vid)
|
||
return None
|
||
return sources
|