goon/app/extractors/tubes/sexu.py
goon-foss ea489454c0 feat(sexu): scraper + extractor HLS, ale bez rejestracji w ingescie
Metadane ma najlepsze z tej serii: kanal = realne studio na 97% scen, 96%
performerow z refem tpdb/stashdb, dokladna dlugosc w sekundach, swiezosc w
godzinach, 120 scen na stronie listingu.

Nie wlaczamy go jednak do ALL_BROWSE_SCRAPERS, bo jednostka tresci jest zla:
to klipy 5-10 minut, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200,
zero filmow >=20 min w calym katalogu. Pilot na 240 scenach dal 1 canonical
match, bo dlugosc i tytul, czyli sygnaly resolvera, sa przepisane pod tube.
Przy ~340 uploadach dziennie to ~10k nierozwiazywalnych wierszy miesiecznie.

Kod zostaje kompletny i zweryfikowany: POST /api/video-info przez Bright Data
(CF blokuje sam POST z VPS), HLS playerData.src zamiast mp4 sources (te maja
ip= requestera w tokenie), master konczy sie .mp4 wiec lapie go istniejacy
hls_needs_passthrough. Gdyby serwis zaczal wrzucac pelne sceny, wystarczy
dopisac klase z powrotem do rejestru.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:42:05 +02:00

95 lines
3.3 KiB
Python

"""sexu.com — HLS z `POST /api/video-info`. Dodany 2026-07-27.
Player jest hydratowany po stronie klienta (`/js/vjs/hydrate-source.js`): strona sceny
nie zawiera żadnego URL-a wideo, dopiero `POST /api/video-info` z `videoId=<id>` oddaje
JSON-a z `playerData`.
**Ten POST wymaga Bright Data.** Z gołego IP VPS-a Cloudflare oddaje na niego challenge
(GET-y na listing i detale przechodzą bez problemu — blokowany jest sam POST). Idzie tu
JSON, nie wideo, więc mieści się w tym, do czego proxy służy.
**Bierzemy `playerData.src` (HLS), a NIE `playerData.sources` (mp4).** Wygląda to
odwrotnie do intuicji, bo mp4 są wygodniejsze, ale:
sources[].src → /key=…,end=…,ip=158.46.155.106/sec=protect/…-720p-x.mp4
playerData.src → /key=…,end=…/multi=854x480:480p,1280x720:720p/media=hls4A/…
mp4 mają w tokenie `ip=` requestera, czyli IP wyjściowe proxy — na telefonie dałyby 403.
Token HLS `ip=` nie ma i jest przenośny: sprawdzone z VPS-a (inne IP niż proxy) master
200, wariant 200, segment 206. Telefon gra go z własnego IP.
Ścieżka mastera kończy się na `.mp4`, nie `.m3u8`, więc ExoPlayer wziąłby go za
progresywny plik i padł. Łapie to istniejąca logika `hls_needs_passthrough` w
`playback.py` (typ `m3u8` + `mobile_direct_ok` + brak `.m3u8` w ścieżce) → manifest
(~430 B) leci przez `/proxy/hls`, a segmenty prosto z CDN.
"""
from __future__ import annotations
import json
import logging
import re
from app.config import get_settings
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
_BASE = "https://sexu.com"
_SCENE_ID_RE = re.compile(r"/(\d+)/?$")
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
m = _SCENE_ID_RE.search(page_url.split("?")[0])
if not m:
log.info("sexu: nie wyłuskałem id z %s", page_url)
return None
video_id = m.group(1)
proxy = get_settings().brightdata_proxy_url
if not proxy:
log.info("sexu: brak BRIGHTDATA_PROXY_URL — CF zablokuje POST /api/video-info")
return None
from curl_cffi import requests as cr
try:
session = cr.Session(
impersonate="chrome120",
proxies={"http": proxy, "https": proxy},
verify=False,
)
# Strona sceny najpierw — bez jej ciasteczek CF odrzuca POST-a.
session.get(page_url, timeout=timeout)
res = session.post(
f"{_BASE}/api/video-info",
data={"videoId": video_id},
headers={
"X-Requested-With": "XMLHttpRequest",
"Referer": page_url,
},
timeout=timeout,
)
data = json.loads(res.text)
except Exception as e:
log.info("sexu: video-info failed %s: %s", page_url, e)
return None
if not data.get("success"):
log.info("sexu: video-info success=false na %s", page_url)
return None
src = (data.get("playerData") or {}).get("src")
if not src:
log.info("sexu: brak playerData.src na %s", page_url)
return None
if src.startswith("//"):
src = "https:" + src
return [
StreamSource(
link=src,
type="m3u8",
quality="720p", # master multi=854x480,1280x720 — ExoPlayer wybierze sam
referer=_BASE + "/",
raw={"mobile_direct_ok": True},
)
]