"""Rozplata performerów-ZLEPKÓW: nazw posklejanych z członów dwóch różnych osób. Scraper, szukając po nazwie, potrafił utworzyć osobę, która nie istnieje, biorąc imię jednej i nazwisko drugiej osoby z tej samej sceny: „Lilly Blossom" = Lilly Bell + Blake Blossom „Chloe Ryder" = Chloe Temple + Sera Ryder „Parker Banks" = Zoe Parker + Cassidy Banks „James Driller" = Kenna James + Ryan Driller **Dlaczego to osobny skrypt, a nie kolejna reguła w `prune_token_overlap_attributions`.** Tamten wymaga, żeby na scenie był INNY udokumentowany performer. Zlepek jest zwykle JEDYNYM przypisanym, więc nie ma z czym go porównać i tamta reguła go nie widzi. **I znowu jest to ODZYSKIWANIE.** Obie prawdziwe osoby stoją w tytule i zwykle NIE są przypisane do sceny, bo scraper wpisał zamiast nich jedną nieistniejącą. Kryterium (wszystkie warunki naraz): 1. przypisanie ze scrapera i zlepek jest JEDYNYM performerem sceny, 2. jego pełnej nazwy NIE MA w tytule, 3. w tytule stoją PEŁNE nazwy co najmniej dwóch innych performerów z naszej bazy, 4. każdy człon nazwy zlepka pochodzi od INNEJ z tych osób, 5. zlepka NIE ZNA kanon (brak refów TPDB/StashDB). Warunek 5 jest tym, który powstrzymuje szkodę. Z 4273 wykrytych zlepków aż 3150 MA odnośniki kanoniczne i tych NIE RUSZAMY: „Nikki Monroe" czy „Valentina Lopez" to nazwy, które mogą istnieć naprawdę, a „Lauren Smith" wzięło się z trójczłonowego nazwiska aktorki („lauren lee smith"). Istnienie osoby nie przesądza, czy jest w tej scenie, więc przy nich potrzeba innego sygnału niż nazwa. Zostaje 1123 przypisania (391 osób), których nie zna żadna baza kanoniczna. Próbka 16/16 to czyste zlepki. Uruchomienie (kontener worker): python -m scripts.fix_composite_performers # dry-run python -m scripts.fix_composite_performers --yes # wykonaj """ from __future__ import annotations import argparse import logging import re from sqlalchemy import text from app.db import session_scope log = logging.getLogger(__name__) #: Człony krótsze odpadają: „Li", „Vi" trafiłyby w losowe słowa tytułu. _MIN_TOKEN = 3 #: Nazwa kandydata na właściciela: min. 2 człony i 9 znaków, żeby nie łapać mononimów #: ani tagów jednowyrazowych. _MIN_NAME_LEN = 9 #: Nazwy-KATEGORIE siedzące w tabeli performerów. MUSZĄ być wykluczone jako „właściciel", #: bo spełniają wszystkie warunki formalne (dwa człony, długość) i stoją w co drugim #: tytule. Przy pierwszym uruchomieniu 2026-08-14 bez tej listy „Big Black" dostał 124 #: sceny, a dziewięć innych kategorii po kilka; poprawione osobno, 197 przypisań. #: #: Lista jest JAWNA, nie heurystyczna, bo obie oczywiste heurystyki zawodzą: „nazwa jest #: też tagiem" jest prawdziwe dla Lauren Phillips i Julii Ann (tuby robią tagi z nazwisk), #: a „ma kanon" jest prawdziwe dla „Big Black" i „Lesbian Love". _NIE_OSOBY = frozenset({ "big black", "juicy ass", "small tits", "anal lover", "mature milf", "double anal", "lesbian love", "double pussy", "natural tits", "anal queen", "first anal", "golden shower", "rough sex", "south asian", "busty girl", }) def _norm(s: str | None) -> str: return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip() def _load(session) -> tuple[dict, set, list]: names: dict[str, tuple] = {} for pid, nm in session.execute( text("SELECT id, canonical_name FROM performers") ).all(): n = _norm(nm) if n in _NIE_OSOBY: continue if len(n.split()) >= 2 and len(n) >= _MIN_NAME_LEN: names.setdefault(n, (pid, nm)) kanon = { p for (p,) in session.execute( text( "SELECT DISTINCT r.performer_id FROM performer_external_refs r " "JOIN sources s ON s.id = r.source_id WHERE s.name IN ('tpdb','stashdb')" ) ).all() } rows = session.execute( text( """ SELECT sp.scene_id, sp.performer_id, sp.source_id, p.canonical_name, s.title FROM scene_performers sp JOIN sources src ON src.id = sp.source_id AND src.name = 'tube-scraper' JOIN performers p ON p.id = sp.performer_id JOIN scenes s ON s.id = sp.scene_id WHERE (SELECT count(*) FROM scene_performers x WHERE x.scene_id = sp.scene_id) = 1 """ ) ).all() return names, kanon, rows def _detect(names: dict, kanon: set, rows: list) -> list[tuple]: out = [] for sid, pid, source_id, pname, title in rows: if pid in kanon: continue # warunek 5 t = " " + _norm(title) + " " pn = _norm(pname) toks = [x for x in pn.split() if len(x) >= _MIN_TOKEN] if len(toks) < 2 or f" {pn} " in t: continue # warunek 2 w = t.split() present: list[tuple] = [] for k in (3, 2): for i in range(len(w) - k + 1): cand = " ".join(w[i : i + k]) hit = names.get(cand) if hit and hit[0] != pid and all(cand != c for c, _ in present): present.append((cand, hit[0])) if len(present) < 2: continue # warunek 3 owners: dict = {} for tok in toks: for cand, oid in present: if tok in cand.split(): owners[oid] = True break if len(owners) >= 2: # warunek 4 out.append((sid, pid, source_id, list(owners))) return out def run(*, apply: bool) -> dict[str, int]: with session_scope() as session: names, kanon, rows = _load(session) hits = _detect(names, kanon, rows) print(f"zlepkow do rozplatania: {len(hits)} przypisan, {len({h[1] for h in hits})} osob") if not apply or not hits: if not apply: print("(dry-run)") return {"matched": len(hits), "attached": 0, "detached": 0} attached = detached = 0 for sid, pid, source_id, owner_ids in hits: for oid in owner_ids: attached += int( session.execute( text( "INSERT INTO scene_performers (scene_id, performer_id, source_id) " "VALUES (:s, :p, :src) ON CONFLICT DO NOTHING" ), {"s": sid, "p": oid, "src": source_id}, ).rowcount or 0 ) session.execute( text( """ INSERT INTO scene_performers_pruned (scene_id, performer_id, source_id, performer_name, scene_title) SELECT sp.scene_id, sp.performer_id, sp.source_id, p.canonical_name, s.title FROM scene_performers sp JOIN performers p ON p.id = sp.performer_id JOIN scenes s ON s.id = sp.scene_id WHERE sp.scene_id = :s AND sp.performer_id = :p ON CONFLICT DO NOTHING """ ), {"s": sid, "p": pid}, ) detached += int( session.execute( text( "DELETE FROM scene_performers WHERE scene_id = :s AND performer_id = :p" ), {"s": sid, "p": pid}, ).rowcount or 0 ) print(f"dołożono {attached} prawdziwych przypisań, zdjęto {detached} zlepków") return {"matched": len(hits), "attached": attached, "detached": detached} def main() -> None: logging.basicConfig(level=logging.INFO, format="%(message)s") ap = argparse.ArgumentParser() ap.add_argument("--yes", action="store_true", help="wykonaj (bez tego dry-run)") run(apply=ap.parse_args().yes) if __name__ == "__main__": main()