"""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