goon/app/normalize/text.py
goon-foss d2b3378d93 fix(ingest): czysc opisy z encji i znacznikow HTML, title_normalized zawsze przez normalize
Opisy scen szly do bazy z `&#34;`, `&lt;br&gt;` i surowymi `<br />`, a apka i strona
SEO pokazuja opis jako tekst, wiec bylo to widac doslownie. clean_description
dekoduje encje, `<br>` i koniec akapitu zamienia na nowa linie, a znane znaczniki
wycina (`<3` zostaje tekstem). Wpiete w normalize_scene i normalize_movie.

bulk_rescrape_hqporner.py i repair_truncated_titles.py liczyly title_normalized
jako lower(title), przez co 6005 scen mialo interpunkcje w kluczu dopasowania.
Oba licza teraz normalize() i dekoduja encje w tytule.

Skrypty: fix_html_entity_titles.py przemianowany na fix_html_entities.py z
przebiegiem po opisach; nowy fix_title_normalized.py przelicza klucz tam, gdzie
rozjechal sie z normalize(title).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:59:39 +02:00

217 lines
8.8 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 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 (`&lpar;`, `&OpenCurlyDoubleQuote;`) 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 `&copyright;` 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&apos;re`, `&lpar;AI&rpar;`),
czasem podwojnie (`&amp;lpar;`). Bez tego `normalize` robi z `&apos;` 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&notice` 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
# `&nbsp;` i `&#65279;` (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 `&lt;br&gt;`. 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 `&#34;` i `<br>`
widac doslownie. Encje ida pierwsze, bo czesc tubow koduje tez znaczniki
(`&lt;br&gt;`). `<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.
_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