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:
parent
9906c665ec
commit
994e4e98ad
6 changed files with 371 additions and 78 deletions
|
|
@ -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).
|
||||
|
|
|
|||
239
app/connectors/direct_scrapers/tubepornclassic.py
Normal file
239
app/connectors/direct_scrapers/tubepornclassic.py
Normal 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
|
||||
|
|
@ -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 →
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
app/extractors/tubes/tubepornclassic.py
Normal file
28
app/extractors/tubes/tubepornclassic.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue