"""porndupe.com: skórka KVS, pliki na OK.ru, klucz sesji zaszyfrowany w HTML. Dodany 2026-09-05. Strona wygląda jak zwykły KVS (kt_player, flashvars z czterema jakościami), ale to atrapa: każdy `get_stream/-.mp4?md5=…×tamp=…` odpowiada dosłownie `error`, także z ciasteczkiem sesji, z Refererem strony i embedu, i po permutacji md5 kodem licencyjnym (sprawdzone cztery warianty). Prawdziwy tor widać dopiero w DevTools: generate_mp4('', '', '', '') → JSON: {ciphertext, iv, salt, iterations: 999} → PBKDF2-SHA512(hasło, salt, 999, 32 B) + AES-256-CBC → klucz sesji OK.ru → GET api.ok.ru/fb.do?method=video.get&session_key=…&vids= → {url_low, url_medium, url_high, url_fullhd, duration (ms), created_ms, thumbnail_url} Hasło i ładunek zmieniają się co wczytanie, ale odszyfrowany klucz sesji jest stały, czyli to zaciemnienie, nie sesja per użytkownik. Klucz aplikacji jest wpisany na sztywno w kt_player_new.js. Wariant PBKDF2 ustalony empirycznie: SHA-1 i SHA-256 dają bad decrypt, SHA-512 oddaje dokładnie klucz z trace'u przeglądarki. **Token okcdn NIE jest związany z IP.** URL ma wbite `srcIp=`, ale pobranie z VPS URL-a wygenerowanego z łącza domowego dało 206 i ten sam plik (514 MB, `ftypisom`), tak samo jak przy 4k69 i 8kporner. Stąd `mobile_direct_ok`. Ważność ~24 h. **Cap 720p, świadomie.** OK.ru dławi pojedyncze połączenie do ~2,1 Mbps i 1080p kończy się „loading forever" (decyzja podjęta dla 4k69 i 8kporner, patrz `kporner8.py`). Oddajemy `url_high` (720p) jako najlepszą, niżej 480p i 360p; `url_fullhd` pomijamy. Ten moduł jest też używany przez scraper porndupe do POBRANIA DŁUGOŚCI i daty przy ingeście, bo HTML strony nie ma ani `duration`, ani `uploadDate`, a scena bez długości znika w apce pod filtrem 60 s. """ from __future__ import annotations import base64 import hashlib import json import logging import re from datetime import UTC, date, datetime import httpx from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from app.extractors._fetch import fetch_tube_html from app.extractors._models import StreamSource log = logging.getLogger(__name__) _BASE = "https://www.porndupe.com" _APP_KEY = "CBAFJIICABABABABA" # stały, wpisany w kt_player_new.js _FIELDS = ",".join( f"video.{f}" for f in ( "url_low", "url_medium", "url_high", "url_fullhd", "duration", "created_ms", "thumbnail_url", ) ) _GEN_RE = re.compile( r"generate_mp4\s*\(\s*'([A-Za-z0-9+/=]+)'\s*,\s*'([^']+)'\s*,\s*'(\d+)'\s*,\s*'(\d+)'" ) #: OK.ru → etykieta. `url_fullhd` celowo pominięte (cap 720p, patrz docstring). _QUALITY = (("url_high", "720p"), ("url_medium", "480p"), ("url_low", "360p")) _UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36" def decrypt_session_key(blob_b64: str, passphrase: str) -> str: """Odszyfruj klucz sesji OK.ru z ładunku `generate_mp4`.""" p = json.loads(base64.b64decode(blob_b64)) salt = bytes.fromhex(p["salt"]) iv = bytes.fromhex(p["iv"]) ct = base64.b64decode(p["ciphertext"]) key = hashlib.pbkdf2_hmac("sha512", passphrase.encode(), salt, int(p.get("iterations", 999)), 32) dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor() pt = dec.update(ct) + dec.finalize() return pt[: -pt[-1]].decode() # PKCS7 def okru_video(page_html: str, *, timeout: float = 25.0) -> dict | None: """Metadane i URL-e OK.ru dla strony porndupe. Zwraca `videos[0]` uzupełnione o `okid` i `vid` (id porndupe) albo None, gdy strona nie ma `generate_mp4` lub API padło.""" m = _GEN_RE.search(page_html) if not m: return None blob, passphrase, okid, vid = m.groups() try: session_key = decrypt_session_key(blob, passphrase) except Exception as e: log.info("porndupe: odszyfrowanie klucza padło: %s", e) return None url = ( f"https://api.ok.ru/fb.do?application_key={_APP_KEY}&method=video.get" f"&fields={_FIELDS}&session_key={session_key}&vids={okid}" ) try: r = httpx.get( url, headers={"User-Agent": _UA, "Origin": _BASE, "Referer": _BASE + "/"}, timeout=timeout, ) videos = r.json().get("videos") or [] except Exception as e: log.info("porndupe: api.ok.ru padło dla okid=%s: %s", okid, e) return None if not videos: return None v = dict(videos[0]) v["okid"], v["vid"] = okid, vid return v def okru_duration_and_date(v: dict) -> tuple[int | None, date | None]: """`duration` jest w milisekundach, `created_ms` to epoch ms wgrania na OK.ru.""" dur = v.get("duration") dur_s = int(dur) // 1000 if isinstance(dur, (int, float)) and dur > 0 else None cm = v.get("created_ms") rel = datetime.fromtimestamp(int(cm) / 1000, tz=UTC).date() if cm else None return dur_s, rel def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: html_text = fetch_tube_html(page_url, timeout=timeout) v = okru_video(html_text) if not v: log.info("porndupe: brak generate_mp4/OK.ru na %s", page_url) return None out: list[StreamSource] = [] for field, label in _QUALITY: link = v.get(field) if link: out.append( StreamSource( link=link, type="mp4", quality=label, referer=None, # okcdn oddaje 206 bez Referera, sprawdzone z VPS raw={"mobile_direct_ok": True, "engine": "okru"}, ) ) if not out: log.info("porndupe: OK.ru bez url_low/medium/high dla %s", page_url) return None return out