"""8kporner.com — browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27. Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny stream. Dlatego mimo małego wolumenu sceny dobrze matchują się do kanonu. Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę: 1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` → 403 również PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc `_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos& page=latest` (20). To jednocześnie SUFIT — deep crawl jest niemożliwy. 2. **Paginacja nie działa** — `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`). 3. **Forma `/watch/_.html` jest CF-blokowana (403)**, ale `?link1=watch&id= ` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny URL, stabilny external_id), a POBIERAMY przez obejście. Extractor `8kpornercom` robi to samo przepisanie przy resolve. **Bramka na obsadę.** Przepuszcza **62 ze 115 scen (54%)**, ale bardzo nierówno: strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie ma żadnego performera i weszłaby jako puste orphany. Bramka się broni — z 117 performerów wciągniętych pilotem **113 (97%) ma ref tpdb/stashdb**, najwyżej ze wszystkich źródeł dodanych w tej rundzie. Pilotowy ingest: `seen 62, new 25, updated 37, errors 0` — czyli 60% scen przypięło się do czegoś, co już mamy (to nie jest fabryka orphanów, tylko dodatkowe, lepsze jakościowo źródło). **Studio.** Scena NIE linkuje studia osobno — na stronie sceny wszystko jest pillem `/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami. Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi studiami. Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio. Trafień jest mało (1 na 62 w pilocie) — większość scen po prostu nie ma pilla studia, ale kosztuje to jeden fetch na run, więc zostaje. Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w canonical — dopasowanie musi opierać się na tytule, obsadzie i długości. """ from __future__ import annotations import logging import re import time from urllib.parse import unquote from app.connectors.base import RawScene from app.connectors.direct_scrapers._playtube import BasePlayTubeScraper from app.extractors import browser_get log = logging.getLogger(__name__) _BASE = "https://8kporner.com" _PAGE_SIZE = 20 _FETCH_DELAY = 1.0 # CF przepuszcza przy ~1 s; szybciej zaczyna rzucać 403 _SCENE_HREF_RE = re.compile( r'href="(https://8kporner\.com/watch/[a-z0-9_-]+_(\d+)\.html)"', re.IGNORECASE ) _SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html") _STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE) # Pozycje z `/studios`, które są normalnymi gatunkami — jako studio dałyby fałszywkę # na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki # (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe. _AMBIGUOUS_STUDIOS = { "abc", "babes", "doc", "freeze", "futanari", "harassment", "hunter", "inserted", "kawaii", "madonna", "milk", "non", "premium", "rabbit", "rookie", "royal", "ruby", "smashed", "sperma", "squirted", "swallowed", "switch", "test shoots", "throated", "venus", } def _skey(name: str) -> str: return re.sub(r"[^a-z0-9]", "", name.lower()) def _bypass_url(scene_url: str) -> str: """Kanoniczny `/watch/_.html` → `?link1=watch&id=` (CF-safe).""" m = _SCENE_ID_IN_URL_RE.search(scene_url) return f"{_BASE}/?link1=watch&id={m.group(1)}" if m else scene_url class KPorner8Scraper(BasePlayTubeScraper): sitetag = "8kpornercom" base_url = _BASE def __init__(self) -> None: super().__init__() self._studios: dict[str, str] | None = None def _studio_index(self) -> dict[str, str]: """{znormalizowany klucz: nazwa} z `/studios`. Ciągniemy z sieci zamiast trzymać listę w kodzie, żeby nie gniła przy zmianach na stronie.""" if self._studios is not None: return self._studios index: dict[str, str] = {} try: r = browser_get(f"{_BASE}/?link1=studios", timeout=self._timeout) r.raise_for_status() for raw in set(_STUDIO_PILL_RE.findall(r.text)): name = unquote(raw).replace("+", " ").strip() if name and name.lower() not in _AMBIGUOUS_STUDIOS: index[_skey(name)] = name except Exception as e: # Brak listy = brak studia. Reszta metadanych jest ważniejsza. log.warning("8kporner: studios list fetch failed: %s", e) self._studios = index return index def _pick_studio(self, category_names: list[str]) -> str | None: index = self._studio_index() for name in category_names: hit = index.get(_skey(name)) if hit: return hit return None def _load_catalog(self) -> list[str] | None: """Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz docstring), więc to jedyne dostępne źródło URL-i — i zarazem sufit katalogu.""" if self._catalog is not None: return self._catalog urls: list[str] = [] seen: set[str] = set() for listing in (f"{_BASE}/", f"{_BASE}/?link1=videos&page=latest"): try: r = browser_get(listing, timeout=self._timeout) r.raise_for_status() except Exception as e: log.warning("8kporner: listing fetch failed %s: %s", listing, e) continue for m in _SCENE_HREF_RE.finditer(r.text): url = m.group(1) if url not in seen: seen.add(url) urls.append(url) time.sleep(_FETCH_DELAY) if not urls: return None log.info("8kporner: catalog loaded — %d scenes (homepage+latest, brak sitemapy)", len(urls)) self._catalog = urls return urls def crawl_page(self, page: int) -> list[RawScene] | None: """Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/` daje 403) i z pauzą — CF tnie przy szybszym tempie.""" catalog = self._load_catalog() if catalog is None: return None chunk = catalog[(page - 1) * _PAGE_SIZE: page * _PAGE_SIZE] if not chunk: return [] out: list[RawScene] = [] for scene_url in chunk: try: res = browser_get(_bypass_url(scene_url), timeout=self._timeout) res.raise_for_status() except Exception as e: log.info("8kporner detail fetch failed %s: %s", scene_url, e) continue try: raw = self._parse_detail(scene_url, res.text) except Exception as e: log.warning("8kporner detail parse failed %s: %s", scene_url, e) continue if raw is not None: out.append(raw) time.sleep(_FETCH_DELAY) return out def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: scene = super()._parse_detail(scene_url, detail_html) if scene is None: return None # BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą) — bez niej # większość katalogu wpadłaby jako puste orphany. if not scene.performers: return None # Baza wycina studio z tagów przez porównanie napisów, a nazwa na `/studios` # bywa inaczej rozstrzelona niż pill kategorii („DDFNetwork" vs „DDF Network") # → dobijamy po znormalizowanym kluczu, żeby studio nie dublowało się jako tag. if scene.studio is not None: skey = _skey(scene.studio.name) scene.tags = [t for t in scene.tags if _skey(t.name) != skey] return scene