Some checks are pending
Backend tests / test (push) Waiting to run
W _PERSON_SEPARATOR_RE zamiast \b staly doslowne znaki 0x08 (heredoc przy
commicie b7a8911), wiec tytul nigdy nie byl ciety na feat/ft. Regex uzywa
teraz prawdziwych granic slowa, lapie tez "featuring", a myslniki podaje
jako escape zamiast doslownych znakow.
Skutek w danych sprawdzony na prodzie: 255 701 przypisan na scenach z
sxyprn, 6530 tytulow z feat/ft, stara i nowa regula roznia sie w 0
przypadkach, bo sam czlon feat/ft i tak przerywal sasiedztwo nazwiska.
Poprawka zamyka przypadki brzegowe (smieciowy performer "Feat").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
9 KiB
Python
221 lines
9 KiB
Python
"""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 html
|
|
import re
|
|
import unicodedata
|
|
from html.entities import html5 as html5_entities
|
|
|
|
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",
|
|
}
|
|
)
|
|
|
|
|
|
# Encja z sredznikiem: nazwana HTML5 (`(`, `“`) albo numeryczna.
|
|
_HTML_ENTITY_RE = re.compile(r"&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});")
|
|
_UNESCAPE_MAX_PASSES = 3
|
|
|
|
|
|
def _decode_entity(m: re.Match[str]) -> str:
|
|
ent = m.group(0)
|
|
if ent[1] == "#":
|
|
return html.unescape(ent)
|
|
# Tylko pelna nazwa ze slownika HTML5. `html.unescape` na nieznanej nazwie dopasowuje
|
|
# przedrostek, wiec `©right;` zrobiloby sie `©right;`.
|
|
name = ent[1:]
|
|
if name in html5_entities:
|
|
return html5_entities[name]
|
|
# `&Amp;` i `&Quot;` biora sie z .title() na zakodowanym tytule, HTML5 ich nie zna.
|
|
return html5_entities.get(name.lower(), ent)
|
|
|
|
|
|
def unescape_html(value: str | None) -> str:
|
|
"""Dekoduje encje HTML w tytule ze scrapera, do skutku (max 3 przebiegi).
|
|
|
|
xvideos wklada do JSON-LD tytul z encjami HTML5 (`we're`, `(AI)`),
|
|
czasem podwojnie (`&lpar;`). Bez tego `normalize` robi z `'` slowo
|
|
`apos` i tytul nie trafia w kanon. Dekodujemy tylko encje zakonczone srednikiem,
|
|
po slowniku HTML5, z ktorego korzysta `html.unescape`. Samo `html.unescape` na
|
|
calym tekscie zamienia tez stare nazwy bez srednika, wiec `Me¬ice` wyszloby
|
|
jako `Me¬ice`.
|
|
"""
|
|
if not value:
|
|
return ""
|
|
return _decode_entities(value).strip()
|
|
|
|
|
|
def _decode_entities(text: str) -> str:
|
|
for _ in range(_UNESCAPE_MAX_PASSES):
|
|
new = _HTML_ENTITY_RE.sub(_decode_entity, text)
|
|
if new == text:
|
|
break
|
|
text = new
|
|
# ` ` i `` (BOM) zostawiaja niewidoczne znaki, w tekscie sa zbedne.
|
|
return text.replace("\N{NO-BREAK SPACE}", " ").replace("\N{ZERO WIDTH NO-BREAK SPACE}", "")
|
|
|
|
|
|
# Znaczniki, ktore tuby zostawiaja w opisach, surowe albo jako `<br>`. Lista nazw
|
|
# jest zamknieta, a atrybuty musza miec postac `nazwa=wartosc`, bo `<3`, `<Yokomiya-san`
|
|
# czy `<a lot of fun>` w opisach to tekst, nie znacznik.
|
|
_BREAK_TAG_RE = re.compile(r"<br\s*/?>|</(?:br|p|div|li|h[1-6])\s*>", re.IGNORECASE)
|
|
_INLINE_TAG_RE = re.compile(
|
|
r"</?(?:a|b|i|u|em|strong|span|div|p|font|small|big|sup|sub|h[1-6]|ul|ol|li|center|blockquote)"
|
|
r"""(?:\s+[\w:-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>]+))*\s*/?>""",
|
|
re.IGNORECASE,
|
|
)
|
|
_TRAILING_WS_RE = re.compile(r"[ \t]+\n")
|
|
_MANY_NEWLINES_RE = re.compile(r"\n{3,}")
|
|
|
|
|
|
def clean_description(value: str | None) -> str | None:
|
|
"""Opis ze scrapera jako zwykly tekst: encje odkodowane, znaczniki HTML usuniete.
|
|
|
|
Apka pokazuje opis w `Text`, strona SEO przez autoescape, wiec `"` i `<br>`
|
|
widac doslownie. Encje ida pierwsze, bo czesc tubow koduje tez znaczniki
|
|
(`<br>`). `<br>` i koniec akapitu zamieniamy na nowa linie, reszte wycinamy.
|
|
"""
|
|
if not value:
|
|
return None
|
|
text = _decode_entities(value)
|
|
text = _BREAK_TAG_RE.sub("\n", text)
|
|
text = _INLINE_TAG_RE.sub("", text)
|
|
text = _TRAILING_WS_RE.sub("\n", text)
|
|
text = _MANY_NEWLINES_RE.sub("\n\n", text)
|
|
return text.strip() or None
|
|
|
|
|
|
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
|
|
# 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
|
|
# śmieciowy performer „Rope Bondage" dopasowywał się do ogona „- rope - bondage",
|
|
# bo po normalizacji separator znika i człony stają się sąsiednie.
|
|
# `\b` wokol feat/ft byl kiedys zapisany jako znak 0x08, wiec ten separator nigdy nie
|
|
# dzialal (naprawione 2026-09-15). Myslniki podajemy jako `\u2013`/`\u2014`, nie doslownie.
|
|
_PERSON_SEPARATOR_RE = re.compile(
|
|
r"\s[-\u2013\u2014]\s|[,|&/;:()\[\]]|\b(?:feat(?:uring)?|ft)\b\.?", re.IGNORECASE
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
name_toks = normalize_person(person).split()
|
|
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, 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
|
|
return False
|