Opisy scen szly do bazy z `"`, `<br>` 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>
214 lines
7 KiB
Python
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'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),
|
|
)
|