Some checks are pending
Backend tests / test (push) Waiting to run
Two bug-report clusters about missing thumbnails: 1. watchporn scenes had no thumbnail on the list, only appearing after opening SceneDetail (which auto-enriches og:image). Coverage was 1.3% (463/36,915). The browse scraper never captured a thumbnail. KVS stores the poster at a fixed derivable path (contents/videos_screenshots/<id//1000*1000>/<id>/preview.jpg, verified 8/8 loading). Scraper now sets thumbnail_url (og:image, else derived); backfilled 36,687 existing rows -> 100% coverage. 2. SceneDetail showed no thumb where the list showed one: the mobile detail picks the first source with a thumbnail_url (origin ASC often puts sxyprncom first), and sxyprn/trafficdeposit stored thumbs rot to 404. The list already swaps those for a live resolver (/proxy/sxyprn-thumb/), but the detail builder did not. It now applies the same live-resolver swap and nulls other rotting thumbs so the detail lands on a working image. Both backend-only, no OTA needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
4.9 KiB
Python
127 lines
4.9 KiB
Python
"""watchporn.to — browse scraper (KVS engine). Re-enabled 2026-07-02.
|
|
|
|
Był search-scraperem (`?s=`), zamarzł, a potem site przebudowano na KVS z nowym
|
|
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął — teraz KVS
|
|
flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound).
|
|
|
|
Browse `/latest-updates/` → detail page:
|
|
- title: og:title ("Studio/Creator - Scene Title")
|
|
- duration + release_date: JSON-LD "duration" (ISO) + "uploadDate"
|
|
- performerzy: `/models/<slug>/` (pomijamy numeryczne id-slugi), nazwa z tekstu linku
|
|
- tagi: `/tags/<slug>/`
|
|
- studio: pierwszy `/categories/<slug>/` (np. EvilAngel, ManyVids)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import re
|
|
|
|
from app.connectors.base import (
|
|
RawPerformer,
|
|
RawPlaybackSource,
|
|
RawScene,
|
|
RawStudio,
|
|
RawTag,
|
|
)
|
|
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper, meta_content
|
|
from app.connectors.direct_scrapers._playtube import _parse_iso_date, _parse_iso_duration
|
|
from app.normalize.text import slugify
|
|
|
|
_BASE = "https://watchporn.to"
|
|
_SCENE_URL_RE = re.compile(r'href="(https://watchporn\.to/video/\d+/[a-z0-9\-]+/)"', re.IGNORECASE)
|
|
_MODEL_RE = re.compile(r'href="https://watchporn\.to/models/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE)
|
|
_TAG_RE = re.compile(r'href="https://watchporn\.to/tags/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE)
|
|
_CAT_RE = re.compile(r'href="https://watchporn\.to/categories/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE)
|
|
_DUR_RE = re.compile(r'"duration"\s*:\s*"([^"]+)"')
|
|
_UPLOAD_RE = re.compile(r'"uploadDate"\s*:\s*"([^"]+)"')
|
|
_VIDEO_ID_RE = re.compile(r"/video/(\d+)/")
|
|
|
|
|
|
def _derive_thumb(scene_url: str) -> str | None:
|
|
"""KVS trzyma poster pod stałym wzorem `contents/videos_screenshots/<id//1000*1000>/
|
|
<id>/preview.jpg` (zweryfikowane). Fallback gdy detail page nie ma og:image, żeby
|
|
kafelek na liście miał miniaturkę OD RAZU (bez czekania na auto-enrich w SceneDetail)."""
|
|
m = _VIDEO_ID_RE.search(scene_url)
|
|
if not m:
|
|
return None
|
|
vid = int(m.group(1))
|
|
return f"{_BASE}/contents/videos_screenshots/{vid // 1000 * 1000}/{vid}/preview.jpg"
|
|
|
|
|
|
class WatchPornScraper(BaseBrowseScraper):
|
|
sitetag = "watchporn"
|
|
|
|
def _listing_url(self, page: int) -> str:
|
|
return f"{_BASE}/latest-updates/" if page <= 1 else f"{_BASE}/latest-updates/{page}/"
|
|
|
|
def _extract_scene_urls(self, listing_html: str) -> list[str]:
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for m in _SCENE_URL_RE.finditer(listing_html):
|
|
url = m.group(1)
|
|
if url not in seen:
|
|
seen.add(url)
|
|
out.append(url)
|
|
return out
|
|
|
|
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
|
title = (meta_content(detail_html, property="og:title") or "").strip()
|
|
if not title:
|
|
return None
|
|
|
|
dm = _DUR_RE.search(detail_html)
|
|
duration_sec = _parse_iso_duration(dm.group(1)) if dm else None
|
|
um = _UPLOAD_RE.search(detail_html)
|
|
release_date = _parse_iso_date(um.group(1)) if um else None
|
|
|
|
performers: list[RawPerformer] = []
|
|
seen_p: set[str] = set()
|
|
for m in _MODEL_RE.finditer(detail_html):
|
|
slug = m.group(1)
|
|
name = html.unescape(m.group(2)).strip()
|
|
if slug.isdigit() or slug in seen_p or not name:
|
|
continue
|
|
seen_p.add(slug)
|
|
performers.append(RawPerformer(external_id=f"{self.sitetag}:model:{slug}", name=name))
|
|
|
|
tags: list[RawTag] = []
|
|
seen_t: set[str] = set()
|
|
for m in _TAG_RE.finditer(detail_html):
|
|
slug = m.group(1)
|
|
name = html.unescape(m.group(2)).strip()
|
|
if slug in seen_t or not name:
|
|
continue
|
|
seen_t.add(slug)
|
|
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
|
|
|
|
thumbnail_url = (
|
|
meta_content(detail_html, property="og:image") or _derive_thumb(scene_url)
|
|
)
|
|
|
|
studio: RawStudio | None = None
|
|
cm = _CAT_RE.search(detail_html)
|
|
if cm:
|
|
cname = html.unescape(cm.group(2)).strip()
|
|
if cname:
|
|
studio = RawStudio(
|
|
external_id=f"{self.sitetag}:studio:{slugify(cname)}", name=cname, slug=slugify(cname)
|
|
)
|
|
|
|
return RawScene(
|
|
external_id=f"{self.sitetag}:{scene_url}",
|
|
title=title,
|
|
release_date=release_date,
|
|
duration_sec=duration_sec,
|
|
url=scene_url,
|
|
studio=studio,
|
|
performers=performers,
|
|
tags=tags,
|
|
playback_sources=[
|
|
RawPlaybackSource(
|
|
origin=f"tube:{self.sitetag}",
|
|
page_url=scene_url,
|
|
duration_sec=duration_sec,
|
|
thumbnail_url=thumbnail_url,
|
|
)
|
|
],
|
|
)
|