diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 6a749b3..98e3599 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -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:`), 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). diff --git a/app/connectors/direct_scrapers/porndupe.py b/app/connectors/direct_scrapers/porndupe.py new file mode 100644 index 0000000..2ae33bb --- /dev/null +++ b/app/connectors/direct_scrapers/porndupe.py @@ -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//` 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: `

` (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"]*>\s*([^<]{2,200}?)\s*

") +_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']*href="[^"]*/tag/([^/"]+)/?"[^>]*>\s*([^<]{1,50}?)\s*') +_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, + ) + ], + ) diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index 87a205c..253c80d 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -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, `` 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 diff --git a/app/extractors/tubes/porndupe.py b/app/extractors/tubes/porndupe.py new file mode 100644 index 0000000..dca93df --- /dev/null +++ b/app/extractors/tubes/porndupe.py @@ -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/-.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