tubepornclassic.com to SPA sieci TXXX (ten sam silnik co vjav): HTML ma pusty
<title> i zero linkow do scen, wszystko idzie z JSON API. Listing
/api/json/videos2/<cache>/str/latest-updates/60/..<page>.all...json oddaje
60 rekordow z pelnymi metadanymi (tytul, slug, dlugosc, post_date, obsada,
kategorie+tagi, miniatura), wiec nie ma fetchu per scena. ~168 tys. wideo,
wgrania codziennie. Koniec katalogu = 200 z code=videos_not_found.
Stream: videofile.php -> dekod video_url -> get_file -> f=video.m3u8 -> HLS
na ahcdn (klucz time-bound, nie IP-bound: adres wybity z VPS gra z lacza
domowego). Ten tor zyl dotad w tubes/vjav.py; wyniesiony do
_txxx.extract_hls, vjav i tubepornclassic wolaja to samo. HLS zamiast mp4,
bo mp4 z tego samego get_file ma w URL CDN speed=... (~1,2 MB/s), a segmenty
HLS leca ~3,6 MB/s. Wyzwanie 429 (ciasteczko w ciele) obsluzone w
ekstraktorze, scraperze i juz wczesniej w /proxy/hls.
Bramka: 38% tytulow to generator SEO ("Fabulous Sex Movie Vintage Try To
Watch For Ever Seen"). Z doczepionym nazwiskiem scena jest odnajdywalna
i zostaje; bez obsady (22%) nie wchodzi, bo nie ma po czym jej znalezc
ani z czym zdeduplikowac. Obsada u 46% scen, studiow brak.
Pilot: 2 strony, 120 scen, 111 nowych, 9 juz w bazie, wszystkie z dlugoscia.
Odtworzenie na emulatorze: playback_events success (ttff 3,3 s), AudioTrack
started. vjav po refaktorze: ten sam wynik ekstraktora co przed.
239 lines
10 KiB
Python
239 lines
10 KiB
Python
"""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 `<title>` 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
|