pornbusy.com exposes every field as a discrete itemprop node (title, duration, uploadDate, thumbnail, description) plus a dedicated <div id="video-actors"> cast block with no sidebar pollution. Measured orphan risk is low: 80% of a 100-scene sample have a performer that already carries a tpdb/stashdb ref, and 21% of titles strongly match scenes we already hold. No studio field exists on the site. Homepage pagination is broken (pages 1/2/3 return an identical set), so the listing is driven off sitemap_index.xml -> 10 post-sitemaps sorted by lastmod, chunked in crawl_page the same way the PlayTube base does it. Playback dispatches on embedURL and covers ~69% of the catalog: the seekplayer family (already handled by the engine after the earlier host regex widening) and zpi.cx (the embedURL is the file itself), plus loadvid at ~41%, which needed new plumbing. loadvid hands back the CONTENT of an m3u8 over POST /videos/resolve-token (CSRF + videoToken from the embed page) and has no manifest URL at all: GET on that endpoint is 405 and the guessable .m3u8 paths are 404. So make_token grew an optional `producer` marker and /proxy/hls calls the producer instead of GETting a URL. Segments in the returned manifest are absolute and fully portable (verified: 206 on a Range request with no headers, no referer, no token), so the phone still pulls them straight from the CDN and only the manifest travels through the VPS. upload18 (~12%) and the tail are deliberately left unresolved: their token embeds the fetcher's /24, so resolving server-side would force the whole video through the VPS. Verified: 19 scenes/page, 19/19 with duration, 17/19 with cast, 19/19 with tags (names read from the title attribute since an icon element precedes the anchor text), playback 8/8 via loadvid, and /proxy/hls returning a 116KB manifest with 869 absolute segments. Pilot ingest of 3 pages: 59 seen, 35 merged into existing scenes, 24 new, 0 errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
128 lines
5 KiB
Python
128 lines
5 KiB
Python
"""cdn.loadvid.com — HLS hoster oddający manifest przez POST (nie URL).
|
|
|
|
Protokół (reverse-engineer 2026-07-27, potwierdzony live):
|
|
1. GET /videos/play/<hash> → HTML z `<meta name="csrf-token">` + inline
|
|
`window.LoadVidConfig = { videoHash, videoToken, ... }`
|
|
2. POST /videos/resolve-token {token, hash} + nagłówek `X-CSRF-TOKEN`
|
|
→ **treść manifestu m3u8** (200, `application/vnd.apple.mpegurl`, ~116 KB,
|
|
~870 segmentów), a NIE URL do manifestu.
|
|
|
|
Dlatego to nie jest zwykły extractor "URL → URL": nie da się oddać linku do
|
|
manifestu, bo taki link nie istnieje (GET na resolve-token → 405, zgadywane
|
|
`/index.m3u8` → 404). Manifest musi wyprodukować backend.
|
|
|
|
Rozwiązanie: `extract()` zwraca **embed URL** oznaczony `manifest_producer=loadvid`.
|
|
`/proxy/hls/<token>/play.m3u8` rozpoznaje ten marker i zamiast GET-a woła
|
|
`fetch_manifest()`, po czym serwuje manifest telefonowi. Segmenty w manifeście są
|
|
ABSOLUTNE i w pełni przenośne (zweryfikowane: 206 `bytes 0-1023` BEZ jakichkolwiek
|
|
nagłówków, bez Referera, bez tokena — serwowane jako `image/png`, w środku TS),
|
|
więc telefon ciągnie je bezpośrednio z CDN. Przez VPS idzie tylko manifest.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from urllib.parse import urlparse
|
|
|
|
from app.extractors._fetch import _DEFAULT_IMPERSONATE, _DEFAULT_UA, _HAS_CURL_CFFI
|
|
from app.extractors._models import HosterDead, StreamSource
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_HOST_RE = re.compile(r"^(?:[a-z0-9]+\.)?loadvid\.com$", re.IGNORECASE)
|
|
_CSRF_RE = re.compile(r'<meta name="csrf-token" content="([^"]+)"', re.IGNORECASE)
|
|
_CONFIG_RE = re.compile(r"LoadVidConfig\s*=\s*(\{.*?\})\s*;", re.DOTALL)
|
|
_TOKEN_RE = re.compile(r"""videoToken\s*:\s*['"]([^'"]+)""")
|
|
_HASH_RE = re.compile(r"""videoHash\s*:\s*['"]([^'"]+)""")
|
|
_RESOLVE_PATH = "/videos/resolve-token"
|
|
|
|
|
|
def matches(url: str) -> bool:
|
|
try:
|
|
return bool(_HOST_RE.match(urlparse(url).hostname or ""))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def fetch_manifest(embed_url: str, *, timeout: float = 30.0) -> str | None:
|
|
"""Embed URL → treść manifestu m3u8 (albo None).
|
|
|
|
CSRF-token i cookie sesji muszą pochodzić z TEGO SAMEGO requestu co POST,
|
|
dlatego jedna sesja curl_cffi na całą operację."""
|
|
if not _HAS_CURL_CFFI:
|
|
log.info("loadvid: curl_cffi unavailable")
|
|
return None
|
|
from curl_cffi import requests as cf
|
|
|
|
parsed = urlparse(embed_url)
|
|
origin = f"{parsed.scheme}://{parsed.hostname}"
|
|
session = cf.Session(impersonate=_DEFAULT_IMPERSONATE)
|
|
try:
|
|
r = session.get(
|
|
embed_url,
|
|
headers={"User-Agent": _DEFAULT_UA, "Accept": "text/html,application/xhtml+xml"},
|
|
timeout=timeout,
|
|
)
|
|
except Exception as e:
|
|
log.info("loadvid: embed fetch failed %s: %s", embed_url, e)
|
|
return None
|
|
if r.status_code in (404, 410):
|
|
raise HosterDead(f"loadvid {embed_url}: HTTP {r.status_code}")
|
|
if r.status_code != 200 or not r.text:
|
|
log.info("loadvid: embed status=%s for %s", r.status_code, embed_url)
|
|
return None
|
|
|
|
csrf = _CSRF_RE.search(r.text)
|
|
cfg = _CONFIG_RE.search(r.text)
|
|
if not csrf or not cfg:
|
|
log.info("loadvid: no csrf/LoadVidConfig on %s", embed_url)
|
|
return None
|
|
tok = _TOKEN_RE.search(cfg.group(1))
|
|
hsh = _HASH_RE.search(cfg.group(1))
|
|
if not tok or not hsh:
|
|
log.info("loadvid: no videoToken/videoHash on %s", embed_url)
|
|
return None
|
|
|
|
try:
|
|
pr = session.post(
|
|
origin + _RESOLVE_PATH,
|
|
headers={
|
|
"User-Agent": _DEFAULT_UA,
|
|
"X-CSRF-TOKEN": csrf.group(1),
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"Accept": "application/vnd.apple.mpegurl",
|
|
"Referer": embed_url,
|
|
},
|
|
json={"token": tok.group(1), "hash": hsh.group(1)},
|
|
timeout=timeout,
|
|
)
|
|
except Exception as e:
|
|
log.info("loadvid: resolve-token failed %s: %s", embed_url, e)
|
|
return None
|
|
if pr.status_code != 200 or "#EXTM3U" not in pr.text:
|
|
log.info("loadvid: resolve-token status=%s len=%d", pr.status_code, len(pr.text or ""))
|
|
return None
|
|
return pr.text
|
|
|
|
|
|
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
|
|
"""Zwraca embed URL z markerem producenta — manifest powstaje dopiero w
|
|
`/proxy/hls` (patrz docstring modułu). Weryfikujemy tu, że manifest realnie
|
|
da się wyprodukować, żeby nie oddawać martwego źródła."""
|
|
if not matches(page_url):
|
|
return None
|
|
manifest = fetch_manifest(page_url, timeout=timeout)
|
|
if not manifest:
|
|
return None
|
|
return [
|
|
StreamSource(
|
|
link=page_url,
|
|
type="m3u8",
|
|
raw={
|
|
# Segmenty absolutne i portable → telefon ciągnie je direct z CDN,
|
|
# przez VPS leci tylko manifest.
|
|
"mobile_direct_ok": True,
|
|
"manifest_producer": "loadvid",
|
|
},
|
|
)
|
|
]
|