sxyland dropped the /<numeric_id>/<slug>/ scene URL format for /<slug>/, so the old regex matched nothing (frozen since 06-07). Rewrote search() to use the performer page /actor/<slug>/ and fetch each scene for full metadata: all performers (with co-stars, from /actor/ links), tags (scoped to the scene's tags-list, not the sidebar), duration + upload date (itemprop), studio from the title prefix (BraZZers/MilfCoach/... , guarded so a performer-name prefix isn't mistaken for a studio). Junk nav pages (Terms of Use etc.) are dropped via a no-duration-and-no-tags guard. Verified: clean studio/performers/tags in DB, 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
8.4 KiB
Python
220 lines
8.4 KiB
Python
"""sxyland.com — performer-page scrape (search-based, performer-driven).
|
|
|
|
2026-06-16 fix (zamrożony od 06-07): sxyland porzucił URL scen `/<numeric_id>/<slug>/`
|
|
na rzecz `/<slug>/`, więc stary regex (wymagał cyfry w ścieżce) dawał 0. WordPress `?s=`
|
|
filtruje, ale miesza — czystsze są **strony performera** `/actor/<slug>/`
|
|
(performer-driven query = nazwa performera → slugify → /actor/<slug>/).
|
|
|
|
Bogate metadane (per-scene detail fetch — sxyland to WP tube, taksonomie na scenie):
|
|
- performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`)
|
|
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
|
|
- studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio
|
|
- duration: `itemprop="duration"` ISO 8601 z dniami (P0DT0H41M12S)
|
|
- release date: `itemprop="uploadDate"`
|
|
- title: `og:title` / `itemprop="name"`
|
|
|
|
Playback przez extractor `sxylandcom` (_embed_iframe → playmogo/dood, phone-side).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import logging
|
|
import re
|
|
from collections.abc import Iterator
|
|
from datetime import date, datetime
|
|
|
|
from app.connectors.base import (
|
|
RawPerformer,
|
|
RawPlaybackSource,
|
|
RawScene,
|
|
RawStudio,
|
|
RawTag,
|
|
)
|
|
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
|
|
from app.extractors import browser_get
|
|
from app.normalize.text import slugify
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_BASE = "https://sxyland.com"
|
|
|
|
# Linki scen na stronie performera: /<slug>/ (multi-word). Wykluczamy taksonomie/nav.
|
|
_SCENE_URL_RE = re.compile(r'href="https://sxyland\.com/([a-z0-9][a-z0-9-]+)/"')
|
|
_NAV_SLUGS = frozenset({
|
|
"actor", "actors", "category", "categories", "tag", "tags", "page", "author",
|
|
"models", "studios", "search", "home", "login", "register", "18-u-s-c-2257",
|
|
"privacy-policy", "cookie-policy", "dmca", "dmca-notice", "contact", "contact-us",
|
|
"terms", "terms-of-use", "about", "about-us", "2257",
|
|
})
|
|
# Scena-tagi siedzą w pierwszym <div class="tags-list">...</div> (NIE w sidebarze/
|
|
# popular-tags widgetcie). Bez scope'u studio łapało globalny "bangbros" na każdej scenie.
|
|
_TAGS_BLOCK_RE = re.compile(r'<div class="tags-list">(.*?)</div>', re.IGNORECASE | re.DOTALL)
|
|
|
|
_ACTOR_LINK_RE = re.compile(
|
|
r'href="https://sxyland\.com/actor/[^"/]+/"\s+title="([^"]+)"', re.IGNORECASE
|
|
)
|
|
_TAG_LINK_RE = re.compile(
|
|
r'href="https://sxyland\.com/(?:tag|category)/[^"/]+/"[^>]*title="([^"]+)"', re.IGNORECASE
|
|
)
|
|
_DURATION_RE = re.compile(r'itemprop="duration"\s+content="([^"]+)"', re.IGNORECASE)
|
|
_UPLOADDATE_RE = re.compile(r'itemprop="uploadDate"\s+content="([^"]+)"', re.IGNORECASE)
|
|
_OGTITLE_RE = re.compile(r'property="og:title"\s+content="([^"]+)"', re.IGNORECASE)
|
|
_ISO_DUR_RE = re.compile(
|
|
r"P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE
|
|
)
|
|
|
|
def _studio_from_title(title: str, performers: list[RawPerformer]) -> RawStudio | None:
|
|
"""Studio z prefiksu "Studio - ..." tytułu (jak hdporngg: paysite reposty mają
|
|
"BraZZers - ...", "MilfCoach - ..."). Guard: prefiks NIE może być performerem
|
|
(tytuł "Amirah Adara - X" → prefiks to imię, nie studio). Brak " - " → brak studio."""
|
|
if " - " not in title:
|
|
return None
|
|
prefix = title.split(" - ", 1)[0].strip()
|
|
if not (2 <= len(prefix) <= 30):
|
|
return None
|
|
pl = prefix.lower()
|
|
for p in performers:
|
|
if pl == p.name.lower() or pl in p.name.lower():
|
|
return None
|
|
return RawStudio(external_id=f"sxylandcom:studio:{slugify(prefix)}", name=prefix, slug=slugify(prefix))
|
|
|
|
|
|
def _parse_iso_duration(value: str | None) -> int | None:
|
|
"""`P0DT0H41M12S` → 2472. None gdy zero/parse fail."""
|
|
if not value:
|
|
return None
|
|
m = _ISO_DUR_RE.match(value.strip())
|
|
if not m:
|
|
return None
|
|
d, h, mn, s = (int(g or 0) for g in m.groups())
|
|
total = d * 86400 + h * 3600 + mn * 60 + s
|
|
return total or None
|
|
|
|
|
|
def _parse_date(value: str | None) -> date | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00")).date()
|
|
except ValueError:
|
|
m = re.match(r"(\d{4}-\d{2}-\d{2})", value)
|
|
return date.fromisoformat(m.group(1)) if m else None
|
|
|
|
|
|
class SxyLandScraper(BaseDirectTubeScraper):
|
|
sitetag = "sxylandcom"
|
|
_timeout: float = 30.0
|
|
|
|
def search(
|
|
self, query: str, *, page: int = 1, limit: int | None = None
|
|
) -> Iterator[RawScene]:
|
|
actor_slug = slugify(query)
|
|
if not actor_slug:
|
|
return
|
|
listing = f"{_BASE}/actor/{actor_slug}/" + (f"page/{page}/" if page > 1 else "")
|
|
try:
|
|
r = browser_get(listing, timeout=self._timeout)
|
|
except Exception as e:
|
|
log.warning("sxyland actor-page fetch failed (%s): %s", listing, e)
|
|
return
|
|
if r.status_code != 200:
|
|
log.debug("sxyland %s status=%d", listing, r.status_code)
|
|
return
|
|
|
|
scene_urls: list[str] = []
|
|
seen: set[str] = set()
|
|
for m in _SCENE_URL_RE.finditer(r.text):
|
|
slug = m.group(1)
|
|
if slug in _NAV_SLUGS or slug in seen:
|
|
continue
|
|
seen.add(slug)
|
|
scene_urls.append(f"{_BASE}/{slug}/")
|
|
|
|
yielded = 0
|
|
for scene_url in scene_urls:
|
|
scene = self._parse_scene(scene_url, query)
|
|
if scene is None:
|
|
continue
|
|
yield scene
|
|
yielded += 1
|
|
if limit is not None and yielded >= limit:
|
|
return
|
|
|
|
def _parse_scene(self, scene_url: str, query: str) -> RawScene | None:
|
|
try:
|
|
r = browser_get(scene_url, timeout=self._timeout)
|
|
if r.status_code != 200:
|
|
return None
|
|
detail = r.text
|
|
except Exception as e:
|
|
log.info("sxyland scene fetch failed %s: %s", scene_url, e)
|
|
return None
|
|
|
|
title = _OGTITLE_RE.search(detail)
|
|
title_s = html.unescape(title.group(1)).strip() if title else ""
|
|
if not title_s:
|
|
return None
|
|
|
|
dm = _DURATION_RE.search(detail)
|
|
duration_sec = _parse_iso_duration(dm.group(1)) if dm else None
|
|
um = _UPLOADDATE_RE.search(detail)
|
|
release_date = _parse_date(um.group(1)) if um else None
|
|
|
|
# Performerzy: wszystkie /actor/ linki (z co-starami).
|
|
performers: list[RawPerformer] = []
|
|
seen_perf: set[str] = set()
|
|
for m in _ACTOR_LINK_RE.finditer(detail):
|
|
name = html.unescape(m.group(1)).strip()
|
|
sl = slugify(name)
|
|
if not sl or sl in seen_perf:
|
|
continue
|
|
seen_perf.add(sl)
|
|
performers.append(
|
|
RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
|
|
)
|
|
if not performers:
|
|
# Fallback: query (jesteśmy na /actor/<query>/, więc to na pewno ona).
|
|
performers.append(
|
|
RawPerformer(
|
|
external_id=f"{self.sitetag}:performer:{slugify(query)}",
|
|
name=query.strip(),
|
|
)
|
|
)
|
|
|
|
# Tagi — TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
|
|
tags: list[RawTag] = []
|
|
seen_tag: set[str] = set()
|
|
block_m = _TAGS_BLOCK_RE.search(detail)
|
|
tags_html = block_m.group(1) if block_m else ""
|
|
for m in _TAG_LINK_RE.finditer(tags_html):
|
|
name = html.unescape(m.group(1)).strip()
|
|
sl = slugify(name)
|
|
if not sl or sl in seen_tag:
|
|
continue
|
|
seen_tag.add(sl)
|
|
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl))
|
|
|
|
# Guard "to realna scena wideo": nav/legal pages (Terms of Use itp.) mają
|
|
# sidebar z aktorami (fałszywi performerzy) ale ZERO duration i ZERO tagów.
|
|
if duration_sec is None and not tags:
|
|
return None
|
|
|
|
studio = _studio_from_title(title_s, performers)
|
|
|
|
return RawScene(
|
|
external_id=f"{self.sitetag}:{scene_url}",
|
|
title=title_s,
|
|
duration_sec=duration_sec,
|
|
release_date=release_date,
|
|
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,
|
|
)
|
|
],
|
|
)
|