goon/app/extractors/tubes/fullvideosporn.py
goon-foss d7442187c9 feat(fullvideosporn): browse scraper behind a cast gate + TXXX extractor
fullvideosporn.com is sextu.com rebranded, not a clone of fullmovies.xxx
(different engine, different catalog, 0/140 title overlap with our
fullmoviesxxx corpus).

The site is only worth ingesting behind a gate: 65-75% of its catalog has
no performer at all, and those scenes also carry SEO-spun titles, so they
would never match canonical and would land as empty orphans. So we ingest
only scenes with at least one performer. That keeps ~25-35% of the catalog
(verified: 19 of 60 on page one) where the signal is good, since 88-89% of
the performer names in the research sample already resolve to a canonical
performer in our DB.

Three site-specific traps, all handled:
- Titles come from the player's vit:"..." field, not og:title/h1, which are
  sometimes an AI SEO rewrite rather than the real scene title.
- Cast is read only from the <h3>Porn-stars:</h3> section; the page carries
  ~22 videos.php?q= links overall but only 1-2 real performers, the same
  pollution that got xxxfiles rejected. Porn Site / Porn Categories are
  separate h3 blocks and are parsed per-section so they don't bleed.
- Every fetch passes a cookie gate: a fresh session gets HTTP 429 plus a
  small JS challenge, so we read the cookie out of it and retry on the same
  session. Hence the custom crawl_page instead of the base browser_get.

The TXXX video_url decoder moved out of vjav into _txxx.py since both tubes
share the engine; vjav keeps an alias and was re-verified after the move.
Playback resolves videofile.php -> decode -> get_file -> 302 -> znvcdn, and
the final CDN URL is portable cross-IP so the phone streams it directly.
Note for future debugging: the VPS itself gets 429 from that CDN because of
datacenter IP reputation, so playback health-checks run from the VPS will
be falsely negative.

Pilot ingest of 2 pages: 39 seen, 11 merged into existing scenes, 28 new,
0 errors; 0/19 without cast or duration on the sampled page.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-07-27 08:56:23 +02:00

144 lines
5.7 KiB
Python

"""fullvideosporn.com (= sextu.com pod nową marką) — TXXX network, direct mp4.
To NIE jest klon fullmovies.xxx (inny silnik, inny katalog — 0/140 pokrycia tytułów).
**Bramka cookie**: pierwszy request z nowej sesji dostaje HTTP 429 + 342-bajtowy JS
challenge ustawiający ciasteczko (`document.cookie = 'PxeA3f=<num>; max-age=600'`).
Wystarczy wyregexować parę nazwa=wartość i powtórzyć request — bez proxy, bez
przeglądarki. Ciasteczko żyje 600 s, więc trzymamy je w sesji modułu.
Stream (identyczny jak vjav, rodzina TXXX):
GET /api/videofile.php?video_id=<id>&lifetime=8640000
→ [{"format":"_lq.mp4","video_url":"<zaciemniony>",...}]
dekod (`_txxx.decode_video_url`) → `/get_file/...` (relatywny, prepend host)
GET get_file bez follow → 302 → `https://sextuN.znvcdn.com/t=.../....mp4`
`_lq.mp4` w nazwie myli — to realnie 1280x720 (~1,29 Mbps), jedyny dostępny format.
**Cross-IP**: finalny URL CDN wybity z IP VPS gra z residential (206, `video/mp4`,
bez Referera) → token NIE jest IP-bound, `mobile_direct_ok`. ALE sam VPS dostaje od
CDN 429 (reputacja IP datacenter), więc **health-check playbacku z VPS będzie
fałszywie negatywny** — nie traktuj tego jako regresji.
"""
from __future__ import annotations
import json
import logging
import re
from urllib.parse import urlparse
from app.extractors._fetch import _DEFAULT_IMPERSONATE, _DEFAULT_UA, _HAS_CURL_CFFI
from app.extractors._models import StreamSource
from app.extractors.tubes import _txxx
log = logging.getLogger(__name__)
_BASE = "https://fullvideosporn.com"
_VIDEO_ID_RE = re.compile(r"/video/(\d+)/")
# `<script>window.addEventListener('click',()=>{...document.cookie = 'PxeA3f=980886937; max-age=600...`
_COOKIE_CHALLENGE_RE = re.compile(
"document.cookie[^']*'([A-Za-z0-9_]+)=([^;']+)", re.IGNORECASE
)
def _new_session():
from curl_cffi import requests as cf
return cf.Session(impersonate=_DEFAULT_IMPERSONATE)
def gated_get(session, url: str, *, timeout: float = 30.0, headers: dict | None = None,
allow_redirects: bool = True):
"""GET przechodzący bramkę 429 (patrz docstring modułu). Ciasteczko ląduje w sesji,
więc kolejne requesty tej samej sesji idą od razu. Zwraca response (albo None)."""
h = {"User-Agent": _DEFAULT_UA, "Accept": "text/html,application/xhtml+xml"}
if headers:
h.update(headers)
try:
r = session.get(url, headers=h, timeout=timeout, allow_redirects=allow_redirects)
except Exception as e:
log.info("fullvideosporn: fetch failed %s: %s", url, e)
return None
if r.status_code == 429:
m = _COOKIE_CHALLENGE_RE.search(r.text or "")
if not m:
log.info("fullvideosporn: 429 bez challenge-cookie na %s", url)
return r
session.cookies.set(m.group(1), m.group(2))
try:
r = session.get(url, headers=h, timeout=timeout, allow_redirects=allow_redirects)
except Exception as e:
log.info("fullvideosporn: retry po cookie failed %s: %s", url, e)
return None
return r
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
if not _HAS_CURL_CFFI:
log.info("fullvideosporn: curl_cffi unavailable")
return None
m = _VIDEO_ID_RE.search(page_url)
if not m:
log.info("fullvideosporn: brak video id w %s", page_url)
return None
vid = m.group(1)
session = _new_session()
api = f"{_BASE}/api/videofile.php?video_id={vid}&lifetime=8640000"
r = gated_get(
session, api, timeout=timeout,
headers={"Referer": page_url, "X-Requested-With": "XMLHttpRequest",
"Accept": "application/json,text/plain,*/*"},
)
if r is None or r.status_code != 200 or not r.text:
log.info("fullvideosporn: videofile.php status=%s", getattr(r, "status_code", None))
return None
try:
data = json.loads(r.text)
except Exception as e:
log.info("fullvideosporn: videofile.php parse fail: %s", e)
return None
if not isinstance(data, list):
return None
out: list[StreamSource] = []
seen: set[str] = set()
for entry in data:
if not isinstance(entry, dict):
continue
fmt = (entry.get("format") or "").strip()
if fmt.strip("_").replace(".mp4", "").lower() == "tr":
continue # trailer, nie pełne wideo
get_file = _txxx.decode_video_url(entry.get("video_url") or "")
if not get_file or "/get_file/" not in get_file:
continue
if get_file.startswith("/"):
get_file = _BASE + get_file
# get_file 302 → finalny CDN. Rozwiązujemy TU (w sesji z ciasteczkiem), bo
# telefon nie ma tej sesji; finalny URL jest portable cross-IP.
rr = gated_get(session, get_file, timeout=timeout,
headers={"Referer": page_url}, allow_redirects=False)
final = None
if rr is not None and rr.status_code in (301, 302, 303, 307, 308):
final = rr.headers.get("location")
elif rr is not None and rr.status_code == 200:
final = get_file # brak redirectu — get_file sam serwuje plik
if not final or final in seen:
continue
seen.add(final)
out.append(
StreamSource(
link=final,
type="mp4",
quality="720p", # `_lq` w nazwie myli: realnie 1280x720
referer=_BASE + "/",
# Token CDN time-bound, NIE IP-bound (zweryfikowane cross-IP) →
# telefon gra direct, zero proxy.
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("fullvideosporn: brak dekodowalnego video_url dla %s", page_url)
return None
return out