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>
This commit is contained in:
parent
b3e1092175
commit
f5b7a45ac0
4 changed files with 281 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
187
app/connectors/direct_scrapers/kporner8.py
Normal file
187
app/connectors/direct_scrapers/kporner8.py
Normal file
|
|
@ -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/<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
|
||||
|
|
@ -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 <source> 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.
|
||||
|
|
|
|||
80
app/extractors/tubes/kporner8.py
Normal file
80
app/extractors/tubes/kporner8.py
Normal file
|
|
@ -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=<id>`, bo kanoniczna forma `/watch/<slug>_<id>.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":"<url>","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
|
||||
Loading…
Add table
Reference in a new issue