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