"""4k69.com — latest-vids browse scraper (PlayTube CMS, patrz _playtube.py). Dołączony 2026-06-10 (user request; probe 2026-06-01 odrzucił po stronie głównej "JS-rendered" — błędnie, scene pages mają pełny SSR + JSON-LD). 7 video sitemapów ≈ ~65k scen, content w dużej mierze studyjny (paysite re-upload, 4K). Specyfika vs baza: studio NIE ma własnego pola na scenie — nazwy studiów występują jako kategorie ("21 Sextury", "Adult Time") obok zwykłych ("Anal", "4K"). Klasyfikacja: lista wszystkich studiów z `/studios` (fetch raz per instancję, match po znormalizowanej nazwie alfanumerycznej — pill "Adult Time" vs slug "AdultTime"). Studio bywa też w prefiksie tytułu, ale kategoria jest pewniejsza. Playback: JSON-LD contentUrl + dwa dodatkowe get_file w HTML (2160m/720m/480m, www.4kporno.xxx) — ta sama platforma co fullmovies/hdporngg: get_file binduje CDN do IP fetchera, więc oddajemy NIEZRESOLWOWANE (mobile_direct), telefon follow-uje 302 z własnym IP. Extractor `4k69com` pomija 2160p (CDN time-out, jak fpvcdn). """ from __future__ import annotations import logging import re from app.connectors.direct_scrapers._playtube import BasePlayTubeScraper from app.extractors import browser_get log = logging.getLogger(__name__) _STUDIO_LINK_RE = re.compile(r"href=['\"][^'\"]*/videos/studio/([^'\"]+)['\"]", re.IGNORECASE) def _norm(name: str) -> str: """`Adult Time` / `AdultTime` → `adulttime` (porównanie pill vs studio slug).""" return re.sub(r"[^a-z0-9]", "", name.lower()) class FourK69Scraper(BasePlayTubeScraper): sitetag = "4k69com" base_url = "https://4k69.com" def __init__(self) -> None: super().__init__() self._studio_set: set[str] | None = None def _load_studio_set(self) -> set[str]: """Znormalizowane nazwy wszystkich studiów z /studios. Pusty set = fetch fail (graceful: sceny pójdą bez studio, composite ma performer+title+dur).""" if self._studio_set is not None: return self._studio_set try: r = browser_get(f"{self.base_url}/studios", timeout=self._timeout) r.raise_for_status() self._studio_set = {_norm(m) for m in _STUDIO_LINK_RE.findall(r.text) if _norm(m)} log.info("4k69: studio list loaded — %d studios", len(self._studio_set)) except Exception as e: log.warning("4k69: studios page fetch failed: %s", e) self._studio_set = set() return self._studio_set def _pick_studio(self, category_names: list[str]) -> str | None: studios = self._load_studio_set() if not studios: return None for name in category_names: if _norm(name) in studios: return name return None