1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt, ' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety; zakresy 0–5 i A–Z na dywiz. Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach, czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu, tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana. Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie). Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika to zero zmiany. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
|