goon/app/connectors/direct_scrapers/sxyprn.py
goon-foss 01298c9699 fix(sxyprn): blokada uploaderow ze spamem obsady/tagow + dopasowanie nazwisk bez przedrostka w obie strony
- person_named_in: jednowyrazowe nazwisko tylko co do znaku (albo zlepek sasiednich
  czlonow tytulu), przedrostek co najwyzej o 2 znaki; "Thebdck" lapal kazde "the"
- sxyprn: autor z sciezki miniatury, _BLOCKED_AUTHORS (Shizuka, Ajx, Denzel),
  karty z >20 hashtagami wchodza bez tagow
- scripts/sxyprn_purge_uploaders.py: dry-run/apply, sceny tych autorow + smieciowi performerzy
2026-09-04 11:09:37 +02:00

230 lines
9.4 KiB
Python

"""sxyprn.com, performer-page scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 05-07): sxyprn NIE ma free-text searcha. Stary endpoint
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
filtr słusznie wycinał wszystko → 0 ingestu → freshness zamrożona). Realny "search"
to **strona performera** `https://sxyprn.com/<Imie-Nazwisko>.html` (spacje → myślniki),
zwraca ~30 kart scen tego performera.
Bonus tej ścieżki: w trybie performer-driven query = czysta nazwa performera, więc
omijamy fragmentację sxyprn (post taguje "Dallas Rae" jako osobne `/Dallas.html` +
`/Rae.html` / `aria-label='Dallas,Rae'`). Performera bierzemy z query (po potwierdzeniu
token-filtrem na tytule), a NIE z połamanych tagów.
Metadane z karty (bez +1 fetch detalu):
- URL sceny: `/post/<id>.html`
- duration: `<span class='duration_small'>MM:SS|HH:MM:SS`
- studio: `<span class='post_el_small_subcat'>Channel` (sxyprn "channel"; ~⅔ kart, opcjonalny)
- title + tagi: `<a class='post_time' title='{New} ...#Tag1 #Tag2... {Watch...} url'>`
- thumbnail: `data-src='//...small.jpg'`
Playback resolwuje extractor `sxyprncom` (osobno; mp4 z trafficdeposit/lulustream).
Blokada uploaderow (2026-09-04). Kilku autorow wrzuca dobre sceny z tytulem w stylu
„Tytul ft. Obsada, Obsada by Studio Kategoria, Kategoria, ..." i 25-35 hashtagami
na kazdym poscie. Kazde nazwisko z tytulu to potencjalne przypisanie performera
(over-attribution), a hashtagi wchodzily jako tagi sceny (mediana 27 zamiast 12).
Karta nie ma pola autora, ale id autora siedzi w sciezce miniatury:
`.../<token>/<ts>/<id_autora>/<id_posta>/small.jpg`. Autorow z `_BLOCKED_AUTHORS`
pomijamy w calosci, a karty z wiecej niz `_MAX_HASHTAGS` hashtagami wchodza bez tagow.
"""
from __future__ import annotations
import html
import logging
import re
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
from app.extractors import browser_get
from app.normalize.text import person_named_in, slugify
log = logging.getLogger(__name__)
_BASE = "https://sxyprn.com"
# js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera)
# + aria-label (połamani performerzy, nieużywane).
_CARD_ANCHOR_RE = re.compile(
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
re.IGNORECASE,
)
# Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc
# NIE używamy `[^>]*` (łamie się na tym `>`), leniwe `.*?` do wartości HH:MM:SS.
_DURATION_RE = re.compile(
r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL
)
_SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE)
_THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", re.IGNORECASE)
# post_time z tytułem, keyowane po ID, bo leży w post_control PO vid_container.
_POSTTIME_RE = re.compile(
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
re.IGNORECASE,
)
_HASHTAG_RE = re.compile(r"#(\w[\w-]*)")
# `.../<id_autora>/<id_posta>/small.jpg`, oba 13 hex. Drugi segment MUSI byc id posta,
# inaczej to inny format miniatury i autora nie znamy.
_AUTHOR_IN_THUMB_RE = re.compile(r"/([a-f0-9]{13})/([a-f0-9]{13})/[^/]+$", re.IGNORECASE)
# id autora sxyprn -> nazwa (tylko do logow). Patrz docstring modulu.
_BLOCKED_AUTHORS: dict[str, str] = {
"69cab3467ec41": "Shizuka",
"68e92925d23d9": "Ajx",
"6a0d125a875d2": "Denzel",
}
# Powyzej tylu hashtagow to spam SEO, nie opis sceny. Mediana katalogu sxyprn to 12.
_MAX_HASHTAGS = 20
_BRACE_RE = re.compile(r"\{[^}]*\}")
_URL_TAIL_RE = re.compile(r"https?://\S+")
def _parse_duration(s: str) -> int | None:
parts = s.strip().split(":")
try:
if len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
except ValueError:
return None
return None
def _author_id(thumb: str | None, post_id: str) -> str | None:
"""Id autora ze sciezki miniatury albo None, gdy format inny niz oczekiwany."""
if not thumb:
return None
m = _AUTHOR_IN_THUMB_RE.search(thumb)
if not m or m.group(2).lower() != post_id.lower():
return None
return m.group(1).lower()
def _clean_title(raw: str) -> tuple[str, list[str]]:
"""Zwraca (czysty tytuł, tagi). Usuwa {markery}, hashtagi i końcowy URL."""
text = html.unescape(raw)
tags = [m.group(1) for m in _HASHTAG_RE.finditer(text)]
text = _BRACE_RE.sub(" ", text) # {New}, {Watch At 1080P}, {WATCH...}
text = _URL_TAIL_RE.sub(" ", text) # końcowy link do hostera
text = _HASHTAG_RE.sub(" ", text) # #Tag
text = re.sub(r"\s+", " ", text).strip(" :-")
return text, tags
class SxyPrnScraper(BaseSearchScraper):
sitetag = "sxyprncom"
# _search_url_template/_scene_url_re z bazy nieużywane, mamy custom search().
_card_window = 2600 # od js-pop anchora do post_control z tytułem
def _performer_path(self, query: str) -> str:
"""`Lana Rhoades` → `Lana-Rhoades` (strona performera sxyprn)."""
cleaned = re.sub(r"[^A-Za-z0-9 ]+", "", query).strip()
return re.sub(r"\s+", "-", cleaned)
def search(self, query, *, page: int = 1, limit=None):
path = self._performer_path(query)
if not path:
return
url = f"{_BASE}/{path}.html" + (f"?page={page}" if page > 1 else "")
try:
r = browser_get(url, timeout=self._timeout)
except Exception as e:
log.warning("sxyprn search fetch failed (%s): %s", url, e)
return
if r.status_code != 200:
log.debug("sxyprn search %s status=%d", url, r.status_code)
return
text = r.text
anchors = list(_CARD_ANCHOR_RE.finditer(text))
seen: set[str] = set()
yielded = 0
for idx, m in enumerate(anchors):
post_id = m.group("id")
if post_id in seen:
continue
seen.add(post_id)
win_end = anchors[idx + 1].start() if idx + 1 < len(anchors) else m.end() + self._card_window
window = text[m.start():win_end]
tm = re.search(_POSTTIME_RE.pattern.format(id=re.escape(post_id)), text, re.IGNORECASE)
if not tm:
continue
title, tags = _clean_title(tm.group(1))
# Scena musi realnie dotyczyć performera. Sprawdzamy CAŁE nazwisko jako ciąg
# sąsiadujących członów, bo poprzedni wariant (każdy człon od trzech znaków
# gdziekolwiek w tytule) degenerował się do samego imienia, gdy nazwisko było
# krótsze: „Julia B." → człon „b." odpada na długości, zostaje „julia", i
# przypisanie łapało każdą scenę ze słowem Julia. Zgłoszenie `e28f2517`,
# 85 tys. błędnych przypisań na samym sxyprn.
if not person_named_in(title, query):
continue
if not title:
continue
scene_url = f"{_BASE}/post/{post_id}.html"
dur_m = _DURATION_RE.search(window)
duration_sec = _parse_duration(dur_m.group(1)) if dur_m else None
thumb_m = _THUMB_RE.search(window)
thumb = thumb_m.group(1) if thumb_m else None
if thumb and thumb.startswith("//"):
thumb = "https:" + thumb
author = _author_id(thumb, post_id)
if author in _BLOCKED_AUTHORS:
log.debug("sxyprn: pomijam post %s autora %s", post_id, _BLOCKED_AUTHORS[author])
continue
if len(tags) > _MAX_HASHTAGS:
log.debug("sxyprn: post %s ma %d hashtagow, wchodzi bez tagow", post_id, len(tags))
tags = []
studio = None
sub_m = _SUBCAT_RE.search(window)
if sub_m:
name = html.unescape(sub_m.group(1)).strip()
if name and name.lower() not in ("all", "trending"):
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(name)}",
name=name,
slug=slugify(name),
)
tag_objs: list[RawTag] = []
seen_tag: set[str] = set()
for t in tags:
sl = slugify(t)
if not sl or sl in seen_tag:
continue
seen_tag.add(sl)
tag_objs.append(RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=t, slug=sl))
performer = RawPerformer(
external_id=f"{self.sitetag}:performer:{slugify(query)}",
name=query.strip(),
)
yield RawScene(
external_id=f"{self.sitetag}:{post_id}",
title=title,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=[performer],
tags=tag_objs,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumb,
)
],
)
yielded += 1
if limit and yielded >= limit:
return