From f5b7a45ac0e15e1b65adc7673ca0fad1f11445ee Mon Sep 17 00:00:00 2001 From: goon-foss Date: Mon, 27 Jul 2026 11:15:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(8kporner):=20browse=20scraper=20za=20obej?= =?UTF-8?q?=C5=9Bciem=20CF=20+=20extractor=20JWPlayer=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/connectors/direct_scrapers/__init__.py | 9 + app/connectors/direct_scrapers/kporner8.py | 187 +++++++++++++++++++++ app/extractors/__init__.py | 5 + app/extractors/tubes/kporner8.py | 80 +++++++++ 4 files changed, 281 insertions(+) create mode 100644 app/connectors/direct_scrapers/kporner8.py create mode 100644 app/extractors/tubes/kporner8.py diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 6b7fd94..0b2ba72 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -47,6 +47,7 @@ from app.connectors.direct_scrapers.fullvideosporn import FullVideosPornScraper from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper from app.connectors.direct_scrapers.pornbusy import PornBusyScraper from app.connectors.direct_scrapers.pornmike import PornMikeScraper +from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper from app.connectors.direct_scrapers.watchporn import WatchPornScraper from app.connectors.direct_scrapers.youperv import YoupervScraper from app.connectors.direct_scrapers.xhamster import XHamsterScraper @@ -180,6 +181,14 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [ # UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez # żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy. PornMikeScraper, + # KPorner8Scraper — dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE + # tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny + # dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html` + # są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać + # tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka + # na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb. + # Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy. + KPorner8Scraper, # FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI). # = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75% # katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko diff --git a/app/connectors/direct_scrapers/kporner8.py b/app/connectors/direct_scrapers/kporner8.py new file mode 100644 index 0000000..da6447f --- /dev/null +++ b/app/connectors/direct_scrapers/kporner8.py @@ -0,0 +1,187 @@ +"""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 diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index 3cac7e7..c5e7d2f 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -35,6 +35,7 @@ from app.extractors.tubes import ( hdporngg, hqfap, hqporner, + kporner8, javflix, neporn, latestpornvideo, @@ -122,6 +123,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { # pornmike — gołe mp4 na twincdn: bez tokenu, bez Referera, bez expiry. # Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView. "pornmike": pornmike.extract, + # 8kporner — JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!). + # okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN + # dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever". + "8kpornercom": kporner8.extract, # fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn. # Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429 # (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy. diff --git a/app/extractors/tubes/kporner8.py b/app/extractors/tubes/kporner8.py new file mode 100644 index 0000000..b260e2a --- /dev/null +++ b/app/extractors/tubes/kporner8.py @@ -0,0 +1,80 @@ +"""8kporner.com — JWPlayer sources → direct mp4 na OK.ru CDN. Dodany 2026-07-27. + +**Pułapka, o którą łatwo się rozbić:** JSON-LD `contentUrl` na tej stronie to ZAWSZE +144p (256x144, sprawdzone). Prawdziwe jakości siedzą wyłącznie w tablicy `sources` +JWPlayera. Skopiowanie wzorca z `hqfap.py` (który czyta `contentUrl`) wysłałoby +każdemu userowi 144p. + +Fetch idzie przez `?link1=watch&id=`, bo kanoniczna forma `/watch/_.html` +jest CF-blokowana (403, również przez proxy). + +CDN to `vd*.okcdn.ru` / `ok6-*.vkuser.net` — ta sama rodzina co 4k69/hqfap: `srcIp` +jest w URL-u, ale NIE jest egzekwowane, więc token działa cross-IP (mobile_direct). + +**Limit przepustowości:** OK.ru dławi pojedyncze połączenie do ~2,1 Mbps. 1080p +potrzebuje 3,8 Mbps, 2K 6,2, a 4K 13,8 — czyli nie dociągną się w czasie rzeczywistym +i dałyby „loading forever" (ten sam objaw co przy 4K na fullmovies). Dlatego oddajemy +maksymalnie 720p (1,5 Mbps), zgodnie z decyzją podjętą już dla 4k69. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + +_BASE = "https://8kporner.com" +_SCENE_ID_RE = re.compile(r"_(\d+)\.html") +# Pary z JWPlayer setupu: "file":"","label":"720p" +_SOURCE_RE = re.compile( + r'"file"\s*:\s*"(https?://[^"]+)"\s*,\s*"label"\s*:\s*"([^"]+)"', re.IGNORECASE +) +# Powyżej 720p CDN nie nadąża (patrz docstring). +_MAX_HEIGHT = 720 + + +def _height(label: str) -> int: + lab = (label or "").strip().lower() + if lab.startswith("4k"): + return 2160 + if lab.startswith("2k"): + return 1440 + m = re.match(r"(\d{3,4})", lab) + return int(m.group(1)) if m else 0 + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + # Kanoniczny URL → forma omijająca CF. + m = _SCENE_ID_RE.search(page_url) + fetch_url = f"{_BASE}/?link1=watch&id={m.group(1)}" if m else page_url + + html_text = fetch_tube_html(fetch_url, timeout=timeout) + + seen: set[str] = set() + out: list[StreamSource] = [] + for sm in _SOURCE_RE.finditer(html_text): + url = sm.group(1).replace("&", "&") + label = sm.group(2).strip() + h = _height(label) + if url in seen or h == 0 or h > _MAX_HEIGHT: + continue + seen.add(url) + out.append( + StreamSource( + link=url, + type="mp4", + quality=label, + referer=_BASE + "/", + # srcIp nieegzekwowane (jak 4k69) → telefon gra direct z CDN. + raw={"mobile_direct_ok": True}, + ) + ) + + if not out: + log.info("8kporner: brak sources <=720p na %s", page_url) + return None + out.sort(key=lambda s: _height(s.quality or ""), reverse=True) + return out