# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/snowball.py # Desc: Connecteur DÉCOUVERTE par boule de neige — exploite les profils # reliés (`related_profiles`) captés par l'enrichissement Instagram # (instagram-apify) pour créer de nouvelles fiches. FILTRE QC # OBLIGATOIRE (marqueur québécois dans le nom/handle) : sans lui, la # proximité algorithmique d'Instagram ramène les vedettes mondiales # (constaté 2026-08-20 : cristiano, selenagomez, ferrari, nasa…). # Aucune requête réseau (lit la base). # ============================================================================== """Découverte par boule de neige sur les profils reliés Instagram. Déclaré `enrichment` pour recevoir la liste des créateurs existants, mais il RETOURNE de NOUVELLES fiches (le pipeline sync_source les ajoute par uid). On lit `account.metrics["related_profiles"]` (posé par instagram-apify), on écarte les handles déjà connus, et on crée une graine par nouveau compte suggéré. GARDE-FOU QC (2026-08-20) : les suggestions d'Instagram débordent largement du Québec (stars mondiales, marques, comptes US/BR) — 3 767 fiches hors-périmètre ajoutées en un passage avant ce filtre. On n'émet donc QUE si le nom public ou le handle porte un marqueur québécois (`is_quebec`). Périmètre (§1-2) : Créa-Ka n'agrège que des créateurs QUÉBÉCOIS — mieux vaut manquer un profil QC sans marqueur que d'inonder l'annuaire de vedettes américaines (§3 travail de moine). Signal `mention` (0.60) : à confirmer par l'enrichissement (§12.1). """ from __future__ import annotations import re from ..identity import account from ..normalize import canonical_url, strip_accents from ..schema import Creator from .base import BaseConnector from .youtube_recherche import is_quebec # Comptes QC qui ne sont PAS des créateurs (revue manuelle du 2026-08-20 : # 95 des 101 survivantes du filtre QC étaient des villes, offices de tourisme, # ministères, partis, commerces, festivals, médias) — on les bloque à la # source pour ne pas re-polluer l'annuaire à chaque vague. _NON_CREATEUR = re.compile( r"ville|tourisme|musee|festival|fondation|ymca|universite|uqam|" r"umontreal|ulaval|cegep|bibliothe|librairie|boutique|clinique|" r"auberge|hotel|ministere|gouv|transport|elections|loto|\bparti\b|" r"liberal|solidaire|conservateur|justepourrire|telequebec|magazine|" r"societe|association|federation") def _est_organisation(text: str) -> bool: return bool(_NON_CREATEUR.search(strip_accents(text or "").lower())) class SnowballConnector(BaseConnector): source_id = "snowball-ig" kind = "enrichment" max_new = 4000 # garde-fou par vague def enrich(self, creators: list[Creator]) -> list[Creator]: known: set[str] = set() for cr in creators: for a in cr.platforms: if a.platform == "instagram": known.add(a.handle) new: list[Creator] = [] emitted: set[str] = set() for cr in creators: ig = next((a for a in cr.platforms if a.platform == "instagram"), None) if ig is None: continue for rel in (ig.metrics or {}).get("related_profiles", []) or []: handle = (rel.get("username") if isinstance(rel, dict) else rel) handle = (handle or "").strip().lstrip("@").lower() if (not handle or rel and isinstance(rel, dict) and rel.get("is_private")): continue if handle in known or handle in emitted: continue name = (rel.get("full_name") if isinstance(rel, dict) else None) or "" # périmètre QC obligatoire : marqueur québécois dans le nom # public ou le handle, sinon on n'émet PAS (§1-2) ; et on # écarte les organismes/marques/médias QC (pas des créateurs) if not is_quebec(f"{name} {handle}"): continue if _est_organisation(f"{name} {handle}"): continue emitted.add(handle) try: acc = account("instagram", handle, "mention", url=canonical_url("instagram", handle), verified=(rel.get("is_verified") if isinstance(rel, dict) else None)).finalize() except Exception: continue new.append(Creator( source=self.source_id, external_id=f"instagram:{handle}", display_name=name or handle, platforms=[acc], notes="découvert par boule de neige (profils reliés IG)", )) if len(new) >= self.max_new: return new return new