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
This commit is contained in:
goon-foss 2026-09-04 11:09:37 +02:00
parent 9c988bc12a
commit 01298c9699
4 changed files with 316 additions and 3 deletions

View file

@ -19,6 +19,14 @@ Metadane z karty (bez +1 fetch detalu):
- thumbnail: `data-src='//...small.jpg'` - thumbnail: `data-src='//...small.jpg'`
Playback resolwuje extractor `sxyprncom` (osobno; mp4 z trafficdeposit/lulustream). 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 from __future__ import annotations
@ -60,6 +68,17 @@ _POSTTIME_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
_HASHTAG_RE = re.compile(r"#(\w[\w-]*)") _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"\{[^}]*\}") _BRACE_RE = re.compile(r"\{[^}]*\}")
_URL_TAIL_RE = re.compile(r"https?://\S+") _URL_TAIL_RE = re.compile(r"https?://\S+")
@ -76,6 +95,16 @@ def _parse_duration(s: str) -> int | None:
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]]: def _clean_title(raw: str) -> tuple[str, list[str]]:
"""Zwraca (czysty tytuł, tagi). Usuwa {markery}, hashtagi i końcowy URL.""" """Zwraca (czysty tytuł, tagi). Usuwa {markery}, hashtagi i końcowy URL."""
text = html.unescape(raw) text = html.unescape(raw)
@ -146,6 +175,14 @@ class SxyPrnScraper(BaseSearchScraper):
if thumb and thumb.startswith("//"): if thumb and thumb.startswith("//"):
thumb = "https:" + thumb 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 studio = None
sub_m = _SUBCAT_RE.search(window) sub_m = _SUBCAT_RE.search(window)
if sub_m: if sub_m:

View file

@ -65,6 +65,14 @@ def slugify(value: str | None) -> str:
# Poniżej wymagamy zgodności co do znaku, bo inicjał („s", „b") pasuje do połowy słów # Poniżej wymagamy zgodności co do znaku, bo inicjał („s", „b") pasuje do połowy słów
# w dowolnym tytule i to jest dokładnie źródło over-atrybucji opisanej niżej. # w dowolnym tytule i to jest dokładnie źródło over-atrybucji opisanej niżej.
_PREFIX_FUZZ_MIN = 3 _PREFIX_FUZZ_MIN = 3
# O ile znakow czlon z tytulu moze byc dluzszy (albo krotszy) od czlonu nazwiska, zeby
# przedrostek jeszcze uchodzil za wariant zapisu. „Savanna" wobec „Savannah" to 1,
# „Jyn" wobec „Jynx" to 1. Bez tego limitu przedrostek dzialal w obie strony bez
# ograniczen i jednowyrazowy „Thebdck" lapal kazdy tytul ze slowem „the", a
# „Sashamalone" kazdy z „Sasha": 36 performerow na jednej scenie sxyprn (2026-09-04).
_PREFIX_FUZZ_MAX_DIFF = 2
# Od tylu znakow jednowyrazowe nazwisko moze byc zlepkiem dwoch czlonow tytulu.
_GLUED_NAME_MIN = 5
# Granice, przez które nazwisko nie może się przelewać. Tytuły tubów to listy osób # Granice, przez które nazwisko nie może się przelewać. Tytuły tubów to listy osób
# rozdzielone przecinkiem albo myślnikiem, a na końcu doklejone tagi. Bez tego cięcia # rozdzielone przecinkiem albo myślnikiem, a na końcu doklejone tagi. Bez tego cięcia
@ -73,10 +81,14 @@ _PREFIX_FUZZ_MIN = 3
_PERSON_SEPARATOR_RE = re.compile(r"\s[-–—]\s|[,|&/;:()\[\]]|feat\.?|ft\.?", re.IGNORECASE) _PERSON_SEPARATOR_RE = re.compile(r"\s[-–—]\s|[,|&/;:()\[\]]|feat\.?|ft\.?", re.IGNORECASE)
def _person_token_match(name_tok: str, text_tok: str) -> bool: def _person_token_match(name_tok: str, text_tok: str, *, exact: bool = False) -> bool:
if name_tok == text_tok: if name_tok == text_tok:
return True return True
if exact:
return False
if len(name_tok) >= _PREFIX_FUZZ_MIN and len(text_tok) >= _PREFIX_FUZZ_MIN: if len(name_tok) >= _PREFIX_FUZZ_MIN and len(text_tok) >= _PREFIX_FUZZ_MIN:
if abs(len(name_tok) - len(text_tok)) > _PREFIX_FUZZ_MAX_DIFF:
return False
return name_tok.startswith(text_tok) or text_tok.startswith(name_tok) return name_tok.startswith(text_tok) or text_tok.startswith(name_tok)
return False return False
@ -95,6 +107,11 @@ def person_named_in(haystack: str | None, person: str | None) -> bool:
zgodność przedrostka, żeby przeżyły warianty zapisu („Savanna Bond" przy tytule zgodność przedrostka, żeby przeżyły warianty zapisu („Savanna Bond" przy tytule
„Savannah Bond", „Jyn Maze" przy „Jynx Maze"). Inicjały muszą zgadzać się dokładnie. „Savannah Bond", „Jyn Maze" przy „Jynx Maze"). Inicjały muszą zgadzać się dokładnie.
Nazwisko jednowyrazowe („Mandingo", „Thebdck") musi zgadzac sie co do znaku: nie ma
drugiego czlonu, ktory by je potwierdzil, a przedrostek robil z niego wytrych.
Wyjatek: zlepek w stylu sxyprn („JoannaAngel", „Bonnieb") pasuje, gdy jest rowny
sklejce dwoch lub trzech sasiednich czlonow tytulu („Joanna Angel", „Bonnie B").
Świadomy koszt: odwrócona kolejność („Goldy Kim" wobec „Kim Gold") nie przejdzie. Świadomy koszt: odwrócona kolejność („Goldy Kim" wobec „Kim Gold") nie przejdzie.
Przy wyborze między pominięciem sceny a przypisaniem jej niewłaściwej osobie wolimy Przy wyborze między pominięciem sceny a przypisaniem jej niewłaściwej osobie wolimy
pominąć, bo błędne przypisanie widać na ekranie performera i to ono jest zgłaszane. pominąć, bo błędne przypisanie widać na ekranie performera i to ono jest zgłaszane.
@ -103,12 +120,22 @@ def person_named_in(haystack: str | None, person: str | None) -> bool:
span = len(name_toks) span = len(name_toks)
if not span: if not span:
return False return False
exact = span == 1
for segment in _PERSON_SEPARATOR_RE.split(haystack or ""): for segment in _PERSON_SEPARATOR_RE.split(haystack or ""):
text_toks = normalize_person(segment).split() text_toks = normalize_person(segment).split()
if len(text_toks) < span: if len(text_toks) < span:
continue continue
if exact and len(name_toks[0]) >= _GLUED_NAME_MIN and any(
"".join(text_toks[i : i + k]) == name_toks[0]
for k in (2, 3)
for i in range(len(text_toks) - k + 1)
):
return True
if any( if any(
all(_person_token_match(n, t) for n, t in zip(name_toks, text_toks[i : i + span])) all(
_person_token_match(n, t, exact=exact)
for n, t in zip(name_toks, text_toks[i : i + span])
)
for i in range(len(text_toks) - span + 1) for i in range(len(text_toks) - span + 1)
): ):
return True return True

View file

@ -0,0 +1,216 @@
"""Usuwa z bazy sceny zablokowanych uploaderow sxyprn i sciaga smieciowych performerow.
Kontekst (2026-09-04): trzej autorzy (Shizuka, Ajx, Denzel) wrzucaja dobre sceny, ale z
obsada wpisana w tytul i 25-35 hashtagami. Tytul z lista nazwisk to over-attribution
performerow, hashtagi to 27 tagow na scene zamiast 12. Scraper ma juz ich na blokadzie
(`_BLOCKED_AUTHORS` w `app/connectors/direct_scrapers/sxyprn.py`), ten skrypt sprzata
to, co juz weszlo.
Autora poznajemy po sciezce miniatury: `.../<id_autora>/<id_posta>/small.jpg`.
Co robi:
1. Sceny, ktore maja TYLKO zrodla tych autorow (zadnego innego zywego zrodla, zadnego
refu tpdb/stashdb): DELETE ze scenes (kaskada zdejmuje refy, performerow, tagi,
ulubione, postep). To jest utrata danych userow, dlatego domyslnie dry-run.
2. Sceny scalone z innymi zrodlami: zostaja, wylatuje tylko zrodlo sxyprn tego autora
i tag „Sizuka" (podpis Shizuki).
3. Smieciowi performerzy: na scenach, ktorych jedynym zrodlem jest sxyprn, zdejmuje
przypisania jednowyrazowych performerow, ktorych nazwa nie stoi doslownie w tytule
(ani jako zlepek „JoannaAngel" przy tytule „Joanna Angel").
To pozostalosc po dopasowaniu przedrostkiem w obie strony („Thebdck" do „the"),
naprawionym tego samego dnia w `person_named_in`.
Uzycie (na VPS, z pliku, nie przez stdin):
docker compose run --rm worker python3 -u /srv/scripts/sxyprn_purge_uploaders.py
docker compose run --rm worker python3 -u /srv/scripts/sxyprn_purge_uploaders.py --apply
"""
from __future__ import annotations
import argparse
import sys
from sqlalchemy import text
from app.db import session_scope
AUTORZY = {
"69cab3467ec41": "Shizuka",
"68e92925d23d9": "Ajx",
"6a0d125a875d2": "Denzel",
}
_CHUNK = 500
# Zrodlo sxyprn z id autora wyciagnietym z miniatury (drugi segment musi byc id posta).
_SQL_ZRODLA_AUTOROW = text(
"""
SELECT ps.id, ps.scene_id, a.aid
FROM playback_sources ps
CROSS JOIN LATERAL (
SELECT substring(
ps.thumbnail_url FROM
'/([a-f0-9]{13})/' || substring(ps.page_url FROM '/post/([a-f0-9]+)\\.html') || '/'
) AS aid
) a
WHERE ps.origin = 'tube:sxyprncom' AND a.aid = ANY(:autorzy)
"""
)
_SQL_KLASYFIKACJA = text(
"""
SELECT s.id,
EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id
AND p.dead_at IS NULL AND p.id <> ALL(:zrodla_autorow)) AS inne_zrodlo,
EXISTS (SELECT 1 FROM scene_external_refs r JOIN sources so ON so.id = r.source_id
WHERE r.scene_id = s.id AND so.name IN ('tpdb', 'stashdb')) AS kanon,
(SELECT count(*) FROM favorite_scenes f WHERE f.scene_id = s.id) AS ulubione,
(SELECT count(*) FROM scene_play_progress f WHERE f.scene_id = s.id) AS ogladane
FROM scenes s WHERE s.id = ANY(:sceny)
"""
)
_SQL_SMIECIOWI_PERF = text(
"""
WITH only_sx AS (
SELECT s.id, s.title FROM scenes s
WHERE EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id AND p.origin = 'tube:sxyprncom')
AND NOT EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id AND p.origin <> 'tube:sxyprncom')
AND NOT EXISTS (SELECT 1 FROM scene_external_refs r JOIN sources so ON so.id = r.source_id
WHERE r.scene_id = s.id AND so.name IN ('tpdb', 'stashdb'))
)
SELECT sp.scene_id, sp.performer_id, p.canonical_name
FROM only_sx o
JOIN scene_performers sp ON sp.scene_id = o.id
JOIN performers p ON p.id = sp.performer_id
WHERE p.canonical_name !~ '\\s'
AND position(lower(p.canonical_name) IN lower(o.title)) = 0
AND NOT (length(p.canonical_name) >= 5
AND position(regexp_replace(lower(p.canonical_name), '[^a-z0-9]', '', 'g')
IN regexp_replace(lower(o.title), '[^a-z0-9]', '', 'g')) > 0)
"""
)
def _chunks(seq: list, n: int):
for i in range(0, len(seq), n):
yield seq[i : i + n]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="bez tego tylko raport")
ap.add_argument("--skip-junk", action="store_true", help="pomin krok 3 (smieciowi performerzy)")
ap.add_argument("--only-junk", action="store_true", help="tylko krok 3, scen i zrodel nie ruszaj")
ap.add_argument("--authors", default=",".join(AUTORZY), help="id autorow po przecinku")
args = ap.parse_args()
autorzy = [a.strip().lower() for a in args.authors.split(",") if a.strip()]
dry = not args.apply
print(f"tryb: {'DRY-RUN' if dry else 'APPLY'}; autorzy: {', '.join(AUTORZY.get(a, a) for a in autorzy)}")
with session_scope() as s:
rows = s.execute(_SQL_ZRODLA_AUTOROW, {"autorzy": autorzy}).all()
zrodla = [r[0] for r in rows]
per_autor: dict[str, set] = {}
sceny_all: set = set()
for _, scene_id, aid in rows:
per_autor.setdefault(aid, set()).add(scene_id)
sceny_all.add(scene_id)
for aid, ids in per_autor.items():
n_src = sum(1 for r in rows if r[2] == aid)
print(f" {AUTORZY.get(aid, aid):<8} zrodel/scen: {n_src}/{len(ids)}")
sceny = sorted(sceny_all, key=str)
print(f"lacznie: {len(zrodla)} zrodel, {len(sceny)} scen")
do_usuniecia: list = []
do_odciecia: list = []
ulub = ogl = 0
for chunk in _chunks(sceny, _CHUNK):
for sid, inne, kanon, u, o in s.execute(
_SQL_KLASYFIKACJA, {"sceny": chunk, "zrodla_autorow": zrodla}
).all():
if inne or kanon:
do_odciecia.append(sid)
else:
do_usuniecia.append(sid)
ulub += u
ogl += o
print(
f"do usuniecia: {len(do_usuniecia)} scen (ulubionych {ulub}, wpisow historii {ogl}); "
f"do odciecia zrodla (scena zostaje): {len(do_odciecia)}"
)
smieci = [] if args.skip_junk else s.execute(_SQL_SMIECIOWI_PERF).all()
if not args.skip_junk:
print(
f"smieciowe przypisania performerow na scenach tylko-sxyprn: {len(smieci)} "
f"na {len({r[0] for r in smieci})} scenach"
)
top: dict[str, int] = {}
for _, _, name in smieci:
top[name] = top.get(name, 0) + 1
najczestsi = sorted(top.items(), key=lambda x: -x[1])[:12]
print(" najczestsi: " + ", ".join(f"{n} ({c})" for n, c in najczestsi))
if dry:
print("dry-run, nic nie zmieniam")
return 0
if args.only_junk:
do_odciecia, do_usuniecia = [], []
print("tylko krok 3, sceny i zrodla zostaja")
# 2. sceny scalone: zostaja, wylatuje tylko zrodlo autora + tag Sizuka
n_ps = 0
for chunk in _chunks(do_odciecia, _CHUNK):
n_ps += s.execute(
text("DELETE FROM playback_sources WHERE scene_id = ANY(:sceny) AND id = ANY(:zrodla)"),
{"sceny": chunk, "zrodla": zrodla},
).rowcount
s.execute(
text(
"DELETE FROM scene_tags st USING tags t "
"WHERE t.id = st.tag_id AND t.slug = 'sizuka' AND st.scene_id = ANY(:sceny)"
),
{"sceny": chunk},
)
s.commit()
print(f"odciete zrodla: {n_ps}")
# 1. sceny wylacznie tych autorow: DELETE, kaskada robi reszte
n_sc = 0
for chunk in _chunks(do_usuniecia, _CHUNK):
s.execute(
text("DELETE FROM merge_candidates WHERE left_id = ANY(:sceny) OR right_id = ANY(:sceny)"),
{"sceny": chunk},
)
n_sc += s.execute(text("DELETE FROM scenes WHERE id = ANY(:sceny)"), {"sceny": chunk}).rowcount
s.commit()
print(f"usuniete sceny: {n_sc}")
# 3. smieciowi performerzy
n_sp = 0
pary = [(r[0], r[1]) for r in smieci]
for chunk in _chunks(pary, _CHUNK):
n_sp += s.execute(
text(
"DELETE FROM scene_performers sp "
"USING unnest(CAST(:sc AS uuid[]), CAST(:pf AS uuid[])) AS u(sc, pf) "
"WHERE sp.scene_id = u.sc AND sp.performer_id = u.pf"
),
{"sc": [c[0] for c in chunk], "pf": [c[1] for c in chunk]},
).rowcount
s.commit()
print(f"zdjete smieciowe przypisania: {n_sp}")
# podpis Shizuki jako tag: bez scen nie ma czego trzymac
s.execute(
text(
"DELETE FROM tags t WHERE t.slug = 'sizuka' "
"AND NOT EXISTS (SELECT 1 FROM scene_tags st WHERE st.tag_id = t.id)"
)
)
s.commit()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,6 +1,12 @@
import pytest import pytest
from app.normalize.text import normalize, normalize_person, slugify, strip_accents from app.normalize.text import (
normalize,
normalize_person,
person_named_in,
slugify,
strip_accents,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -48,3 +54,30 @@ def test_strip_accents_idempotent() -> None:
once = strip_accents("naïve") once = strip_accents("naïve")
twice = strip_accents(once) twice = strip_accents(once)
assert once == twice == "naive" assert once == twice == "naive"
@pytest.mark.parametrize(
("title", "person", "expected"),
[
("Savannah Bond Takes It All", "Savanna Bond", True),
("Jynx Maze Anal", "Jyn Maze", True),
("Julia Ann and Bridgette B", "Julia B.", False),
("Kim Gold Solo", "Goldy Kim", False),
# Jednowyrazowe nazwisko: tylko co do znaku. „Thebdck" lapal kazde „the".
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Thebdck", False),
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Sashamalone", False),
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Dickxy", False),
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Sasha Paige", True),
("Mandingo Destroys Her", "Mandingo", True),
# Zlepek sxyprn rowny sklejce sasiednich czlonow tytulu.
("Joanna Angel Rides Again", "JoannaAngel", True),
("Bonnie B Solo", "Bonnieb", True),
("Joanna Smith and Angel Dark", "JoannaAngel", False),
("Mandingos Best", "Mandingo", False),
# Przedrostek z limitem dlugosci: wariant zapisu tak, doklejone slowo nie.
("Alexis Crystal Returns", "Alexis Crystalline", False),
("Sasha Grey Returns", "Sash Grey", True),
],
)
def test_person_named_in(title: str, person: str, expected: bool) -> None:
assert person_named_in(title, person) is expected