SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
8.0 KB · 193 lines python
Raw Blame History
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