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>
This commit is contained in:
goon-foss 2026-07-27 08:56:23 +02:00
parent d716b0c75d
commit d7442187c9
6 changed files with 404 additions and 25 deletions

View file

@ -43,6 +43,7 @@ from app.connectors.direct_scrapers.youporn_browse import YouPornBrowseScraper
from app.connectors.direct_scrapers.siska import SiskaScraper
from app.connectors.direct_scrapers.sxyland import SxyLandScraper
from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper
from app.connectors.direct_scrapers.fullvideosporn import FullVideosPornScraper
from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
@ -171,6 +172,12 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
PornBusyScraper,
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
# Tytuł z `vit` playera (og:title to AI-spin SEO), bramka cookie 429 na fetchach.
FullVideosPornScraper,
# Browse równolegle do istniejącego search scrapera (wzorzec xvideos/eporner):
# search zostaje (pokrycie back-catalogu performerów), browse gwarantuje świeżość
# wprost z feedu (watchdog 48h zamiast 168h). Konwersja 2026-06-24 (user request).

View file

@ -0,0 +1,204 @@
"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ.
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
Ingestujemy WYŁĄCZNIE sceny z 1 performerem wtedy sygnał jest dobry: 88-89%
nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
Pozostałe pułapki, wszystkie obsłużone niżej:
- **tytuł bierzemy z `vit:""` (JS playera), NIE z `og:title`/`h1`** te bywają
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: </h3>`** na całej stronie
jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
- listing wymaga bramki cookie (429 challenge retry), stąd własny `crawl_page`
zamiast domyślnego `browser_get` z bazy.
Playback: extractor `fullvideosporn` (TXXX get_file 302 znvcdn, portable cross-IP).
"""
from __future__ import annotations
import html
import logging
import re
from datetime import UTC, datetime, timedelta
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors.tubes.fullvideosporn import _new_session, gated_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://fullvideosporn.com"
_SCENE_URL_RE = re.compile(r'href="(/en/video/(\d+)/[a-z0-9\-_]+/)"', re.IGNORECASE)
_VIT_RE = re.compile(r'vit:\s*"([^"]+)"')
_OGTITLE_RE = re.compile(r'property="og:title" content="([^"]+)"', re.IGNORECASE)
_DUR_RE = re.compile(r'og:video:duration" content="([0-9]+)"', re.IGNORECASE)
_THUMB_RE = re.compile(r'property="og:image" content="([^"]+)"', re.IGNORECASE)
# Każda sekcja to osobny <h3 class="item">Label: <a>..</a><a>..</a></h3>
_SECTION_RE = re.compile(r'<h3 class="item">(.*?)</h3>', re.IGNORECASE | re.DOTALL)
_ANCHOR_RE = re.compile(r">([^<>]{2,60})</a>")
_SUBMITTED_RE = re.compile(
r"Submitted:\s*<em>\s*(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago",
re.IGNORECASE,
)
_UNIT_DAYS = {
"second": 0, "minute": 0, "hour": 0,
"day": 1, "week": 7, "month": 30, "year": 365,
}
def _submitted_to_date(detail_html: str):
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
to jest data uploadu na tube (nie premiery studia) traktujemy jak inne tuby."""
m = _SUBMITTED_RE.search(detail_html)
if not m:
return None
n, unit = int(m.group(1)), m.group(2).lower()
days = n * _UNIT_DAYS.get(unit, 0)
return (datetime.now(UTC) - timedelta(days=days)).date()
class FullVideosPornScraper(BaseBrowseScraper):
sitetag = "fullvideosporn"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/en/videos.php?p={page}&s=l"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for m in _SCENE_URL_RE.finditer(listing_html):
url = _BASE + m.group(1)
if url not in seen:
seen.add(url)
out.append(url)
return out
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Własna implementacja, bo listing i detale wymagają bramki cookie (429 →
challenge retry) i wspólnej sesji, czego `browser_get` z bazy nie robi."""
session = _new_session()
r = gated_get(session, self._listing_url(page), timeout=self._timeout)
if r is None or r.status_code != 200:
log.warning(
"fullvideosporn listing page=%d status=%s", page, getattr(r, "status_code", None)
)
return None
urls = self._extract_scene_urls(r.text)
if not urls:
return []
out: list[RawScene] = []
gated = 0
for scene_url in urls:
d = gated_get(session, scene_url, timeout=self._timeout)
if d is None or d.status_code != 200:
continue
try:
raw = self._parse_detail(scene_url, d.text)
except Exception as e:
log.warning("fullvideosporn detail parse failed %s: %s", scene_url, e)
continue
if raw is None:
gated += 1
continue
out.append(raw)
if gated:
log.info(
"fullvideosporn page=%d: %d/%d scen pominietych (brak obsady)",
page, gated, len(urls),
)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
# Sekcje metadanych — każda w swoim <h3 class="item">.
performers: list[RawPerformer] = []
tags: list[RawTag] = []
studio: RawStudio | None = None
seen_p: set[str] = set()
seen_t: set[str] = set()
for sec in _SECTION_RE.findall(detail_html):
label = re.sub(r"<[^>]+>", " ", sec).strip().lower()
names = [html.unescape(x).strip() for x in _ANCHOR_RE.findall(sec)]
if label.startswith("porn-stars"):
for name in names:
sl = slugify(name)
if sl and sl not in seen_p:
seen_p.add(sl)
performers.append(
RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
)
elif label.startswith("porn site"):
if names and studio is None:
sname = names[0]
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}",
name=sname,
slug=slugify(sname),
)
elif label.startswith("porn categories"):
for name in names:
sl = slugify(name)
if sl and sl not in seen_t:
seen_t.add(sl)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
)
# BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to
# spunowane tytuły bez performera, które weszłyby jako puste orphany).
if not performers:
return None
# Tytuł z playera; og:title/h1 bywa AI-owym spinem SEO.
tm = _VIT_RE.search(detail_html)
title = html.unescape(tm.group(1)).strip() if tm else ""
if not title:
om = _OGTITLE_RE.search(detail_html)
title = html.unescape(om.group(1)).strip() if om else ""
if not title:
return None
dm = _DUR_RE.search(detail_html)
duration_sec = int(dm.group(1)) if dm else None
thm = _THUMB_RE.search(detail_html)
thumbnail_url = thm.group(1) if thm else None
# Tag == nazwisko performera (np. „Octavia Red" bywa i kategorią) → wytnij.
tags = [t for t in tags if t.slug not in seen_p]
return RawScene(
external_id=f"{self.sitetag}:{scene_url}",
title=title,
release_date=_submitted_to_date(detail_html),
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -30,6 +30,7 @@ from app.extractors.tubes import (
eporner,
freshporno,
fullmovies,
fullvideosporn,
galaxyporn,
hdporngg,
hqfap,
@ -117,6 +118,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
"pornbusycom": pornbusy.extract,
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
"fullvideosporn": fullvideosporn.extract,
"siskavideo": _embed_iframe.extract,
"porn4dayspw": _embed_iframe.extract,
"porndishcom": _embed_iframe.extract,

View file

@ -0,0 +1,39 @@
"""Wspólne elementy sieci TXXX (vjav, fullvideosporn/sextu, …).
Silnik TXXX oddaje URL pliku przez `GET /api/videofile.php?video_id=<id>&lifetime=N`
w polu `video_url`, zaciemnionym DWIEMA warstwami:
1. wielkie/małe litery łacińskie podmienione na cyrylickie homoglify (МM, СC, ),
2. custom alfabet base64: `,``/`, `~``=`, `-``+`.
Po odkręceniu obu i b64decode dostajemy `/get_file/...` (czasem absolutny URL).
Wyniesione tutaj, żeby vjav i fullvideosporn nie trzymały dwóch kopii dekodera.
"""
from __future__ import annotations
import base64
# Cyrylickie homoglify → łacina. Bez tego b64decode dostaje śmieci.
HOMOGLYPHS = str.maketrans(
{
"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H", "К": "K", "М": "M",
"О": "O", "Р": "P", "Т": "T", "Х": "X", "У": "Y",
"а": "a", "с": "c", "е": "e", "о": "o", "р": "p", "х": "x", "у": "y",
}
)
def decode_video_url(obfuscated: str) -> str | None:
"""Zaciemniony `video_url` → ścieżka/URL `get_file`. None gdy dekod padnie."""
if not obfuscated:
return None
clean = (
obfuscated.translate(HOMOGLYPHS)
.replace(",", "/")
.replace("~", "=")
.replace("-", "+")
)
clean += "=" * (-len(clean) % 4) # padding do wielokrotności 4
try:
return base64.b64decode(clean).decode("utf-8", "ignore")
except Exception:
return None

View file

@ -0,0 +1,144 @@
"""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

View file

@ -27,43 +27,23 @@ direct z telefonu; patrz stream_proxy.proxy_hls_manifest). Media id w get_file (
"""
from __future__ import annotations
import base64
import json
import logging
import re
from app.extractors import browser_get
from app.extractors._models import StreamSource
from app.extractors.tubes import _txxx
log = logging.getLogger(__name__)
_BASE = "https://vjav.com"
_VIDEO_ID_RE = re.compile(r"/videos/(\d+)/")
# KVS video_url: base64 z cyrylica-homoglifami zamiast łacińskich liter (wielkie +
# część małych). Odkręcamy je z powrotem na łacinę przed b64decode.
_HOMOGLYPHS = str.maketrans(
{
"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H", "К": "K", "М": "M",
"О": "O", "Р": "P", "Т": "T", "Х": "X", "У": "Y",
"а": "a", "с": "c", "е": "e", "о": "o", "р": "p", "х": "x", "у": "y",
}
)
def _decode_video_url(obfuscated: str) -> str | None:
# (1) cyrylica-homoglif -> łacina, (2) custom alfabet base64 -> standardowy.
clean = (
obfuscated.translate(_HOMOGLYPHS)
.replace(",", "/")
.replace("~", "=")
.replace("-", "+")
)
clean += "=" * (-len(clean) % 4) # padding do wielokrotności 4
try:
return base64.b64decode(clean).decode("utf-8", "ignore")
except Exception:
return None
# Dekoder video_url wyniesiony do `_txxx.py` — ten sam silnik TXXX obsługuje też
# fullvideosporn/sextu, więc trzymamy jedną implementację. Alias zachowany dla
# czytelności modułu i ewentualnych importów.
_decode_video_url = _txxx.decode_video_url
def _quality_label(fmt: str | None) -> str | None: