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:
parent
d716b0c75d
commit
d7442187c9
6 changed files with 404 additions and 25 deletions
|
|
@ -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).
|
||||
|
|
|
|||
204
app/connectors/direct_scrapers/fullvideosporn.py
Normal file
204
app/connectors/direct_scrapers/fullvideosporn.py
Normal 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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
39
app/extractors/tubes/_txxx.py
Normal file
39
app/extractors/tubes/_txxx.py
Normal 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
|
||||
144
app/extractors/tubes/fullvideosporn.py
Normal file
144
app/extractors/tubes/fullvideosporn.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue