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>
204 lines
8.2 KiB
Python
204 lines
8.2 KiB
Python
"""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,
|
|
)
|
|
],
|
|
)
|