Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: creaka/schema.py4# Desc: Modèle de données standardisé — Creator + PlatformAccount (CLAUDE.md §5)5# + normalisation centrale via finalize()6# ==============================================================================7"""Schéma standard d'une fiche créateur et de ses comptes de plateformes.89Chaque connecteur, peu importe la source, doit produire des objets `Creator`10conformes. `finalize()` applique la couche commune (creaka/normalize.py) :11handles nettoyés, URLs canoniques, niches/région/langue/tier — les connecteurs12restent simples et remplissent les champs bruts.1314Validation stricte (CLAUDE.md §18) : un compte sans `url` ou sans `confidence`15est REJETÉ à la finalisation.16"""17from __future__ import annotations1819import hashlib20import json21from dataclasses import dataclass, field, asdict22from datetime import datetime, timezone2324from .normalize import (25 LANGUAGES,26 NICHES,27 PLATFORMS,28 audience_tier,29 canonical_url,30 clean_bio,31 clean_handle,32 clean_name,33 guess_language,34 infer_creator_type,35 map_niche,36 normalize_platform,37 slugify,38)3940__all__ = ["Creator", "PlatformAccount", "now_iso"]414243def now_iso() -> str:44 return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")454647@dataclass48class PlatformAccount:49 """Un compte public d'un créateur sur UNE plateforme (§5)."""5051 platform: str # * canonique §6.252 handle: str # * sans @, minuscules53 url: str = "" # * forme canonique54 followers: int | None = None # abonnés (None = inconnu)55 verified: bool | None = None # badge vérifié (None = inconnu)56 confidence: float | None = None # * confiance du rattachement (§12)57 last_checked: str = "" # * ISO-860158 signal: str = "" # signal d'origine du rattachement (§12.1)59 metrics: dict = field(default_factory=dict) # métriques étendues publiques :60 # following, posts, likes, views, videos, category… (selon la plateforme)6162 def finalize(self) -> "PlatformAccount":63 self.platform = normalize_platform(self.platform)64 self.handle = clean_handle(self.handle)65 self.url = canonical_url(self.platform, self.handle, fallback=self.url)66 if not self.handle:67 raise ValueError("compte sans handle")68 if not self.url:69 raise ValueError(f"compte {self.platform}:{self.handle} sans url")70 if self.confidence is None:71 raise ValueError(f"compte {self.platform}:{self.handle} sans confidence")72 self.confidence = max(0.0, min(1.0, float(self.confidence)))73 if self.followers is not None:74 self.followers = max(0, int(self.followers))75 if not self.last_checked:76 self.last_checked = now_iso()77 return self7879 @property80 def key(self) -> str:81 """Clé d'unicité d'un compte : plateforme + handle normalisé."""82 return f"{self.platform}:{self.handle}"838485@dataclass86class Creator:87 """Fiche créateur canonique Créa-Ka (CLAUDE.md §5)."""8889 source: str # id de la source (registre data/sources.json)90 external_id: str # identifiant chez la source91 display_name: str # * nom public / nom de scène92 legal_name: str | None = None # SEULEMENT si rendu public par le créateur93 bio: str = ""94 niches: list[str] = field(default_factory=list)95 creator_type: str = ""96 region: str | None = None # une des 17 régions §7, sinon None97 city: str | None = None98 languages: list[str] = field(default_factory=list)99 primary_platform: str = ""100 platforms: list[PlatformAccount] = field(default_factory=list)101 link_in_bio_url: str | None = None102 avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)103 banner_url: str | None = None # bannière/cover PUBLIQUE (URL source)104 total_reach: int | None = None105 audience_tier: str = ""106 business_contact: str | None = None # courriel PRO affiché publiquement (§15)107 agency: str | None = None108 is_minor: bool = False109 status: str = "active" # active | inactive | opted_out110 notes: str = "" # contexte public (listes médias), affichable111 source_ids: list[str] = field(default_factory=list)112113 @property114 def uid(self) -> str:115 return f"{self.source}:{self.external_id}"116117 def content_hash(self) -> str:118 """Hash du contenu pour la détection de changements."""119 payload = asdict(self)120 for acc in payload["platforms"]:121 acc.pop("last_checked", None) # horodatage ≠ changement de contenu122 blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)123 return hashlib.sha256(blob.encode("utf-8")).hexdigest()124125 def finalize(self) -> "Creator":126 """Applique la normalisation commune. Appelé par le pipeline d'ingestion.127128 Idempotent ; ne remplace jamais une valeur explicite du connecteur.129 Rejette la fiche si aucun compte valide (un créateur sans lien n'a pas130 sa place dans un annuaire de liens).131 """132 self.display_name = clean_name(self.display_name)133 if not self.display_name:134 raise ValueError("créateur sans nom d'affichage")135 if not self.external_id:136 self.external_id = slugify(self.display_name)137 self.bio = clean_bio(self.bio)138139 # comptes : normaliser, valider, dédupliquer (meilleure confiance gagne)140 seen: dict[str, PlatformAccount] = {}141 for acc in self.platforms:142 try:143 acc = acc.finalize()144 except ValueError:145 continue # un compte invalide ne bloque pas la fiche (§18 : rejeté)146 prev = seen.get(acc.key)147 if prev is None or (acc.confidence or 0) > (prev.confidence or 0):148 if prev is not None and prev.followers and not acc.followers:149 acc.followers = prev.followers150 if prev is not None:151 acc.metrics = {**prev.metrics, **acc.metrics}152 seen[acc.key] = acc153 # tri : abonnés décroissants (None en dernier), pour primary_platform154 self.platforms = sorted(155 seen.values(), key=lambda a: (a.followers is None, -(a.followers or 0)))156 if not self.platforms:157 raise ValueError(f"{self.display_name}: aucun compte valide")158159 self.niches = [map_niche(n) for n in (self.niches or ["autre"])]160 self.niches = sorted({n for n in self.niches if n in NICHES}) or ["autre"]161162 if not self.primary_platform:163 self.primary_platform = self.platforms[0].platform164 self.primary_platform = normalize_platform(self.primary_platform)165 if self.primary_platform not in PLATFORMS:166 self.primary_platform = self.platforms[0].platform167168 if not self.languages:169 self.languages = [guess_language(self.bio, self.notes)]170 self.languages = [l for l in self.languages if l in LANGUAGES] or ["fr"]171172 if not self.creator_type:173 self.creator_type = infer_creator_type(174 [a.platform for a in self.platforms], self.niches[0])175176 # portée totale : somme indicative des audiences connues177 known = [a.followers for a in self.platforms if a.followers]178 self.total_reach = sum(known) if known else None179 primary = next((a.followers for a in self.platforms180 if a.platform == self.primary_platform and a.followers), None)181 if not self.audience_tier:182 self.audience_tier = audience_tier(primary or (max(known) if known else None))183184 if not self.source_ids:185 self.source_ids = [self.uid]186187 # régime restreint pour les mineurs (§15) — appliqué aussi dans ethics.py188 if self.is_minor:189 self.business_contact = None190 self.city = None191 self.region = None192 return self193