From 01298c9699c5b9e340f210790d5b62f8cec4d33e Mon Sep 17 00:00:00 2001 From: goon-foss Date: Fri, 4 Sep 2026 11:09:37 +0200 Subject: [PATCH] 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 --- app/connectors/direct_scrapers/sxyprn.py | 37 ++++ app/normalize/text.py | 31 +++- scripts/sxyprn_purge_uploaders.py | 216 +++++++++++++++++++++++ tests/test_normalize.py | 35 +++- 4 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 scripts/sxyprn_purge_uploaders.py diff --git a/app/connectors/direct_scrapers/sxyprn.py b/app/connectors/direct_scrapers/sxyprn.py index cdd9468..39797d7 100644 --- a/app/connectors/direct_scrapers/sxyprn.py +++ b/app/connectors/direct_scrapers/sxyprn.py @@ -19,6 +19,14 @@ Metadane z karty (bez +1 fetch detalu): - 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: +`.../////small.jpg`. Autorow z `_BLOCKED_AUTHORS` +pomijamy w calosci, a karty z wiecej niz `_MAX_HASHTAGS` hashtagami wchodza bez tagow. """ from __future__ import annotations @@ -60,6 +68,17 @@ _POSTTIME_RE = re.compile( re.IGNORECASE, ) _HASHTAG_RE = re.compile(r"#(\w[\w-]*)") +# `...///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+") @@ -76,6 +95,16 @@ def _parse_duration(s: str) -> int | 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) @@ -146,6 +175,14 @@ class SxyPrnScraper(BaseSearchScraper): 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: diff --git a/app/normalize/text.py b/app/normalize/text.py index 80d8d72..a1086cc 100644 --- a/app/normalize/text.py +++ b/app/normalize/text.py @@ -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 # w dowolnym tytule i to jest dokładnie źródło over-atrybucji opisanej niżej. _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 # 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) -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: return True + if exact: + return False 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 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 „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. 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. @@ -103,12 +120,22 @@ def person_named_in(haystack: str | None, person: str | None) -> bool: span = len(name_toks) if not span: return False + exact = span == 1 for segment in _PERSON_SEPARATOR_RE.split(haystack or ""): text_toks = normalize_person(segment).split() if len(text_toks) < span: 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( - 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) ): return True diff --git a/scripts/sxyprn_purge_uploaders.py b/scripts/sxyprn_purge_uploaders.py new file mode 100644 index 0000000..6a3dec1 --- /dev/null +++ b/scripts/sxyprn_purge_uploaders.py @@ -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: `...///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()) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 76e2671..009dd3f 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -1,6 +1,12 @@ 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( @@ -48,3 +54,30 @@ def test_strip_accents_idempotent() -> None: once = strip_accents("naïve") twice = strip_accents(once) 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