feat(tubepornclassic): konektor retro/vintage na silniku TXXX, wspolny tor HLS z vjav

tubepornclassic.com to SPA sieci TXXX (ten sam silnik co vjav): HTML ma pusty
<title> i zero linkow do scen, wszystko idzie z JSON API. Listing
/api/json/videos2/<cache>/str/latest-updates/60/..<page>.all...json oddaje
60 rekordow z pelnymi metadanymi (tytul, slug, dlugosc, post_date, obsada,
kategorie+tagi, miniatura), wiec nie ma fetchu per scena. ~168 tys. wideo,
wgrania codziennie. Koniec katalogu = 200 z code=videos_not_found.

Stream: videofile.php -> dekod video_url -> get_file -> f=video.m3u8 -> HLS
na ahcdn (klucz time-bound, nie IP-bound: adres wybity z VPS gra z lacza
domowego). Ten tor zyl dotad w tubes/vjav.py; wyniesiony do
_txxx.extract_hls, vjav i tubepornclassic wolaja to samo. HLS zamiast mp4,
bo mp4 z tego samego get_file ma w URL CDN speed=... (~1,2 MB/s), a segmenty
HLS leca ~3,6 MB/s. Wyzwanie 429 (ciasteczko w ciele) obsluzone w
ekstraktorze, scraperze i juz wczesniej w /proxy/hls.

Bramka: 38% tytulow to generator SEO ("Fabulous Sex Movie Vintage Try To
Watch For Ever Seen"). Z doczepionym nazwiskiem scena jest odnajdywalna
i zostaje; bez obsady (22%) nie wchodzi, bo nie ma po czym jej znalezc
ani z czym zdeduplikowac. Obsada u 46% scen, studiow brak.

Pilot: 2 strony, 120 scen, 111 nowych, 9 juz w bazie, wszystkie z dlugoscia.
Odtworzenie na emulatorze: playback_events success (ttff 3,3 s), AudioTrack
started. vjav po refaktorze: ten sam wynik ekstraktora co przed.
This commit is contained in:
goon-foss 2026-09-07 10:04:24 +02:00
parent 9906c665ec
commit 994e4e98ad
6 changed files with 371 additions and 78 deletions

View file

@ -154,6 +154,7 @@ from app.connectors.direct_scrapers.vjav import VjavScraper # noqa: E402
from app.connectors.direct_scrapers.supjav import SupjavScraper # noqa: E402
from app.connectors.direct_scrapers.neporn import NepornScraper # noqa: E402
from app.connectors.direct_scrapers.porndupe import PorndupeScraper # noqa: E402
from app.connectors.direct_scrapers.tubepornclassic import TubePornClassicScraper # noqa: E402
from app.connectors.direct_scrapers.superporn import SuperpornScraper # noqa: E402
from app.connectors.direct_scrapers.eporner_api import EpornerApiScraper # noqa: E402
from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper # noqa: E402
@ -338,6 +339,11 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# Family Strokes), 87% katalogu nowe dla nas, trafia w kanon. Chodzenie po id
# (listing = catch-all), dlugosc i data z api.ok.ru przy ingescie.
PorndupeScraper,
# TubePornClassicScraper, dodany 2026-09-07 (user request). Retro/vintage, TXXX
# (silnik vjav): listing to JSON API z pelnymi metadanymi (60 scen na request, bez
# fetchu per scena), wyzwanie 429 przez ciasteczko. Obsada u ~46% scen, studiow brak.
# Stream HLS przez _txxx.extract_hls + /proxy/hls.
TubePornClassicScraper,
# FourK69Scraper, USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
# JavflixScraper, JAV vertical (osobna sekcja). origin tube:javflix jest w
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).

View file

@ -0,0 +1,239 @@
"""tubepornclassic.com, browse scraper po JSON API listingu (silnik TXXX). Dodany 2026-09-07.
Retro i vintage (lata 70-00), ~168 tys. wideo, świeże wgrania codziennie (2026-09-06 przy
dodawaniu). Zachodnia treść studyjna, więc wchodzi do głównego feedu, nie do wertykału.
**Listing z API, nie z HTML.** Strona to SPA: HTML ma pusty `<title>` i zero linków do
scen. Aplikacja woła `GET /api/json/videos2/<cache>/str/latest-updates/<count>/
..<page>.all...json` (sekcja, object_id, typ, długość i data puste) i dostaje
60 rekordów Z PEŁNYMI METADANYMI: tytuł, `dir` (slug), `duration`, `post_date`,
`models` (obsada, CSV), `categories` + `tags` (CSV), `scr` (miniatura), `is_private`.
Nie ma więc żadnego fetchu per scena, jedna strona = jeden request, w odróżnieniu od
vjav (sitemap + meta per id). Koniec katalogu = 200 z `{"error":1,"code":"videos_not_found"}`
(strona 2801 wciąż oddaje rekordy mimo `pages: 2800`, licznik jest z Sphinxa i przybliżony).
**Wyzwanie 429.** Z serwerowni pierwszy request dostaje 429 i stronkę ustawiającą
ciasteczko (`_txxx.CHALLENGE_COOKIE_RE`), z ciasteczkiem 200. Trzymamy je w instancji,
gdyby i tak padło, a jest Bright Data, powtarzamy przez proxy (przez proxy 200 od razu).
**Obsada tylko u 46 % scen** (28/60 w próbce), studia brak (`content_source_name` puste
we wszystkich 60). Jan o tym wie („gorzej ze studiami i aktorami").
**Bramka na spun-tytuły BEZ obsady.** 38 % tytułów (23/60) to generator SEO w stylu
„Fabulous Sex Movie Vintage Try To Watch For Ever Seen" albo „Hottest Xxx Clip Fat Great
Will Enslaves Your Mind" (ten sam szablon co na fullvideosporn, to jedna sieć). Gdy do
takiego tytułu jest doczepione nazwisko („... - Marissa Malibu"), scena jest w apce
odnajdywalna po performerze i zostaje. Gdy nazwiska nie ma (13/60 = 22 %), nie ma po
czym jej znaleźć ani z czym zdeduplikować, więc NIE wchodzi. Zwykłe tytuły bez obsady
(19/60, „Unforgettable Sensation") wchodzą: dopasowanie po tytule ma sens, retro ma
w kanonie sporo scen.
**Miniatura:** `scr` to 240x180. Ten sam katalog ma `preview.jpg` 1280x720 (sprawdzone),
inne rozmiary w `videos_screenshots/<id>/<WxH>/1.jpg` dają 404, bierzemy preview.
**Data:** `post_date` to data wgrania na tube, nie premiery (retro z lat 80. ma
`2026-09-06`). Tak samo jak inne tuby; deep_crawl od strony 3 ustawia `backfill`.
Playback: ekstraktor `tubepornclassic` (`_txxx.extract_hls`, HLS przez /proxy/hls).
"""
from __future__ import annotations
import html as html_mod
import json
import logging
import re
from datetime import date, datetime
from app.config import get_settings
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.extractors.tubes._txxx import CHALLENGE_COOKIE_RE
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://tubepornclassic.com"
_PER_PAGE = 60
#: Cache po stronie serwera (sekundy). Aplikacja używa 86400; bierzemy krótszy, żeby
#: strona 1 nie była o dobę stara przy godzinnym browse.
_CACHE_LIFETIME = 3600
_THUMB_SMALL_RE = re.compile(r"/\d+x\d+/\d+\.jpg$")
#: Tagi-śmieci z pola `tags` (techniczne, nie opisowe).
_SKIP_TAGS = frozenset({"hd", "4k", "1080p", "720p"})
#: Szablon tytułu z generatora SEO: przymiotnik, potem (porn|xxx|sex|adult) i zaraz
#: (video|clip|movie|scene). Zwykłe tytuły („Unforgettable Sensation", „Oceane, Amazing
#: Blonde Bimbo, With 1 Or 3 Dicks") go nie łapią, bo brak im pary porn+video obok siebie.
_SPUN_TITLE_RE = re.compile(
r"\b(fabulous|incredible|exotic|amazing|hottest|crazy|horny|best|excellent|astonishing|"
r"exclusive|fantastic|great|wild)\b.{0,24}\b(porn|xxx|sex|adult)\b.{0,3}\b(video|clip|movie|scene)\b",
re.IGNORECASE,
)
def _parse_duration(value: str | None) -> int | None:
"""`1:16:43` / `14:17` -> sekundy."""
if not value:
return None
try:
parts = [int(p) for p in value.strip().split(":")]
except ValueError:
return None
sec = 0
for p in parts:
sec = sec * 60 + p
return sec or None
def _parse_post_date(value: str | None) -> date | None:
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").date()
except ValueError:
return None
def _csv(value) -> list[str]:
if not isinstance(value, str):
return []
return [html_mod.unescape(x).strip() for x in value.split(",") if x.strip()]
class TubePornClassicScraper(BaseBrowseScraper):
sitetag = "tubepornclassic"
def __init__(self) -> None:
super().__init__()
self._cookie: str | None = None
self._proxy = get_settings().brightdata_proxy_url
# ---- fetch z wyzwaniem 429 ------------------------------------------------------
def _gated_get(self, url: str):
"""GET z ciasteczkiem wyzwania; None przy błędzie sieci albo trwałym 429."""
hdr = {"Referer": _BASE + "/", "Accept": "application/json,text/plain,*/*"}
if self._cookie:
hdr["Cookie"] = self._cookie
try:
res = browser_get(url, timeout=self._timeout, headers=hdr)
if res.status_code == 429:
ck = CHALLENGE_COOKIE_RE.search(res.text or "")
if ck is not None:
self._cookie = f"{ck.group(1)}={ck.group(2)}"
res = browser_get(url, timeout=self._timeout, headers=dict(hdr, Cookie=self._cookie))
if res.status_code == 429 and self._proxy:
res = browser_get(url, timeout=self._timeout, headers=hdr, proxy=self._proxy)
except Exception as e:
log.warning("tubepornclassic: fetch fail %s: %s", url, e)
return None
if res.status_code >= 400:
log.warning("tubepornclassic: HTTP %s na %s", res.status_code, url)
return None
return res
def _listing_url(self, page: int) -> str:
return (
f"{_BASE}/api/json/videos2/{_CACHE_LIFETIME}/str/latest-updates/{_PER_PAGE}/"
f"..{page}.all...json"
)
def crawl_page(self, page: int) -> list[RawScene] | None:
res = self._gated_get(self._listing_url(page))
if res is None:
return None # transient: NIE oznaczaj końca katalogu
try:
data = json.loads(res.text)
except Exception as e:
log.warning("tubepornclassic: listing parse fail (page %d): %s", page, e)
return None
# Za końcem katalogu API oddaje 200 z `{"error":1,"code":"videos_not_found"}`
# (bez klucza `videos`). To jest wyczerpanie, nie awaria, stąd [] a nie None.
if data.get("code") == "videos_not_found":
return []
videos = data.get("videos")
if not isinstance(videos, list):
return None
out: list[RawScene] = []
for v in videos:
if not isinstance(v, dict):
continue
try:
scene = self._parse_item(v)
except Exception as e:
log.info("tubepornclassic: parse fail id=%s: %s", v.get("video_id"), e)
continue
if scene is not None:
out.append(scene)
return out
# ---- rekord listingu -> RawScene ------------------------------------------------
def _parse_item(self, v: dict) -> RawScene | None:
if str(v.get("is_private") or "0") != "0":
return None
try:
vid = int(v.get("video_id"))
except (TypeError, ValueError):
return None
title = html_mod.unescape(v.get("title") or "").strip()
slug = (v.get("dir") or "").strip()
if not title or not slug:
return None
page_url = f"{_BASE}/videos/{vid}/{slug}/"
duration_sec = _parse_duration(v.get("duration"))
thumb = (v.get("scr") or "").strip() or None
if thumb:
thumb = _THUMB_SMALL_RE.sub("/preview.jpg", thumb)
performers: list[RawPerformer] = []
seen: set[str] = set()
for name in _csv(v.get("models")):
s = slugify(name)
if s and s not in seen:
seen.add(s)
performers.append(RawPerformer(external_id=f"{self.sitetag}:model:{s}", name=name))
if not performers and _SPUN_TITLE_RE.search(title):
return None # nieodnajdywalna: spun-tytuł i zero obsady, patrz docstring
studio = None
sname = (v.get("content_source_name") or "").strip()
if sname and slugify(sname):
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}", name=sname, slug=slugify(sname)
)
tags: list[RawTag] = []
seen_t: set[str] = set()
for name in _csv(v.get("categories")) + _csv(v.get("tags")):
s = slugify(name)
if not s or s in seen_t or s in _SKIP_TAGS or len(s) > 60:
continue
seen_t.add(s)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{s}", name=name, slug=s))
return RawScene(
external_id=f"{self.sitetag}:{vid}",
title=title,
release_date=_parse_post_date(v.get("post_date")),
duration_sec=duration_sec,
url=page_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=page_url,
duration_sec=duration_sec,
thumbnail_url=thumb,
)
],
raw={"source": "tubepornclassic_api", "id": vid},
)
# ---- HTML stuby (ABC wymaga, nieużywane: listing i metadane idą z JSON) --------
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
return []
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
return None

View file

@ -48,6 +48,7 @@ from app.extractors.tubes import (
javflix,
neporn,
porndupe,
tubepornclassic,
latestpornvideo,
paradisehill,
porn00,
@ -265,6 +266,8 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
"javflix": javflix.extract,
# vjav (JAV, TXXX network), videofile.php -> get_file HLS (patrz tubes/vjav.py).
"vjav": vjav.extract,
# tubepornclassic (retro, TXXX network), ten sam tor co vjav przez _txxx.extract_hls.
"tubepornclassic": tubepornclassic.extract,
# supjav (JAV, embed-aggregator), data-link reverse-hex -> lk1 -> hoster (type=hoster).
"supjav": supjav.extract,
# neporn, KVS function/0 + license (jak freshporno). Server-side _kvs resolve →

View file

@ -1,4 +1,4 @@
"""Wspólne elementy sieci TXXX (vjav, fullvideosporn/sextu, …).
"""Wspólne elementy sieci TXXX (vjav, tubepornclassic, fullvideosporn/sextu, …).
Silnik TXXX oddaje URL pliku przez `GET /api/videofile.php?video_id=<id>&lifetime=N`
w polu `video_url`, zaciemnionym DWIEMA warstwami:
@ -7,10 +7,29 @@ w polu `video_url`, zaciemnionym DWIEMA warstwami:
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.
**Wspólny ekstraktor HLS** (`extract_hls`, vjav + tubepornclassic). Cała rodzina oddaje
strumień tak samo: po dekodzie `/get_file/...`, dopisanie `f=video.m3u8` daje 302 na HLS
w *.ahcdn.com (klucz time-bound, NIE IP-bound, `referer=none` na whiteliście). Zwracamy
get_file jako `m3u8` + `mobile_direct_ok`, a `playback.py` owija go w `/proxy/hls`
(przez VPS idzie ~1 KB manifestu, segmenty telefon ciągnie sam; `/proxy/hls` zna
wyzwanie 429 tej sieci). Historia i pułapki w docstringu `tubes/vjav.py`.
Dlaczego HLS, a nie mp4 z tego samego get_file: mp4 ma w URL-u CDN `speed=...`, czyli
limit ~1,2 MB/s po pierwszych 2 MB (pomiar 2026-09-07 na tubepornclassic), a segmenty
HLS lecą ~3,6 MB/s. Przy plikach HD 2,8 Mb/s mp4 ledwo nadąża, HLS ma zapas.
"""
from __future__ import annotations
import base64
import json
import logging
import re
from app.extractors import browser_get
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
# Cyrylickie homoglify → łacina. Bez tego b64decode dostaje śmieci.
HOMOGLYPHS = str.maketrans(
@ -21,6 +40,11 @@ HOMOGLYPHS = str.maketrans(
}
)
#: Wyzwanie anty-botowe sieci TXXX: 429 i `document.cookie = 'NAZWA=WARTOSC; max-age=...'`
#: w ciele odpowiedzi. Wartość stoi wprost, wystarczy ją odczytać i powtórzyć zapytanie,
#: bez wykonywania JS. Dotyczy ruchu z serwerowni, łącze domowe przechodzi bez tego.
CHALLENGE_COOKIE_RE = re.compile(r"document\.cookie\s*=\s*'([^=]+)=([^;']+)")
def decode_video_url(obfuscated: str) -> str | None:
"""Zaciemniony `video_url` → ścieżka/URL `get_file`. None gdy dekod padnie."""
@ -37,3 +61,67 @@ def decode_video_url(obfuscated: str) -> str | None:
return base64.b64decode(clean).decode("utf-8", "ignore")
except Exception:
return None
def quality_label(fmt: str | None) -> str | None:
"""`_hq.mp4` -> 'HD', `_sd.mp4` -> 'SD', `.mp4` (bez sufiksu) -> None."""
if not fmt:
return None
tok = fmt.strip("_").replace(".mp4", "").lower()
return {"hq": "HD", "sd": "SD", "lq": "LOW", "hd": "HD"}.get(tok, tok.upper() or None)
def extract_hls(
base: str, page_url: str, vid: str, *, tag: str, timeout: float = 60.0
) -> list[StreamSource] | None:
"""videofile.php → get_file (`?f=video.m3u8`) dla tuba TXXX pod `base`."""
api = f"{base}/api/videofile.php?video_id={vid}&lifetime=8640000"
hdr = {"Referer": page_url, "X-Requested-With": "XMLHttpRequest"}
try:
res = browser_get(api, timeout=timeout, headers=hdr)
body = res.text if hasattr(res, "text") else res
if (ck := CHALLENGE_COOKIE_RE.search(body)) is not None:
hdr = dict(hdr, Cookie=f"{ck.group(1)}={ck.group(2)}")
res = browser_get(api, timeout=timeout, headers=hdr)
body = res.text if hasattr(res, "text") else res
data = json.loads(body)
except Exception as e:
log.info("%s: videofile.php fetch/parse fail %s: %s", tag, api, e)
return None
if not isinstance(data, list):
return None
seen: set[str] = set()
out: list[StreamSource] = []
for entry in data:
if not isinstance(entry, dict):
continue
fmt = (entry.get("format") or "").strip()
# `_tr.mp4` = zwiastun, NIE pełne wideo.
if fmt.strip("_").replace(".mp4", "").lower() == "tr":
continue
get_file = decode_video_url(entry.get("video_url") or "")
if not get_file or "/get_file/" not in get_file:
continue
# Nowe wideo daje URL relatywny (/get_file/...), prepend host tuba.
if get_file.startswith("/"):
get_file = base + get_file
m3u8 = get_file + ("&" if "?" in get_file else "?") + "f=video.m3u8"
if m3u8 in seen:
continue
seen.add(m3u8)
out.append(
StreamSource(
link=m3u8,
type="m3u8",
quality=quality_label(fmt),
referer=page_url,
# Finalny HLS (*.ahcdn.com) time-bound + referer=none whitelisted ->
# portable. mobile_direct_ok -> playback.py owija w /proxy/hls passthrough.
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("%s: brak dekodowalnego video_url dla %s", tag, page_url)
return None
return out

View file

@ -0,0 +1,28 @@
"""tubepornclassic.com, TXXX network (ten sam silnik co vjav). HLS przez wspólny tor.
SPA bez strumienia w HTML; `videofile.php` → dekod → get_file → `f=video.m3u8` → HLS na
*.ahcdn.com. Wszystko w `_txxx.extract_hls`, tu tylko host i wyłuskanie id ze ścieżki
`/videos/<id>/<slug>/`. Sprawdzone 2026-09-07: get_file wybity z IP VPS-a gra z łącza
domowego (206, manifest 40 KB), czyli klucz nie jest IP-bound; sam VPS na get_file
dostaje 429 z wyzwaniem cookie, które `/proxy/hls` już zna po vjav.
"""
from __future__ import annotations
import logging
import re
from app.extractors._models import StreamSource
from app.extractors.tubes import _txxx
log = logging.getLogger(__name__)
_BASE = "https://tubepornclassic.com"
_VIDEO_ID_RE = re.compile(r"/videos/(\d+)/")
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
m = _VIDEO_ID_RE.search(page_url)
if not m:
log.info("tubepornclassic: brak video id w %s", page_url)
return None
return _txxx.extract_hls(_BASE, page_url, m.group(1), tag="tubepornclassic", timeout=timeout)

View file

@ -24,14 +24,15 @@ Zwracamy get_file (`?f=video.m3u8`) jako type='m3u8' + `mobile_direct_ok` -> pla
owija w `/proxy/hls/<token>/play.m3u8` (passthrough manifestu ~1KB przez VPS, segmenty
direct z telefonu; patrz stream_proxy.proxy_hls_manifest). Media id w get_file (318577)
!= page id (5197), dlatego MUSIMY przejść przez videofile.php. RE 2026-07-10.
Wyzwanie 429 (ciasteczko w ciele odpowiedzi, od 2026-08-21) i sam tor są w
`_txxx.extract_hls`, wspólnym z tubepornclassic.
"""
from __future__ import annotations
import json
import logging
import re
from app.extractors import browser_get
from app.extractors._models import StreamSource
from app.extractors.tubes import _txxx
@ -40,86 +41,14 @@ log = logging.getLogger(__name__)
_BASE = "https://vjav.com"
_VIDEO_ID_RE = re.compile(r"/videos/(\d+)/")
# 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.
# Dekoder i cały tor videofile.php → get_file → HLS siedzą w `_txxx.py`, bo od 2026-09-07
# ten sam silnik obsługuje też tubepornclassic. Alias zachowany dla ewentualnych importów.
_decode_video_url = _txxx.decode_video_url
#: Wyzwanie anty-botowe: `document.cookie = 'NAZWA=WARTOSC; max-age=...'` w ciele 429.
_CHALLENGE_COOKIE_RE = re.compile(r"document\.cookie\s*=\s*'([^=]+)=([^;']+)")
def _quality_label(fmt: str | None) -> str | None:
"""`_hq.mp4` -> 'HD', `_sd.mp4` -> 'SD'. Trailer (`_tr`) filtrowany osobno."""
if not fmt:
return None
tok = fmt.strip("_").replace(".mp4", "").lower()
return {"hq": "HD", "sd": "SD", "lq": "LOW", "hd": "HD"}.get(tok, tok.upper() or None)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
m = _VIDEO_ID_RE.search(page_url)
if not m:
log.info("vjav: brak video id w %s", page_url)
return None
vid = m.group(1)
api = f"{_BASE}/api/videofile.php?video_id={vid}&lifetime=8640000"
hdr = {"Referer": page_url, "X-Requested-With": "XMLHttpRequest"}
try:
res = browser_get(api, timeout=timeout, headers=hdr)
body = res.text if hasattr(res, "text") else res
# Serwis dlawi ruch z serwerowni: zamiast JSON-a oddaje 429 i mikroskopijna
# strone, ktora w JS ustawia jedno ciasteczko i przeladowuje sie. Wartosc jest
# WPROST w tej odpowiedzi, wiec wystarczy ja odczytac i powtorzyc zapytanie,
# bez zadnego wykonywania JS. Sprawdzone 2026-08-21: pierwsza proba 429 i 343
# bajty, druga z ciasteczkiem 200 i poprawny JSON z video_url. Bez tego caly
# vjav resolwowal sie do zera (smoke test: 0/3 OK, "extractor None").
if (ck := _CHALLENGE_COOKIE_RE.search(body)) is not None:
hdr = dict(hdr, Cookie=f"{ck.group(1)}={ck.group(2)}")
res = browser_get(api, timeout=timeout, headers=hdr)
body = res.text if hasattr(res, "text") else res
data = json.loads(body)
except Exception as e:
log.info("vjav: videofile.php fetch/parse fail %s: %s", api, e)
return None
if not isinstance(data, list):
return None
seen: set[str] = set()
out: list[StreamSource] = []
for entry in data:
if not isinstance(entry, dict):
continue
fmt = (entry.get("format") or "").strip()
# `_tr.mp4` = trailer preview, NIE pełne wideo, pomijamy.
if fmt.strip("_").replace(".mp4", "").lower() == "tr":
continue
vu = entry.get("video_url")
if not vu:
continue
get_file = _decode_video_url(vu)
if not get_file or "/get_file/" not in get_file:
continue
# Nowe wideo daje URL relatywny (/get_file/...), prepend host vjav.
if get_file.startswith("/"):
get_file = _BASE + get_file
m3u8 = get_file + ("&" if "?" in get_file else "?") + "f=video.m3u8"
if m3u8 in seen:
continue
seen.add(m3u8)
out.append(
StreamSource(
link=m3u8,
type="m3u8",
quality=_quality_label(fmt),
referer=page_url,
# Finalny HLS (txxx.ahcdn.com) time-bound + referer=none whitelisted ->
# portable. mobile_direct_ok -> playback.py owija w /proxy/hls passthrough.
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("vjav: brak dekodowalnego video_url dla %s", page_url)
return None
return out
return _txxx.extract_hls(_BASE, page_url, m.group(1), tag="vjav", timeout=timeout)