diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py
index 98e3599..1f97335 100644
--- a/app/connectors/direct_scrapers/__init__.py
+++ b/app/connectors/direct_scrapers/__init__.py
@@ -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).
diff --git a/app/connectors/direct_scrapers/tubepornclassic.py b/app/connectors/direct_scrapers/tubepornclassic.py
new file mode 100644
index 0000000..a989c1e
--- /dev/null
+++ b/app/connectors/direct_scrapers/tubepornclassic.py
@@ -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 `
` i zero linków do
+scen. Aplikacja woła `GET /api/json/videos2//str/latest-updates//
+...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///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
diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py
index 253c80d..5fbf5cc 100644
--- a/app/extractors/__init__.py
+++ b/app/extractors/__init__.py
@@ -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 →
diff --git a/app/extractors/tubes/_txxx.py b/app/extractors/tubes/_txxx.py
index 3ddb042..b205d37 100644
--- a/app/extractors/tubes/_txxx.py
+++ b/app/extractors/tubes/_txxx.py
@@ -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=&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
diff --git a/app/extractors/tubes/tubepornclassic.py b/app/extractors/tubes/tubepornclassic.py
new file mode 100644
index 0000000..d1e1667
--- /dev/null
+++ b/app/extractors/tubes/tubepornclassic.py
@@ -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///`. 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)
diff --git a/app/extractors/tubes/vjav.py b/app/extractors/tubes/vjav.py
index 10a5d18..441de5a 100644
--- a/app/extractors/tubes/vjav.py
+++ b/app/extractors/tubes/vjav.py
@@ -24,14 +24,15 @@ Zwracamy get_file (`?f=video.m3u8`) jako type='m3u8' + `mobile_direct_ok` -> pla
owija w `/proxy/hls//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)