goon/app/connectors/direct_scrapers/kporner8.py
goon-foss f5b7a45ac0 feat(8kporner): browse scraper za obejściem CF + extractor JWPlayer sources
Sitemapa i /watch/*.html są za Cloudflare, paginacja zwraca stronę 1, a load-more
chce logowania, więc katalog buduje się z homepage (~115 scen), a detale lecą przez
?link1=watch&id=. Bramka na obsadę przepuszcza 62/115; z tego 97% performerów ma
ref tpdb/stashdb, a 60% scen dopina się do tego, co już mamy.

Extractor czyta tablicę sources JWPlayera, nie JSON-LD contentUrl (ten jest zawsze
144p). Cap 720p, bo okcdn dławi połączenie do ~2,1 Mbps. Stream przenośny cross-IP
(206 bez Referera, faststart) → mobile_direct_ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:15:32 +02:00

187 lines
8.4 KiB
Python

"""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/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id=
<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/<slug>_<id>.html` → `?link1=watch&id=<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