goon/app/extractors/tubes/watchporn.py
jtrzupek c419d3ab4b feat(watchporn): re-enable as KVS browse scraper + native mp4 extractor
watchporn.to was disabled 2026-05-12 (all iframes were DoodStream/CAPTCHA
-> black-screen WebView). The site has since been rebuilt as a KVS tube:
the DoodStream hosters are gone, scenes now expose flashvars get_file
direct mp4. Rewrote the dead ?s= search scraper as a /latest-updates/
browse scraper (og:title, JSON-LD duration/uploadDate, /models/ performers,
/tags/ tags, /categories/ studio) and added a KVS get_file extractor
(porntrex-style same-session 302 resolve; token not IP-bound, plays from
VPS). Moved to ALL_BROWSE_SCRAPERS, extractor swapped from _embed_iframe.
Verified: 35 fresh scenes/page (today), multi-performer, playback 1080p mp4 206.

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

125 lines
4.2 KiB
Python

"""watchporn.to — KVS engine direct stream extractor (re-enabled 2026-07-02).
Site przebudowany na KVS: detail page ma flashvars `video_url`/`video_alt_url*`,
każdy to `get_file/<srv>/<token>/<path>_<q>p.mp4/?v-acctoken=...`. Ten sam wzorzec co
porntrex. get_file 302 → finalny CDN url; resolvujemy same-session (token bywa
cookie/session-bound) i oddajemy finalny url per jakość. Token nie IP-bound
(cross-IP 206 z VPS zweryfikowane 2026-07-02) → mobile gra direct, zero proxy.
Wcześniejszy DoodStream-CAPTCHA zniknął.
"""
from __future__ import annotations
import logging
import re
import time
from app.extractors._fetch import _DEFAULT_IMPERSONATE, _DEFAULT_UA, _HAS_CURL_CFFI, fetch_tube_html
from app.extractors._models import HosterDead, StreamSource
log = logging.getLogger(__name__)
_BASE = "https://watchporn.to"
_DEAD_RE = re.compile(
r"this video (?:was|has been) deleted|video (?:was|has been) removed"
r"|no longer available|video is unavailable",
re.IGNORECASE,
)
_URL_RE = re.compile(
r"(video(?:_alt)?_url\d*)\s*:\s*'(https?://[^']+/get_file/[^']+)'",
re.IGNORECASE,
)
_TEXT_RE = re.compile(
r"(video(?:_alt)?_url\d*)_text\s*:\s*'([^']*)'",
re.IGNORECASE,
)
def _quality_rank(label: str | None) -> int:
if not label:
return -1
m = re.search(r"(\d{3,4})\s*p", label, re.IGNORECASE)
return int(m.group(1)) if m else -1
def _resolve_get_file(session, get_file_url: str, timeout: float) -> str | None:
sep = "&" if "?" in get_file_url else "?"
url = f"{get_file_url}{sep}rnd={int(time.time() * 1000)}"
try:
r = session.get(
url,
timeout=timeout,
allow_redirects=True,
stream=True,
headers={"Referer": _BASE + "/", "Range": "bytes=0-1"},
)
final = str(r.url)
status = r.status_code
r.close()
except Exception as e:
log.info("watchporn: get_file resolve failed (%s): %s", get_file_url[:60], e)
return None
if status >= 400 or "/get_file/" in final:
log.info("watchporn: get_file resolve bad status=%s final=%s", status, final[:70])
return None
return final
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
session = None
if _HAS_CURL_CFFI:
from curl_cffi import requests as _cf_requests
session = _cf_requests.Session(impersonate=_DEFAULT_IMPERSONATE)
try:
resp = session.get(
page_url,
headers={"User-Agent": _DEFAULT_UA, "Accept": "text/html,application/xhtml+xml"},
timeout=timeout,
allow_redirects=True,
)
html_text = resp.text if resp.status_code < 400 else ""
except Exception as e:
log.info("watchporn: page fetch failed %s: %s", page_url, e)
html_text = ""
if not html_text:
html_text = fetch_tube_html(page_url, timeout=timeout)
session = None
else:
html_text = fetch_tube_html(page_url, timeout=timeout)
if html_text and _DEAD_RE.search(html_text):
raise HosterDead(f"watchporn {page_url}: video deleted/removed")
quality_by_var: dict[str, str] = {}
for m in _TEXT_RE.finditer(html_text):
quality_by_var[m.group(1).lower()] = m.group(2).strip()
seen: set[str] = set()
result: list[StreamSource] = []
for m in _URL_RE.finditer(html_text):
var_name = m.group(1).lower()
url = m.group(2)
if url in seen:
continue
seen.add(url)
quality = quality_by_var.get(var_name)
final_link = url
if session is not None:
resolved = _resolve_get_file(session, url, timeout)
if resolved:
final_link = resolved
result.append(
StreamSource(
link=final_link,
type="mp4",
quality=quality or None,
referer=_BASE + "/",
raw={"mobile_direct_ok": True},
)
)
if not result:
log.info("watchporn: no KVS video_url in flashvars on %s", page_url)
return None
result.sort(key=lambda s: _quality_rank(s.quality), reverse=True)
return result