# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/schema.py # Desc: Modèle de données standardisé — Creator + PlatformAccount (CLAUDE.md §5) # + normalisation centrale via finalize() # ============================================================================== """Schéma standard d'une fiche créateur et de ses comptes de plateformes. Chaque connecteur, peu importe la source, doit produire des objets `Creator` conformes. `finalize()` applique la couche commune (creaka/normalize.py) : handles nettoyés, URLs canoniques, niches/région/langue/tier — les connecteurs restent simples et remplissent les champs bruts. Validation stricte (CLAUDE.md §18) : un compte sans `url` ou sans `confidence` est REJETÉ à la finalisation. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass, field, asdict from datetime import datetime, timezone from .normalize import ( LANGUAGES, NICHES, PLATFORMS, audience_tier, canonical_url, clean_bio, clean_handle, clean_name, guess_language, infer_creator_type, map_niche, normalize_platform, slugify, ) __all__ = ["Creator", "PlatformAccount", "now_iso"] def now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @dataclass class PlatformAccount: """Un compte public d'un créateur sur UNE plateforme (§5).""" platform: str # * canonique §6.2 handle: str # * sans @, minuscules url: str = "" # * forme canonique followers: int | None = None # abonnés (None = inconnu) verified: bool | None = None # badge vérifié (None = inconnu) confidence: float | None = None # * confiance du rattachement (§12) last_checked: str = "" # * ISO-8601 signal: str = "" # signal d'origine du rattachement (§12.1) metrics: dict = field(default_factory=dict) # métriques étendues publiques : # following, posts, likes, views, videos, category… (selon la plateforme) def finalize(self) -> "PlatformAccount": self.platform = normalize_platform(self.platform) self.handle = clean_handle(self.handle) self.url = canonical_url(self.platform, self.handle, fallback=self.url) if not self.handle: raise ValueError("compte sans handle") if not self.url: raise ValueError(f"compte {self.platform}:{self.handle} sans url") if self.confidence is None: raise ValueError(f"compte {self.platform}:{self.handle} sans confidence") self.confidence = max(0.0, min(1.0, float(self.confidence))) if self.followers is not None: self.followers = max(0, int(self.followers)) if not self.last_checked: self.last_checked = now_iso() return self @property def key(self) -> str: """Clé d'unicité d'un compte : plateforme + handle normalisé.""" return f"{self.platform}:{self.handle}" @dataclass class Creator: """Fiche créateur canonique Créa-Ka (CLAUDE.md §5).""" source: str # id de la source (registre data/sources.json) external_id: str # identifiant chez la source display_name: str # * nom public / nom de scène legal_name: str | None = None # SEULEMENT si rendu public par le créateur bio: str = "" niches: list[str] = field(default_factory=list) creator_type: str = "" region: str | None = None # une des 17 régions §7, sinon None city: str | None = None languages: list[str] = field(default_factory=list) primary_platform: str = "" platforms: list[PlatformAccount] = field(default_factory=list) link_in_bio_url: str | None = None avatar_url: str | None = None # photo de profil PUBLIQUE (URL source) banner_url: str | None = None # bannière/cover PUBLIQUE (URL source) total_reach: int | None = None audience_tier: str = "" business_contact: str | None = None # courriel PRO affiché publiquement (§15) agency: str | None = None is_minor: bool = False status: str = "active" # active | inactive | opted_out notes: str = "" # contexte public (listes médias), affichable source_ids: list[str] = field(default_factory=list) @property def uid(self) -> str: return f"{self.source}:{self.external_id}" def content_hash(self) -> str: """Hash du contenu pour la détection de changements.""" payload = asdict(self) for acc in payload["platforms"]: acc.pop("last_checked", None) # horodatage ≠ changement de contenu blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def finalize(self) -> "Creator": """Applique la normalisation commune. Appelé par le pipeline d'ingestion. Idempotent ; ne remplace jamais une valeur explicite du connecteur. Rejette la fiche si aucun compte valide (un créateur sans lien n'a pas sa place dans un annuaire de liens). """ self.display_name = clean_name(self.display_name) if not self.display_name: raise ValueError("créateur sans nom d'affichage") if not self.external_id: self.external_id = slugify(self.display_name) self.bio = clean_bio(self.bio) # comptes : normaliser, valider, dédupliquer (meilleure confiance gagne) seen: dict[str, PlatformAccount] = {} for acc in self.platforms: try: acc = acc.finalize() except ValueError: continue # un compte invalide ne bloque pas la fiche (§18 : rejeté) prev = seen.get(acc.key) if prev is None or (acc.confidence or 0) > (prev.confidence or 0): if prev is not None and prev.followers and not acc.followers: acc.followers = prev.followers if prev is not None: acc.metrics = {**prev.metrics, **acc.metrics} seen[acc.key] = acc # tri : abonnés décroissants (None en dernier), pour primary_platform self.platforms = sorted( seen.values(), key=lambda a: (a.followers is None, -(a.followers or 0))) if not self.platforms: raise ValueError(f"{self.display_name}: aucun compte valide") self.niches = [map_niche(n) for n in (self.niches or ["autre"])] self.niches = sorted({n for n in self.niches if n in NICHES}) or ["autre"] if not self.primary_platform: self.primary_platform = self.platforms[0].platform self.primary_platform = normalize_platform(self.primary_platform) if self.primary_platform not in PLATFORMS: self.primary_platform = self.platforms[0].platform if not self.languages: self.languages = [guess_language(self.bio, self.notes)] self.languages = [l for l in self.languages if l in LANGUAGES] or ["fr"] if not self.creator_type: self.creator_type = infer_creator_type( [a.platform for a in self.platforms], self.niches[0]) # portée totale : somme indicative des audiences connues known = [a.followers for a in self.platforms if a.followers] self.total_reach = sum(known) if known else None primary = next((a.followers for a in self.platforms if a.platform == self.primary_platform and a.followers), None) if not self.audience_tier: self.audience_tier = audience_tier(primary or (max(known) if known else None)) if not self.source_ids: self.source_ids = [self.uid] # régime restreint pour les mineurs (§15) — appliqué aussi dans ethics.py if self.is_minor: self.business_contact = None self.city = None self.region = None return self