goon/app/connectors/direct_scrapers/sexu.py
goon-foss ea489454c0 feat(sexu): scraper + extractor HLS, ale bez rejestracji w ingescie
Metadane ma najlepsze z tej serii: kanal = realne studio na 97% scen, 96%
performerow z refem tpdb/stashdb, dokladna dlugosc w sekundach, swiezosc w
godzinach, 120 scen na stronie listingu.

Nie wlaczamy go jednak do ALL_BROWSE_SCRAPERS, bo jednostka tresci jest zla:
to klipy 5-10 minut, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200,
zero filmow >=20 min w calym katalogu. Pilot na 240 scenach dal 1 canonical
match, bo dlugosc i tytul, czyli sygnaly resolvera, sa przepisane pod tube.
Przy ~340 uploadach dziennie to ~10k nierozwiazywalnych wierszy miesiecznie.

Kod zostaje kompletny i zweryfikowany: POST /api/video-info przez Bright Data
(CF blokuje sam POST z VPS), HLS playerData.src zamiast mp4 sources (te maja
ip= requestera w tokenie), master konczy sie .mp4 wiec lapie go istniejacy
hls_needs_passthrough. Gdyby serwis zaczal wrzucac pelne sceny, wystarczy
dopisac klase z powrotem do rejestru.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:42:05 +02:00

188 lines
6.8 KiB
Python

"""sexu.com — browse scraper. Dodany 2026-07-27.
Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen:
obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X,
Moms Bang Teens, 2 Girls 1 Camera), dokładna długość w sekundach 25/25, data 25/25.
Świeżość: cała próbka miała 7-8 godzin. Listing daje 120 scen na stronę.
Dlatego **nie ma tu bramki na obsadę** (w odróżnieniu od fullvideosporn czy 8kporner).
Te 20% scen bez performera i tak niesie studio + dokładną długość + oryginalny tytuł,
czyli komplet sygnałów, których resolver potrzebuje do canonical matcha. Bramka
odrzuciłaby dobre dane.
**Dwie pułapki w HTML, obie sprawdzone:**
1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych
tagów sceny tylko 5 (reszta to sidebar „popularne wyszukiwania"). Bez zawężenia
do bloku „Tags:" wpisalibyśmy każdej scenie te same 20 śmieci.
2. **W bloku tagów siedzą też linki `/search?q=`** — to fraz-y pod SEO („pleases",
„hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
performerze (`/channel/priya+rai` przy scenie z Priya Rai). Taki kanał odrzucamy,
bo performer w tabeli studios to zanieczyszczenie, nie dana.
Data jest względna („7 hours ago", „yesterday"), więc liczymy ją wstecz od teraz.
To i tak data uploadu na tube, nie premiery studia.
Playback: `POST /api/video-info` (extractor `sexu`) — wymaga Bright Data, bo
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import UTC, datetime, timedelta
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://sexu.com"
_SCENE_HREF_RE = re.compile(r'href="(/\d+/)"')
_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]+?)\s*</h1>")
_DURATION_RE = re.compile(r'property="video:duration"\s+content="(\d+)"')
_OG_IMAGE_RE = re.compile(r'property="og:image"\s+content="([^"]+)"')
_DATE_RE = re.compile(r'player-block__date">\s*([^<]+?)\s*<')
_REL_DATE_RE = re.compile(
r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago", re.IGNORECASE
)
_UNIT_DAYS = {
"second": 0, "minute": 0, "hour": 0,
"day": 1, "week": 7, "month": 30, "year": 365,
}
# Bloki metadanych: `<div class="player-tags__title">Tags:</div> … <div class="x_container">`.
# Linki sceny są między tytułem a `_container` (tam zaczyna się formularz „Suggest…").
_BLOCK_END_RE = re.compile(r"_container|player-tags__title")
def _block(detail_html: str, label: str) -> str:
"""Fragment HTML należący do bloku `<label>:` — patrz pułapka 1 w docstringu."""
start = detail_html.find(f'player-tags__title">{label}:')
if start < 0:
return ""
rest = detail_html[start + len(label) + 22:]
end = _BLOCK_END_RE.search(rest)
return rest[: end.start()] if end else rest
def _links(block: str, kind: str) -> list[str]:
"""Nazwy z anchorów `/<kind>/<slug>` w obrębie bloku, z zachowaniem kolejności."""
pat = re.compile(rf'href="/{kind}/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
out: list[str] = []
seen: set[str] = set()
for name in pat.findall(block):
name = html_mod.unescape(name).strip()
key = name.lower()
if name and key not in seen:
seen.add(key)
out.append(name)
return out
def _relative_date(detail_html: str):
m = _DATE_RE.search(detail_html)
if not m:
return None
text = m.group(1).strip().lower()
now = datetime.now(UTC)
if text in ("today", "just now"):
return now.date()
if text == "yesterday":
return (now - timedelta(days=1)).date()
rel = _REL_DATE_RE.search(text)
if not rel:
return None
days = int(rel.group(1)) * _UNIT_DAYS.get(rel.group(2).lower(), 0)
return (now - timedelta(days=days)).date()
class SexuScraper(BaseBrowseScraper):
sitetag = "sexu"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/new/" if page <= 1 else f"{_BASE}/new?page={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for m in _SCENE_HREF_RE.finditer(listing_html):
path = m.group(1)
if path not in seen:
seen.add(path)
out.append(_BASE + path)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
if not tm:
return None
title = html_mod.unescape(tm.group(1)).strip()
if not title:
return None
scene_id = scene_url.rstrip("/").rsplit("/", 1)[-1]
dm = _DURATION_RE.search(detail_html)
duration_sec = int(dm.group(1)) if dm else None
thumbnail_url = None
om = _OG_IMAGE_RE.search(detail_html)
if om:
thumbnail_url = om.group(1)
if thumbnail_url.startswith("//"):
thumbnail_url = "https:" + thumbnail_url
performers = [
RawPerformer(external_id=f"{self.sitetag}:performer:{slugify(n)}", name=n)
for n in _links(_block(detail_html, "Pornstars"), "pornstar")
if slugify(n)
]
# Kanał == performer → to upload amatorski nazwany po dziewczynie, nie studio.
studio = None
perf_keys = {p.name.lower() for p in performers}
for name in _links(_block(detail_html, "Channel"), "channel"):
if name.lower() in perf_keys or not slugify(name):
continue
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(name)}",
name=name,
slug=slugify(name),
)
break
tags = [
RawTag(external_id=f"{self.sitetag}:tag:{slugify(n)}", name=n, slug=slugify(n))
for n in _links(_block(detail_html, "Tags"), "tag")
if slugify(n)
]
return RawScene(
external_id=f"{self.sitetag}:{scene_id}",
title=title,
release_date=_relative_date(detail_html),
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,
)
],
)