feat(porndupe): konektor, skorka KVS z plikami na OK.ru i zaszyfrowanym kluczem sesji
Z szesciu stron z listy piec juz jest lub bylo w bazie: fullmovies.xxx,
hdporn.gg, neporn.com sa zywe (ingest z dzisiaj, odtwarzanie 84-99%),
a hqfap.com i 4k69.com nadal oddaja Cloudflare JS-challenge na podstronach
(403 "Just a moment" takze z lacza domowego, wiec to nie blokada po IP).
Jedyna nowa to porndupe.
Porndupe wyglada jak KVS, ale to atrapa: kazdy get_stream/<id>-<q>.mp4?md5=
odpowiada doslownie "error" (sprawdzone z sesja, Refererem strony i embedu,
z permutacja md5 kodem licencyjnym). Prawdziwy tor widac w DevTools:
generate_mp4('<b64 JSON>', '<haslo>', '<okid>', '<id>')
-> {ciphertext, iv, salt, iterations: 999}
-> PBKDF2-SHA512 + AES-256-CBC -> klucz sesji OK.ru (staly; haslo i
ladunek zmieniaja sie co wczytanie, to zaciemnienie)
-> api.ok.ru/fb.do method=video.get -> url_low/medium/high/fullhd,
duration (ms), created_ms, thumbnail_url
Wariant PBKDF2 ustalony empirycznie: SHA-1/SHA-256 daja bad decrypt,
SHA-512 oddaje dokladnie klucz z trace'u.
okcdn: srcIp wbite w URL, NIE egzekwowane (URL z lacza domowego pobrany
z VPS -> 206, 514 MB, ftypisom) -> mobile_direct_ok. Cap 720p, bo OK.ru
dlawi polaczenie do ~2,1 Mbps (decyzja z 4k69/8kporner).
Browse po id (vjav pattern): kazdy listing to catch-all z tymi samymi 24
linkami, sitemapy pokrywaja 3500 z 12 887 id. /video/<id>/ -> 301 slug
(istnieje) albo 302 / (brak). Dlugosc z api.ok.ru przy ingescie (HTML nie
ma duration ani uploadDate). Daty CELOWO nie: created_ms to dwie fale
hurtowego wgrania (07.2024, 04.2025), nie premiery.
Katalog ZAMROZONY od kwietnia 2025, wiec to jednorazowy import, nie zrodlo
swiezosci. Wchodzi, bo 87% scen (52/60) nie mamy, a to tresc studyjna
(Nubile Films, NF Busty, Family Strokes) trafiajaca w kanon, w odroznieniu
od hentaicloud (17%). Pilot crawl_page(1): 22/22 z dlugoscia, 21/22 z
performerem, studia i tagi per-video.
Blad diagnostyczny po drodze: strona blankuje sie do about:blank po wykryciu
CDP (disable-devtool z jsdelivr); trace przezyl, DOM nie.
This commit is contained in:
parent
25416a2858
commit
9906c665ec
4 changed files with 357 additions and 0 deletions
|
|
@ -153,6 +153,7 @@ from app.connectors.direct_scrapers.javflix import JavflixScraper # noqa: E402
|
|||
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.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
|
||||
|
|
@ -333,6 +334,10 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
|||
# (listing / + ?p=N) daje ingest niezależny od stanu performerów. external_id ten sam
|
||||
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
|
||||
HQPornerBrowseScraper,
|
||||
# PorndupeScraper, dodany 2026-09-05. Re-uploader scen studyjnych (Nubile, NF Busty,
|
||||
# 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,
|
||||
# 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).
|
||||
|
|
|
|||
207
app/connectors/direct_scrapers/porndupe.py
Normal file
207
app/connectors/direct_scrapers/porndupe.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""porndupe.com, browse scraper po id. Dodany 2026-09-05.
|
||||
|
||||
Re-uploader zachodnich scen studyjnych (Nubile Films, NF Busty, Family Strokes, Bad MILFs,
|
||||
Pure Taboo...), czyli treść, która TRAFIA W KANON TPDB/StashDB zamiast robić orphany.
|
||||
Pomiar 2026-09-05 na 60 losowych: 52 sceny (87%) nie było w bazie po tytule i performerze.
|
||||
|
||||
**Dlaczego chodzenie po id, a nie listing.** Każda ścieżka listingu (`/latest-updates/`,
|
||||
`/new/`, `/videos/`, `/page/2/`, KVS-owe bloki `mode=async`) oddaje IDENTYCZNĄ stronę
|
||||
główną z tymi samymi 24 linkami, to catch-all. Sitemapy `videos-1..7.xml` są posortowane
|
||||
ROSNĄCO i mają 3500 URL-i, a id sięgają 12 887, czyli pokrywają ~27% katalogu. Za to
|
||||
`/video/<id>/` przekierowuje 301 na slug (istnieje) albo 302 na `/` (brak), więc pełny
|
||||
katalog daje wzór z vjav: najnowsze = najwyższe id, strona N = kolejny wycinek malejąco.
|
||||
|
||||
**Długość przychodzi z OK.ru, nie z HTML.** Strona nie ma `duration` ani `uploadDate`
|
||||
(JSON-LD tylko z tytułem i miniaturką), a scena bez długości znika w apce pod filtrem
|
||||
60 s i nie deduplikuje się. Dlatego przy ingeście robimy to samo, co ekstraktor:
|
||||
odszyfrowujemy klucz sesji i pytamy `api.ok.ru` o `duration` (ms). Jeden dodatkowy
|
||||
request na scenę, ~12,9 tys. na cały katalog.
|
||||
|
||||
**Daty CELOWO nie bierzemy.** `created_ms` z OK.ru to data hurtowego wgrania pliku, nie
|
||||
premiery: cały katalog ma dokładnie dwie (28-30.07.2024 dla id 1-6000 i 6-7.04.2025 dla
|
||||
id 12000+). Wpisanie tego jako `release_date` zrobiłoby z 6 tys. scen „premiery" z jednego
|
||||
dnia. Zostaje None, datę dołoży kanon przy dopasowaniu.
|
||||
|
||||
**Katalog jest ZAMROŻONY od kwietnia 2025** (ostatnia fala wgrań), czyli to jednorazowy
|
||||
import, nie źródło świeżości. Wchodzi mimo to, bo 87% scen jest dla nas nowe i to treść
|
||||
studyjna trafiająca w kanon, w odróżnieniu od odrzuconego hentaicloud (17% nowych).
|
||||
|
||||
Metadane z HTML: `<h1>` (czysty tytuł, bez dopisków), `/pornstar/` (jeden anchor, właściwa
|
||||
osoba), `/studio/`, `/tag/` (sprawdzone na dwóch scenach: 16 i 11 tagów, 7 wspólnych,
|
||||
czyli per-video, nie chmura z sidebara), `og:image`.
|
||||
|
||||
Playback: ekstraktor `porndupe` (OK.ru, cap 720p, `mobile_direct_ok`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_mod
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
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.porndupe import okru_duration_and_date, okru_video # data nieużywana, patrz docstring
|
||||
from app.normalize.text import slugify
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://www.porndupe.com"
|
||||
_PER_PAGE = 50
|
||||
#: Najwyższe id znane 2026-09-05. Od niego idziemy W GÓRĘ, żeby znaleźć bieżące maksimum.
|
||||
_SEED_ID = 12887
|
||||
#: Tyle kolejnych braków (302 na `/`) uznajemy za koniec katalogu przy szukaniu maksimum.
|
||||
_MAX_MISS = 20
|
||||
_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
|
||||
|
||||
_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]{2,200}?)\s*</h1>")
|
||||
_OG_IMAGE_RE = re.compile(r'property="og:image"[^>]*content="([^"]+)"')
|
||||
_PERF_RE = re.compile(r'href="[^"]*/pornstar/([^/"]+)/?"[^>]*>\s*([^<]{2,60}?)\s*<')
|
||||
_STUDIO_RE = re.compile(r'href="[^"]*/studio/([^/"]+)/?"[^>]*>\s*([^<]{2,60}?)\s*<')
|
||||
_TAG_RE = re.compile(r'<a[^>]*href="[^"]*/tag/([^/"]+)/?"[^>]*>\s*([^<]{1,50}?)\s*</a>')
|
||||
_VID_RE = re.compile(r"generate_mp4\s*\([^)]*'(\d+)'\s*\)")
|
||||
|
||||
|
||||
class PorndupeScraper(BaseBrowseScraper):
|
||||
sitetag = "porndupe"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._max_id: int | None = None
|
||||
|
||||
# ---- ABC wymaga tych dwóch; przy chodzeniu po id są nieużywane -------------------
|
||||
def _listing_url(self, page: int) -> str: # pragma: no cover
|
||||
return f"{_BASE}/"
|
||||
|
||||
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
|
||||
return []
|
||||
|
||||
# ---- maksimum id -----------------------------------------------------------------
|
||||
def _exists(self, vid: int) -> bool | None:
|
||||
"""True = 301 na slug, False = 302 na `/`, None = błąd sieci (nie wiadomo)."""
|
||||
try:
|
||||
r = httpx.get(f"{_BASE}/video/{vid}/", headers=_UA, timeout=20, follow_redirects=False)
|
||||
except Exception:
|
||||
return None
|
||||
return r.status_code == 301 and "/video/" in r.headers.get("location", "")
|
||||
|
||||
def _load_max_id(self) -> None:
|
||||
"""Od `_SEED_ID` w górę do `_MAX_MISS` kolejnych braków. Błąd sieci zostawia None,
|
||||
co `crawl_page` tłumaczy na transient-fail (NIE na koniec katalogu; patrz vjav)."""
|
||||
vid, last_ok, miss = _SEED_ID, _SEED_ID, 0
|
||||
while miss < _MAX_MISS:
|
||||
vid += 1
|
||||
ok = self._exists(vid)
|
||||
if ok is None:
|
||||
log.warning("porndupe: błąd sieci przy szukaniu maksimum id (%d)", vid)
|
||||
return
|
||||
if ok:
|
||||
last_ok, miss = vid, 0
|
||||
else:
|
||||
miss += 1
|
||||
self._max_id = last_ok
|
||||
log.info("porndupe: maksimum id = %d", last_ok)
|
||||
|
||||
# ---- strona = wycinek id malejąco -----------------------------------------------
|
||||
def crawl_page(self, page: int) -> list[RawScene] | None:
|
||||
if self._max_id is None:
|
||||
self._load_max_id()
|
||||
if self._max_id is None:
|
||||
return None
|
||||
start = self._max_id - (page - 1) * _PER_PAGE
|
||||
if start < 1:
|
||||
return []
|
||||
out: list[RawScene] = []
|
||||
for vid in range(start, max(start - _PER_PAGE, 0), -1):
|
||||
try:
|
||||
res = browser_get(f"{_BASE}/video/{vid}/", timeout=self._timeout, follow_redirects=True)
|
||||
except Exception as e:
|
||||
log.info("porndupe: fetch %d fail: %s", vid, e)
|
||||
continue
|
||||
final = str(getattr(res, "url", "") or "")
|
||||
if getattr(res, "status_code", 200) >= 400 or "/video/" not in final:
|
||||
continue # 302 na `/`, czyli brak takiego id
|
||||
try:
|
||||
scene = self._parse_detail(final, res.text, vid=vid)
|
||||
except Exception as e:
|
||||
log.info("porndupe: parse %d fail: %s", vid, e)
|
||||
continue
|
||||
if scene is not None:
|
||||
out.append(scene)
|
||||
return out
|
||||
|
||||
def _parse_detail(self, scene_url: str, detail_html: str, *, vid: int | None = None) -> RawScene | None:
|
||||
tm = _TITLE_RE.search(detail_html)
|
||||
if not tm:
|
||||
return None
|
||||
title = html_mod.unescape(tm.group(1)).strip()
|
||||
if not title:
|
||||
return None
|
||||
if vid is None:
|
||||
m = _VID_RE.search(detail_html)
|
||||
vid = int(m.group(1)) if m else None
|
||||
if vid is None:
|
||||
return None
|
||||
|
||||
# Długość i data z OK.ru. Gdy API padnie, scena wchodzi BEZ długości i loguje to,
|
||||
# zamiast cicho znikać; potok i tak nie tnie nieznanej długości.
|
||||
duration_sec = None
|
||||
release_date = None # celowo: created_ms to data wgrania hurtowego, patrz docstring
|
||||
v = okru_video(detail_html)
|
||||
if v:
|
||||
duration_sec, _upload_date = okru_duration_and_date(v)
|
||||
else:
|
||||
log.info("porndupe: brak danych OK.ru dla id=%s", vid)
|
||||
|
||||
thumbnail_url = None
|
||||
im = _OG_IMAGE_RE.search(detail_html)
|
||||
if im:
|
||||
thumbnail_url = im.group(1).strip()
|
||||
|
||||
performers: list[RawPerformer] = []
|
||||
seen: set[str] = set()
|
||||
for _slug, name in _PERF_RE.findall(detail_html):
|
||||
name = html_mod.unescape(name).strip()
|
||||
s = slugify(name)
|
||||
if s and s not in seen:
|
||||
seen.add(s)
|
||||
performers.append(RawPerformer(external_id=f"{self.sitetag}:performer:{s}", name=name))
|
||||
|
||||
studio = None
|
||||
sm = _STUDIO_RE.search(detail_html)
|
||||
if sm:
|
||||
sname = html_mod.unescape(sm.group(2)).strip()
|
||||
if 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 _slug, name in _TAG_RE.findall(detail_html):
|
||||
name = html_mod.unescape(name).strip()
|
||||
s = slugify(name)
|
||||
if s and s not in seen_t:
|
||||
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=release_date,
|
||||
duration_sec=duration_sec,
|
||||
url=scene_url,
|
||||
studio=studio,
|
||||
performers=performers,
|
||||
tags=tags,
|
||||
playback_sources=[
|
||||
RawPlaybackSource(
|
||||
origin=f"tube:{self.sitetag}",
|
||||
page_url=scene_url,
|
||||
duration_sec=duration_sec,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -47,6 +47,7 @@ from app.extractors.tubes import (
|
|||
sexu,
|
||||
javflix,
|
||||
neporn,
|
||||
porndupe,
|
||||
latestpornvideo,
|
||||
paradisehill,
|
||||
porn00,
|
||||
|
|
@ -269,6 +270,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
|||
# neporn, KVS function/0 + license (jak freshporno). Server-side _kvs resolve →
|
||||
# data001.neporn.com/remote_control.php portable (cross-IP 206, 2026-06-10).
|
||||
"neporncom": neporn.extract,
|
||||
# porndupe, skorka KVS z atrapa get_stream (odpowiada "error"); prawdziwy plik na
|
||||
# OK.ru, klucz sesji zaszyfrowany w HTML (PBKDF2-SHA512 + AES-256-CBC). okcdn
|
||||
# srcIp NIEegzekwowane (cross-IP 206) -> mobile direct. Cap 720p jak 4k69/8kporner.
|
||||
"porndupe": porndupe.extract,
|
||||
# superporn, `<source>` mp4 (cdnst*.superporn.com) token IP-bound do fetchera
|
||||
# (403 cross-IP, test 2026-06-10), a sama strona CF-blocked z VPS. Resolve MUSI
|
||||
# być phone-side: WebView ładuje stronę z residential IP telefonu, INJECTED_JS
|
||||
|
|
|
|||
140
app/extractors/tubes/porndupe.py
Normal file
140
app/extractors/tubes/porndupe.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""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/<id>-<q>.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('<base64 JSON>', '<hasło 16 zn.>', '<id ok.ru>', '<id porndupe>')
|
||||
→ 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=<id ok.ru>
|
||||
→ {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=<IP pobierającego>`, 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
|
||||
Loading…
Add table
Reference in a new issue