Zgloszenie e28f2517: scena "Bridgette B, Gigi Rivera, Julia Ann, Phoenix Marie, ..."
miala przypisana Julie B., ktorej w tytule nie ma.
Mechanizm: scraper wola strone performera i sprawdza, czy scena naprawde go dotyczy,
porownujac czlony nazwiska z tytulem. Robil to jednak KAZDY Z OSOBNA i tylko od trzech
znakow. Dla "Julia B." czlon "b." odpadal na dlugosci, zostawalo samo "julia", i
przypisanie lapalo kazda scene z tym imieniem w tytule. Stad tez "Chloe S" przy
"Chloe Kreams", "Sharon D" przy "Sharon White", "May E" przy "Danielle Maye".
Nowa regula (`person_named_in` w `app/normalize/text.py`): czlony musza stac OBOK
SIEBIE i w tej samej kolejnosci. Od trzech znakow wystarczy zgodnosc przedrostka, zeby
przezyly warianty zapisu ("Savanna Bond" przy tytule "Savannah Bond", "Jyn Maze" przy
"Jynx Maze"). Inicjaly musza zgadzac sie dokladnie. Tytul tniemy wczesniej na segmenty
po przecinkach i myslnikach, bo bez tego smieciowy performer "Rope Bondage" dopasowywal
sie do doklejonego ogona tagow "- rope - bondage".
Swiadomy koszt: odwrocona kolejnosc ("Goldy Kim" wobec "Kim Gold") nie przejdzie, a
performer z inicjalem zamiast nazwiska nie dostanie ze sxyprn nic. Przy wyborze miedzy
pominieciem sceny a przypisaniem jej niewlasciwej osoby wolimy pominac, bo to bledne
przypisanie widac na ekranie performera i to ono jest zglaszane.
Smoke test po zmianie: Lana Rhoades, Julia Ann, Angela White i Abella Danger dalej po
30 scen ze strony performera, wiec scraper sie nie zamrozil (to sie juz raz zdarzilo
przy zbyt ostrym filtrze). "Julia B." oddaje teraz zero zamiast cudzych scen.
Dolozony `scripts/sxyprn_fix_overattribution.py`, ktory stosuje te sama regule wstecz.
Uruchomiony na produkcji: skasowane 85 379 z 239 686 przypisan (35,6%). Ruszamy tylko
sceny majace WYLACZNIE zrodlo sxyprn i tylko przypisania od scrapera; kanon
(tpdb/stashdb) nietkniety. Regula sprawdzona recznie na dwoch probkach po ~20 pozycji,
wszystkie kasowane byly faktycznie bledne.
Skutek uboczny: scen bez zadnego performera przybylo z 3 622 do 23 891. To sceny,
ktorych jedyne przypisanie bylo bledne, wiec brak jest uczciwszy niz cudze nazwisko.
193 lines
7.7 KiB
Python
193 lines
7.7 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).
|
|
"""
|
|
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-]*)")
|
|
_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 _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
|
|
|
|
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
|