goon/app/connectors/direct_scrapers/porndupe.py
goon-foss 9906c665ec 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.
2026-09-05 22:34:39 +02:00

207 lines
8.9 KiB
Python

"""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,
)
],
)