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>
144 lines
5.7 KiB
Python
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
|