goon/app/extractors/tubes/eporner.py
goon-foss 41ad7f1818 feat: automatyczne sprzatanie martwych zrodel sxyprn i eporner
Oba serwisy kasuja filmy po cichu: strona odpowiada 200 z "Post Not Found" albo "Video has
been deleted". Probka 2026-09-19: 57% zywych w bazie zrodel sxyprn w wieku 10-40 dni i 22%
eporner bylo juz usunietych; w apce to puste kafelki i "the tube removed" po kliknieciu.

- eporner: ekstraktor rozpoznaje usunieta strone i rzuca HosterDead, wiec resolve oznacza
  zrodlo zamiast oddawac "blad chwilowy" (82 takie wejscia w 48 h, nic nie oznaczal).
- proxy miniatur: "Post Not Found" na sxyprn oznacza zrodlo od razu, 404 na miniaturze
  eporner uruchamia w tle sprawdzenie strony (28/30 takich miniatur to usuniete filmy).
  Indeks hash po thumbnail_url tylko dla eporner (0044), zeby znalezc zrodlo po obrazku.
- job dead-reap co 6 h: po 1500 zywych zrodel na serwis, polowa z ostatnich 30 dni.
  Oznacza tylko przy pewnym sygnale; siec i timeouty zostawia.

Sprawdzone na produkcji: 10/10 miniatur eporner i 23/23 postow sxyprn oznaczone, 17 zywych
postow nietknietych.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00

105 lines
3.7 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 HosterDead, 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 is_deleted_page(html: str) -> bool:
"""Strona usunietego filmu odpowiada 200 z tytulem, ale bez playera i z tekstem
"Video has been deleted" (np. "File has been removed due to copyright owner request").
Zywa strona tego tekstu nie ma (sprawdzone 2026-09-19)."""
return "Video has been deleted" in html and not _VID_RE.search(html)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html = fetch_tube_html(page_url, timeout=timeout)
if is_deleted_page(html):
# Bez tego resolve zwracal "blad chwilowy" (503) i zrodlo nigdy nie bylo oznaczane
# jako martwe: 82 takie wejscia w 48 h, te same martwe sceny wisialy w feedzie.
raise HosterDead(f"eporner {page_url}: video has been deleted")
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