goon/app/connectors/direct_scrapers/supjav.py
goon-foss 8c3ad2b09a feat(jav): supjav.com scraper + extractor (embed-aggregator, 4th JAV source)
Fourth JAV vertical source (origin tube:supjav, gated to JAV tab via JAV_ORIGINS).

Browse: CF-blocks datacenter IPs, so listing goes through the Bright Data ISP proxy
with retry (the proxy rotates IPs and CF only lets some through). Homepage-latest only
(~66 newest); deeper /page/N/ stays CF-403 even via proxy. Metadata parsed from the
listing cards (title, thumbnail, JAV code, year/month) rather than per-post details,
which are flaky and slow through the proxy.

Stream: supjav hides the real hoster behind a per-server data-link (hex). base.js loads
lk1.supremejav.com/supjav.php?l=<data-link>, which reverses the hex string and fetches
?c=<reversed>, 302-ing to the hoster (TV->turbovid, FST->fc2stream, ST->streamtape,
VOE->voe). The extractor reproduces that: fetch detail via proxy, reverse each data-link,
resolve through lk1 (reachable direct from the server), return the hosters as type=hoster
so the phone resolves them (dood/filemoon native, the rest via the WebView fallback on the
residential IP). lk1 needs no proxy; only the detail fetch does.

Backend-only, no mobile change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:10:43 +02:00

126 lines
5.6 KiB
Python

"""supjav.com — JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
Osobna pula JAV (sekcja JAV), origin `tube:supjav` w JAV_ORIGINS. supjav agreguje
embedy (serwery TV/FST/ST/VOE), gdzie realny hoster jest schowany za `data-link`
(reverse-hex → lk1.supremejav.com); rozwiązuje go ekstraktor `app/extractors/tubes/supjav.py`.
Browse (RE 2026-07-10):
- listing: homepage `/` (najnowsze ~66, WordPress). Głębsza paginacja `/page/N/` leci
CF 403 nawet przez proxy → browse ogranicza się do strony 1 (scheduler odświeża).
- karty: `<div class="post"><a href="/<id>.html" title="<title>" rel="bookmark"
class="img"><img src=".../images/YYYY/MM/<CODE>.jpg!320x216.jpg" class="thumb">`.
- Metadane bierzemy Z KARTY (tytuł/thumb/URL/kod JAV/data z roku-miesiąca ścieżki
thumb), NIE z detalu — detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
per-post fetch byłby wolny i lossy. Studio/tagi (poza kodem) dorabia enrich później.
supjav blokuje VPS IP twardym CF → listing idzie przez Bright Data ISP proxy z retry
(`fetch_supjav_html`, rotacja IP aż trafi przepuszczający). Bez proxy scraper no-op.
"""
from __future__ import annotations
import html as _html
import logging
import re
from datetime import date
from app.config import get_settings
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors.tubes.supjav import fetch_supjav_html
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://supjav.com"
# Karta: link-obraz z href + title + thumb. title="([^"]*)" jest bezpieczne (wartość
# atrybutu nie zawiera surowego "), więc tytuły z `>` (np. `<<3-Day Offer>>`) przechodzą.
# Link-obraz karty: <a href="URL" ... title="TITLE"><img src|data-original="THUMB">.
# supjav miesza kolejność atrybutów (href/rel/class="img"/title w dwóch wariantach), więc
# NIE wymagamy pozycji class="img" — wymóg `>\s*<img` i tak izoluje link-obraz od
# link-tekstu (ten drugi po `>` ma tekst, nie <img>). Thumb eager `src=` lub lazy
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
_CARD_RE = re.compile(
r'href="(https://supjav\.com/\d+\.html)"[^>]*?\btitle="([^"]*)"[^>]*?>\s*'
r'<img[^>]*?\b(?:src|data-original|data-src|data-lazy-src)="(https://img\.supjav\.com/[^"]+)"',
re.IGNORECASE,
)
_THUMB_DATE_RE = re.compile(r"/images/(\d{4})/(\d{2})/")
# Kod JAV: FC2PPV ma cyfrę w prefiksie (FC2PPV 4931572), studia mają myślnik (MGOLD-053,
# DLDSS-510, SNOS-258). Prefiks liter+cyfr, separator - lub spacja, numer.
_CODE_RE = re.compile(r"^([A-Z][A-Z0-9]{1,7}[-\s]\d{2,7})", re.IGNORECASE)
class SupjavScraper(BaseBrowseScraper):
sitetag = "supjav"
_timeout = 70.0
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# CF-blok VPS IP → listing przez Bright Data proxy (jak superporn).
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("supjav: BRIGHTDATA_PROXY_URL unset — scraper disabled")
def _listing_url(self, page: int) -> str:
return f"{_BASE}/" if page <= 1 else f"{_BASE}/page/{page}/"
def crawl_page(self, page: int) -> list[RawScene] | None:
if not self._proxy:
return [] # bez proxy gwarantowany CF 403 (exhausted)
html = fetch_supjav_html(self._listing_url(page), proxy=self._proxy, timeout=self._timeout)
if not html or len(html) < 15000:
# sam challenge / fetch fail. page 1 = transient (None, retry potem);
# page > 1 = najpewniej CF-403 głębszej paginacji = koniec (exhausted).
return None if page <= 1 else []
seen: set[str] = set()
out: list[RawScene] = []
for m in _CARD_RE.finditer(html):
url, title_raw, thumb_raw = m.group(1), m.group(2), m.group(3)
if url in seen:
continue
seen.add(url)
raw = self._card_to_scene(url, title_raw, thumb_raw)
if raw is not None:
out.append(raw)
return out
def _card_to_scene(self, url: str, title_raw: str, thumb_raw: str) -> RawScene | None:
title = _html.unescape(title_raw).strip()
if not title:
return None
thumb = thumb_raw.split("!")[0] or None # utnij `!320x216.jpg` → pełna rozdz.
release_date: date | None = None
dm = _THUMB_DATE_RE.search(thumb_raw)
if dm:
try:
release_date = date(int(dm.group(1)), int(dm.group(2)), 1)
except ValueError:
release_date = None
tags: list[RawTag] = []
cm = _CODE_RE.match(title)
if cm:
code = re.sub(r"[ ]", "-", cm.group(1).upper())
tags.append(RawTag(external_id=f"javcode:{code}", name=code, slug=slugify(code)))
return RawScene(
external_id=f"{self.sitetag}:{url}",
title=title,
release_date=release_date,
url=url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=url,
thumbnail_url=thumb,
)
],
)
# HTML detail nieużywany (browse z kart), ale ABC wymaga.
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
return []
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
return None