goon/app/extractors/tubes/eporner.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

94 lines
3.1 KiB
Python
Raw Permalink 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.

"""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 (240p1080p).
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