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%
16.3 KB · 380 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   creaka/normalize.py4# Desc:   Normalisation commune — handles, URLs canoniques par plateforme,5#         niches, régions, langue, taille d'audience (voir CLAUDE.md §6, §7, §11)6# ==============================================================================7"""Couche de normalisation partagée par tous les connecteurs.89Chaque connecteur remplit des champs bruts ; `schema.Creator.finalize()`10appelle ces fonctions pour produire une fiche canonique. Idempotent.11"""12from __future__ import annotations1314import re15import unicodedata1617# --- taxonomies (CLAUDE.md §6) ------------------------------------------------18NICHES = {19    "humour", "mode", "beaute", "lifestyle", "famille-parentalite", "cuisine",20    "gaming", "tech", "sport-fitness", "plein-air", "voyage", "musique", "arts",21    "danse", "education", "finance-affaires", "sante-mieux-etre", "bouffe-resto",22    "actualite-opinion", "autre",23}2425PLATFORMS = {26    "instagram", "tiktok", "youtube", "twitch", "kick", "x", "facebook",27    "snapchat", "substack", "patreon", "onlyfans", "mym", "fansly",28    "linkedin", "threads", "spotify", "discord", "podcast", "site-web", "autre",29}3031CREATOR_TYPES = {32    "influenceur", "youtubeur", "streamer", "podcasteur", "createur-tiktok",33    "createur-ecrit", "musicien", "humoriste", "autre",34}3536LANGUAGES = {"fr", "en", "bilingue"}3738REGIONS = {39    "Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale",40    "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue",41    "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine",42    "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides",43    "Montérégie", "Centre-du-Québec",44}4546# --- URLs canoniques par plateforme (CLAUDE.md §11) ---------------------------47# gabarit : handle SANS @ ; None = pas de forme canonique (garder l'URL source)48_URL_TEMPLATES: dict[str, str | None] = {49    "instagram": "https://www.instagram.com/{h}/",50    "tiktok": "https://www.tiktok.com/@{h}",51    "youtube": "https://www.youtube.com/@{h}",52    "twitch": "https://www.twitch.tv/{h}",53    "kick": "https://kick.com/{h}",54    "x": "https://x.com/{h}",55    "facebook": "https://www.facebook.com/{h}",56    "snapchat": "https://www.snapchat.com/add/{h}",57    "substack": "https://{h}.substack.com",58    "patreon": "https://www.patreon.com/{h}",59    "onlyfans": "https://onlyfans.com/{h}",60    "mym": "https://mym.fans/{h}",61    "fansly": "https://fansly.com/{h}",62    "linkedin": "https://www.linkedin.com/in/{h}",63    "threads": "https://www.threads.net/@{h}",64    # IDs Spotify et codes d'invitation Discord : sensibles à la casse →65    # pas de forme canonique reconstruite, on conserve l'URL source66    "spotify": None,67    "discord": None,68    "podcast": None,69    "site-web": None,70    "autre": None,71}7273# détection de plateforme à partir d'une URL (pages link-in-bio, bios, etc.)74_URL_PLATFORM_RES: list[tuple[str, re.Pattern]] = [75    ("instagram", re.compile(r"instagram\.com/([\w.\-]+)", re.I)),76    ("tiktok", re.compile(r"tiktok\.com/@([\w.\-]+)", re.I)),77    ("youtube", re.compile(r"youtube\.com/(?:@|c/|user/|channel/)([\w.\-]+)", re.I)),78    ("youtube", re.compile(r"youtu\.be/([\w.\-]+)", re.I)),79    ("twitch", re.compile(r"twitch\.tv/([\w.\-]+)", re.I)),80    ("kick", re.compile(r"kick\.com/([\w.\-]+)", re.I)),81    ("x", re.compile(r"(?:twitter|x)\.com/([\w.\-]+)", re.I)),82    ("facebook", re.compile(r"facebook\.com/([\w.\-]+)", re.I)),83    ("snapchat", re.compile(r"snapchat\.com/add/([\w.\-]+)", re.I)),84    ("substack", re.compile(r"https?://([\w\-]+)\.substack\.com", re.I)),85    ("patreon", re.compile(r"patreon\.com/([\w.\-]+)", re.I)),86    ("onlyfans", re.compile(r"onlyfans\.com/([\w.\-]+)", re.I)),87    ("mym", re.compile(r"mym\.fans/([\w.\-]+)", re.I)),88    ("fansly", re.compile(r"fansly\.com/([\w.\-]+)", re.I)),89    ("linkedin", re.compile(r"linkedin\.com/in/([\w.\-]+)", re.I)),90    ("threads", re.compile(r"threads\.net/@?([\w.\-]+)", re.I)),91    ("spotify", re.compile(92        r"open\.spotify\.com/(?:intl-[\w\-]+/)?(?:artist|show|user)/([A-Za-z0-9]+)",93        re.I)),94    ("discord", re.compile(r"discord(?:\.gg|(?:app)?\.com/invite)/([\w\-]+)", re.I)),95    # une page Apple Podcasts pointe le MÊME balado que balados-itunes96    # (handle = collectionId) → fusion naturelle des comptes podcast97    ("podcast", re.compile(r"podcasts\.apple\.com/\S*?/id(\d+)", re.I)),98]99100# chemins qui ne sont PAS des handles (pages génériques des plateformes)101_NOT_HANDLES = {102    "p", "reel", "reels", "stories", "share", "watch", "video", "videos",103    "playlist", "shorts", "live", "intent", "hashtag", "explore", "home",104    "profile.php", "groups", "pages", "events", "posts", "status", "search",105}106107108def strip_accents(text: str) -> str:109    """« Bédard » → « Bedard » (clés de comparaison, jamais pour l'affichage)."""110    return "".join(c for c in unicodedata.normalize("NFD", text)111                   if unicodedata.category(c) != "Mn")112113114def slugify(text: str) -> str:115    """Nom d'affichage → slug d'identifiant interne (ex. « Enola Bédard » → enola-bedard)."""116    s = strip_accents(text or "").lower()117    s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")118    return s or "inconnu"119120121def clean_handle(raw: str | None) -> str:122    """Nettoie un handle brut : retire @, URL, espaces, barre finale ; minuscules.123124    Les handles des grandes plateformes sont insensibles à la casse — on125    normalise en minuscules pour la comparaison ET la forme canonique.126    """127    if not raw:128        return ""129    h = raw.strip()130    # si on a reçu une URL complète, garder le dernier segment utile131    if "://" in h or h.startswith("www."):132        h = h.rstrip("/").split("/")[-1]133    h = h.lstrip("@").split("?")[0].strip().strip("/")134    # ID de chaîne YouTube (UC…) : sensible à la casse — ne JAMAIS minusculiser135    if re.fullmatch(r"UC[0-9A-Za-z_-]{22}", h):136        return h137    return h.lower()138139140def normalize_platform(raw: str | None) -> str:141    """Nom de plateforme brut → valeur canonique de §6.2 (défaut : « autre »)."""142    if not raw:143        return "autre"144    p = strip_accents(raw).lower().strip()145    aliases = {146        "ig": "instagram", "insta": "instagram", "instagram": "instagram",147        "tiktok": "tiktok", "tik-tok": "tiktok", "tik tok": "tiktok",148        "youtube": "youtube", "yt": "youtube",149        "twitch": "twitch", "kick": "kick",150        "twitter": "x", "x": "x",151        "facebook": "facebook", "fb": "facebook",152        "snapchat": "snapchat", "snap": "snapchat",153        "substack": "substack", "patreon": "patreon", "onlyfans": "onlyfans",154        "of": "onlyfans", "mym": "mym", "fansly": "fansly",155        "linkedin": "linkedin", "threads": "threads",156        "spotify": "spotify", "discord": "discord",157        "podcast": "podcast", "balado": "podcast",158        "apple podcasts": "podcast", "apple-podcasts": "podcast",159        "site": "site-web", "site-web": "site-web", "website": "site-web",160        "web": "site-web", "blogue": "site-web", "blog": "site-web",161    }162    return aliases.get(p, p if p in PLATFORMS else "autre")163164165def canonical_url(platform: str, handle: str, fallback: str = "") -> str:166    """Forme canonique de l'URL d'un compte (CLAUDE.md §11)."""167    h = clean_handle(handle)168    # chaîne YouTube identifiée par son ID (pas de @handle connu)169    if platform == "youtube" and re.fullmatch(r"UC[0-9A-Za-z_-]{22}", h):170        return f"https://www.youtube.com/channel/{h}"171    tpl = _URL_TEMPLATES.get(platform)172    if tpl and h:173        return tpl.format(h=h)174    return fallback or ""175176177def platform_from_url(url: str) -> tuple[str, str] | None:178    """URL → (plateforme, handle) si reconnue, sinon None.179180    Utilisé par le connecteur link-in-bio : chaque lien d'une page Linktree181    est classé ici. Les chemins génériques (posts, vidéos) sont rejetés.182    """183    if not url:184        return None185    for platform, rx in _URL_PLATFORM_RES:186        m = rx.search(url)187        if m:188            handle = clean_handle(m.group(1))189            if not handle or handle in _NOT_HANDLES:190                return None191            return platform, handle192    return None193194195def clean_name(raw: str | None) -> str:196    """Nettoie un nom d'affichage (espaces, emojis décoratifs en bordure)."""197    if not raw:198        return ""199    name = re.sub(r"\s+", " ", raw).strip()200    # retirer les emojis/symboles en tête et en queue seulement (jamais au milieu :201    # certains noms de scène en contiennent volontairement)202    name = re.sub(r"^[\W_]+(?=\w)", "", name)203    name = re.sub(r"(?<=\w)[^\w).!?'’\"]+$", "", name)204    return name.strip()205206207def clean_bio(raw: str | None) -> str:208    """Bio publique : espaces normalisés, longueur bornée."""209    if not raw:210        return ""211    bio = re.sub(r"[ \t]+", " ", raw).strip()212    return bio[:1200]213214215def map_niche(raw: str | None) -> str:216    """Catégorie source → niche canonique (§6.1)."""217    if not raw:218        return "autre"219    c = strip_accents(raw).lower().strip()220    mapping = {221        "humour": "humour", "comedie": "humour", "comedy": "humour",222        "mode": "mode", "fashion": "mode",223        "beaute": "beaute", "beauty": "beaute", "makeup": "beaute",224        "lifestyle": "lifestyle", "tele-realite": "lifestyle",225        "famille": "famille-parentalite", "parentalite": "famille-parentalite",226        "famille-parentalite": "famille-parentalite", "maman": "famille-parentalite",227        "cuisine": "cuisine", "food": "bouffe-resto", "bouffe": "bouffe-resto",228        "bouffe-resto": "bouffe-resto", "resto": "bouffe-resto",229        "gaming": "gaming", "jeux video": "gaming", "jeux-video": "gaming",230        "tech": "tech", "technologie": "tech",231        "sport": "sport-fitness", "fitness": "sport-fitness",232        "sport-fitness": "sport-fitness", "entrainement": "sport-fitness",233        "plein air": "plein-air", "plein-air": "plein-air", "chasse": "plein-air",234        "peche": "plein-air", "outdoor": "plein-air",235        "voyage": "voyage", "travel": "voyage",236        "musique": "musique", "music": "musique",237        "arts": "arts", "art": "arts",238        "danse": "danse", "dance": "danse",239        "education": "education",240        "finance": "finance-affaires", "affaires": "finance-affaires",241        "finance-affaires": "finance-affaires", "entrepreneur": "finance-affaires",242        "business": "finance-affaires", "immobilier": "finance-affaires",243        "sante": "sante-mieux-etre", "mieux-etre": "sante-mieux-etre",244        "sante-mieux-etre": "sante-mieux-etre", "bien-etre": "sante-mieux-etre",245        "actualite": "actualite-opinion", "opinion": "actualite-opinion",246        "actualite-opinion": "actualite-opinion", "medias": "actualite-opinion",247        "podcast": "actualite-opinion",248    }249    return mapping.get(c, c if c in NICHES else "autre")250251252# localisation publique déclarée → région administrative (§7).253# ⚠️ On ne mappe QUE ce qui est sans ambiguïté ; « Québec » seul désigne la254# PROVINCE dans les listes médias → region None. Jamais d'inférence privée.255_CITY_REGION = {256    "montreal": "Montréal", "lachine": "Montréal", "verdun": "Montréal",257    "quebec (ville)": "Capitale-Nationale", "ville de quebec": "Capitale-Nationale",258    "charlevoix": "Capitale-Nationale",259    "laval": "Laval",260    "levis": "Chaudière-Appalaches", "beauce": "Chaudière-Appalaches",261    "gatineau": "Outaouais",262    "sherbrooke": "Estrie", "waterloo": "Estrie",  # Haute-Yamaska → Estrie (2021)263    "granby": "Estrie",264    "trois-rivieres": "Mauricie", "shawinigan": "Mauricie",265    "saguenay": "Saguenay–Lac-Saint-Jean", "chicoutimi": "Saguenay–Lac-Saint-Jean",266    "alma": "Saguenay–Lac-Saint-Jean",267    "rimouski": "Bas-Saint-Laurent", "riviere-du-loup": "Bas-Saint-Laurent",268    "gaspe": "Gaspésie–Îles-de-la-Madeleine",269    "rouyn-noranda": "Abitibi-Témiscamingue", "val-d'or": "Abitibi-Témiscamingue",270    "drummondville": "Centre-du-Québec", "victoriaville": "Centre-du-Québec",271    "longueuil": "Montérégie", "brossard": "Montérégie",272    "saint-jean-sur-richelieu": "Montérégie",273    "blainville": "Laurentides", "pointe-calumet": "Laurentides",274    "saint-jerome": "Laurentides", "mont-tremblant": "Laurentides",275    "terrebonne": "Lanaudière", "repentigny": "Lanaudière", "joliette": "Lanaudière",276    "sept-iles": "Côte-Nord", "baie-comeau": "Côte-Nord",277}278279280def map_region(location: str | None) -> tuple[str | None, str | None]:281    """Localisation publique (texte libre) → (région §7, ville) ou (None, None).282283    Prudence maximale : ne retourne une région que si la localisation déclarée284    est un lieu québécois reconnu sans ambiguïté. « Québec » seul = la province.285    """286    if not location:287        return None, None288    loc = strip_accents(location).lower().strip()289    # tronquer les précisions entre parenthèses SAUF « Québec (ville) »290    base = re.sub(r"\s*\(.*?\)\s*", " ", loc).strip().rstrip(",")291    if "quebec (ville)" in loc or base in ("ville de quebec",):292        return "Capitale-Nationale", "Québec"293    # retirer le suffixe province (« Blainville, Québec » → « blainville »)294    base = re.sub(r",?\s*(quebec|qc|canada)\s*$", "", base).strip().rstrip(",")295    if not base or base == "quebec":296        return None, None  # province seule → région inconnue297    for city, region in _CITY_REGION.items():298        if base == city or base.startswith(city + ",") or base.endswith(" " + city):299            return region, location.split(",")[0].split("(")[0].strip()300    if base in {strip_accents(r).lower() for r in REGIONS}:301        for r in REGIONS:302            if strip_accents(r).lower() == base:303                return r, None304    return None, None305306307def parse_count(raw) -> int | None:308    """« 1,2 M », « 12.3k », « 4 567 abonnés », 4567 → entier (None si inconnu)."""309    if raw is None:310        return None311    if isinstance(raw, (int, float)):312        return int(raw)313    if str(raw).strip().startswith("@"):314        return None  # un handle (« @nom5b ») n'est pas un compteur315    # frontières de mot obligatoires — sinon « tw5bu » se lirait « 5 milliards »316    m = re.search(r"(?<![\w@.])([\d][\d\s .,]*)\s*([kKmMbB])?(?![\w])", str(raw))317    if not m:318        return None319    num = m.group(1).replace(" ", "").replace(" ", "")320    # « 1.234.567 » / « 1,234,567 » = séparateurs de milliers ; « 1,2 »/« 1.2 » = décimal321    if num.count(",") > 1 or (num.count(",") == 1 and len(num.split(",")[1]) == 3322                              and not m.group(2)):323        num = num.replace(",", "")324    if num.count(".") > 1 or (num.count(".") == 1 and len(num.split(".")[1]) == 3325                              and not m.group(2)):326        num = num.replace(".", "")327    num = num.replace(",", ".")328    try:329        value = float(num)330    except ValueError:331        return None332    mult = {"k": 1e3, "m": 1e6, "b": 1e9}.get((m.group(2) or "").lower(), 1)333    return int(value * mult)334335336def audience_tier(followers: int | None) -> str:337    """Taille d'audience (§6.3), basée sur l'audience de la plateforme principale."""338    if not followers or followers <= 0:339        return "nano"340    if followers < 10_000:341        return "nano"342    if followers < 100_000:343        return "micro"344    if followers < 1_000_000:345        return "macro"346    return "mega"347348349def infer_creator_type(platforms: list[str], niche: str) -> str:350    """Type de créateur par défaut selon sa plateforme dominante et sa niche."""351    first = platforms[0] if platforms else ""352    if first == "youtube":353        return "youtubeur"354    if first in ("twitch", "kick"):355        return "streamer"356    if first == "tiktok":357        return "createur-tiktok"358    if first in ("substack", "site-web"):359        return "createur-ecrit"360    if first == "podcast":361        return "podcasteur"362    if niche == "humour":363        return "humoriste"364    if niche == "musique":365        return "musicien"366    return "influenceur"367368369def guess_language(*texts: str) -> str:370    """Langue de création (§6.4) à partir des textes publics. Défaut : fr (Québec)."""371    blob = strip_accents(" ".join(t for t in texts if t)).lower()372    if not blob:373        return "fr"374    if re.search(r"\b(bilingue|bilingual|fr/en|en/fr)\b", blob):375        return "bilingue"376    if re.search(r"\b(contenu|content)\s+(quotidien\s+)?(en\s+)?anglais\b", blob) \377            or re.search(r"\benglish(-|\s)?(only|content|speaking)\b", blob):378        return "en"379    return "fr"380