xvideos (JSON-LD) i kilka innych tubow oddaje tytuly z encjami HTML5 (`we're`, `(AI)`), czasem podwojnie zakodowane. normalize robil z nich slowa `apos`, `lpar`, wiec tytul nie trafial w wyszukiwarke ani w kanon. unescape_html w app/normalize/text.py dekoduje encje zakonczone srednikiem po slowniku HTML5, do skutku (max 3 przebiegi). Wpiete w normalize_scene i normalize_movie oraz w parsery xvideos_browse i pornxp. Skrypt fix_html_entity_titles.py poprawia istniejace wiersze (title, title_normalized, slug gdy byl wyliczony z tytulu). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
7.5 KiB
Python
184 lines
7.5 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 ""
|
||
text = value
|
||
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 tytule sa zbedne.
|
||
return text.replace("\xa0", " ").replace("", "").strip()
|
||
|
||
|
||
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.
|
||
_PERSON_SEPARATOR_RE = re.compile(r"\s[-–—]\s|[,|&/;:()\[\]]|feat\.?|ft\.?", 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
|