"""Kasowanie śmieciowych performerów — kategorii i studiów podszywających się pod osoby. Skąd się biorą: `_search_base` filtruje wyniki tube-searcha warunkiem „slug zawiera ≥1 token z zapytania", a `resolve_performer` tworzy performera z dowolnej nazwy podanej przez tube. W efekcie „Creampie", „Natural tits", „reverse cowgirl", „69", „Brazzers" i „Blacked" wylądowały w tabeli `performers`. Audyt 2026-07-27: 277 takich rekordów z 33 784 przypisaniami do scen. **Kryterium jest wąskie celowo, bo szerokie kasuje ludzi.** Kryterium używane w `app/api/scenes.py` do de-rankingu (slug pokrywa się z tagiem I brak refa tpdb/stashdb) daje 3111 trafień, ale przy próbkowaniu okazało się mieć ~60% fałszywek dla nazw wielowyrazowych: wpadają w nie **Hatano Yui, Davina Raines, Michelle Ferrari, Clanddi, SolaZola, Kate Kuray**. Powód: założenie „prawdziwy performer ma ref kanoniczny" nie działa dla JAV, amatorek i debiutantek, których nie ma w TPDB. Do de-rankingu to wystarcza (fałszywka = gorszy ranking), do kasowania nie. Rozstrzyga dopiero **promiskuityczność taga**: ile RÓŻNYCH innych performerów pojawia się na scenach otagowanych tym samym slugiem. Nazwisko współwystępuje głównie ze swoim właścicielem i partnerami ze sceny, kategoria z całym katalogiem. Zmierzone: prawdziwe osoby Hatano Yui 0, Clanddi 0, Chyna 0, Kwini 2, Katekuray 9, SolaZola 4, Julia Reaves 20 (maksimum 20) kategorie Deepthroat 39541, Creampie 33661, riding 16284, Skinny 9443 Próg 50 leży ponad dwukrotnie powyżej najwyższej prawdziwej osoby. Dodatkowo chronimy nazwy w formacie „Imię Nazwisko", bo sygnał studia (odrzucony) wciągał marki osobiste. **Drugie kryterium: tag przytłacza performera.** Promiskuityczność zawodzi dla śmieci, które mają ref kanoniczny — bo TPDB i StashDB SAME zawierają wpisy „Hardcore", „Blonde", „Masturbation", „Amateur". Co gorsza, `obcy` mierzy tam popularność, nie śmieciowość: przy wartości 50-59 stoją Adriana Chechik (4201 scen), Lauren Phillips i Jane Wilde. Rozstrzyga stosunek `scen z tagiem / scen performera`. Nazwisko-tag siedzi głównie na scenach swojej właścicielki, więc stosunek jest ułamkiem; kategoria wisi na całym katalogu, więc idzie w setki: Hardcore 1720,6 Masturbation 1570,4 Blonde 770,0 Anal Creampie 31,4 najwyższy wśród 921 PEWNYCH osób (ref kanoniczny + ≥500 scen): **0,35** **Próg 2 wynika z zasady, nie z próbki.** Nazwisko użyte jako własny tag daje stosunek ≈1 Z DEFINICJI, bo tag siedzi dokładnie na scenach swojej właścicielki — potwierdzone na twórcach z własną marką: Hotwifevictoria 1,02, Rossmexicana 0,99, Sexwithmilfstella 0,87. Wielkie gwiazdy mają jeszcze mniej (tuby tagują tylko część ich scen). Stosunek ≥2 oznacza więc, że słowo jest doklejane do scen, które do tej osoby nie należą. Wymóg ≥50 scen odcina szum przy małych liczbach (przy 11 scenach „Denzel" wychodził na 161, ale to za mało danych, żeby przesądzać). Przy progu 2 na 78 trafień dwa są dyskusyjne („Teddy", „Grey" mogą być nazwiskami) — ale i one mają tag rozlany na 3× więcej scen, niż mają przypisań, więc ich atrybucje i tak są w większości słowne. Job, nie bramka w `resolve_performer`: kasowanie cyklem nie dotyka gorącej ścieżki ingestu, a śmieci i tak odrastają wolno. Dotyczy to zwłaszcza tego drugiego kryterium, bo tamte rekordy odtwarza sam ingest TPDB/StashDB. Ta sama logika co przy [[phash_blacklist]]. """ from __future__ import annotations import logging from sqlalchemy import text from sqlalchemy.orm import Session log = logging.getLogger(__name__) #: Ponad 2× powyżej najwyższej zmierzonej prawdziwej osoby (Julia Reaves, 20). DEFAULT_MIN_FOREIGN_PERFORMERS = 50 #: 2× powyżej naturalnej wartości dla nazwiska użytego jako własny tag (≈1). DEFAULT_MIN_TAG_RATIO = 2 #: Poniżej tego stosunek jest szumem — za mało scen, żeby cokolwiek przesądzać. DEFAULT_MIN_SCENES_FOR_RATIO = 50 #: Nazwy 1-2 znakowe („A", „L", „Ro", „Js") to artefakty parsowania tytułów, ale #: kilka to realne krótkie pseudonimy (Cj, PD, JD mają obsadę potwierdzoną w TPDB #: i StashDB, z prawdziwymi partnerami scenicznymi). Dlatego NIE kasujemy tu #: performera, tylko przypisania, o których WIEMY, że dokleił je scraper — reszta #: rozstrzyga się sama: komu nic nie zostanie, ten znika. MAX_ARTIFACT_NAME_LEN = 2 _SELECT_SQL = """ WITH junk AS ( SELECT pf.id, pf.canonical_name, pf.slug FROM performers pf WHERE EXISTS (SELECT 1 FROM tags t WHERE t.slug = pf.slug) AND NOT EXISTS ( SELECT 1 FROM performer_external_refs r JOIN sources s ON s.id = r.source_id WHERE r.performer_id = pf.id AND s.kind IN ('tpdb', 'stashdb') ) -- „Imię Nazwisko" zostaje: marki osobiste (Kate Kuray, Julia Reaves) wyglądają -- jak studio, ale są osobami. AND pf.canonical_name !~ '^[A-Z][a-z]+ [A-Z][a-z]+$' ) SELECT j.id FROM junk j WHERE ( SELECT count(DISTINCT sp.performer_id) FROM tags t JOIN scene_tags st ON st.tag_id = t.id JOIN scene_performers sp ON sp.scene_id = st.scene_id WHERE t.slug = j.slug AND sp.performer_id <> j.id ) >= :min_foreign """ _RATIO_SQL = """ SELECT pf.id FROM performers pf WHERE EXISTS (SELECT 1 FROM tags t WHERE t.slug = pf.slug) AND (SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id) >= :min_scenes AND (SELECT count(*) FROM scene_tags st JOIN tags t ON t.id = st.tag_id WHERE t.slug = pf.slug)::numeric / NULLIF((SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id), 0) >= :min_ratio """ def find_junk_performers( session: Session, *, min_foreign: int = DEFAULT_MIN_FOREIGN_PERFORMERS, min_ratio: int = DEFAULT_MIN_TAG_RATIO, min_scenes: int = DEFAULT_MIN_SCENES_FOR_RATIO, ): """Suma dwóch niezależnych kryteriów — pierwsze łapie śmieci bez refa kanonicznego, drugie te, które ref mają (bo TPDB/StashDB też zawierają „Hardcore" czy „Blonde").""" ids = {r[0] for r in session.execute(text(_SELECT_SQL), {"min_foreign": min_foreign})} ids |= { r[0] for r in session.execute( text(_RATIO_SQL), {"min_ratio": min_ratio, "min_scenes": min_scenes} ) } return list(ids) # Jednotokenowy performer jest ZBĘDNY na scenie, która ma już osobę pełnoimienną # zawierającą to samo słowo: „Devine" obok „Shalina Devine", „Kitana" obok „Kitana A". # To rozbite zapisy tej samej osoby, powstałe z parsowania tytułów przez tube-search — # jeden wpis nazwiskiem łapie potem wszystkie sceny z tym nazwiskiem, należące do # zupełnie innych ludzi (audyt 2026-07-27: 62 tys. takich przypisań, 2156 wpisów). # # Kasowanie jest DOWODLIWIE BEZSTRATNE: warunek wymaga, żeby konkretna osoba już była # przypisana do tej sceny, więc informacja o obsadzie zostaje. Żadna scena nie może # przez to zostać bez obsady (sprawdzone: 0). # # Ograniczone do `kind='scraper'` — przypisania z kanonu zostają nietknięte (73 takie). _PRUNE_SURNAME_SQL = """ DELETE FROM scene_performers sp USING sources s, performers p WHERE p.id = sp.performer_id AND p.canonical_name !~ ' ' AND length(p.canonical_name) >= 4 AND s.id = sp.source_id AND s.kind = 'scraper' AND EXISTS ( SELECT 1 FROM scene_performers sp2 JOIN performers q ON q.id = sp2.performer_id WHERE sp2.scene_id = sp.scene_id AND q.id <> p.id AND q.canonical_name ~ ' ' AND q.canonical_name ~* ('\\m' || p.canonical_name || '\\M') ) """ _PRUNE_ARTIFACT_SQL = """ DELETE FROM scene_performers sp USING sources s, performers p WHERE p.id = sp.performer_id AND length(trim(p.canonical_name)) <= :max_len AND s.id = sp.source_id AND s.kind = 'scraper' """ _DROP_EMPTY_ARTIFACT_SQL = """ DELETE FROM performers p WHERE length(trim(p.canonical_name)) <= :max_len AND NOT EXISTS (SELECT 1 FROM scene_performers sp WHERE sp.performer_id = p.id) """ def prune_artifact_name_attributions( session: Session, *, max_len: int = MAX_ARTIFACT_NAME_LEN ) -> tuple[int, int]: """Zdejmij scraperowe przypisania w dwóch przypadkach i usuń tych, którym nic nie zostało: (1) nazwy 1-2 znakowe, (2) jednotokenowy performer na scenie, która ma już osobę pełnoimienną zawierającą to słowo. Kanoniczne przypisania zostają nietknięte. Wymaga wypełnionego `scene_performers.source_id` (migracja 0026): bez provenance nie dałoby się odróżnić „Cj" z obsady TPDB od „Cj" wyłuskanego z tytułu przez tube-search. Wiersze z NULL-em (sceny wieloźródłowe) zostawiamy — nie zgadujemy.""" pruned = int( session.execute(text(_PRUNE_ARTIFACT_SQL), {"max_len": max_len}).rowcount or 0 ) pruned += int(session.execute(text(_PRUNE_SURNAME_SQL)).rowcount or 0) dropped = int( session.execute(text(_DROP_EMPTY_ARTIFACT_SQL), {"max_len": max_len}).rowcount or 0 ) dropped += int( session.execute( text( "DELETE FROM performers p WHERE p.canonical_name !~ ' ' " "AND length(p.canonical_name) >= 4 " "AND NOT EXISTS (SELECT 1 FROM scene_performers sp " "WHERE sp.performer_id = p.id) " "AND NOT EXISTS (SELECT 1 FROM performer_external_refs r " "JOIN sources s ON s.id = r.source_id WHERE r.performer_id = p.id " "AND s.kind IN ('tpdb','stashdb'))" ) ).rowcount or 0 ) return pruned, dropped def run_junk_performer_cleanup( *, min_foreign: int = DEFAULT_MIN_FOREIGN_PERFORMERS, commit: bool = True ) -> dict[str, int]: """Skasuj śmieciowych performerów. `scene_performers` leci kaskadą — sceny i tagi zostają, więc treść nie ginie: „creampie" nadal jest tagiem przy 176 tys. scen, tylko przestaje udawać osobę.""" from app.db import session_scope with session_scope() as session: ids = find_junk_performers(session, min_foreign=min_foreign) deleted = pruned = dropped = 0 if commit: if ids: deleted = int( session.execute( text("DELETE FROM performers WHERE id = ANY(:ids)"), {"ids": ids} ).rowcount or 0 ) pruned, dropped = prune_artifact_name_attributions(session) session.commit() log.info( "junk performers: znaleziono %d, skasowano %d; nazwy 1-2 znakowe: zdjęto %d " "przypisań, usunięto %d pustych", len(ids), deleted, pruned, dropped, ) return {"found": len(ids), "deleted": deleted, "pruned": pruned, "dropped": dropped}