From 994e4e98adf80c3baa98bfedbfb8a44b2d5961f3 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Mon, 7 Sep 2026 10:04:24 +0200 Subject: [PATCH] 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 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. --- app/connectors/direct_scrapers/__init__.py | 6 + .../direct_scrapers/tubepornclassic.py | 239 ++++++++++++++++++ app/extractors/__init__.py | 3 + app/extractors/tubes/_txxx.py | 90 ++++++- app/extractors/tubes/tubepornclassic.py | 28 ++ app/extractors/tubes/vjav.py | 83 +----- 6 files changed, 371 insertions(+), 78 deletions(-) create mode 100644 app/connectors/direct_scrapers/tubepornclassic.py create mode 100644 app/extractors/tubes/tubepornclassic.py 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 `<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 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=<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/<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) 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/<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)