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>
This commit is contained in:
parent
f5b7a45ac0
commit
ea489454c0
4 changed files with 298 additions and 0 deletions
|
|
@ -189,6 +189,15 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
||||||
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
|
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
|
||||||
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
|
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
|
||||||
KPorner8Scraper,
|
KPorner8Scraper,
|
||||||
|
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
|
||||||
|
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
|
||||||
|
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
|
||||||
|
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
|
||||||
|
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
|
||||||
|
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
|
||||||
|
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
|
||||||
|
# performera. Scraper i extractor zostają w repo — gdyby serwis kiedyś zaczął wrzucać
|
||||||
|
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
|
||||||
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
|
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
|
||||||
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
|
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
|
||||||
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
|
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
|
||||||
|
|
|
||||||
188
app/connectors/direct_scrapers/sexu.py
Normal file
188
app/connectors/direct_scrapers/sexu.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -36,6 +36,7 @@ from app.extractors.tubes import (
|
||||||
hqfap,
|
hqfap,
|
||||||
hqporner,
|
hqporner,
|
||||||
kporner8,
|
kporner8,
|
||||||
|
sexu,
|
||||||
javflix,
|
javflix,
|
||||||
neporn,
|
neporn,
|
||||||
latestpornvideo,
|
latestpornvideo,
|
||||||
|
|
@ -127,6 +128,11 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||||
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
|
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
|
||||||
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
|
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
|
||||||
"8kpornercom": kporner8.extract,
|
"8kpornercom": kporner8.extract,
|
||||||
|
# sexu — POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
|
||||||
|
# Bierzemy HLS `playerData.src`, NIE mp4 `sources` — te mają w tokenie `ip=`
|
||||||
|
# requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi
|
||||||
|
# hls_needs_passthrough i manifest idzie przez /proxy/hls, segmenty direct.
|
||||||
|
"sexu": sexu.extract,
|
||||||
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
|
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
|
||||||
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
|
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
|
||||||
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
|
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
|
||||||
|
|
|
||||||
95
app/extractors/tubes/sexu.py
Normal file
95
app/extractors/tubes/sexu.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""sexu.com — HLS z `POST /api/video-info`. Dodany 2026-07-27.
|
||||||
|
|
||||||
|
Player jest hydratowany po stronie klienta (`/js/vjs/hydrate-source.js`): strona sceny
|
||||||
|
nie zawiera żadnego URL-a wideo, dopiero `POST /api/video-info` z `videoId=<id>` oddaje
|
||||||
|
JSON-a z `playerData`.
|
||||||
|
|
||||||
|
**Ten POST wymaga Bright Data.** Z gołego IP VPS-a Cloudflare oddaje na niego challenge
|
||||||
|
(GET-y na listing i detale przechodzą bez problemu — blokowany jest sam POST). Idzie tu
|
||||||
|
JSON, nie wideo, więc mieści się w tym, do czego proxy służy.
|
||||||
|
|
||||||
|
**Bierzemy `playerData.src` (HLS), a NIE `playerData.sources` (mp4).** Wygląda to
|
||||||
|
odwrotnie do intuicji, bo mp4 są wygodniejsze, ale:
|
||||||
|
|
||||||
|
sources[].src → /key=…,end=…,ip=158.46.155.106/sec=protect/…-720p-x.mp4
|
||||||
|
playerData.src → /key=…,end=…/multi=854x480:480p,1280x720:720p/media=hls4A/…
|
||||||
|
|
||||||
|
mp4 mają w tokenie `ip=` requestera, czyli IP wyjściowe proxy — na telefonie dałyby 403.
|
||||||
|
Token HLS `ip=` nie ma i jest przenośny: sprawdzone z VPS-a (inne IP niż proxy) master
|
||||||
|
200, wariant 200, segment 206. Telefon gra go z własnego IP.
|
||||||
|
|
||||||
|
Ścieżka mastera kończy się na `.mp4`, nie `.m3u8`, więc ExoPlayer wziąłby go za
|
||||||
|
progresywny plik i padł. Łapie to istniejąca logika `hls_needs_passthrough` w
|
||||||
|
`playback.py` (typ `m3u8` + `mobile_direct_ok` + brak `.m3u8` w ścieżce) → manifest
|
||||||
|
(~430 B) leci przez `/proxy/hls`, a segmenty prosto z CDN.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.extractors._models import StreamSource
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://sexu.com"
|
||||||
|
_SCENE_ID_RE = re.compile(r"/(\d+)/?$")
|
||||||
|
|
||||||
|
|
||||||
|
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
|
||||||
|
m = _SCENE_ID_RE.search(page_url.split("?")[0])
|
||||||
|
if not m:
|
||||||
|
log.info("sexu: nie wyłuskałem id z %s", page_url)
|
||||||
|
return None
|
||||||
|
video_id = m.group(1)
|
||||||
|
|
||||||
|
proxy = get_settings().brightdata_proxy_url
|
||||||
|
if not proxy:
|
||||||
|
log.info("sexu: brak BRIGHTDATA_PROXY_URL — CF zablokuje POST /api/video-info")
|
||||||
|
return None
|
||||||
|
|
||||||
|
from curl_cffi import requests as cr
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = cr.Session(
|
||||||
|
impersonate="chrome120",
|
||||||
|
proxies={"http": proxy, "https": proxy},
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
# Strona sceny najpierw — bez jej ciasteczek CF odrzuca POST-a.
|
||||||
|
session.get(page_url, timeout=timeout)
|
||||||
|
res = session.post(
|
||||||
|
f"{_BASE}/api/video-info",
|
||||||
|
data={"videoId": video_id},
|
||||||
|
headers={
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Referer": page_url,
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
data = json.loads(res.text)
|
||||||
|
except Exception as e:
|
||||||
|
log.info("sexu: video-info failed %s: %s", page_url, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not data.get("success"):
|
||||||
|
log.info("sexu: video-info success=false na %s", page_url)
|
||||||
|
return None
|
||||||
|
src = (data.get("playerData") or {}).get("src")
|
||||||
|
if not src:
|
||||||
|
log.info("sexu: brak playerData.src na %s", page_url)
|
||||||
|
return None
|
||||||
|
if src.startswith("//"):
|
||||||
|
src = "https:" + src
|
||||||
|
|
||||||
|
return [
|
||||||
|
StreamSource(
|
||||||
|
link=src,
|
||||||
|
type="m3u8",
|
||||||
|
quality="720p", # master multi=854x480,1280x720 — ExoPlayer wybierze sam
|
||||||
|
referer=_BASE + "/",
|
||||||
|
raw={"mobile_direct_ok": True},
|
||||||
|
)
|
||||||
|
]
|
||||||
Loading…
Add table
Reference in a new issue