"""tubepornclassic.com, browse scraper po JSON API listingu (silnik TXXX). Dodany 2026-09-07. Retro i vintage (lata 70-00), ~168 tys. wideo, świeże wgrania codziennie (2026-09-06 przy dodawaniu). Zachodnia treść studyjna, więc wchodzi do głównego feedu, nie do wertykału. **Listing z API, nie z HTML.** Strona to SPA: HTML ma pusty `` i zero linków do scen. Aplikacja woła `GET /api/json/videos2/<cache>/str/latest-updates/<count>/ ..<page>.all...json` (sekcja, object_id, typ, długość i data puste) i dostaje 60 rekordów Z PEŁNYMI METADANYMI: tytuł, `dir` (slug), `duration`, `post_date`, `models` (obsada, CSV), `categories` + `tags` (CSV), `scr` (miniatura), `is_private`. Nie ma więc żadnego fetchu per scena, jedna strona = jeden request, w odróżnieniu od vjav (sitemap + meta per id). Koniec katalogu = 200 z `{"error":1,"code":"videos_not_found"}` (strona 2801 wciąż oddaje rekordy mimo `pages: 2800`, licznik jest z Sphinxa i przybliżony). **Wyzwanie 429.** Z serwerowni pierwszy request dostaje 429 i stronkę ustawiającą ciasteczko (`_txxx.CHALLENGE_COOKIE_RE`), z ciasteczkiem 200. Trzymamy je w instancji, gdyby i tak padło, a jest Bright Data, powtarzamy przez proxy (przez proxy 200 od razu). **Obsada tylko u 46 % scen** (28/60 w próbce), studia brak (`content_source_name` puste we wszystkich 60). Jan o tym wie („gorzej ze studiami i aktorami"). **Bramka na spun-tytuły BEZ obsady.** 38 % tytułów (23/60) to generator SEO w stylu „Fabulous Sex Movie Vintage Try To Watch For Ever Seen" albo „Hottest Xxx Clip Fat Great Will Enslaves Your Mind" (ten sam szablon co na fullvideosporn, to jedna sieć). Gdy do takiego tytułu jest doczepione nazwisko („... - Marissa Malibu"), scena jest w apce odnajdywalna po performerze i zostaje. Gdy nazwiska nie ma (13/60 = 22 %), nie ma po czym jej znaleźć ani z czym zdeduplikować, więc NIE wchodzi. Zwykłe tytuły bez obsady (19/60, „Unforgettable Sensation") wchodzą: dopasowanie po tytule ma sens, retro ma w kanonie sporo scen. **Miniatura:** `scr` to 240x180. Ten sam katalog ma `preview.jpg` 1280x720 (sprawdzone), inne rozmiary w `videos_screenshots/<id>/<WxH>/1.jpg` dają 404, bierzemy preview. **Data:** `post_date` to data wgrania na tube, nie premiery (retro z lat 80. ma `2026-09-06`). Tak samo jak inne tuby; deep_crawl od strony 3 ustawia `backfill`. Playback: ekstraktor `tubepornclassic` (`_txxx.extract_hls`, HLS przez /proxy/hls). """ from __future__ import annotations import html as html_mod import json import logging import re from datetime import date, datetime from app.config import get_settings from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper from app.extractors import browser_get from app.extractors.tubes._txxx import CHALLENGE_COOKIE_RE from app.normalize.text import slugify log = logging.getLogger(__name__) _BASE = "https://tubepornclassic.com" _PER_PAGE = 60 #: Cache po stronie serwera (sekundy). Aplikacja używa 86400; bierzemy krótszy, żeby #: strona 1 nie była o dobę stara przy godzinnym browse. _CACHE_LIFETIME = 3600 _THUMB_SMALL_RE = re.compile(r"/\d+x\d+/\d+\.jpg$") #: Tagi-śmieci z pola `tags` (techniczne, nie opisowe). _SKIP_TAGS = frozenset({"hd", "4k", "1080p", "720p"}) #: Szablon tytułu z generatora SEO: przymiotnik, potem (porn|xxx|sex|adult) i zaraz #: (video|clip|movie|scene). Zwykłe tytuły („Unforgettable Sensation", „Oceane, Amazing #: Blonde Bimbo, With 1 Or 3 Dicks") go nie łapią, bo brak im pary porn+video obok siebie. _SPUN_TITLE_RE = re.compile( r"\b(fabulous|incredible|exotic|amazing|hottest|crazy|horny|best|excellent|astonishing|" r"exclusive|fantastic|great|wild)\b.{0,24}\b(porn|xxx|sex|adult)\b.{0,3}\b(video|clip|movie|scene)\b", re.IGNORECASE, ) def _parse_duration(value: str | None) -> int | None: """`1:16:43` / `14:17` -> sekundy.""" if not value: return None try: parts = [int(p) for p in value.strip().split(":")] except ValueError: return None sec = 0 for p in parts: sec = sec * 60 + p return sec or None def _parse_post_date(value: str | None) -> date | None: if not value: return None try: return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").date() except ValueError: return None def _csv(value) -> list[str]: if not isinstance(value, str): return [] return [html_mod.unescape(x).strip() for x in value.split(",") if x.strip()] class TubePornClassicScraper(BaseBrowseScraper): sitetag = "tubepornclassic" def __init__(self) -> None: super().__init__() self._cookie: str | None = None self._proxy = get_settings().brightdata_proxy_url # ---- fetch z wyzwaniem 429 ------------------------------------------------------ def _gated_get(self, url: str): """GET z ciasteczkiem wyzwania; None przy błędzie sieci albo trwałym 429.""" hdr = {"Referer": _BASE + "/", "Accept": "application/json,text/plain,*/*"} if self._cookie: hdr["Cookie"] = self._cookie try: res = browser_get(url, timeout=self._timeout, headers=hdr) if res.status_code == 429: ck = CHALLENGE_COOKIE_RE.search(res.text or "") if ck is not None: self._cookie = f"{ck.group(1)}={ck.group(2)}" res = browser_get(url, timeout=self._timeout, headers=dict(hdr, Cookie=self._cookie)) if res.status_code == 429 and self._proxy: res = browser_get(url, timeout=self._timeout, headers=hdr, proxy=self._proxy) except Exception as e: log.warning("tubepornclassic: fetch fail %s: %s", url, e) return None if res.status_code >= 400: log.warning("tubepornclassic: HTTP %s na %s", res.status_code, url) return None return res def _listing_url(self, page: int) -> str: return ( f"{_BASE}/api/json/videos2/{_CACHE_LIFETIME}/str/latest-updates/{_PER_PAGE}/" f"..{page}.all...json" ) def crawl_page(self, page: int) -> list[RawScene] | None: res = self._gated_get(self._listing_url(page)) if res is None: return None # transient: NIE oznaczaj końca katalogu try: data = json.loads(res.text) except Exception as e: log.warning("tubepornclassic: listing parse fail (page %d): %s", page, e) return None # Za końcem katalogu API oddaje 200 z `{"error":1,"code":"videos_not_found"}` # (bez klucza `videos`). To jest wyczerpanie, nie awaria, stąd [] a nie None. if data.get("code") == "videos_not_found": return [] videos = data.get("videos") if not isinstance(videos, list): return None out: list[RawScene] = [] for v in videos: if not isinstance(v, dict): continue try: scene = self._parse_item(v) except Exception as e: log.info("tubepornclassic: parse fail id=%s: %s", v.get("video_id"), e) continue if scene is not None: out.append(scene) return out # ---- rekord listingu -> RawScene ------------------------------------------------ def _parse_item(self, v: dict) -> RawScene | None: if str(v.get("is_private") or "0") != "0": return None try: vid = int(v.get("video_id")) except (TypeError, ValueError): return None title = html_mod.unescape(v.get("title") or "").strip() slug = (v.get("dir") or "").strip() if not title or not slug: return None page_url = f"{_BASE}/videos/{vid}/{slug}/" duration_sec = _parse_duration(v.get("duration")) thumb = (v.get("scr") or "").strip() or None if thumb: thumb = _THUMB_SMALL_RE.sub("/preview.jpg", thumb) performers: list[RawPerformer] = [] seen: set[str] = set() for name in _csv(v.get("models")): s = slugify(name) if s and s not in seen: seen.add(s) performers.append(RawPerformer(external_id=f"{self.sitetag}:model:{s}", name=name)) if not performers and _SPUN_TITLE_RE.search(title): return None # nieodnajdywalna: spun-tytuł i zero obsady, patrz docstring studio = None sname = (v.get("content_source_name") or "").strip() if sname and slugify(sname): studio = RawStudio( external_id=f"{self.sitetag}:studio:{slugify(sname)}", name=sname, slug=slugify(sname) ) tags: list[RawTag] = [] seen_t: set[str] = set() for name in _csv(v.get("categories")) + _csv(v.get("tags")): s = slugify(name) if not s or s in seen_t or s in _SKIP_TAGS or len(s) > 60: continue seen_t.add(s) tags.append(RawTag(external_id=f"{self.sitetag}:tag:{s}", name=name, slug=s)) return RawScene( external_id=f"{self.sitetag}:{vid}", title=title, release_date=_parse_post_date(v.get("post_date")), duration_sec=duration_sec, url=page_url, studio=studio, performers=performers, tags=tags, playback_sources=[ RawPlaybackSource( origin=f"tube:{self.sitetag}", page_url=page_url, duration_sec=duration_sec, thumbnail_url=thumb, ) ], raw={"source": "tubepornclassic_api", "id": vid}, ) # ---- HTML stuby (ABC wymaga, nieużywane: listing i metadane idą z JSON) -------- 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