goon/app/normalize/text.py
goon-foss b7a8911441 fix(sxyprn): koniec z przypisywaniem scen po samym imieniu
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.
2026-08-24 13:49:31 +02:00

115 lines
4.5 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Normalizacja tekstu używana wszędzie w pipeline'ie matchingu.
Cel: dwie formy tej samej nazwy (różne casing, akcenty, interpunkcja, whitespace, „the"/myślniki)
muszą produkować identyczny string po `normalize`. Trgram index na tych polach robi resztę.
"""
from __future__ import annotations
import re
import unicodedata
from slugify import slugify as _slugify
_NON_ASCII_ALNUM_RE = re.compile(r"[^a-z0-9\s]+")
_WS_RE = re.compile(r"\s+")
_LEADING_ARTICLE_RE = re.compile(r"^(the|a|an)\s+", flags=re.IGNORECASE)
# Litery których NFKD nie rozkłada na ASCII, mapujemy ręcznie.
_EXTRA_TRANSLIT = str.maketrans(
{
"Ł": "L", "ł": "l",
"Ø": "O", "ø": "o",
"Æ": "AE", "æ": "ae",
"Œ": "OE", "œ": "oe",
"Þ": "Th", "þ": "th",
"Ð": "D", "ð": "d",
"Đ": "D", "đ": "d",
"ß": "ss",
"Ħ": "H", "ħ": "h",
}
)
def strip_accents(value: str) -> str:
pre = value.translate(_EXTRA_TRANSLIT)
nfkd = unicodedata.normalize("NFKD", pre)
return "".join(c for c in nfkd if not unicodedata.combining(c))
def normalize(value: str | None) -> str:
"""Lower + strip accents + remove punctuation + collapse whitespace + drop leading article."""
if not value:
return ""
out = strip_accents(value).lower()
out = _NON_ASCII_ALNUM_RE.sub(" ", out)
out = _WS_RE.sub(" ", out).strip()
out = _LEADING_ARTICLE_RE.sub("", out)
return out
def normalize_person(value: str | None) -> str:
"""Normalizacja imion: jak `normalize`, plus zwija inicjały „M." → „m"."""
if not value:
return ""
out = normalize(value)
return _WS_RE.sub(" ", out).strip()
def slugify(value: str | None) -> str:
if not value:
return ""
return _slugify(value, lowercase=True, max_length=200)
# Minimalna długość członu, przy której dopuszczamy dopasowanie po przedrostku.
# 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
# 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
# śmieciowy performer „Rope Bondage" dopasowywał się do ogona „- rope - bondage",
# bo po normalizacji separator znika i człony stają się sąsiednie.
_PERSON_SEPARATOR_RE = re.compile(r"\s[-–—]\s|[,|&/;:()\[\]]|feat\.?|ft\.?", re.IGNORECASE)
def _person_token_match(name_tok: str, text_tok: str) -> bool:
if name_tok == text_tok:
return True
if len(name_tok) >= _PREFIX_FUZZ_MIN and len(text_tok) >= _PREFIX_FUZZ_MIN:
return name_tok.startswith(text_tok) or text_tok.startswith(name_tok)
return False
def person_named_in(haystack: str | None, person: str | None) -> bool:
"""Czy `person` występuje w `haystack` jako ciąg SĄSIADUJĄCYCH członów.
Po co osobno, skoro jest zwykłe `in`: tube wołany nazwiskiem oddaje całą stronę
wyników, więc scraper musi sam sprawdzić, czy scena naprawdę dotyczy tej osoby.
Sprawdzanie członów osobno („julia" gdziekolwiek i „b" gdziekolwiek) przypisywało
„Julia B." do każdej sceny, w której tytule stało „Julia Ann" obok „Bridgette B".
Na samym sxyprn zrobiło się z tego 85 tys. błędnych przypisań (zgłoszenie
`e28f2517`, 2026-08-24).
Człony muszą stać obok siebie i w tej samej kolejności. Od trzech znaków wystarczy
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.
Ś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.
"""
name_toks = normalize_person(person).split()
span = len(name_toks)
if not span:
return False
for segment in _PERSON_SEPARATOR_RE.split(haystack or ""):
text_toks = normalize_person(segment).split()
if len(text_toks) < span:
continue
if any(
all(_person_token_match(n, t) for n, t in zip(name_toks, text_toks[i : i + span]))
for i in range(len(text_toks) - span + 1)
):
return True
return False