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.
127 lines
5.2 KiB
Python
127 lines
5.2 KiB
Python
"""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:
|
||
1. wielkie/małe litery łacińskie podmienione na cyrylickie homoglify (М→M, С→C, …),
|
||
2. custom alfabet base64: `,`→`/`, `~`→`=`, `-`→`+`.
|
||
|
||
Po odkręceniu obu i b64decode dostajemy `/get_file/...` (czasem absolutny URL).
|
||
Wyniesione tutaj, żeby vjav i fullvideosporn nie trzymały dwóch kopii dekodera.
|
||
|
||
**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(
|
||
{
|
||
"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H", "К": "K", "М": "M",
|
||
"О": "O", "Р": "P", "Т": "T", "Х": "X", "У": "Y",
|
||
"а": "a", "с": "c", "е": "e", "о": "o", "р": "p", "х": "x", "у": "y",
|
||
}
|
||
)
|
||
|
||
#: 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."""
|
||
if not obfuscated:
|
||
return None
|
||
clean = (
|
||
obfuscated.translate(HOMOGLYPHS)
|
||
.replace(",", "/")
|
||
.replace("~", "=")
|
||
.replace("-", "+")
|
||
)
|
||
clean += "=" * (-len(clean) % 4) # padding do wielokrotności 4
|
||
try:
|
||
return base64.b64decode(clean).decode("utf-8", "ignore")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
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
|