goon/app/normalize/scenes.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

214 lines
7 KiB
Python

"""Mapowanie RawScene/RawPerformer/RawStudio/RawTag → znormalizowane DTO gotowe do upsertu.
Normalizacja = wyliczenie pól indeksujących (`*_normalized`, `slug`). Surowe pola jak
`title`, `release_date` przekazujemy bez zmian, kanon wybiera resolver.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.normalize.tag_inference import infer_tag_slugs
from app.normalize.text import (
clean_description,
normalize,
normalize_person,
slugify,
unescape_html,
)
@dataclass
class NormalizedTag:
name: str
slug: str
# Optional, `resolve_tag` keys on slug and `Tag` has no external_id column.
external_id: str | None = None
@dataclass
class NormalizedStudio:
name: str
name_normalized: str
slug: str
external_id: str | None
parent_external_id: str | None
parent_name: str | None
network: str | None
homepage_url: str | None
@dataclass
class NormalizedPerformer:
canonical_name: str
name_normalized: str
slug: str
external_id: str | None
aliases: list[str]
aliases_normalized: list[str]
gender: str | None
birth_date: date | None
country: str | None
as_alias_in_scene: str | None
@dataclass
class NormalizedScene:
external_id: str
title: str
title_normalized: str
slug: str
release_date: date | None
description: str | None
duration_sec: int | None
code: str | None
director: str | None
url: str | None
studio: NormalizedStudio | None = None
performers: list[NormalizedPerformer] = field(default_factory=list)
tags: list[NormalizedTag] = field(default_factory=list)
fingerprints: list[tuple[str, str]] = field(default_factory=list)
"""Lista (kind, value), np. [('phash', 'abc...'), ('oshash', '...')]."""
playback_sources: list[RawPlaybackSource] = field(default_factory=list)
"""Linki do odtwarzania (passthrough, resolver dorzuca do tabeli playback_sources)."""
cross_source_refs: dict[str, str] = field(default_factory=dict)
"""Mapowanie source_name → external_id deklarowane przez źródło z którego pochodzi
ta scena (np. StashDB ujawnia tpdb_id w `urls`)."""
def normalize_tag(raw: RawTag) -> NormalizedTag:
return NormalizedTag(
name=raw.name,
slug=raw.slug or slugify(raw.name),
external_id=raw.external_id,
)
def _merge_inferred_tags(raw_tags: list[RawTag], *, title: str) -> list[NormalizedTag]:
"""Tagi z connectora + tagi wywnioskowane z tytułu (`infer_tag_slugs`).
Tube'y (96% grywalnego katalogu) przychodzą prawie bez tagów, tylko 16%
tube-only scen ma jakikolwiek tag, vs 99% scen zmatchowanych z TPDB/StashDB.
`infer_tag_slugs` mapuje phrasy z tytułu na canonical slugi (zgrane z DB), więc
resolve_tag trafia w istniejące Tag rows. Union, nie nadpisanie: gdy scena potem
zmergeuje się z TPDB, tagi się sumują. Inference odpalamy dla KAŻDEJ sceny,
dla canonical to no-op (już mają komplet), dla tube to wypełnienie braku.
"""
tags = [normalize_tag(t) for t in raw_tags]
seen = {t.slug for t in tags}
for slug in infer_tag_slugs(title):
if slug in seen:
continue
seen.add(slug)
tags.append(NormalizedTag(name=slug.replace("-", " ").title(), slug=slug, external_id=None))
return tags
def normalize_studio(raw: RawStudio) -> NormalizedStudio:
return NormalizedStudio(
name=raw.name,
name_normalized=normalize(raw.name),
slug=raw.slug or slugify(raw.name),
external_id=raw.external_id,
parent_external_id=raw.parent_external_id,
parent_name=raw.parent_name,
network=raw.network,
homepage_url=raw.homepage_url,
)
_GENDER_ALIASES = {
"female": "female",
"f": "female",
"male": "male",
"m": "male",
"transgender female": "transgender_female",
"trans female": "transgender_female",
"trans-female": "transgender_female",
"transgender_female": "transgender_female",
"transgender male": "transgender_male",
"trans male": "transgender_male",
"trans-male": "transgender_male",
"transgender_male": "transgender_male",
"non binary": "non_binary",
"non-binary": "non_binary",
"nonbinary": "non_binary",
"non_binary": "non_binary",
"intersex": "intersex",
"unknown": "unknown",
}
def _normalize_gender(value: str | None) -> str | None:
"""TPDB/StashDB zwracają warianty z spacjami/myślnikami. Normalizujemy do enum
`performer_gender` w bazie. Wartość niezmapowana → None (NULL w DB)."""
if not value:
return None
return _GENDER_ALIASES.get(value.strip().lower())
def normalize_performer(raw: RawPerformer) -> NormalizedPerformer:
aliases = list(dict.fromkeys(raw.aliases)) # de-dup zachowując kolejność
return NormalizedPerformer(
canonical_name=raw.name,
name_normalized=normalize_person(raw.name),
slug=slugify(raw.name),
external_id=raw.external_id,
aliases=aliases,
aliases_normalized=[normalize_person(a) for a in aliases],
gender=_normalize_gender(raw.gender),
birth_date=raw.birth_date,
country=raw.country,
as_alias_in_scene=raw.as_alias_in_scene,
)
#: Ile dni w przod jeszcze uznajemy date premiery za wiarygodna. Studia zapowiadaja
#: sceny z niewielkim wyprzedzeniem, ale data odlegla o miesiace to literowka w roku
#: albo blad parsera. Takie daty sa szkodliwe, bo listy sortowane po premierze pinuja
#: je na stale na gorze: dwie sceny porndish (najdalsza 2026-12-02, dodana w maju)
#: siedzialy nad wszystkim, przez co serwis wygladal, jakby przestal ingestowac
#: (zgloszenie de732da2). Odrzucamy je, czyli scena sortuje sie po dacie dodania.
_MAX_FUTURE_RELEASE_DAYS = 14
def _sane_release_date(value):
if value is None:
return None
from datetime import date, timedelta
return None if value > date.today() + timedelta(days=_MAX_FUTURE_RELEASE_DAYS) else value
def normalize_scene(raw: RawScene) -> NormalizedScene:
# Tuby oddaja tytuly z encjami HTML (`we&apos;re`), patrz `unescape_html`.
title = unescape_html(raw.title)
return NormalizedScene(
external_id=raw.external_id,
title=title,
title_normalized=normalize(title),
slug=slugify(title),
release_date=_sane_release_date(raw.release_date),
description=clean_description(raw.description),
duration_sec=raw.duration_sec,
code=raw.code,
director=raw.director,
url=raw.url,
studio=normalize_studio(raw.studio) if raw.studio else None,
performers=[normalize_performer(p) for p in raw.performers],
tags=_merge_inferred_tags(raw.tags, title=title),
fingerprints=[(fp.kind, fp.value) for fp in raw.fingerprints],
playback_sources=list(raw.playback_sources),
cross_source_refs=dict(raw.cross_source_refs),
)