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%

feat: initial commit

Simon-Pierre Boucher committed 1 mo ago (Aug 17, 2026)

46 changed files +16,247 −0

added .env.example +13 −0
@@ -0,0 +1,13 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: .env.example
4 +# Desc: Variables d'environnement (copier vers .env — JAMAIS commité, §16)
5 +# ==============================================================================
6 +# Scrapfly — profils Instagram/TikTok (palier 4, à minimiser §10)
7 +SCRAPFLY_KEY=
8 +# Firecrawl — repli pages link-in-bio / listes publiques
9 +FIRECRAWL_API_KEY=
10 +# API officielles (privilégiées §10)
11 +YOUTUBE_API_KEY=
12 +TWITCH_CLIENT_ID=
13 +TWITCH_CLIENT_SECRET=
added .gitignore +12 −0
@@ -0,0 +1,12 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: .gitignore
4 +# Desc: Exclusions git — secrets, base de données, caches
5 +# ==============================================================================
6 +.env
7 +__pycache__/
8 +*.pyc
9 +data/creaka.db*
10 +data/optout_requests.jsonl
11 +.pytest_cache/
12 +.venv/
added CLAUDE.md +519 −0
@@ -0,0 +1,519 @@
1 +<!--
2 +================================================================================
3 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 + File: CLAUDE.md
5 + Project: Créa·Ka — Agrégateur des créateurs de contenu & influenceurs québécois
6 +================================================================================
7 +-->
8 +
9 +# CLAUDE.md — Créa·Ka
10 +
11 +> **Agrégateur exhaustif des créateurs de contenu et influenceurs québécois,
12 +> avec liens vers TOUTES leurs plateformes.**
13 +> Chaque créateur, tous ses comptes (Instagram, TikTok, YouTube, Twitch, X,
14 +> Facebook, Snapchat, Kick, Substack, Patreon…), regroupés, cherchables et
15 +> découvrables par niche, région, langue et taille d'audience.
16 +
17 +Ce fichier est la source de vérité pour tout agent Claude (ou humain) qui travaille
18 +sur ce dépôt. Lis-le **en entier** avant d'écrire une seule ligne de code.
19 +
20 +---
21 +
22 +## 0. À LIRE EN PREMIER — Règles non négociables
23 +
24 +1. **En-tête d'auteur obligatoire.** **Chaque fichier** du dépôt (code, config,
25 + doc, script) doit commencer par un en-tête mentionnant l'auteur :
26 + `Simon-Pierre Boucher <contact@spboucher.ai>`. Voir la **section 17** pour le
27 + format exact. Aucun fichier ne doit être commité sans cet en-tête.
28 +
29 +2. **Inspire-toi du dépôt Lou·Ka.** Avant de concevoir ou d'écrire **le moindre
30 + connecteur**, va lire le **dépôt de Lou·Ka** (le site jumeau, agrégateur de
31 + logements). Ses connecteurs sont le patron de référence : structure des modules,
32 + extraction (Firecrawl / Scrapfly), normalisation, déduplication, gestion des
33 + mises à jour, planification. **Ne réinvente rien** : reprends les mêmes
34 + conventions, la même architecture, les mêmes utilitaires partagés. Créa·Ka doit
35 + être cohérent avec l'écosystème `·Ka`. Voir **section 3**.
36 +
37 +3. **Travail de moine.** L'agrégation est minutieuse, créateur par créateur,
38 + plateforme par plateforme, sans rien précipiter. Un mauvais rattachement de
39 + compte (attribuer à un créateur un profil qui n'est pas le sien) est une faute
40 + grave. Mieux vaut un lien vérifié de moins qu'un lien erroné.
41 +
42 +4. **Vérifie l'existant avant d'ajouter.** Avant d'intégrer un créateur ou une
43 + source, confirme qu'il/elle n'est **pas déjà couvert(e)** (index créateurs,
44 + registre des sources — sections 9 et 12). Un créateur = **une fiche canonique**,
45 + quelles que soient les plateformes et sources qui l'alimentent.
46 +
47 +5. **Chaque plateforme a son connecteur sur mesure**, mais les sources qui
48 + partagent une même mécanique (API d'une plateforme, agrégateurs de liens type
49 + Linktree) sont couvertes par un **connecteur générique templatisé** réutilisable.
50 + Voir **section 8**.
51 +
52 +6. **VIE PRIVÉE & ÉTHIQUE — règle sacrée.** Créa·Ka n'agrège **que des profils
53 + publics et professionnels** de créateurs qui se présentent publiquement comme
54 + tels. **Jamais** de données privées, de coordonnées personnelles non publiées,
55 + ni de compilation intrusive. Mécanisme d'**opt-out** obligatoire. Prudence
56 + extrême avec les **créateurs mineurs**. Voir **section 15** — cette section prime
57 + sur toute autre considération de complétude ou de volume.
58 +
59 +---
60 +
61 +## 1. Mission
62 +
63 +Rendre visible, en un seul endroit, **l'ensemble des créateurs de contenu et
64 +influenceurs québécois**, avec **un lien direct vers chacune de leurs plateformes**,
65 +et permettre de les **découvrir** par niche, région, langue et taille d'audience.
66 +
67 +En clair : un croisement entre un **annuaire** (recherche et découverte) et un
68 +**« Linktree collectif »** (tous les comptes d'un créateur au même endroit),
69 +centré sur le Québec.
70 +
71 +### Ce qui nous distingue
72 +
73 +Les bases d'influenceurs existantes (Heepsy, Upfluence, HypeAuditor) sont
74 +**payantes, généralistes et anglophones**, pensées pour les marques. Les « tops
75 +influenceurs » des médias québécois sont **ponctuels et non exhaustifs**. Personne
76 +n'offre un **annuaire public, québécois, exhaustif et cross-plateforme**. C'est le
77 +créneau de Créa·Ka.
78 +
79 +---
80 +
81 +## 2. Périmètre — TOUS les créateurs québécois
82 +
83 +Périmètre maximaliste. Si la personne crée du contenu publiquement et a une audience
84 +au Québec, elle a sa place :
85 +
86 +- **Influenceurs** (Instagram, TikTok, Snapchat) : mode, beauté, lifestyle, famille.
87 +- **YouTubeurs** : vlog, humour, gaming, tech, éducation, cuisine.
88 +- **Streamers** : Twitch, Kick, YouTube Live (gaming, IRL, just chatting).
89 +- **Créateurs TikTok** toutes niches.
90 +- **Podcasteurs** et créateurs audio.
91 +- **Créateurs écrits** : Substack, blogues, infolettres.
92 +- **Créateurs sur abonnement** : Patreon (contenu grand public/professionnel).
93 +- **Créateurs de niche** : sport, plein air, finance, humour, musique, arts,
94 + cuisine, parentalité, tech, jeux vidéo, etc.
95 +- **Micro et nano-créateurs** locaux (souvent ignorés des bases existantes).
96 +
97 +> Règle d'or : en cas de doute sur l'inclusion, **on inclut** et on catégorise —
98 +> **sauf** si la règle de vie privée/éthique (section 15) s'y oppose.
99 +
100 +---
101 +
102 +## 3. Dépôts de référence — étudier Lou·Ka AVANT de coder
103 +
104 +Créa·Ka fait partie de la famille `·Ka`. **Le dépôt de Lou·Ka est la référence
105 +canonique pour l'architecture des connecteurs.** Avant tout développement :
106 +
107 +1. **Ouvrir le dépôt de Lou·Ka** et localiser le dossier des connecteurs
108 + (typiquement `connectors/`, `scrapers/` ou `sources/`).
109 +2. **Lire 2 ou 3 connecteurs complets** pour comprendre : structure d'un module,
110 + extraction (Firecrawl / Scrapfly, endpoints JSON), normalisation, déduplication,
111 + mises à jour, planification (cadence, retries, backoff), erreurs, logging.
112 +3. **Réutiliser** les utilitaires partagés de Lou·Ka (clients Firecrawl/Scrapfly,
113 + helpers de normalisation, déduplication, géocodage) plutôt que d'en réécrire.
114 +4. **Calquer** conventions de nommage, structure des dossiers et style de code.
115 +
116 +> Adaptation propre à Créa·Ka : là où Lou·Ka normalise une **annonce**, Créa·Ka
117 +> normalise un **créateur + N comptes de plateformes rattachés**. Le défi central
118 +> n'est pas le volume par source mais la **résolution d'identité** : rattacher les
119 +> bons comptes à la bonne personne (section 12).
120 +
121 +> Si tu ne trouves pas le dépôt Lou·Ka ou l'accès aux connecteurs, **arrête-toi et
122 +> demande** plutôt que d'inventer une architecture divergente.
123 +
124 +Écosystème complet, pour contexte :
125 +
126 +| Site | Domaine | Agrège |
127 +|--------------|------------------|----------------------------------------------|
128 +| Lou·Ka | lou-ka.com | Logements à louer |
129 +| Immo·Ka | immo-ka.com | Propriétés à vendre |
130 +| Auto·Ka | auto-ka.com | Véhicules usagés |
131 +| Food·Ka | food-ka.com | Prix d'épicerie & circulaires |
132 +| Fabri·Ka | fabri-ka.com | Produits québécois |
133 +| Sorti·Ka | sorti-ka.com | Sorties & événements |
134 +| Resto·Ka | resto-ka.com | Restaurants, menus & prix |
135 +| **Créa·Ka** | **crea-ka.com** | **Créateurs & influenceurs (ce dépôt)** |
136 +
137 +---
138 +
139 +## 4. Architecture
140 +
141 +Pipeline `·Ka`, adapté aux créateurs (l'étape clé en plus : **résolution d'identité**) :
142 +
143 +```
144 + ┌────────────┐ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ ┌────────┐
145 + │ DÉCOUVERTE │──▶│ EXTRACTION │──▶│ RÉSOLUTION │──▶│ NORMALISATION│──▶│ DÉDUP. │──▶│STOCKAGE│
146 + │ (trouver │ │ FC/Scrapfly│ │ D'IDENTITÉ │ │ (schéma │ │ (créateur│ │ (DB) │
147 + │ créateurs)│ │ + APIs │ │ (lier comptes)│ │ commun) │ │ unique) │ │ │
148 + └────────────┘ └────────────┘ └──────────────┘ └──────────────┘ └──────────┘ └───┬────┘
149 + │
150 + ┌──────────▼──────────┐
151 + │ API / RECHERCHE / UI │
152 + │ (annuaire + liens) │
153 + └─────────────────────┘
154 +```
155 +
156 +- **Découverte** : trouver les créateurs québécois (hashtags, géo, listes, agences).
157 +- **Extraction** : enrichir chaque profil (bio, abonnés, liens) via API ou scraping.
158 +- **Résolution d'identité** : rattacher tous les comptes d'une même personne
159 + (souvent via sa page de liens type Linktree).
160 +- **Normalisation / Déduplication / Stockage** : une fiche créateur canonique.
161 +- **API / UI** : recherche et découverte par niche, région, langue, taille.
162 +
163 +---
164 +
165 +## 5. Modèle de données — schéma Créateur + Plateformes
166 +
167 +```jsonc
168 +{
169 + "id": "string", // * id interne canonique
170 + "source_ids": ["linktree:handle"], // * id(s) d'origine par source
171 + "display_name": "string", // * nom public / nom de scène
172 + "legal_name": "string|null", // seulement si rendu public par le créateur
173 + "bio": "string", // bio publique agrégée / résumée
174 + "niches": ["humour", "gaming"], // * taxonomie (section 6)
175 + "creator_type": "streamer", // influenceur|youtubeur|streamer|podcasteur|...
176 + "region": "string", // une des 17 régions si connu (section 7)
177 + "city": "string|null",
178 + "languages": ["fr"], // fr | en | bilingue
179 + "primary_platform": "youtube", // * plateforme principale
180 + "platforms": [ // * TOUS les comptes rattachés
181 + {
182 + "platform": "youtube", // * (section 6.2)
183 + "handle": "@nom", // *
184 + "url": "https://youtube.com/@nom",// *
185 + "followers": 125000, // abonnés (peut être null)
186 + "verified": true,
187 + "confidence": 0.98, // * confiance du rattachement (section 12)
188 + "last_checked": "ISO-8601" // *
189 + }
190 + ],
191 + "link_in_bio_url": "https://linktr.ee/nom", // page de liens source, si trouvée
192 + "total_reach": 350000, // somme dédupliquée des audiences (indicatif)
193 + "audience_tier": "macro", // section 6.3
194 + "business_contact": "string|null", // courriel pro SEULEMENT si public (section 15)
195 + "agency": "string|null", // agence / management si public
196 + "is_minor": false, // * drapeau si le créateur est mineur (§15)
197 + "status": "active", // active | inactive | opted_out
198 + "first_seen": "ISO-8601", // *
199 + "last_seen": "ISO-8601", // *
200 + "updated_at": "ISO-8601" // *
201 +}
202 +```
203 +
204 +Règles :
205 +- `platforms` est le cœur : **tous** les comptes publics, chacun avec son `url` et
206 + son `confidence` de rattachement.
207 +- Ne jamais inscrire `legal_name`, `business_contact` ou toute coordonnée **non
208 + rendue publique** par le créateur lui-même (section 15).
209 +- `is_minor` : si détecté, appliquer le régime restreint de la section 15.
210 +
211 +---
212 +
213 +## 6. Taxonomies
214 +
215 +### 6.1 Niches
216 +`humour` · `mode` · `beaute` · `lifestyle` · `famille-parentalite` · `cuisine` ·
217 +`gaming` · `tech` · `sport-fitness` · `plein-air` · `voyage` · `musique` · `arts` ·
218 +`danse` · `education` · `finance-affaires` · `sante-mieux-etre` · `bouffe-resto` ·
219 +`actualite-opinion` · `autre`
220 +
221 +### 6.2 Plateformes (`platform`)
222 +`instagram` · `tiktok` · `youtube` · `twitch` · `kick` · `x` · `facebook` ·
223 +`snapchat` · `substack` · `patreon` · `linkedin` · `threads` · `podcast` ·
224 +`site-web` · `autre`
225 +
226 +### 6.3 Taille d'audience (`audience_tier`) — basée sur l'audience principale
227 +- `nano` (< 10k) · `micro` (10k–100k) · `macro` (100k–1M) · `mega` (> 1M).
228 +
229 +### 6.4 Langue
230 +`fr` · `en` · `bilingue`
231 +
232 +---
233 +
234 +## 7. Régions du Québec (quand connu)
235 +
236 +Bas-Saint-Laurent · Saguenay–Lac-Saint-Jean · Capitale-Nationale · Mauricie ·
237 +Estrie · Montréal · Outaouais · Abitibi-Témiscamingue · Côte-Nord ·
238 +Nord-du-Québec · Gaspésie–Îles-de-la-Madeleine · Chaudière-Appalaches · Laval ·
239 +Lanaudière · Laurentides · Montérégie · Centre-du-Québec.
240 +
241 +La région est souvent **inconnue ou non déclarée** pour un créateur — c'est
242 +acceptable (`region: null`). Ne **jamais** deviner ou inférer une localisation
243 +précise à partir de données privées. Se limiter à ce que le créateur déclare
244 +publiquement.
245 +
246 +---
247 +
248 +## 8. Philosophie des connecteurs
249 +
250 +- **Deux familles de connecteurs :**
251 + 1. **Découverte** — trouver de nouveaux créateurs québécois (hashtags, géo,
252 + listes médias, agences, palmarès).
253 + 2. **Enrichissement** — pour un handle donné, récupérer profil, abonnés, liens.
254 +- **Le levier nº 1 : les pages de liens (link-in-bio).** Linktree, Beacons,
255 + Linkin.bio, etc. exposent **d'un coup tous les comptes** d'un créateur → c'est la
256 + source la plus efficace pour remplir `platforms`. Un connecteur générique
257 + « link-in-bio » (templatisé par service) est prioritaire.
258 +- **Connecteurs templatisés par plateforme** pour l'enrichissement (un connecteur
259 + YouTube, un Twitch, etc.), à réutiliser pour tous les créateurs.
260 +- Interface commune (à calquer sur Lou·Ka) : `fetch()` → `normalize()` → `emit()`.
261 + Idempotent, incrémental quand c'est possible.
262 +- **Robustesse** : timeouts, retries + backoff, tolérance aux changements, alerte si
263 + un connecteur cesse de retourner des données (probable blocage).
264 +
265 +---
266 +
267 +## 9. Catalogue des sources à connecter (priorisé)
268 +
269 +> ⚠️ Vérifie l'index des créateurs et le registre des sources **avant** d'ajouter.
270 +
271 +### Palier 1 — Pages de liens (link-in-bio) : révèlent toutes les plateformes
272 +- **Linktree**, **Beacons**, **Linkin.bio (Later)**, **Milkshake**, **Campsite**,
273 + **Snipfeed**. Une seule page → tous les comptes du créateur. À templatiser.
274 +
275 +### Palier 2 — API officielles des plateformes (enrichissement fiable)
276 +- **YouTube Data API**, **Twitch API**, **X/Threads API** (selon accès), **TikTok**
277 + et **Instagram** (via API officielles/Graph quand disponibles). Respecter quotas
278 + et CGU. À privilégier sur le scraping.
279 +
280 +### Palier 3 — Découverte de créateurs québécois
281 +- **Hashtags & mots-clés** : #quebec, #mtl, #madeinquebec, #créateurquébécois, etc.
282 +- **Signaux de langue (français) et de lieu** déclarés publiquement.
283 +- **Listes et palmarès médias** (« influenceurs québécois »), articles de presse.
284 +- **Palmarès / galas** du milieu (récompenses de création numérique).
285 +- **Agences de talents / management** québécoises (rosters publics).
286 +
287 +### Palier 4 — Profils publics des plateformes (scraping encadré)
288 +- Pages de profil publiques (bio, liens, abonnés) quand l'API ne suffit pas —
289 + **Scrapfly** requis, **CGU à respecter**, voir section 15.
290 +
291 +### Palier 5 — Créateurs sur abonnement / écrits
292 +- **Substack**, **Patreon** (pages publiques : nom, thème, liens ; **pas** le
293 + contenu réservé ni les données d'abonnés).
294 +
295 +---
296 +
297 +## 10. Extraction — Firecrawl, Scrapfly & APIs
298 +
299 +- **API officielle de la plateforme** → toujours privilégié (YouTube, Twitch, etc.).
300 + Fiable, structuré, conforme.
301 +- **Parsing des pages link-in-bio** (Firecrawl) → extraire la liste des liens/comptes.
302 +- **Firecrawl** → pages publiques relativement ouvertes (agences, listes, blogues).
303 +- **Scrapfly** → plateformes hostiles au scraping (Instagram, TikTok) : anti-bot,
304 + rendu JS, rotation d'IP. À **minimiser** et n'utiliser que faute d'API, dans le
305 + respect des CGU (section 15).
306 +
307 +Chaque connecteur **documente** en tête son mode d'accès (API / Firecrawl / Scrapfly)
308 +et pourquoi. Réutiliser les clients partagés de Lou·Ka.
309 +
310 +---
311 +
312 +## 11. Normalisation
313 +
314 +- Nettoyer noms, bios, handles (emoji superflus, doublons, casse).
315 +- Mapper vers le **schéma Créateur + Plateformes** (section 5).
316 +- Classer **niches**, **type de créateur**, **langue**, **taille d'audience**.
317 +- Normaliser chaque `url` de plateforme (forme canonique par plateforme).
318 +- Rattacher une **région** uniquement si déclarée publiquement (sinon `null`).
319 +- Renseigner `confidence` de chaque rattachement de compte (section 12).
320 +- Conserver les **`source_ids`** préfixés (ex. `linktree:nom`, `youtube:UCxxxx`).
321 +
322 +---
323 +
324 +## 12. Résolution d'identité & déduplication — LE cœur du projet
325 +
326 +Le vrai défi n'est pas le volume, c'est de **rattacher les bons comptes à la bonne
327 +personne** et de **ne pas fusionner deux personnes distinctes**.
328 +
329 +### 12.1 Rattachement des comptes (identity resolution)
330 +Signaux, du plus fort au plus faible :
331 +1. **Page link-in-bio** listant explicitement les comptes → rattachement **fort**.
332 +2. **Liens croisés** : un profil qui pointe vers un autre (IG → TikTok en bio).
333 +3. **Handle identique** + même nom d'affichage + même photo sur plusieurs plateformes.
334 +4. **Mentions réciproques / mêmes liens externes**.
335 +- Chaque rattachement reçoit un score `confidence`. En dessous d'un seuil, marquer
336 + « à vérifier » plutôt que de rattacher à tort.
337 +
338 +### 12.2 Déduplication des créateurs
339 +- **Clé** : ensemble de comptes rattachés + nom d'affichage normalisé.
340 +- Deux fiches qui partagent au moins un compte à `confidence` élevée → fusion.
341 +- **Homonymes** : ne **jamais** fusionner sur le seul nom. Exiger un compte partagé.
342 +- À la fusion : union des `platforms`, meilleure source par champ, on **ne perd
343 + aucun** lien vérifié.
344 +
345 +S'inspirer de la déduplication de **Lou·Ka** (entités multi-sources), en gardant à
346 +l'esprit qu'ici une erreur de fusion mélange deux **personnes réelles** — donc
347 +**prudence maximale**.
348 +
349 +---
350 +
351 +## 13. Fraîcheur & états
352 +
353 +- **Abonnés / audience** changent constamment → rafraîchir et horodater
354 + (`last_checked` par plateforme).
355 +- **Comptes disparus / renommés** : marquer inactifs, conserver l'historique ; ne
356 + pas supprimer brutalement (politique de grâce via `last_seen`, comme Lou·Ka).
357 +- **Créateur inactif** (plus de publication) → `status: inactive`.
358 +- **Opt-out** : si un créateur demande le retrait → `status: opted_out`, fiche
359 + masquée et **non ré-agrégée** (voir section 15).
360 +
361 +---
362 +
363 +## 14. Cadence de rafraîchissement
364 +
365 +- Métriques d'audience des gros créateurs : **hebdomadaire**.
366 +- Enrichissement standard : **mensuel**.
367 +- Découverte de nouveaux créateurs : **continu / hebdomadaire** selon les sources.
368 +- Toujours mettre à jour `last_checked` et `last_seen` à chaque passage réussi.
369 +
370 +---
371 +
372 +## 15. Vie privée, éthique & conformité — PRIME SUR TOUT
373 +
374 +> Créa·Ka agrège des **personnes**. Cette section a **priorité absolue** sur la
375 +> complétude et le volume. En cas de doute, **on n'inclut pas** et on demande.
376 +
377 +- **Uniquement du public et du professionnel.** N'agréger que les profils publics de
378 + créateurs qui se présentent publiquement comme créateurs/influenceurs, et **que**
379 + les informations qu'ils rendent publiques (nom de scène, comptes, bio, liens,
380 + courriel *pro* affiché). **Jamais** d'adresse personnelle, de téléphone privé, de
381 + courriel personnel, ni de données déduites/privées.
382 +- **Pas de compilation intrusive.** Ne pas croiser des sources pour reconstituer des
383 + informations qu'une personne n'a pas choisi de rendre publiques.
384 +- **Opt-out obligatoire et respecté.** Fournir un mécanisme simple de retrait ;
385 + une fois `opted_out`, la fiche est masquée et **exclue des ré-agrégations futures**
386 + (liste de suppression persistante).
387 +- **Créateurs mineurs — prudence extrême.** Si `is_minor` est détecté (ou probable),
388 + appliquer un régime restreint : pas de données de contact, pas de métriques
389 + précises de localisation, et en cas de doute **exclure**. Ne jamais produire quoi
390 + que ce soit qui pourrait faciliter le ciblage ou l'identification d'un mineur.
391 +- **CGU & robots.txt** de chaque plateforme respectés. Privilégier les **API
392 + officielles** ; minimiser le scraping ; rate-limiting raisonnable ; identification
393 + honnête. Les plateformes (Instagram, TikTok) sont restrictives : ne pas
394 + industrialiser un accès contraire à leurs conditions — **demander en cas de doute**.
395 +- **Attribution & exactitude.** Lien vers la source ; ne republier que le nécessaire
396 + à la découverte ; corriger vite toute erreur de rattachement (une fausse
397 + association est un préjudice réel pour la personne).
398 +- **Loi 25 (protection des renseignements personnels au Québec).** Concevoir en
399 + tenant compte des obligations applicables aux renseignements personnels ; documenter
400 + les finalités, permettre l'accès/rectification/retrait.
401 +
402 +---
403 +
404 +## 16. Conventions de code
405 +
406 +- Suivre **les mêmes conventions que Lou·Ka** (langage, dossiers, nommage, linting,
407 + formatage). Cohérence > préférences personnelles.
408 +- Un connecteur = un module isolé, testable indépendamment.
409 +- Secrets (clés API des plateformes, Firecrawl/Scrapfly) via variables
410 + d'environnement / gestionnaire de secrets. Jamais en clair.
411 +- Logging structuré (source, nb de créateurs, nb de comptes rattachés, confiance
412 + moyenne, erreurs, durée).
413 +- Commits atomiques, messages clairs.
414 +
415 +---
416 +
417 +## 17. En-tête d'auteur — OBLIGATOIRE dans chaque fichier
418 +
419 +**Tout fichier** du dépôt doit débuter par un en-tête d'auteur. L'auteur est
420 +toujours **Simon-Pierre Boucher \<contact@spboucher.ai\>**.
421 +
422 +**Python / Shell / YAML / TOML / Dockerfile :**
423 +```python
424 +# ==============================================================================
425 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
426 +# File: <nom_du_fichier>
427 +# Desc: <brève description>
428 +# ==============================================================================
429 +```
430 +
431 +**JavaScript / TypeScript / Go / Rust / Java / C :**
432 +```javascript
433 +// ==============================================================================
434 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
435 +// File: <nom_du_fichier>
436 +// Desc: <brève description>
437 +// ==============================================================================
438 +```
439 +
440 +**Markdown / HTML / XML :**
441 +```html
442 +<!--
443 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
444 + File: <nom_du_fichier>
445 + Desc: <brève description>
446 +-->
447 +```
448 +
449 +**CSS / SCSS :**
450 +```css
451 +/*
452 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
453 + File: <nom_du_fichier>
454 +*/
455 +```
456 +
457 +Règles :
458 +- **Aucun** fichier commité sans cet en-tête.
459 +- Pour un connecteur, indiquer dans `Desc:` la **source/plateforme couverte**, le
460 + **mode d'accès** (API / Firecrawl / Scrapfly / link-in-bio) et s'il fait de la
461 + **découverte** ou de l'**enrichissement**.
462 +- Vérifier la présence de l'en-tête en revue de code (idéalement hook de pré-commit).
463 +
464 +---
465 +
466 +## 18. Tests & qualité
467 +
468 +- Chaque connecteur : **tests unitaires** de normalisation sur fixtures réelles
469 + (réponses API, pages link-in-bio, profils publics figés).
470 +- Tests de **résolution d'identité** : cas connus de comptes multi-plateformes +
471 + cas-pièges d'**homonymes** à ne pas fusionner.
472 +- **Validation de schéma** : toute fiche émise doit valider le schéma ; rejeter un
473 + compte sans `url`/`confidence`.
474 +- **Contrôle éthique automatisé** : vérifier qu'aucune donnée privée n'entre en base
475 + et que les fiches `opted_out` restent exclues.
476 +- **Monitoring** : alerte si une source cesse de retourner des données (blocage).
477 +
478 +---
479 +
480 +## 19. Checklist — ajouter un connecteur / créateur
481 +
482 +1. [ ] Lire les connecteurs de référence dans le **dépôt Lou·Ka** (section 3).
483 +2. [ ] Vérifier que la source/le créateur **n'est pas déjà couvert** (index/registre).
484 +3. [ ] Découverte ou enrichissement ? Identifier la **plateforme/template** réutilisable.
485 +4. [ ] Choisir le **mode d'accès** : API officielle > link-in-bio > Firecrawl >
486 + Scrapfly (section 10), dans le respect des CGU.
487 +5. [ ] Implémenter `fetch()` → `normalize()` → `emit()` (interface commune).
488 +6. [ ] Mapper le schéma créateur + plateformes ; renseigner `confidence` par compte.
489 +7. [ ] Brancher la **résolution d'identité** et la **déduplication** (section 12).
490 +8. [ ] Appliquer les **règles de vie privée/éthique** (section 15) : public seulement,
491 + opt-out respecté, régime mineurs, pas de données privées.
492 +9. [ ] Gérer **fraîcheur, comptes disparus, inactifs, opt-out** (section 13).
493 +10. [ ] Ajouter l'**en-tête d'auteur** (section 17).
494 +11. [ ] Écrire les **tests** + fixtures, dont identité et contrôle éthique (section 18).
495 +12. [ ] Enregistrer la source dans le **registre** avec sa cadence.
496 +13. [ ] Valider volume, confiance des rattachements et qualité sur un premier run.
497 +
498 +---
499 +
500 +## 20. Roadmap / MVP
501 +
502 +**MVP** : templatiser **un** connecteur **link-in-bio** (Linktree/Beacons) pour
503 +remplir `platforms` d'un coup, + les **API officielles** YouTube et Twitch pour
504 +l'enrichissement fiable. Découverte initiale via listes médias, agences et hashtags
505 +québécois. Résolution d'identité de base (link-in-bio + liens croisés). UI d'annuaire
506 +avec recherche/filtres par **niche, région, langue, plateforme, taille d'audience**,
507 +et **page créateur** listant tous ses comptes.
508 +
509 +**Ensuite** : Instagram/TikTok (API officielles en priorité), Substack/Patreon,
510 +métriques d'audience historisées, score de confiance affiné, opt-out en libre-service,
511 +et cross-linking avec les autres sites `·Ka`.
512 +
513 +**Nord stratégique** : être l'**annuaire public de référence** des créateurs
514 +québécois — exhaustif, cross-plateforme et respectueux de la vie privée — là où les
515 +bases existantes sont payantes, généralistes et anglophones.
516 +
517 +---
518 +
519 +*Fin de CLAUDE.md — Créa·Ka. Auteur : Simon-Pierre Boucher \<contact@spboucher.ai\>.*
added README.md +135 −0
@@ -0,0 +1,135 @@
1 +<!--
2 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 + File: README.md
4 + Desc: Créa-Ka — agrégateur des créateurs de contenu québécois
5 +-->
6 +<div align="center">
7 +
8 +# Créa·Ka
9 +
10 +**Tous les créateurs québécois. Tous leurs liens.**
11 +
12 +[![Créateurs](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.crea-ka.com%2Fapi%2Fstats&query=%24.creators&label=cr%C3%A9ateurs&color=5b34a8&style=for-the-badge)](https://www.crea-ka.com)
13 +[![Comptes reliés](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.crea-ka.com%2Fapi%2Fstats&query=%24.accounts&label=comptes%20reli%C3%A9s&color=c9a3ff&style=for-the-badge)](https://www.crea-ka.com/stats)
14 +[![Site](https://img.shields.io/website?url=https%3A%2F%2Fwww.crea-ka.com%2Fhealthz&label=crea-ka.com&style=for-the-badge)](https://www.crea-ka.com)
15 +
16 +[![python](https://img.shields.io/badge/python-3.14-3776ab)](https://www.python.org)
17 +[![backend](https://img.shields.io/badge/backend-FastAPI%20%2B%20SQLite%20WAL-141814)](creaka/web.py)
18 +[![frontend](https://img.shields.io/badge/frontend-SPA%20z%C3%A9ro%20build-c9a3ff)](frontend/src/index.template.html)
19 +[![tests](https://img.shields.io/badge/tests-25%20pass-1c5c41)](tests/)
20 +[![connecteurs](https://img.shields.io/badge/connecteurs-10-e8a33d)](creaka/connectors/)
21 +[![host](https://img.shields.io/badge/host-M3U96b%20%C2%B7%20PM2%20%C2%B7%20ngrok-4f8cff)](https://www.crea-ka.com)
22 +
23 +Annuaire public, québécois, exhaustif et **cross-plateforme** des créateurs de
24 +contenu — un croisement entre un annuaire cherchable et un « Linktree
25 +collectif », membre de la famille **Groupe KA**.
26 +
27 +**https://www.crea-ka.com**
28 +
29 +</div>
30 +
31 +---
32 +
33 +## Captures
34 +
35 +| Accueil (annuaire) | Page créateur |
36 +|---|---|
37 +| ![Accueil](docs/screenshots/accueil.png) | ![Créateur](docs/screenshots/createur.png) |
38 +
39 +| Statistiques | Mobile (tab bar Lou·Ka) |
40 +|---|---|
41 +| ![Stats](docs/screenshots/stats.png) | ![Mobile](docs/screenshots/mobile.png) |
42 +
43 +## Ce que fait Créa·Ka
44 +
45 +Chaque créateur québécois a **une fiche canonique** qui regroupe **tous ses
46 +comptes publics** — Instagram, TikTok, YouTube, Twitch, X, Facebook, Snapchat,
47 +Kick, Substack, Patreon, OnlyFans, balado… — chacun avec son **URL canonique**,
48 +ses **métriques** (abonnés, publications, j'aime, vues, épisodes) et son **score
49 +de confiance de rattachement**. L'annuaire se filtre par **niche** (20), **région**
50 +(17 régions administratives), **langue**, **plateforme** et **taille d'audience**
51 +(nano/micro/macro/méga). Chaque créateur a sa page dédiée `/createur/{id}`.
52 +
53 +## Architecture (calquée sur Lou·Ka)
54 +
55 +```
56 +DÉCOUVERTE → EXTRACTION → RÉSOLUTION D'IDENTITÉ → NORMALISATION → DÉDUP. → SQLite → API/UI
57 +```
58 +
59 +| Module | Rôle |
60 +|---|---|
61 +| `creaka/schema.py` | fiche `Creator` + `PlatformAccount`, validation stricte (compte sans url/confidence = rejeté) |
62 +| `creaka/identity.py` | signaux de rattachement §12.1 : `profil_source` 0.98 · `link_in_bio` 0.95 · `cross_link` 0.90 · `listes_medias`/`base_publique` 0.85 · `handle_match` 0.75 |
63 +| `creaka/dedup.py` | fusion **uniquement** sur compte fort partagé — jamais sur le nom (homonymes) |
64 +| `creaka/ethics.py` | purge des données privées, opt-out persistant, régime restreint des mineurs |
65 +| `creaka/db.py` | SQLite WAL : fiches canoniques + index des comptes + journal des syncs |
66 +| `creaka/web.py` | API FastAPI + frontend statique |
67 +
68 +### Connecteurs (registre auto-découvrant)
69 +
70 +| Connecteur | Famille | Accès | Signal |
71 +|---|---|---|---|
72 +| `listes-medias` | découverte | dataset local (palmarès médias publics croisés, 376 profils) | 0.85 |
73 +| `wikidata-qc` | découverte | SPARQL Wikidata (nés/résidant au QC, handles curés) | 0.85 |
74 +| `onlyqueb` | découverte | sitemap + JSON-LD (annuaire public, liens auto-déclarés) | 0.90 |
75 +| `youtube-recherche` | découverte | recherche publique YouTube, ~120 requêtes × marqueur QC obligatoire | 0.98 |
76 +| `balados-itunes` | découverte | **API officielle** iTunes/Apple Podcasts | 0.98 |
77 +| `instagram-profil` | enrichissement | Scrapfly (profil public : abonnés, bio, **liens de bio** → cross-link) | — |
78 +| `tiktok-profil` | enrichissement | Scrapfly (JSON d'hydratation public) | — |
79 +| `youtube` | enrichissement | API officielle v3 si clé, sinon page publique | — |
80 +| `twitch` | enrichissement | API officielle Helix (sauté sans clés) | — |
81 +| `link-in-bio` | enrichissement | Linktree/Beacons/Bio.link… — **le levier nº 1** : une page → tous les comptes | 0.95 |
82 +
83 +La couverture **s'accumule à chaque passage** (watch quotidien) : la découverte
84 +trouve de nouveaux créateurs, l'enrichissement rafraîchit les métriques et
85 +révèle les comptes croisés via les liens de bio et les pages link-in-bio.
86 +
87 +## API publique
88 +
89 +```
90 +GET /api/creators?q&niche&region&langue&plateforme&tier&sort&limit&offset
91 +GET /api/creators/{id} GET /api/stats GET /api/taxonomies
92 +GET /api/sources POST /api/optout GET /healthz
93 +```
94 +
95 +## Éthique — prime sur tout (CLAUDE.md §15)
96 +
97 +- **Profils publics et professionnels seulement** ; `ethics.scrub()` purge
98 + téléphones, adresses civiques et courriels personnels avant tout stockage.
99 +- **Opt-out immédiat et persistant** (`/retrait`) : fiche masquée sur-le-champ,
100 + liste de suppression `data/optout.json`, **jamais ré-agrégée**.
101 +- **Mineurs** : régime restreint automatique (année de naissance Wikidata) et
102 + **exclusion de l'annuaire public**.
103 +- **Score de confiance** sur chaque rattachement ; sous 0.50 → « à vérifier »,
104 + jamais publié. Mieux vaut un lien de moins qu'un lien erroné.
105 +- Loi 25 : finalités documentées, accès/rectification/retrait via `/retrait`.
106 +
107 +## Développement
108 +
109 +```bash
110 +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
111 +cp .env.example .env # SCRAPFLY_KEY, YOUTUBE_API_KEY…
112 +python3 -m pytest tests/ -q # 25 tests (identité, homonymes, éthique…)
113 +python3 run.py sync # découverte + enrichissement
114 +python3 run.py serve 8160 # API + frontend
115 +python3 scripts/build_frontend.py # regénère frontend/dist (icônes SVG injectées)
116 +python3 run.py optout "instagram:handle" # retrait manuel
117 +```
118 +
119 +## Déploiement
120 +
121 +Node **M3U96b** (`~/apps/crea-ka`) : PM2 `crea-ka-web` (:8160) +
122 +`crea-ka-sync` (watch 24 h) + `crea-ka-ngrok` (**www.crea-ka.com**).
123 +Redéploiement : `scripts/deploy-m3u96b.sh` · captures : `scripts/screenshots.sh`.
124 +
125 +---
126 +
127 +<div align="center">
128 +
129 +**Groupe KA** · [KA ID](https://www.groupe-ka.com) · [Lou-Ka](https://www.lou-ka.com) ·
130 +[Immo-Ka](https://www.immo-ka.com) · [Auto-Ka](https://www.auto-ka.com) ·
131 +[Food-Ka](https://www.food-ka.com) · [Fabri-Ka](https://www.fabri-ka.com)
132 +
133 +© Simon-Pierre Boucher — <contact@spboucher.ai>
134 +
135 +</div>
added creaka/__init__.py +13 −0
@@ -0,0 +1,13 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/__init__.py
4 +# Desc: Paquet Créa-Ka — agrégateur des créateurs de contenu québécois
5 +# (calqué sur l'architecture du dépôt Lou-Ka)
6 +# ==============================================================================
7 +"""Créa-Ka — annuaire public, québécois et cross-plateforme des créateurs.
8 +
9 +Pipeline : DÉCOUVERTE → EXTRACTION → RÉSOLUTION D'IDENTITÉ → NORMALISATION
10 +→ DÉDUPLICATION → STOCKAGE → API/UI. Voir CLAUDE.md (source de vérité).
11 +"""
12 +
13 +__version__ = "1.0.0"
added creaka/connectors/__init__.py +29 −0
@@ -0,0 +1,29 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/__init__.py
4 +# Desc: Registre AUTO-DÉCOUVRANT des connecteurs (calqué sur Lou-Ka) —
5 +# tout module contenant une sous-classe de BaseConnector avec un
6 +# source_id non vide est enregistré automatiquement
7 +# ==============================================================================
8 +from __future__ import annotations
9 +
10 +import importlib
11 +import pkgutil
12 +import sys
13 +
14 +from .base import BaseConnector
15 +
16 +CONNECTORS: dict[str, type[BaseConnector]] = {}
17 +
18 +for _mod in pkgutil.iter_modules(__path__):
19 + if _mod.name in ("base", "__init__"):
20 + continue
21 + try:
22 + module = importlib.import_module(f"{__name__}.{_mod.name}")
23 + except Exception as exc: # un connecteur cassé ne bloque pas les autres
24 + print(f"[crea-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr)
25 + continue
26 + for obj in vars(module).values():
27 + if (isinstance(obj, type) and issubclass(obj, BaseConnector)
28 + and obj is not BaseConnector and getattr(obj, "source_id", "")):
29 + CONNECTORS[obj.source_id] = obj
added creaka/connectors/balados_itunes.py +115 −0
@@ -0,0 +1,115 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/balados_itunes.py
4 +# Desc: Connecteur DÉCOUVERTE — balados québécois via l'API de recherche
5 +# iTunes/Apple Podcasts (OFFICIELLE, gratuite, sans clé). Palier 2-3
6 +# (§9). Filtre conservateur : marqueur québécois requis dans le nom du
7 +# balado ou de l'artiste.
8 +# ==============================================================================
9 +"""Découverte des podcasteurs québécois via l'API iTunes Search.
10 +
11 +`itunes.apple.com/search?media=podcast` retourne pour chaque balado : nom,
12 +artiste, pochette (artworkUrl600), genres, nb d'épisodes, flux RSS et page
13 +Apple Podcasts. Le balado est le compte SOURCE de la fiche → profil_source.
14 +"""
15 +from __future__ import annotations
16 +
17 +from ..identity import account
18 +from ..normalize import slugify
19 +from ..schema import Creator
20 +from .base import BaseConnector
21 +from .youtube_recherche import is_quebec
22 +
23 +SEARCH_URL = "https://itunes.apple.com/search"
24 +
25 +QUERIES = [
26 + "québécois", "québécoise", "balado québec", "podcast québec", "montréal",
27 + "humour québécois", "balado montréal", "saguenay", "gatineau", "sherbrooke",
28 + "trois-rivières", "ville de québec balado", "balado gaspésie", "mauricie",
29 + "balado estrie", "rimouski", "abitibi", "lévis balado", "laval balado",
30 + "balado", "podcast montréal", "balado hockey", "canadiens de montréal",
31 + "histoire du québec", "crime québec", "balado finance québec",
32 + "balado maman québec", "balado couple québec", "balado santé québec",
33 + "balado sport québec", "balado politique québec", "balado culture québec",
34 + "balado musique québec", "balado cinéma québec", "balado jeux vidéo québec",
35 + "balado plein air québec", "balado immobilier québec", "balado entrepreneur québec",
36 + "outaouais", "lanaudière", "laurentides balado", "montérégie",
37 + "côte-nord balado", "charlevoix balado", "beauce balado",
38 + "drummondville", "victoriaville balado", "granby balado", "chicoutimi",
39 + # longue traîne : villes + créneaux (nano/micro podcasteurs locaux)
40 + "longueuil", "terrebonne balado", "repentigny balado", "saint-jérôme",
41 + "saint-hyacinthe", "shawinigan", "rouyn-noranda", "val-d'or",
42 + "sept-îles", "baie-comeau", "alma balado", "matane", "rivière-du-loup",
43 + "sorel-tracy", "magog balado", "joliette balado", "châteauguay",
44 + "balado chasse québec", "balado pêche québec", "balado agriculture québec",
45 + "balado construction québec", "balado véhicule québec", "balado techno québec",
46 + "balado ados québec", "balado église québec", "balado histoire vraie québec",
47 + "balado humour absurde québec", "balado société québec", "balado femmes québec",
48 + "balado papa québec", "balado éducation québec", "balado science québec",
49 + "balado littérature québec", "balado poésie québec", "balado bd québec",
50 + "balado jeux de société québec", "balado vin québec", "balado bière québec",
51 +]
52 +
53 +_GENRE_NICHE = {
54 + "Comedy": "humour", "Food": "bouffe-resto", "Sports": "sport-fitness",
55 + "Music": "musique", "Business": "finance-affaires", "Investing": "finance-affaires",
56 + "Health & Fitness": "sante-mieux-etre", "Education": "education",
57 + "True Crime": "actualite-opinion", "News": "actualite-opinion",
58 + "Politics": "actualite-opinion", "Society & Culture": "actualite-opinion",
59 + "TV & Film": "actualite-opinion", "Leisure": "gaming",
60 + "Video Games": "gaming", "Technology": "tech", "Arts": "arts",
61 + "Parenting": "famille-parentalite", "Kids & Family": "famille-parentalite",
62 + "Fiction": "arts", "Religion & Spirituality": "sante-mieux-etre",
63 + "Science": "education", "History": "education", "Wilderness": "plein-air",
64 +}
65 +
66 +
67 +class BaladosItunesConnector(BaseConnector):
68 + source_id = "balados-itunes"
69 + kind = "discovery"
70 + request_delay = 1.5
71 +
72 + def fetch(self) -> list[Creator]:
73 + creators: list[Creator] = []
74 + seen: set[int] = set()
75 + for query in QUERIES:
76 + try:
77 + resp = self.get(SEARCH_URL, params={
78 + "media": "podcast", "term": query, "country": "CA",
79 + "limit": 200})
80 + results = resp.json().get("results") or []
81 + except Exception:
82 + continue
83 + for r in results:
84 + cid = r.get("collectionId")
85 + name = (r.get("collectionName") or "").strip()
86 + artist = (r.get("artistName") or "").strip()
87 + if not cid or cid in seen or not name:
88 + continue
89 + # filtre conservateur : marqueur QC dans le nom du balado/artiste
90 + if not is_quebec(f"{name} {artist}"):
91 + continue
92 + seen.add(cid)
93 + url = r.get("collectionViewUrl") or r.get("feedUrl") or ""
94 + if not url:
95 + continue
96 + acc = account("podcast", str(cid), "profil_source", url=url)
97 + acc.metrics = {k: v for k, v in {
98 + "episodes": r.get("trackCount"),
99 + "genre": r.get("primaryGenreName"),
100 + }.items() if v}
101 + niche = _GENRE_NICHE.get(r.get("primaryGenreName") or "",
102 + "actualite-opinion")
103 + creators.append(Creator(
104 + source=self.source_id,
105 + external_id=str(cid),
106 + display_name=name,
107 + bio=f"Balado de {artist}." if artist and artist != name else "",
108 + niches=[niche],
109 + creator_type="podcasteur",
110 + avatar_url=r.get("artworkUrl600") or r.get("artworkUrl100"),
111 + platforms=[acc],
112 + notes="Référencé sur Apple Podcasts (API de recherche officielle).",
113 + source_ids=[f"{self.source_id}:{cid}"],
114 + ))
115 + return creators
added creaka/connectors/base.py +130 −0
@@ -0,0 +1,130 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/base.py
4 +# Desc: Classe de base des connecteurs + backends de fetch (requests direct,
5 +# Firecrawl, Scrapfly) — calquée sur louka/connectors/base.py
6 +# ==============================================================================
7 +from __future__ import annotations
8 +
9 +import json
10 +import os
11 +import time
12 +
13 +import requests
14 +
15 +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
16 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
17 + "CreaKaBot/1.0 (+https://www.crea-ka.com/bot; contact@spboucher.ai)")
18 +
19 +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
20 +SCRAPFLY_API = "https://api.scrapfly.io/scrape"
21 +
22 +
23 +class BaseConnector:
24 + """Un connecteur = un adaptateur propre à une source (CLAUDE.md §8).
25 +
26 + Deux familles :
27 + - `kind = "discovery"` : `fetch()` retourne la liste de fiches `Creator`
28 + découvertes (listes médias, hashtags, agences…).
29 + - `kind = "enrichment"` : `enrich(creators)` reçoit les fiches existantes
30 + et retourne celles qu'il a enrichies (profil, abonnés, liens).
31 +
32 + Le pipeline (ingest.py) s'occupe du diff avec la base de données.
33 + Chaque connecteur documente en tête son MODE D'ACCÈS (API / Firecrawl /
34 + Scrapfly / link-in-bio) et sa famille (§10, §17).
35 + """
36 +
37 + source_id: str = ""
38 + kind: str = "discovery" # discovery | enrichment
39 + request_delay: float = 0.8 # politesse entre requêtes (§15 rate-limit)
40 + timeout: int = 30
41 +
42 + def __init__(self) -> None:
43 + self.session = requests.Session()
44 + self.session.headers["User-Agent"] = USER_AGENT
45 + self._last_request = 0.0
46 +
47 + # -- backends -------------------------------------------------------------
48 + def _throttle(self) -> None:
49 + wait = self.request_delay - (time.time() - self._last_request)
50 + if wait > 0:
51 + time.sleep(wait)
52 +
53 + def get(self, url: str, **kw) -> requests.Response:
54 + """GET direct avec throttling poli."""
55 + self._throttle()
56 + resp = self.session.get(url, timeout=self.timeout, **kw)
57 + self._last_request = time.time()
58 + resp.raise_for_status()
59 + return resp
60 +
61 + def post(self, url: str, **kw) -> requests.Response:
62 + """POST direct avec throttling poli (APIs officielles)."""
63 + self._throttle()
64 + resp = self.session.post(url, timeout=self.timeout, **kw)
65 + self._last_request = time.time()
66 + resp.raise_for_status()
67 + return resp
68 +
69 + def get_rendered(self, url: str) -> str:
70 + """HTML rendu (JavaScript exécuté) via Firecrawl.
71 +
72 + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). Pour les
73 + pages publiques relativement ouvertes (agences, listes, blogues).
74 + """
75 + key = os.environ.get("FIRECRAWL_API_KEY")
76 + if not key:
77 + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
78 + resp = requests.post(
79 + FIRECRAWL_API,
80 + json={"url": url, "formats": ["html"]},
81 + headers={"Authorization": f"Bearer {key}"},
82 + timeout=90,
83 + )
84 + resp.raise_for_status()
85 + data = resp.json()
86 + return (data.get("data") or {}).get("html", "")
87 +
88 + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
89 + rendering_wait: int = 0, country: str = "ca",
90 + wait_for_selector: str | None = None,
91 + headers: dict | None = None) -> dict:
92 + """Appel Scrapfly — retourne le dict `result` (content, status_code…).
93 +
94 + À MINIMISER (§10) : uniquement les plateformes hostiles au scraping,
95 + faute d'API officielle, dans le respect des CGU (§15).
96 + """
97 + key = os.environ.get("SCRAPFLY_KEY")
98 + if not key:
99 + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
100 + params: dict = {"key": key, "url": url, "country": country}
101 + if asp:
102 + params["asp"] = "true"
103 + if render_js:
104 + params["render_js"] = "true"
105 + if rendering_wait:
106 + params["rendering_wait"] = rendering_wait
107 + if wait_for_selector:
108 + params["wait_for_selector"] = wait_for_selector
109 + if headers:
110 + for k, v in headers.items():
111 + params[f"headers[{k}]"] = v
112 + self._throttle()
113 + resp = requests.get(SCRAPFLY_API, params=params, timeout=120)
114 + self._last_request = time.time()
115 + resp.raise_for_status()
116 + return resp.json().get("result") or {}
117 +
118 + # -- interface ------------------------------------------------------------
119 + def fetch(self) -> list:
120 + """Découverte : liste complète des fiches `Creator` de la source."""
121 + raise NotImplementedError
122 +
123 + def enrich(self, creators: list) -> list:
124 + """Enrichissement : retourne les fiches modifiées (sous-ensemble)."""
125 + raise NotImplementedError
126 +
127 +
128 +def load_json(text: str):
129 + """json.loads tolérant (BOM, espaces)."""
130 + return json.loads(text.strip().lstrip(""))
added creaka/connectors/instagram.py +107 −0
@@ -0,0 +1,107 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/instagram.py
4 +# Desc: Connecteur ENRICHISSEMENT Instagram — profil public par créateur :
5 +# abonnés, badge vérifié, bio, liens de bio (→ découverte croisée des
6 +# autres plateformes + page link-in-bio). Mode d'accès : Scrapfly
7 +# (plateforme hostile au scraping, pas d'API publique de lecture) —
8 +# palier 4 du catalogue (§9), CGU/rate-limit respectés (§15).
9 +# ==============================================================================
10 +"""Enrichissement Instagram, créateur par créateur.
11 +
12 +Pour chaque fiche ayant un compte Instagram rattaché, on lit le profil PUBLIC
13 +via l'endpoint web_profile_info (JSON servi par le web public d'Instagram),
14 +à travers Scrapfly (anti-bot). On en tire :
15 +- followers / verified / bio du compte Instagram (métriques, §13-14) ;
16 +- `external_url` + `bio_links` : si c'est une page link-in-bio connue →
17 + `link_in_bio_url` (traitée ensuite par le connecteur link-in-bio) ; si c'est
18 + un autre réseau → nouveau compte rattaché avec signal `cross_link` (0.90,
19 + §12.1 : le créateur pointe LUI-MÊME vers cet autre compte).
20 +
21 +Uniquement des profils publics ; un profil privé/introuvable est ignoré.
22 +"""
23 +from __future__ import annotations
24 +
25 +import json
26 +
27 +from ..identity import account, merge_accounts
28 +from ..normalize import parse_count, platform_from_url
29 +from ..schema import Creator, now_iso
30 +from .base import BaseConnector
31 +from .linkinbio import is_supported as is_linkinbio
32 +
33 +PROFILE_API = ("https://i.instagram.com/api/v1/users/web_profile_info/"
34 + "?username={h}")
35 +IG_APP_ID = "936619743392459" # app id du web public instagram.com
36 +
37 +
38 +class InstagramConnector(BaseConnector):
39 + source_id = "instagram-profil"
40 + kind = "enrichment"
41 + request_delay = 1.5
42 + max_profiles = 700 # garde-fou par passage
43 +
44 + def fetch_profile(self, handle: str) -> dict | None:
45 + """Profil public JSON d'un handle Instagram (None si privé/introuvable)."""
46 + result = self.scrapfly(PROFILE_API.format(h=handle), render_js=False,
47 + headers={"x-ig-app-id": IG_APP_ID,
48 + "accept": "application/json"})
49 + if result.get("status_code") != 200:
50 + return None
51 + try:
52 + data = json.loads(result.get("content") or "{}")
53 + except Exception:
54 + return None
55 + user = ((data.get("data") or {}).get("user")) or None
56 + if not user or user.get("is_private"):
57 + return None
58 + return user
59 +
60 + def enrich(self, creators: list[Creator]) -> list[Creator]:
61 + enriched: list[Creator] = []
62 + fetched = 0
63 + for cr in creators:
64 + ig = next((a for a in cr.platforms if a.platform == "instagram"), None)
65 + if ig is None or fetched >= self.max_profiles:
66 + continue
67 + try:
68 + user = self.fetch_profile(ig.handle)
69 + except Exception:
70 + continue
71 + fetched += 1
72 + if not user:
73 + continue
74 + # métriques du compte lui-même (étendues : la référence, pas le minimum)
75 + ig.followers = parse_count(
76 + (user.get("edge_followed_by") or {}).get("count")) or ig.followers
77 + ig.verified = bool(user.get("is_verified"))
78 + ig.last_checked = now_iso()
79 + extra = {
80 + "following": (user.get("edge_follow") or {}).get("count"),
81 + "posts": (user.get("edge_owner_to_timeline_media") or {}).get("count"),
82 + "category": user.get("category_name"),
83 + "is_business": user.get("is_business_account"),
84 + }
85 + ig.metrics.update({k: v for k, v in extra.items() if v not in (None, "")})
86 + if not cr.avatar_url and user.get("profile_pic_url_hd"):
87 + cr.avatar_url = user["profile_pic_url_hd"]
88 + if not cr.bio and user.get("biography"):
89 + cr.bio = user["biography"]
90 + # liens de bio → découverte croisée (§12.1 signal 2)
91 + new_accounts = []
92 + links = [user.get("external_url") or ""]
93 + links += [(b.get("url") or "") for b in (user.get("bio_links") or [])]
94 + for url in links:
95 + if not url:
96 + continue
97 + if is_linkinbio(url) and not cr.link_in_bio_url:
98 + cr.link_in_bio_url = url
99 + continue
100 + hit = platform_from_url(url)
101 + if hit and hit[0] != "instagram":
102 + new_accounts.append(
103 + account(hit[0], hit[1], "cross_link", url=url).finalize())
104 + if new_accounts:
105 + cr.platforms = merge_accounts(cr.platforms, new_accounts)
106 + enriched.append(cr)
107 + return enriched
added creaka/connectors/linkinbio.py +132 −0
@@ -0,0 +1,132 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/linkinbio.py
4 +# Desc: Connecteur ENRICHISSEMENT générique « link-in-bio » (templatisé) —
5 +# Linktree, Beacons, Bio.link, Milkshake, Campsite, Snipfeed.
6 +# Mode d'accès : requêtes directes (pages publiques SSR), repli
7 +# Firecrawl. Palier 1 du catalogue (§9) — le levier nº 1 : une page
8 +# de liens révèle D'UN COUP tous les comptes d'un créateur.
9 +# ==============================================================================
10 +"""Enrichissement par pages de liens (link-in-bio).
11 +
12 +Pour chaque créateur dont `link_in_bio_url` est connue (déclarée par le
13 +créateur dans une de ses bios — jamais devinée), on récupère la page et on
14 +en extrait les comptes de plateformes. Signal d'identité : `link_in_bio`
15 +(le créateur liste lui-même ses comptes) → confidence 0.95, le plus fort
16 +(§12.1).
17 +
18 +Templatisé : chaque service a son extracteur ; le repli générique parcourt
19 +les ancres de la page et classe chaque URL via normalize.platform_from_url.
20 +"""
21 +from __future__ import annotations
22 +
23 +import json
24 +import re
25 +
26 +from ..identity import account, merge_accounts
27 +from ..normalize import platform_from_url
28 +from ..schema import Creator
29 +from .base import BaseConnector
30 +
31 +# services couverts par le gabarit (§9 palier 1)
32 +SERVICES = ("linktr.ee", "beacons.ai", "bio.link", "msha.ke",
33 + "campsite.bio", "snipfeed.co", "linkin.bio")
34 +
35 +_NEXT_DATA_RE = re.compile(
36 + r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
37 +_HREF_RE = re.compile(r'href="([^"]+)"', re.I)
38 +
39 +
40 +def is_supported(url: str | None) -> bool:
41 + return bool(url) and any(s in url for s in SERVICES)
42 +
43 +
44 +def parse_links(html: str) -> list[str]:
45 + """Extrait toutes les URLs sortantes d'une page link-in-bio.
46 +
47 + Linktree/Beacons sont des apps Next.js : les liens vivent dans le JSON
48 + __NEXT_DATA__ (plus fiable que le DOM). Repli : ancres du HTML.
49 + """
50 + urls: list[str] = []
51 + m = _NEXT_DATA_RE.search(html)
52 + if m:
53 + try:
54 + data = json.loads(m.group(1))
55 + urls.extend(_walk_urls(data))
56 + except Exception:
57 + pass
58 + for href in _HREF_RE.findall(html):
59 + if href.startswith("http"):
60 + urls.append(href)
61 + # dédupliquer en conservant l'ordre (l'ordre de la page = priorité du créateur)
62 + seen: set[str] = set()
63 + out = []
64 + for u in urls:
65 + u = u.split("?utm")[0].rstrip("/")
66 + if u not in seen:
67 + seen.add(u)
68 + out.append(u)
69 + return out
70 +
71 +
72 +def _walk_urls(node) -> list[str]:
73 + """Parcourt récursivement le JSON Next.js et récolte toute valeur 'url'."""
74 + found: list[str] = []
75 + if isinstance(node, dict):
76 + for k, v in node.items():
77 + if k in ("url", "href") and isinstance(v, str) and v.startswith("http"):
78 + found.append(v)
79 + else:
80 + found.extend(_walk_urls(v))
81 + elif isinstance(node, list):
82 + for item in node:
83 + found.extend(_walk_urls(item))
84 + return found
85 +
86 +
87 +def extract_accounts(html: str) -> list:
88 + """Page link-in-bio → comptes de plateformes (signal fort link_in_bio)."""
89 + accounts = []
90 + seen: set[str] = set()
91 + for url in parse_links(html):
92 + hit = platform_from_url(url)
93 + if not hit:
94 + continue
95 + platform, handle = hit
96 + key = f"{platform}:{handle}"
97 + if key in seen:
98 + continue
99 + seen.add(key)
100 + accounts.append(account(platform, handle, "link_in_bio", url=url))
101 + return accounts
102 +
103 +
104 +class LinkInBioConnector(BaseConnector):
105 + source_id = "link-in-bio"
106 + kind = "enrichment"
107 + request_delay = 1.2
108 + max_pages = 500 # garde-fou par passage
109 +
110 + def enrich(self, creators: list[Creator]) -> list[Creator]:
111 + enriched: list[Creator] = []
112 + fetched = 0
113 + for cr in creators:
114 + if fetched >= self.max_pages:
115 + break
116 + if not is_supported(cr.link_in_bio_url):
117 + continue
118 + try:
119 + html = self.get(cr.link_in_bio_url).text
120 + except Exception:
121 + try:
122 + html = self.get_rendered(cr.link_in_bio_url)
123 + except Exception:
124 + continue
125 + fetched += 1
126 + accounts = extract_accounts(html)
127 + if not accounts:
128 + continue
129 + cr.platforms = merge_accounts(cr.platforms, [a.finalize()
130 + for a in accounts])
131 + enriched.append(cr)
132 + return enriched
added creaka/connectors/listes_medias.py +89 −0
@@ -0,0 +1,89 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/listes_medias.py
4 +# Desc: Connecteur DÉCOUVERTE — listes & palmarès médias québécois compilés
5 +# (dataset local data/seed/qc_influenceurs.json, croisement de listes
6 +# publiques : billie, frank, metricool, Favikon QC…). Mode d'accès :
7 +# dataset local (aucune requête réseau). Palier 3 du catalogue (§9).
8 +# ==============================================================================
9 +"""Découverte initiale : 376 créateurs québécois issus de listes médias publiques.
10 +
11 +Le dataset (compilé pour influenceursqc.com à partir de palmarès publics —
12 +tops médias, classements Favikon/metricool, articles de presse) fournit pour
13 +chaque créateur : nom public, handles par plateforme, abonnés approximatifs,
14 +catégorie et localisation DÉCLARÉE. Signal d'identité : `listes_medias`
15 +(plusieurs listes publiques concordantes) → confidence 0.85 (§12.1).
16 +
17 +Aucune donnée privée : uniquement des profils publics de créateurs qui se
18 +présentent publiquement comme tels (§15).
19 +"""
20 +from __future__ import annotations
21 +
22 +import json
23 +from pathlib import Path
24 +
25 +from ..identity import account
26 +from ..normalize import map_region, slugify
27 +from ..schema import Creator
28 +from .base import BaseConnector
29 +
30 +SEED_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "seed" / \
31 + "qc_influenceurs.json"
32 +
33 +# clés du dataset → (plateforme canonique, clé de métrique)
34 +_PLATFORM_KEYS = {
35 + "instagram": "instagram_followers",
36 + "tiktok": "tiktok_followers",
37 + "youtube": "youtube_subscribers",
38 + "facebook": "facebook_followers",
39 + "x": "x_followers",
40 + "twitch": "twitch_followers",
41 +}
42 +
43 +
44 +class ListesMediasConnector(BaseConnector):
45 + source_id = "listes-medias"
46 + kind = "discovery"
47 +
48 + def fetch(self) -> list[Creator]:
49 + if not SEED_PATH.exists():
50 + raise RuntimeError(f"dataset seed introuvable : {SEED_PATH}")
51 + raw = json.loads(SEED_PATH.read_text(encoding="utf-8"))
52 + creators: list[Creator] = []
53 + for entry in raw:
54 + try:
55 + cr = self._normalize(entry)
56 + if cr is not None:
57 + creators.append(cr)
58 + except Exception:
59 + continue # une entrée malformée ne bloque pas la source
60 + return creators
61 +
62 + def _normalize(self, entry: dict) -> Creator | None:
63 + name = (entry.get("name") or "").strip()
64 + if not name:
65 + return None
66 + handles = entry.get("handles") or {}
67 + metrics = entry.get("metrics") or {}
68 + accounts = []
69 + for platform, metric_key in _PLATFORM_KEYS.items():
70 + handle = handles.get(platform)
71 + if not handle:
72 + continue
73 + accounts.append(account(platform, handle, "listes_medias",
74 + followers=metrics.get(metric_key)))
75 + if not accounts:
76 + return None
77 + region, city = map_region(entry.get("location"))
78 + slug = slugify(name)
79 + return Creator(
80 + source=self.source_id,
81 + external_id=slug,
82 + display_name=name,
83 + niches=[entry.get("category") or "autre"],
84 + region=region,
85 + city=city,
86 + platforms=accounts,
87 + notes=(entry.get("notes") or "")[:1200],
88 + source_ids=[f"{self.source_id}:{slug}"],
89 + )
added creaka/connectors/onlyqueb.py +104 −0
@@ -0,0 +1,104 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/onlyqueb.py
4 +# Desc: Connecteur DÉCOUVERTE — OnlyQueb (onlyqueb.com), annuaire public de
5 +# créatrices/créateurs québécois sur abonnement. Mode d'accès : requêtes
6 +# directes (sitemap.xml + JSON-LD des pages profil, SSR). Palier 3 (§9).
7 +# Les liens `sameAs` sont AUTO-DÉCLARÉS par la personne sur son profil
8 +# → signal cross_link (0.90, §12.1).
9 +# ==============================================================================
10 +"""Découverte via le hub OnlyQueb.
11 +
12 +Chaque page profil expose un JSON-LD schema.org ProfilePage propre :
13 +nom public, photo de profil publique et liste `sameAs` des comptes
14 +(OnlyFans, Instagram, TikTok, X…). Adultes uniquement (plateforme 18+).
15 +On n'agrège que ce que la personne affiche elle-même sur son profil (§15).
16 +"""
17 +from __future__ import annotations
18 +
19 +import json
20 +import re
21 +
22 +from ..identity import account
23 +from ..normalize import platform_from_url, slugify
24 +from ..schema import Creator
25 +from .base import BaseConnector
26 +
27 +SITEMAP_URL = "https://onlyqueb.com/sitemap.xml"
28 +_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")
29 +_LD_RE = re.compile(r'application/ld\+json"[^>]*>(.*?)</script>', re.S)
30 +_EXCLUDED = ("/en", "/categories", "/explore", "/seo", "/signup", "/blog")
31 +
32 +
33 +def parse_profile_ld(html: str) -> dict | None:
34 + """Page profil → {name, image, links[]} depuis le JSON-LD (None si absent)."""
35 + for blob in _LD_RE.findall(html):
36 + try:
37 + data = json.loads(blob)
38 + except Exception:
39 + continue
40 + if data.get("@type") != "ProfilePage":
41 + continue
42 + person = data.get("mainEntity") or {}
43 + if person.get("@type") != "Person":
44 + continue
45 + return {"name": (person.get("name") or "").strip(),
46 + "image": person.get("image") or None,
47 + "links": [u for u in (person.get("sameAs") or [])
48 + if isinstance(u, str)]}
49 + return None
50 +
51 +
52 +class OnlyQuebConnector(BaseConnector):
53 + source_id = "onlyqueb"
54 + kind = "discovery"
55 + request_delay = 1.0
56 + max_profiles = 600 # garde-fou par passage
57 +
58 + def _profile_urls(self) -> list[str]:
59 + xml = self.get(SITEMAP_URL).text
60 + urls = []
61 + for u in _LOC_RE.findall(xml):
62 + path = u.split("onlyqueb.com", 1)[-1]
63 + if path and path != "/" and path.count("/") == 1 \
64 + and not any(path.startswith(e) for e in _EXCLUDED):
65 + urls.append(u)
66 + return urls[:self.max_profiles]
67 +
68 + def fetch(self) -> list[Creator]:
69 + creators: list[Creator] = []
70 + for url in self._profile_urls():
71 + try:
72 + html = self.get(url).text
73 + except Exception:
74 + continue
75 + prof = parse_profile_ld(html)
76 + if not prof or not prof["name"]:
77 + continue
78 + accounts = []
79 + seen: set[str] = set()
80 + for link in prof["links"]:
81 + hit = platform_from_url(link)
82 + if not hit:
83 + continue
84 + platform, handle = hit
85 + if f"{platform}:{handle}" in seen:
86 + continue
87 + seen.add(f"{platform}:{handle}")
88 + accounts.append(account(platform, handle, "cross_link", url=link))
89 + if not accounts:
90 + continue # sans lien vérifiable, pas de fiche (annuaire de liens)
91 + slug = slugify(prof["name"])
92 + creators.append(Creator(
93 + source=self.source_id,
94 + external_id=slug,
95 + display_name=prof["name"],
96 + niches=["autre"],
97 + creator_type="influenceur",
98 + avatar_url=prof["image"],
99 + platforms=accounts,
100 + notes="Profil auto-déclaré sur l'annuaire OnlyQueb (créateurs "
101 + "québécois sur abonnement, 18+).",
102 + source_ids=[f"{self.source_id}:{slug}"],
103 + ))
104 + return creators
added creaka/connectors/tiktok.py +100 −0
@@ -0,0 +1,100 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/tiktok.py
4 +# Desc: Connecteur ENRICHISSEMENT TikTok — profil public par créateur :
5 +# abonnés, badge vérifié, bio, lien de bio (découverte croisée).
6 +# Mode d'accès : Scrapfly (plateforme hostile au scraping, API
7 +# officielle limitée aux comptes connectés) — palier 4 (§9), §15.
8 +# ==============================================================================
9 +"""Enrichissement TikTok, créateur par créateur.
10 +
11 +La page publique tiktok.com/@handle embarque un JSON d'hydratation
12 +(__UNIVERSAL_DATA_FOR_REHYDRATION__) contenant stats.followerCount, la bio
13 +(`signature`), le badge vérifié et le lien de bio (`bioLink`). On le lit via
14 +Scrapfly. Lien de bio → page link-in-bio ou compte croisé (signal cross_link).
15 +"""
16 +from __future__ import annotations
17 +
18 +import json
19 +import re
20 +
21 +from ..identity import account, merge_accounts
22 +from ..normalize import parse_count, platform_from_url
23 +from ..schema import Creator, now_iso
24 +from .base import BaseConnector
25 +from .linkinbio import is_supported as is_linkinbio
26 +
27 +PROFILE_URL = "https://www.tiktok.com/@{h}"
28 +_STATE_RE = re.compile(
29 + r'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(.*?)</script>', re.S)
30 +
31 +
32 +def parse_profile(html: str) -> dict | None:
33 + """HTML de profil TikTok → dict userInfo {user:…, stats:…} (None si absent)."""
34 + m = _STATE_RE.search(html)
35 + if not m:
36 + return None
37 + try:
38 + data = json.loads(m.group(1))
39 + return (data.get("__DEFAULT_SCOPE__", {})
40 + .get("webapp.user-detail", {})
41 + .get("userInfo")) or None
42 + except Exception:
43 + return None
44 +
45 +
46 +class TikTokConnector(BaseConnector):
47 + source_id = "tiktok-profil"
48 + kind = "enrichment"
49 + request_delay = 1.5
50 + max_profiles = 700
51 +
52 + def enrich(self, creators: list[Creator]) -> list[Creator]:
53 + enriched: list[Creator] = []
54 + fetched = 0
55 + for cr in creators:
56 + tk = next((a for a in cr.platforms if a.platform == "tiktok"), None)
57 + if tk is None or fetched >= self.max_profiles:
58 + continue
59 + try:
60 + result = self.scrapfly(PROFILE_URL.format(h=tk.handle),
61 + render_js=False)
62 + except Exception:
63 + continue
64 + fetched += 1
65 + if result.get("status_code") != 200:
66 + continue
67 + info = parse_profile(result.get("content") or "")
68 + if not info:
69 + continue
70 + user = info.get("user") or {}
71 + stats = info.get("stats") or {}
72 + if user.get("privateAccount"):
73 + continue # §15 : uniquement du public
74 + tk.followers = parse_count(stats.get("followerCount")) or tk.followers
75 + tk.verified = bool(user.get("verified"))
76 + tk.last_checked = now_iso()
77 + extra = {
78 + "following": parse_count(stats.get("followingCount")),
79 + "likes": parse_count(stats.get("heartCount")),
80 + "videos": parse_count(stats.get("videoCount")),
81 + }
82 + tk.metrics.update({k: v for k, v in extra.items() if v is not None})
83 + if not cr.avatar_url and user.get("avatarLarger"):
84 + cr.avatar_url = user["avatarLarger"]
85 + if not cr.bio and user.get("signature"):
86 + cr.bio = user["signature"]
87 + url = ((user.get("bioLink") or {}).get("link") or "").strip()
88 + if url and not url.startswith("http"):
89 + url = "https://" + url
90 + if url:
91 + if is_linkinbio(url) and not cr.link_in_bio_url:
92 + cr.link_in_bio_url = url
93 + else:
94 + hit = platform_from_url(url)
95 + if hit and hit[0] != "tiktok":
96 + cr.platforms = merge_accounts(cr.platforms, [
97 + account(hit[0], hit[1], "cross_link",
98 + url=url).finalize()])
99 + enriched.append(cr)
100 + return enriched
added creaka/connectors/twitch.py +67 −0
@@ -0,0 +1,67 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/twitch.py
4 +# Desc: Connecteur ENRICHISSEMENT Twitch — existence + profil par créateur
5 +# via l'API OFFICIELLE Helix (client_credentials). Palier 2 (§9, §10).
6 +# Sans TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET : passage sauté proprement.
7 +# ==============================================================================
8 +"""Enrichissement Twitch via l'API officielle Helix.
9 +
10 +`users?login=` confirme l'existence du compte, son type (partner/affiliate)
11 +et sa description. (Le total d'abonnés de chaîne exige un jeton de modérateur
12 +depuis 2023 — on ne le collecte pas plutôt que de contourner, §15.)
13 +"""
14 +from __future__ import annotations
15 +
16 +import os
17 +
18 +from ..schema import Creator, now_iso
19 +from .base import BaseConnector
20 +
21 +TOKEN_URL = "https://id.twitch.tv/oauth2/token"
22 +USERS_URL = "https://api.twitch.tv/helix/users"
23 +
24 +
25 +class TwitchConnector(BaseConnector):
26 + source_id = "twitch"
27 + kind = "enrichment"
28 + request_delay = 0.5
29 + max_profiles = 400
30 +
31 + def _token(self, cid: str, secret: str) -> str:
32 + resp = self.post(TOKEN_URL, data={"client_id": cid,
33 + "client_secret": secret,
34 + "grant_type": "client_credentials"})
35 + return resp.json()["access_token"]
36 +
37 + def enrich(self, creators: list[Creator]) -> list[Creator]:
38 + cid = os.environ.get("TWITCH_CLIENT_ID", "")
39 + secret = os.environ.get("TWITCH_CLIENT_SECRET", "")
40 + if not cid or not secret:
41 + return [] # pas de clés → on saute (jamais de contournement, §15)
42 + token = self._token(cid, secret)
43 + headers = {"Client-Id": cid, "Authorization": f"Bearer {token}"}
44 + enriched: list[Creator] = []
45 + fetched = 0
46 + for cr in creators:
47 + tw = next((a for a in cr.platforms if a.platform == "twitch"), None)
48 + if tw is None or fetched >= self.max_profiles:
49 + continue
50 + try:
51 + resp = self.get(USERS_URL, params={"login": tw.handle},
52 + headers=headers)
53 + except Exception:
54 + continue
55 + fetched += 1
56 + data = resp.json().get("data") or []
57 + if not data:
58 + continue
59 + user = data[0]
60 + tw.verified = user.get("broadcaster_type") == "partner"
61 + tw.confidence = max(tw.confidence or 0, 0.90)
62 + tw.signal = tw.signal or "api_officielle"
63 + tw.last_checked = now_iso()
64 + if not cr.bio and user.get("description"):
65 + cr.bio = user["description"]
66 + enriched.append(cr)
67 + return enriched
added creaka/connectors/wikidata_qc.py +128 −0
@@ -0,0 +1,128 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/wikidata_qc.py
4 +# Desc: Connecteur DÉCOUVERTE — créateurs québécois recensés dans Wikidata
5 +# (personnes nées ou résidant au Québec, occupation créateur, avec
6 +# handles sociaux curés P2003/P7085/P2397/P5797/P2002). Mode d'accès :
7 +# API SPARQL publique de Wikidata (gratuite, conforme). Palier 3 (§9).
8 +# ==============================================================================
9 +"""Découverte via Wikidata : au-delà des listes médias.
10 +
11 +Wikidata référence les créateurs notables (humoristes, youtubeurs, vidéastes
12 +web, streamers, podcasteurs, influenceurs) avec leurs comptes sociaux CURÉS
13 +par la communauté → signal `base_publique` (0.85). Deux requêtes (lieu de
14 +naissance / lieu de résidence au Québec) fusionnées.
15 +
16 +Prudence mineurs (§15) : l'année de naissance publique permet de lever
17 +`is_minor` automatiquement → régime restreint appliqué par ethics.py.
18 +"""
19 +from __future__ import annotations
20 +
21 +from datetime import datetime, timezone
22 +
23 +from ..identity import account
24 +from ..normalize import slugify
25 +from ..schema import Creator
26 +from .base import BaseConnector
27 +
28 +SPARQL_URL = "https://query.wikidata.org/sparql"
29 +
30 +# occupations « créateur de contenu » (ids Wikidata)
31 +# Q17125263 vidéaste web · Q94791573 tiktokeur · Q50279140 streamer
32 +# Q15077007 podcasteur · Q2906862 influenceur · Q245068 humoriste
33 +_OCCS = "wd:Q17125263 wd:Q94791573 wd:Q50279140 wd:Q15077007 wd:Q2906862 wd:Q245068"
34 +
35 +_OCC_NICHE = {
36 + "Q245068": "humour", "Q50279140": "gaming", "Q15077007": "actualite-opinion",
37 + "Q17125263": "lifestyle", "Q94791573": "lifestyle", "Q2906862": "lifestyle",
38 +}
39 +
40 +_QUERY = """
41 +SELECT DISTINCT ?person ?personLabel ?occ ?ig ?tk ?yt ?tw ?x (YEAR(?dob) AS ?birthYear) WHERE {
42 + VALUES ?occ { %(occs)s }
43 + ?person wdt:P106 ?occ .
44 + ?person wdt:%(place_prop)s ?place .
45 + ?place wdt:P131* wd:Q176 . hint:Prior hint:gearing "forward" .
46 + OPTIONAL { ?person wdt:P2003 ?ig }
47 + OPTIONAL { ?person wdt:P7085 ?tk }
48 + OPTIONAL { ?person wdt:P2397 ?yt }
49 + OPTIONAL { ?person wdt:P5797 ?tw }
50 + OPTIONAL { ?person wdt:P2002 ?x }
51 + OPTIONAL { ?person wdt:P569 ?dob }
52 + FILTER(BOUND(?ig) || BOUND(?tk) || BOUND(?yt) || BOUND(?tw) || BOUND(?x))
53 + SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en". }
54 +}
55 +LIMIT 3000
56 +"""
57 +
58 +
59 +class WikidataQcConnector(BaseConnector):
60 + source_id = "wikidata-qc"
61 + kind = "discovery"
62 + request_delay = 2.0
63 + timeout = 90
64 +
65 + def _query(self, place_prop: str) -> list[dict]:
66 + resp = self.get(SPARQL_URL,
67 + params={"query": _QUERY % {"occs": _OCCS,
68 + "place_prop": place_prop}},
69 + headers={"Accept": "application/sparql-results+json"})
70 + return resp.json().get("results", {}).get("bindings", [])
71 +
72 + def fetch(self) -> list[Creator]:
73 + rows: list[dict] = []
74 + for prop in ("P19", "P551"): # naissance, résidence
75 + try:
76 + rows.extend(self._query(prop))
77 + except Exception:
78 + continue # une branche en échec ne bloque pas l'autre
79 + by_person: dict[str, dict] = {}
80 + for r in rows:
81 + def val(key):
82 + return (r.get(key) or {}).get("value")
83 + pid = val("person")
84 + if not pid:
85 + continue
86 + entry = by_person.setdefault(
87 + pid, {"name": val("personLabel"), "occs": set(), "birth": None,
88 + "handles": {}})
89 + entry["occs"].add((val("occ") or "").rsplit("/", 1)[-1])
90 + if val("birthYear"):
91 + entry["birth"] = int(val("birthYear"))
92 + for key, platform in (("ig", "instagram"), ("tk", "tiktok"),
93 + ("tw", "twitch"), ("x", "x")):
94 + if val(key):
95 + entry["handles"][platform] = val(key)
96 + # P2397 = ID de chaîne YouTube (UC…) → URL directe, pas un handle @
97 + if val("yt"):
98 + entry["handles"]["youtube_channel"] = val("yt")
99 + year_now = datetime.now(timezone.utc).year
100 + creators: list[Creator] = []
101 + for pid, e in by_person.items():
102 + name = (e["name"] or "").strip()
103 + qid = pid.rsplit("/", 1)[-1]
104 + if not name or name == qid:
105 + continue # entité sans libellé humain
106 + accounts = []
107 + for platform, handle in e["handles"].items():
108 + if platform == "youtube_channel":
109 + accounts.append(account(
110 + "youtube", handle, "base_publique",
111 + url=f"https://www.youtube.com/channel/{handle}"))
112 + else:
113 + accounts.append(account(platform, handle, "base_publique"))
114 + if not accounts:
115 + continue
116 + is_minor = bool(e["birth"] and (year_now - e["birth"]) < 18)
117 + niches = sorted({_OCC_NICHE.get(o, "autre") for o in e["occs"]}) or ["autre"]
118 + creators.append(Creator(
119 + source=self.source_id,
120 + external_id=qid,
121 + display_name=name,
122 + niches=niches,
123 + is_minor=is_minor,
124 + platforms=accounts,
125 + source_ids=[f"{self.source_id}:{qid}"],
126 + ))
127 + # tri stable pour des passages reproductibles
128 + return sorted(creators, key=lambda c: slugify(c.display_name))
added creaka/connectors/youtube.py +89 −0
@@ -0,0 +1,89 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/youtube.py
4 +# Desc: Connecteur ENRICHISSEMENT YouTube — abonnés + badge par créateur.
5 +# Mode d'accès : YouTube Data API v3 (OFFICIELLE, privilégiée §10) si
6 +# YOUTUBE_API_KEY est définie ; sinon repli page publique /@handle
7 +# (requêtes directes polies). Palier 2 du catalogue (§9).
8 +# ==============================================================================
9 +"""Enrichissement YouTube, créateur par créateur.
10 +
11 +Voie privilégiée : l'API officielle `channels?forHandle=` (fiable, conforme,
12 +quota 10 000 unités/jour largement suffisant pour la cadence §14). Sans clé,
13 +repli léger : la page publique youtube.com/@handle expose le nombre d'abonnés
14 +dans ses métadonnées. Signal : `api_officielle` (0.95) quand confirmé par
15 +l'API — le handle interrogé est celui déjà rattaché.
16 +"""
17 +from __future__ import annotations
18 +
19 +import os
20 +import re
21 +
22 +from ..normalize import parse_count
23 +from ..schema import Creator, now_iso
24 +from .base import BaseConnector
25 +
26 +API_URL = ("https://www.googleapis.com/youtube/v3/channels"
27 + "?part=statistics,snippet&forHandle={h}&key={key}")
28 +PAGE_URL = "https://www.youtube.com/@{h}"
29 +_SUBS_RES = (
30 + re.compile(r'"subscriberCountText"[^}]*?"(?:simpleText|content)"\s*:\s*"([^"]+)"'),
31 + re.compile(r'"subscriberCount"\s*:\s*"?(\d+)"?'),
32 + re.compile(r'"metadataParts".{0,200}?"content"\s*:\s*"([\d.,\s]+[kKmM]?\s*'
33 + r'(?:subscribers|abonn[ée]s))"'),
34 +)
35 +
36 +
37 +class YouTubeConnector(BaseConnector):
38 + source_id = "youtube"
39 + kind = "enrichment"
40 + request_delay = 1.0
41 + max_profiles = 1500
42 +
43 + def _via_api(self, handle: str, key: str) -> dict | None:
44 + resp = self.get(API_URL.format(h=handle, key=key))
45 + items = resp.json().get("items") or []
46 + if not items:
47 + return None
48 + stats = items[0].get("statistics") or {}
49 + return {"followers": parse_count(stats.get("subscriberCount")),
50 + "views": parse_count(stats.get("viewCount")),
51 + "videos": parse_count(stats.get("videoCount")),
52 + "confirmed": True}
53 +
54 + def _via_page(self, handle: str) -> dict | None:
55 + resp = self.get(PAGE_URL.format(h=handle),
56 + headers={"Accept-Language": "en-CA,en;q=0.8"})
57 + html = resp.text
58 + for rx in _SUBS_RES:
59 + m = rx.search(html)
60 + if m:
61 + return {"followers": parse_count(m.group(1)), "confirmed": False}
62 + return None
63 +
64 + def enrich(self, creators: list[Creator]) -> list[Creator]:
65 + key = os.environ.get("YOUTUBE_API_KEY", "")
66 + enriched: list[Creator] = []
67 + fetched = 0
68 + for cr in creators:
69 + yt = next((a for a in cr.platforms if a.platform == "youtube"), None)
70 + if yt is None or fetched >= self.max_profiles:
71 + continue
72 + try:
73 + info = self._via_api(yt.handle, key) if key \
74 + else self._via_page(yt.handle)
75 + except Exception:
76 + continue
77 + fetched += 1
78 + if not info or info.get("followers") is None:
79 + continue
80 + yt.followers = info["followers"]
81 + yt.last_checked = now_iso()
82 + extra = {k: info.get(k) for k in ("views", "videos")}
83 + yt.metrics.update({k: v for k, v in extra.items() if v is not None})
84 + if info.get("confirmed"):
85 + # existence confirmée par l'API officielle → signal renforcé
86 + yt.confidence = max(yt.confidence or 0, 0.90)
87 + yt.signal = yt.signal or "api_officielle"
88 + enriched.append(cr)
89 + return enriched
added creaka/connectors/youtube_recherche.py +281 −0
@@ -0,0 +1,281 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/connectors/youtube_recherche.py
4 +# Desc: Connecteur DÉCOUVERTE — chaînes YouTube québécoises via la recherche
5 +# publique (filtre « chaînes »), requêtes thématiques × marqueurs QC.
6 +# Mode d'accès : requêtes directes (HTML public, ytInitialData).
7 +# Palier 3 (§9). Filtre conservateur : marqueur québécois OBLIGATOIRE
8 +# dans le titre ou la description de la chaîne.
9 +# ==============================================================================
10 +"""Découverte massive de chaînes YouTube québécoises.
11 +
12 +La page de résultats (filtre chaînes, sp=EgIQAg==) embarque un JSON
13 +ytInitialData avec, pour chaque chaîne : handle, nom, nb d'abonnés et extrait
14 +de description. On ne crée une fiche QUE si un marqueur québécois figure dans
15 +le titre/la description (jamais d'inférence, §7/§15). Le compte YouTube est le
16 +compte SOURCE de la fiche → signal profil_source (0.98) ; l'enrichissement
17 +(liens de bio, link-in-bio) rattache ensuite ses autres plateformes.
18 +"""
19 +from __future__ import annotations
20 +
21 +import json
22 +import re
23 +
24 +from ..identity import account
25 +from ..normalize import parse_count, slugify, strip_accents
26 +from ..schema import Creator
27 +from .base import BaseConnector
28 +
29 +SEARCH_URL = "https://www.youtube.com/results"
30 +CHANNEL_FILTER = "EgIQAg%3D%3D" # sp= filtre « chaînes »
31 +_YTID_RE = re.compile(r"var ytInitialData = (\{.*?\});</script>", re.S)
32 +
33 +# marqueurs québécois (comparés SANS accents, en minuscules)
34 +_QC_MARKERS = re.compile(
35 + r"queb|montreal|\bmtl\b|saguenay|gaspesie|mauricie|estrie|outaouais|"
36 + r"abitibi|levis|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|"
37 + r"rimouski|chicoutimi|drummondville|granby|beauce|charlevoix|lanaudiere|"
38 + r"laurentides|monteregie|temiscamingue|joliette|victoriaville|"
39 + r"\bfleuve\b|rive-sud|rive-nord|ville de quebec")
40 +
41 +# requêtes thématiques → niche par défaut de la fiche
42 +QUERIES: list[tuple[str, str]] = [
43 + ("vlog québécois", "lifestyle"), ("youtubeur québécois", "lifestyle"),
44 + ("humour québécois", "humour"), ("humoriste québécois", "humour"),
45 + ("cuisine québécoise", "cuisine"), ("recettes québec", "cuisine"),
46 + ("gaming québécois", "gaming"), ("gamer québec", "gaming"),
47 + ("chasse québec", "plein-air"), ("pêche québec", "plein-air"),
48 + ("plein air québec", "plein-air"), ("camping québec", "plein-air"),
49 + ("musique québécoise", "musique"), ("rap québécois", "musique"),
50 + ("maquillage québec", "beaute"), ("beauté québécoise", "beaute"),
51 + ("mode québécoise", "mode"), ("fitness québec", "sport-fitness"),
52 + ("entraînement québécois", "sport-fitness"), ("hockey québec", "sport-fitness"),
53 + ("famille québécoise vlog", "famille-parentalite"),
54 + ("maman québécoise", "famille-parentalite"),
55 + ("voyage québécois", "voyage"), ("finance québec", "finance-affaires"),
56 + ("immobilier québec", "finance-affaires"), ("tech québec", "tech"),
57 + ("balado québécois", "actualite-opinion"), ("podcast québécois", "actualite-opinion"),
58 + ("éducation québec", "education"), ("science québec", "education"),
59 + ("art québécois", "arts"), ("danse québec", "danse"),
60 + ("resto montréal", "bouffe-resto"), ("bouffe québec", "bouffe-resto"),
61 + ("vlog montréal", "lifestyle"), ("vlog ville de québec", "lifestyle"),
62 + ("agriculture québec", "autre"), ("auto québec", "autre"),
63 + ("santé bien-être québec", "sante-mieux-etre"),
64 + ("actualité québécoise", "actualite-opinion"),
65 + # villes × vlog/scène locale
66 + ("vlog sherbrooke", "lifestyle"), ("vlog gatineau", "lifestyle"),
67 + ("vlog saguenay", "lifestyle"), ("vlog trois-rivières", "lifestyle"),
68 + ("vlog gaspésie", "voyage"), ("vlog rimouski", "lifestyle"),
69 + ("vlog abitibi", "lifestyle"), ("vlog lévis", "lifestyle"),
70 + ("vlog laval", "lifestyle"), ("vlog longueuil", "lifestyle"),
71 + ("vlog drummondville", "lifestyle"), ("vlog granby", "lifestyle"),
72 + # gaming & streaming
73 + ("minecraft québécois", "gaming"), ("fortnite québécois", "gaming"),
74 + ("streamer québécois", "gaming"), ("twitch québécois", "gaming"),
75 + ("jeux vidéo québec", "gaming"), ("rétro gaming québec", "gaming"),
76 + # niches ciblées
77 + ("asmr québécois", "lifestyle"), ("vanlife québec", "voyage"),
78 + ("ski québec", "plein-air"), ("vélo québec", "sport-fitness"),
79 + ("course à pied québec", "sport-fitness"), ("musculation québec", "sport-fitness"),
80 + ("nutrition québec", "sante-mieux-etre"), ("yoga québec", "sante-mieux-etre"),
81 + ("rénovation québec", "autre"), ("menuiserie québec", "autre"),
82 + ("jardinage québec", "plein-air"), ("moto québec", "autre"),
83 + ("auto détail québec", "autre"), ("mécanique québec", "autre"),
84 + ("crypto québec", "finance-affaires"), ("bourse québec", "finance-affaires"),
85 + ("entrepreneur québécois", "finance-affaires"),
86 + ("développeur québécois", "tech"), ("programmation québec", "tech"),
87 + ("photographie québec", "arts"), ("dessin québécois", "arts"),
88 + ("couture québec", "arts"), ("tricot québec", "arts"),
89 + ("chasse orignal québec", "plein-air"), ("motoneige québec", "plein-air"),
90 + ("quatre-roues québec", "plein-air"), ("survie en forêt québec", "plein-air"),
91 + ("astronomie québec", "education"), ("histoire du québec", "education"),
92 + ("hockey canadiens de montréal", "sport-fitness"),
93 + ("pêche mouche québec", "plein-air"), ("kayak québec", "plein-air"),
94 + ("randonnée québec", "plein-air"), ("microbrasserie québec", "bouffe-resto"),
95 + ("bbq québec", "cuisine"), ("pâtisserie québec", "cuisine"),
96 + ("végane québec", "cuisine"), ("chef québécois", "cuisine"),
97 + ("critique resto montréal", "bouffe-resto"),
98 + ("maquilleuse québécoise", "beaute"), ("coiffure québec", "beaute"),
99 + ("soins de peau québec", "beaute"), ("friperie montréal", "mode"),
100 + ("streetwear montréal", "mode"), ("chanteur québécois", "musique"),
101 + ("chanteuse québécoise", "musique"), ("guitare québec", "musique"),
102 + ("dj québec", "musique"), ("country québécois", "musique"),
103 + ("télé-réalité québec", "lifestyle"), ("occupation double", "lifestyle"),
104 + ("humoriste relève québec", "humour"), ("stand up québec", "humour"),
105 + ("améliorer son français québec", "education"),
106 + ("prof québécois", "education"), ("garderie maman québec", "famille-parentalite"),
107 + ("grossesse québec", "famille-parentalite"),
108 + ("astuces parents québec", "famille-parentalite"),
109 + ("spiritualité québec", "sante-mieux-etre"),
110 + ("astrologie québec", "autre"), ("paranormal québec", "autre"),
111 + ("urbex québec", "autre"), ("faits divers québec", "actualite-opinion"),
112 + ("politique québécoise", "actualite-opinion"),
113 + ("nouvelles québec", "actualite-opinion"),
114 +]
115 +
116 +# --- longue traîne : villes/MRC × créneaux (nano/micro créateurs locaux) --------
117 +# On ne vise PAS que les mégastars : chaque ville a ses vloggeurs, gamers,
118 +# chasseurs, cuisiniers… Le filtre _QC_MARKERS garde la prudence d'attribution.
119 +_TOWNS = [
120 + "montréal", "ville de québec", "laval", "gatineau", "longueuil",
121 + "sherbrooke", "lévis", "saguenay", "trois-rivières", "terrebonne",
122 + "brossard", "repentigny", "drummondville", "saint-jérôme", "granby",
123 + "blainville", "saint-hyacinthe", "shawinigan", "dollard-des-ormeaux",
124 + "rimouski", "châteauguay", "saint-jean-sur-richelieu", "mascouche",
125 + "joliette", "victoriaville", "salaberry-de-valleyfield", "rouyn-noranda",
126 + "chicoutimi", "alma", "sept-îles", "baie-comeau", "val-d'or", "matane",
127 + "rivière-du-loup", "sorel-tracy", "thetford mines", "magog", "cowansville",
128 + "la tuque", "mont-laurier", "mont-tremblant", "sainte-agathe",
129 + "saint-georges beauce", "montmagny", "la malbaie", "baie-saint-paul",
130 + "gaspé", "chandler", "amqui", "dolbeau-mistassini", "roberval",
131 + "chibougamau", "lachute", "varennes", "chambly", "boucherville",
132 +]
133 +_TOWN_THEMES = [("vlog", "lifestyle"), ("youtubeur", "lifestyle"),
134 + ("gaming", "gaming"), ("chasse pêche", "plein-air"),
135 + ("humour", "humour")]
136 +for _t in _TOWNS:
137 + for _th, _n in _TOWN_THEMES:
138 + QUERIES.append((f"{_th} {_t}", _n))
139 +
140 +# --- longue traîne : passe-temps & métiers × marqueur QC ------------------------
141 +_HOBBIES = [
142 + ("skateboard", "sport-fitness"), ("snowboard", "plein-air"),
143 + ("pickleball", "sport-fitness"), ("golf", "sport-fitness"),
144 + ("baseball", "sport-fitness"), ("soccer", "sport-fitness"),
145 + ("basketball", "sport-fitness"), ("boxe", "sport-fitness"),
146 + ("mma", "sport-fitness"), ("crossfit", "sport-fitness"),
147 + ("échecs", "gaming"), ("donjons et dragons", "gaming"),
148 + ("cartes pokémon", "gaming"), ("warhammer", "gaming"),
149 + ("speedrun", "gaming"), ("simracing", "gaming"),
150 + ("lego", "autre"), ("maquette", "arts"), ("bois", "arts"),
151 + ("forge", "arts"), ("céramique", "arts"), ("peinture", "arts"),
152 + ("aquarelle", "arts"), ("calligraphie", "arts"),
153 + ("astrophotographie", "education"), ("ornithologie", "plein-air"),
154 + ("apiculture", "plein-air"), ("permaculture", "plein-air"),
155 + ("homesteading", "plein-air"), ("érablière", "plein-air"),
156 + ("acériculture", "plein-air"), ("motocross", "autre"),
157 + ("vtt", "plein-air"), ("side-by-side", "plein-air"),
158 + ("camion", "autre"), ("remorquage", "autre"), ("soudure", "autre"),
159 + ("électricien", "autre"), ("plombier", "autre"), ("excavation", "autre"),
160 + ("déneigement", "autre"), ("paysagement", "autre"),
161 + ("courtier immobilier", "finance-affaires"), ("comptable", "finance-affaires"),
162 + ("avocat", "education"), ("infirmière", "sante-mieux-etre"),
163 + ("massothérapie", "sante-mieux-etre"), ("ostéopathie", "sante-mieux-etre"),
164 + ("naturopathie", "sante-mieux-etre"), ("psychologie", "sante-mieux-etre"),
165 + ("méditation", "sante-mieux-etre"), ("tarot", "autre"),
166 + ("généalogie", "education"), ("philosophie", "education"),
167 + ("booktube", "education"), ("lecture", "education"),
168 + ("cinéma", "actualite-opinion"), ("séries télé", "actualite-opinion"),
169 + ("anime", "gaming"), ("manga", "gaming"), ("cosplay", "arts"),
170 + ("drag", "arts"), ("magie", "arts"), ("cirque", "arts"),
171 + ("violon", "musique"), ("piano", "musique"), ("batterie", "musique"),
172 + ("chorale", "musique"), ("trad", "musique"), ("métal", "musique"),
173 + ("punk", "musique"), ("folk", "musique"),
174 + ("fromage", "bouffe-resto"), ("vin", "bouffe-resto"),
175 + ("café", "bouffe-resto"), ("boulangerie", "cuisine"),
176 + ("cabane à sucre", "bouffe-resto"), ("poutine", "bouffe-resto"),
177 + ("food truck", "bouffe-resto"), ("mixologie", "bouffe-resto"),
178 + ("couture", "arts"), ("crochet", "arts"), ("broderie", "arts"),
179 + ("thrift", "mode"), ("sneakers", "mode"), ("barbier", "beaute"),
180 + ("ongles", "beaute"), ("tatouage", "arts"), ("esthétique", "beaute"),
181 + ("organisation maison", "lifestyle"), ("minimalisme", "lifestyle"),
182 + ("van aménagé", "voyage"), ("chalet", "lifestyle"),
183 + ("astuces budget", "finance-affaires"), ("coupons", "finance-affaires"),
184 + ("frugal", "finance-affaires"), ("bourse dividendes", "finance-affaires"),
185 + ("intelligence artificielle", "tech"), ("cybersécurité", "tech"),
186 + ("impression 3d", "tech"), ("domotique", "tech"), ("drones", "tech"),
187 +]
188 +for _h, _n in _HOBBIES:
189 + QUERIES.append((f"{_h} québec", _n))
190 + QUERIES.append((f"{_h} québécois", _n))
191 +
192 +
193 +def _walk_channels(node, out: list) -> None:
194 + if isinstance(node, dict):
195 + if "channelRenderer" in node:
196 + out.append(node["channelRenderer"])
197 + for v in node.values():
198 + _walk_channels(v, out)
199 + elif isinstance(node, list):
200 + for item in node:
201 + _walk_channels(item, out)
202 +
203 +
204 +def parse_channels(html: str) -> list[dict]:
205 + """HTML de résultats → [{handle, channel_id, name, subs, description}]."""
206 + m = _YTID_RE.search(html)
207 + if not m:
208 + return []
209 + try:
210 + data = json.loads(m.group(1))
211 + except Exception:
212 + return []
213 + renderers: list[dict] = []
214 + _walk_channels(data, renderers)
215 + channels = []
216 + for r in renderers:
217 + try:
218 + handle = ((r.get("navigationEndpoint") or {}).get("browseEndpoint")
219 + or {}).get("canonicalBaseUrl", "").lstrip("/")
220 + name = ((r.get("title") or {}).get("simpleText")) or ""
221 + desc = "".join(run.get("text", "") for run in
222 + ((r.get("descriptionSnippet") or {}).get("runs") or []))
223 + subs_txt = ((r.get("videoCountText") or {}).get("simpleText")) or \
224 + ((r.get("subscriberCountText") or {}).get("simpleText")) or ""
225 + channels.append({"handle": handle, "channel_id": r.get("channelId", ""),
226 + "name": name, "subs": parse_count(subs_txt),
227 + "description": desc})
228 + except Exception:
229 + continue
230 + return channels
231 +
232 +
233 +def is_quebec(text: str) -> bool:
234 + return bool(_QC_MARKERS.search(strip_accents(text or "").lower()))
235 +
236 +
237 +class YouTubeRechercheConnector(BaseConnector):
238 + source_id = "youtube-recherche"
239 + kind = "discovery"
240 + request_delay = 1.2
241 + max_queries = 800
242 +
243 + def fetch(self) -> list[Creator]:
244 + creators: list[Creator] = []
245 + seen: set[str] = set()
246 + for query, niche in QUERIES[:self.max_queries]:
247 + try:
248 + html = self.get(
249 + SEARCH_URL,
250 + params={"search_query": query, "sp": "EgIQAg=="},
251 + headers={"Accept-Language": "fr-CA,fr;q=0.9"}).text
252 + except Exception:
253 + continue
254 + for ch in parse_channels(html):
255 + handle = ch["handle"].lstrip("@")
256 + key = handle or ch["channel_id"]
257 + if not key or key in seen:
258 + continue
259 + # filtre conservateur : marqueur QC requis dans titre+description
260 + if not is_quebec(f"{ch['name']} {ch['description']}"):
261 + continue
262 + seen.add(key)
263 + if handle:
264 + acc = account("youtube", handle, "profil_source",
265 + followers=ch["subs"])
266 + else:
267 + acc = account("youtube", ch["channel_id"], "profil_source",
268 + followers=ch["subs"],
269 + url="https://www.youtube.com/channel/"
270 + + ch["channel_id"])
271 + creators.append(Creator(
272 + source=self.source_id,
273 + external_id=slugify(ch["name"]),
274 + display_name=ch["name"],
275 + bio=ch["description"][:1200],
276 + niches=[niche],
277 + creator_type="youtubeur",
278 + platforms=[acc],
279 + source_ids=[f"{self.source_id}:{key}"],
280 + ))
281 + return creators
added creaka/db.py +362 −0
@@ -0,0 +1,362 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/db.py
4 +# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,
5 +# journal des synchronisations (fraîcheur §13-14, opt-out §15)
6 +# ==============================================================================
7 +"""Base de données Créa-Ka (SQLite, WAL).
8 +
9 +- `creators` : une ligne = une fiche canonique (document JSON + colonnes
10 + indexées pour la recherche).
11 +- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est
12 + l'index qui permet la déduplication inter-sources (§12.2).
13 +- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).
14 +
15 +Politique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`
16 +horodate chaque passage ; un créateur disparu passe `inactive`, un opt-out
17 +passe `opted_out` (masqué, jamais ré-agrégé).
18 +"""
19 +from __future__ import annotations
20 +
21 +import json
22 +import sqlite3
23 +from dataclasses import asdict
24 +from pathlib import Path
25 +
26 +from . import ethics
27 +from .dedup import merge_creators, should_merge
28 +from .identity import STRONG_THRESHOLD, needs_review
29 +from .normalize import slugify
30 +from .schema import Creator, PlatformAccount, now_iso
31 +
32 +ROOT = Path(__file__).resolve().parent.parent
33 +DB_PATH = ROOT / "data" / "creaka.db"
34 +
35 +_SCHEMA = """
36 +CREATE TABLE IF NOT EXISTS creators (
37 + id TEXT PRIMARY KEY,
38 + display_name TEXT NOT NULL,
39 + status TEXT NOT NULL DEFAULT 'active',
40 + is_minor INTEGER NOT NULL DEFAULT 0,
41 + region TEXT, city TEXT,
42 + niches TEXT, languages TEXT,
43 + creator_type TEXT, primary_platform TEXT,
44 + audience_tier TEXT, total_reach INTEGER,
45 + bio TEXT,
46 + first_seen TEXT, last_seen TEXT, updated_at TEXT,
47 + doc TEXT NOT NULL
48 +);
49 +CREATE TABLE IF NOT EXISTS accounts (
50 + platform TEXT NOT NULL,
51 + handle TEXT NOT NULL,
52 + creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,
53 + url TEXT NOT NULL,
54 + followers INTEGER,
55 + verified INTEGER,
56 + confidence REAL NOT NULL,
57 + signal TEXT,
58 + needs_review INTEGER NOT NULL DEFAULT 0,
59 + last_checked TEXT,
60 + metrics TEXT,
61 + PRIMARY KEY (platform, handle)
62 +);
63 +CREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);
64 +CREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);
65 +CREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);
66 +CREATE TABLE IF NOT EXISTS sync_log (
67 + id INTEGER PRIMARY KEY AUTOINCREMENT,
68 + ts TEXT NOT NULL,
69 + source TEXT NOT NULL,
70 + creators INTEGER, accounts INTEGER, avg_confidence REAL,
71 + added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,
72 + alert TEXT
73 +);
74 +"""
75 +
76 +
77 +def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
78 + Path(path).parent.mkdir(parents=True, exist_ok=True)
79 + con = sqlite3.connect(path, check_same_thread=False)
80 + con.row_factory = sqlite3.Row
81 + con.execute("PRAGMA journal_mode=WAL")
82 + con.execute("PRAGMA foreign_keys=ON")
83 + con.executescript(_SCHEMA)
84 + # migrations légères (bases existantes)
85 + try:
86 + con.execute("ALTER TABLE accounts ADD COLUMN metrics TEXT")
87 + except sqlite3.OperationalError:
88 + pass
89 + return con
90 +
91 +
92 +# --- (dé)sérialisation ----------------------------------------------------------
93 +
94 +def creator_to_doc(cr: Creator) -> str:
95 + return json.dumps(asdict(cr), ensure_ascii=False)
96 +
97 +
98 +def creator_from_row(row: sqlite3.Row) -> Creator:
99 + doc = json.loads(row["doc"])
100 + doc["platforms"] = [PlatformAccount(**a) for a in doc.get("platforms", [])]
101 + cr = Creator(**doc)
102 + return cr
103 +
104 +
105 +def _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:
106 + """Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1)."""
107 + doc = json.loads(row["doc"])
108 + doc["id"] = row["id"]
109 + doc["first_seen"] = row["first_seen"]
110 + doc["last_seen"] = row["last_seen"]
111 + doc["updated_at"] = row["updated_at"]
112 + doc["platforms"] = [
113 + {"platform": a["platform"], "handle": a["handle"], "url": a["url"],
114 + "followers": a["followers"],
115 + "verified": bool(a["verified"]) if a["verified"] is not None else None,
116 + "confidence": a["confidence"], "last_checked": a["last_checked"],
117 + "metrics": json.loads(a["metrics"]) if a["metrics"] else {}}
118 + for a in accounts if not a["needs_review"]]
119 + # champs internes / sensibles jamais exposés tels quels
120 + doc.pop("source", None)
121 + doc.pop("external_id", None)
122 + doc.pop("legal_name", None)
123 + return doc
124 +
125 +
126 +# --- écriture -------------------------------------------------------------------
127 +
128 +def _unique_id(con: sqlite3.Connection, base: str) -> str:
129 + cid, n = base, 2
130 + while con.execute("SELECT 1 FROM creators WHERE id=?", (cid,)).fetchone():
131 + cid = f"{base}-{n}"
132 + n += 1
133 + return cid
134 +
135 +
136 +def _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,
137 + first_seen: str, ts: str) -> None:
138 + con.execute(
139 + """INSERT INTO creators (id, display_name, status, is_minor, region, city,
140 + niches, languages, creator_type, primary_platform, audience_tier,
141 + total_reach, bio, first_seen, last_seen, updated_at, doc)
142 + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
143 + ON CONFLICT(id) DO UPDATE SET
144 + display_name=excluded.display_name, status=excluded.status,
145 + is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,
146 + niches=excluded.niches, languages=excluded.languages,
147 + creator_type=excluded.creator_type,
148 + primary_platform=excluded.primary_platform,
149 + audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,
150 + bio=excluded.bio, last_seen=excluded.last_seen,
151 + updated_at=excluded.updated_at, doc=excluded.doc""",
152 + (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,
153 + ",".join(cr.niches), ",".join(cr.languages), cr.creator_type,
154 + cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,
155 + first_seen, ts, ts, creator_to_doc(cr)))
156 + con.execute("DELETE FROM accounts WHERE creator_id=?", (cid,))
157 + for acc in cr.platforms:
158 + con.execute(
159 + """INSERT INTO accounts (platform, handle, creator_id, url, followers,
160 + verified, confidence, signal, needs_review, last_checked, metrics)
161 + VALUES (?,?,?,?,?,?,?,?,?,?,?)
162 + ON CONFLICT(platform, handle) DO UPDATE SET
163 + creator_id=excluded.creator_id, url=excluded.url,
164 + followers=excluded.followers, verified=excluded.verified,
165 + confidence=excluded.confidence, signal=excluded.signal,
166 + needs_review=excluded.needs_review,
167 + last_checked=excluded.last_checked, metrics=excluded.metrics""",
168 + (acc.platform, acc.handle, cid, acc.url, acc.followers,
169 + None if acc.verified is None else int(acc.verified),
170 + acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,
171 + json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))
172 +
173 +
174 +def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:
175 + """Fiche existante partageant un compte FORT avec la fiche entrante (§12.2)."""
176 + for acc in cr.platforms:
177 + if (acc.confidence or 0) < STRONG_THRESHOLD:
178 + continue
179 + row = con.execute(
180 + "SELECT creator_id FROM accounts WHERE platform=? AND handle=? "
181 + "AND confidence>=?", (acc.platform, acc.handle, STRONG_THRESHOLD)
182 + ).fetchone()
183 + if row:
184 + return row["creator_id"]
185 + return None
186 +
187 +
188 +def sync_source(con: sqlite3.Connection, source_id: str,
189 + creators: list[Creator]) -> dict:
190 + """Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out."""
191 + ts = now_iso()
192 + added = updated = skipped_optout = 0
193 + confs: list[float] = []
194 + for cr in creators:
195 + cr = ethics.scrub(cr)
196 + # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer
197 + if ethics.is_opted_out(cr):
198 + skipped_optout += 1
199 + existing = _find_existing(con, cr)
200 + if existing:
201 + con.execute("UPDATE creators SET status='opted_out', updated_at=? "
202 + "WHERE id=?", (ts, existing))
203 + continue
204 + confs.extend(a.confidence or 0 for a in cr.platforms)
205 + existing_id = _find_existing(con, cr)
206 + if existing_id:
207 + row = con.execute("SELECT * FROM creators WHERE id=?",
208 + (existing_id,)).fetchone()
209 + current = creator_from_row(row)
210 + if current.status == "opted_out":
211 + continue # masqué : on ne ré-agrège pas
212 + if should_merge(current, cr):
213 + merged = merge_creators(current, cr)
214 + _write_creator(con, existing_id, merged, row["first_seen"], ts)
215 + updated += 1
216 + continue
217 + cid = _unique_id(con, slugify(cr.display_name))
218 + _write_creator(con, cid, cr, ts, ts)
219 + added += 1
220 + con.commit()
221 + n_acc = sum(len(c.platforms) for c in creators)
222 + stats = {"source": source_id, "creators": len(creators), "accounts": n_acc,
223 + "added": added, "updated": updated, "optout_skipped": skipped_optout,
224 + "avg_confidence": round(sum(confs) / len(confs), 3) if confs else None}
225 + alert = None
226 + if not creators:
227 + alert = "0 créateur retourné — source possiblement bloquée"
228 + stats["alert"] = alert
229 + con.execute(
230 + "INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,"
231 + " added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,0,0,?)",
232 + (ts, source_id, len(creators), n_acc, stats["avg_confidence"],
233 + added, updated, alert))
234 + con.commit()
235 + return stats
236 +
237 +
238 +def log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:
239 + con.execute(
240 + "INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)"
241 + " VALUES (?,?,0,0,1,?)", (now_iso(), source_id, message[:500]))
242 + con.commit()
243 +
244 +
245 +def apply_optout(con: sqlite3.Connection, *, name: str | None = None,
246 + account: str | None = None) -> int:
247 + """Masque immédiatement les fiches correspondant à une demande de retrait."""
248 + ids: set[str] = set()
249 + if account and ":" in account:
250 + platform, handle = account.lower().split(":", 1)
251 + for row in con.execute(
252 + "SELECT creator_id FROM accounts WHERE platform=? AND handle=?",
253 + (platform, handle.lstrip("@"))):
254 + ids.add(row["creator_id"])
255 + if name:
256 + from .dedup import name_key
257 + for row in con.execute("SELECT id, display_name FROM creators"):
258 + if name_key(row["display_name"]) == name_key(name):
259 + ids.add(row["id"])
260 + ts = now_iso()
261 + for cid in ids:
262 + con.execute("UPDATE creators SET status='opted_out', updated_at=? WHERE id=?",
263 + (ts, cid))
264 + con.commit()
265 + return len(ids)
266 +
267 +
268 +# --- lecture (API) ----------------------------------------------------------------
269 +
270 +_TIERS = ("nano", "micro", "macro", "mega")
271 +
272 +
273 +def search(con: sqlite3.Connection, *, q: str = "", niche: str = "",
274 + region: str = "", langue: str = "", plateforme: str = "",
275 + tier: str = "", sort: str = "reach", limit: int = 60,
276 + offset: int = 0) -> dict:
277 + """Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.
278 +
279 + Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont
280 + exclues de l'annuaire public (prudence extrême, §15).
281 + """
282 + where = ["status='active'", "is_minor=0"]
283 + params: list = []
284 + if q:
285 + where.append("(lower(display_name) LIKE ? OR lower(bio) LIKE ?)")
286 + needle = f"%{q.lower()}%"
287 + params += [needle, needle]
288 + if niche:
289 + where.append("(','||niches||',') LIKE ?")
290 + params.append(f"%,{niche},%")
291 + if region:
292 + where.append("region=?")
293 + params.append(region)
294 + if langue:
295 + where.append("(','||languages||',') LIKE ?")
296 + params.append(f"%,{langue},%")
297 + if tier in _TIERS:
298 + where.append("audience_tier=?")
299 + params.append(tier)
300 + if plateforme:
301 + where.append("id IN (SELECT creator_id FROM accounts WHERE platform=? "
302 + "AND needs_review=0)")
303 + params.append(plateforme)
304 + order = {"reach": "total_reach IS NULL, total_reach DESC",
305 + "nom": "display_name COLLATE NOCASE ASC",
306 + "recent": "updated_at DESC"}.get(sort, "total_reach IS NULL, total_reach DESC")
307 + sql_where = " AND ".join(where)
308 + total = con.execute(f"SELECT COUNT(*) c FROM creators WHERE {sql_where}",
309 + params).fetchone()["c"]
310 + rows = con.execute(
311 + f"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?",
312 + params + [max(1, min(200, limit)), max(0, offset)]).fetchall()
313 + items = []
314 + for row in rows:
315 + accounts = con.execute(
316 + "SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC",
317 + (row["id"],)).fetchall()
318 + items.append(_public_dict(row, accounts))
319 + return {"total": total, "count": len(items), "offset": offset, "items": items}
320 +
321 +
322 +def get_creator(con: sqlite3.Connection, cid: str) -> dict | None:
323 + row = con.execute(
324 + "SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0",
325 + (cid,)).fetchone()
326 + if not row:
327 + return None
328 + accounts = con.execute(
329 + "SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC",
330 + (row["id"],)).fetchall()
331 + return _public_dict(row, accounts)
332 +
333 +
334 +def stats(con: sqlite3.Connection) -> dict:
335 + """Statistiques d'ensemble pour l'accueil de l'annuaire."""
336 + base = "FROM creators WHERE status='active' AND is_minor=0"
337 + n = con.execute(f"SELECT COUNT(*) c {base}").fetchone()["c"]
338 + n_acc = con.execute(
339 + "SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id"
340 + " WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0"
341 + ).fetchone()["c"]
342 + by_platform = {r["platform"]: r["c"] for r in con.execute(
343 + "SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 "
344 + "ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' "
345 + "AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC")}
346 + by_tier = {r["audience_tier"]: r["c"] for r in con.execute(
347 + f"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier")}
348 + niches: dict[str, int] = {}
349 + for row in con.execute(f"SELECT niches {base}"):
350 + for nch in (row["niches"] or "").split(","):
351 + if nch:
352 + niches[nch] = niches.get(nch, 0) + 1
353 + regions: dict[str, int] = {}
354 + for row in con.execute(f"SELECT region {base} AND region IS NOT NULL"):
355 + regions[row["region"]] = regions.get(row["region"], 0) + 1
356 + last_sync = con.execute(
357 + "SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1").fetchone()
358 + return {"creators": n, "accounts": n_acc, "by_platform": by_platform,
359 + "by_tier": by_tier,
360 + "by_niche": dict(sorted(niches.items(), key=lambda x: -x[1])),
361 + "by_region": dict(sorted(regions.items(), key=lambda x: -x[1])),
362 + "last_sync": last_sync["ts"] if last_sync else None}
added creaka/dedup.py +93 −0
@@ -0,0 +1,93 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/dedup.py
4 +# Desc: Déduplication des créateurs — fusion prudente multi-sources
5 +# (CLAUDE.md §12.2 : jamais de fusion sur le seul nom — homonymes !)
6 +# ==============================================================================
7 +"""Déduplication : un créateur = UNE fiche canonique.
8 +
9 +Règle de fusion (§12.2) : deux fiches ne fusionnent QUE si elles partagent au
10 +moins un compte (plateforme+handle) à confiance élevée des deux côtés. Le nom
11 +seul ne suffit JAMAIS — une erreur de fusion mélange deux personnes réelles.
12 +"""
13 +from __future__ import annotations
14 +
15 +from .identity import STRONG_THRESHOLD, merge_accounts
16 +from .normalize import strip_accents
17 +from .schema import Creator
18 +
19 +
20 +def name_key(name: str) -> str:
21 + """Clé de comparaison de nom (accents/casse neutralisés) — INDICATIVE seulement."""
22 + return " ".join(strip_accents(name or "").lower().split())
23 +
24 +
25 +def shared_strong_account(a: Creator, b: Creator) -> bool:
26 + """Vrai si a et b partagent ≥1 compte à confiance ≥ STRONG_THRESHOLD des 2 côtés."""
27 + strong_a = {acc.key for acc in a.platforms
28 + if (acc.confidence or 0) >= STRONG_THRESHOLD}
29 + for acc in b.platforms:
30 + if acc.key in strong_a and (acc.confidence or 0) >= STRONG_THRESHOLD:
31 + return True
32 + return False
33 +
34 +
35 +def should_merge(a: Creator, b: Creator) -> bool:
36 + """Décision de fusion : compte fort partagé requis. Homonymes ≠ fusion."""
37 + return shared_strong_account(a, b)
38 +
39 +
40 +def merge_creators(canon: Creator, other: Creator) -> Creator:
41 + """Fusionne `other` dans `canon` : union des comptes, meilleure valeur par champ.
42 +
43 + On ne perd aucun lien vérifié ; les champs descriptifs prennent la valeur
44 + la plus riche (bio la plus longue, champ non nul).
45 + """
46 + canon.platforms = merge_accounts(canon.platforms, other.platforms)
47 + canon.source_ids = sorted(set(canon.source_ids) | set(other.source_ids))
48 + if len(other.bio or "") > len(canon.bio or ""):
49 + canon.bio = other.bio
50 + if len(other.notes or "") > len(canon.notes or ""):
51 + canon.notes = other.notes
52 + canon.niches = sorted(set(canon.niches) | set(other.niches)) or ["autre"]
53 + canon.languages = sorted(set(canon.languages) | set(other.languages)) or ["fr"]
54 + for field_ in ("region", "city", "legal_name", "business_contact",
55 + "agency", "link_in_bio_url", "avatar_url"):
56 + if getattr(canon, field_) in (None, "") and getattr(other, field_):
57 + setattr(canon, field_, getattr(other, field_))
58 + # prudence maximale : si UNE source signale un mineur, le régime s'applique
59 + canon.is_minor = canon.is_minor or other.is_minor
60 + # opt-out prime sur tout (§15)
61 + if "opted_out" in (canon.status, other.status):
62 + canon.status = "opted_out"
63 + # recalculer les agrégats (tier, portée, plateforme principale)
64 + canon.audience_tier = ""
65 + canon.primary_platform = ""
66 + canon.total_reach = None
67 + return canon.finalize()
68 +
69 +
70 +def dedupe(creators: list[Creator]) -> list[Creator]:
71 + """Déduplique une liste de fiches (intra-lot). Utilisé par l'ingestion.
72 +
73 + Index par clé de compte : deux fiches partageant un compte fort fusionnent.
74 + """
75 + canon_by_acc: dict[str, Creator] = {}
76 + result: list[Creator] = []
77 + for cr in creators:
78 + target = None
79 + for acc in cr.platforms:
80 + if (acc.confidence or 0) >= STRONG_THRESHOLD and acc.key in canon_by_acc:
81 + cand = canon_by_acc[acc.key]
82 + if should_merge(cand, cr):
83 + target = cand
84 + break
85 + if target is None:
86 + result.append(cr)
87 + target = cr
88 + else:
89 + merge_creators(target, cr)
90 + for acc in target.platforms:
91 + if (acc.confidence or 0) >= STRONG_THRESHOLD:
92 + canon_by_acc[acc.key] = target
93 + return result
added creaka/ethics.py +114 −0
@@ -0,0 +1,114 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/ethics.py
4 +# Desc: Vie privée & éthique — garde anti-données-privées, opt-out persistant,
5 +# régime restreint des mineurs (CLAUDE.md §15 — PRIME SUR TOUT)
6 +# ==============================================================================
7 +"""Contrôles éthiques appliqués à CHAQUE fiche avant stockage.
8 +
9 +- `scrub()` : purge toute donnée d'apparence privée (téléphones, adresses
10 + civiques, courriels non déclarés comme professionnels publics).
11 +- Opt-out : liste de suppression persistante (data/optout.json). Une fiche
12 + opted_out est masquée et EXCLUE des ré-agrégations futures.
13 +- Mineurs : régime restreint (pas de contact, pas de localisation précise).
14 +"""
15 +from __future__ import annotations
16 +
17 +import json
18 +import re
19 +import threading
20 +from pathlib import Path
21 +
22 +from .dedup import name_key
23 +from .schema import Creator
24 +
25 +ROOT = Path(__file__).resolve().parent.parent
26 +OPTOUT_PATH = ROOT / "data" / "optout.json"
27 +OPTOUT_REQUESTS_PATH = ROOT / "data" / "optout_requests.jsonl"
28 +
29 +_lock = threading.Lock()
30 +
31 +# téléphone nord-américain / adresse civique — n'ont RIEN à faire dans une bio
32 +_PHONE_RE = re.compile(r"\(?\b\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4}\b")
33 +_CIVIC_RE = re.compile(
34 + r"\b\d{1,5},?\s+(?:rue|avenue|av\.|boulevard|boul\.|chemin|app\.?|apt\.?)\s", re.I)
35 +_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
36 +# domaines de courriel personnel : jamais conservés même si trouvés en clair
37 +_PERSONAL_EMAIL_DOMAINS = ("gmail.", "hotmail.", "outlook.", "yahoo.", "icloud.",
38 + "videotron.", "live.", "protonmail.", "me.com")
39 +
40 +
41 +def scrub(creator: Creator) -> Creator:
42 + """Purge les données privées d'une fiche (bio, notes, contact). Idempotent."""
43 + for field_ in ("bio", "notes"):
44 + text = getattr(creator, field_) or ""
45 + text = _PHONE_RE.sub("[retiré]", text)
46 + text = _CIVIC_RE.sub("[retiré] ", text)
47 + text = _EMAIL_RE.sub("[retiré]", text)
48 + setattr(creator, field_, text)
49 + # business_contact : uniquement un courriel PRO affiché publiquement.
50 + # Un domaine grand public (gmail, hotmail…) est traité comme personnel → retiré.
51 + bc = creator.business_contact
52 + if bc:
53 + bc = bc.strip().lower()
54 + if not _EMAIL_RE.fullmatch(bc) or any(d in bc for d in _PERSONAL_EMAIL_DOMAINS):
55 + creator.business_contact = None
56 + # régime restreint des mineurs (§15)
57 + if creator.is_minor:
58 + creator.business_contact = None
59 + creator.city = None
60 + creator.region = None
61 + creator.legal_name = None
62 + return creator
63 +
64 +
65 +# --- opt-out -------------------------------------------------------------------
66 +
67 +def _load_optout() -> dict:
68 + if OPTOUT_PATH.exists():
69 + try:
70 + return json.loads(OPTOUT_PATH.read_text(encoding="utf-8"))
71 + except Exception:
72 + pass
73 + return {"names": [], "accounts": []}
74 +
75 +
76 +def optout_keys() -> tuple[set[str], set[str]]:
77 + """(clés de noms, clés de comptes plateforme:handle) de la liste de suppression."""
78 + data = _load_optout()
79 + names = {name_key(n) for n in data.get("names", []) if n}
80 + accounts = {a.strip().lower() for a in data.get("accounts", []) if a}
81 + return names, accounts
82 +
83 +
84 +def is_opted_out(creator: Creator) -> bool:
85 + """Vrai si la fiche correspond à la liste de suppression persistante.
86 +
87 + Correspondance par COMPTE (plateforme:handle — fiable) ou par nom exact
88 + (filet de sécurité : on préfère masquer trop que pas assez, §15).
89 + """
90 + names, accounts = optout_keys()
91 + if name_key(creator.display_name) in names:
92 + return True
93 + return any(acc.key in accounts for acc in creator.platforms)
94 +
95 +
96 +def add_optout(name: str | None = None, account: str | None = None,
97 + reason: str = "", contact: str = "") -> None:
98 + """Ajoute une entrée à la liste de suppression + journalise la demande."""
99 + with _lock:
100 + data = _load_optout()
101 + if name and name_key(name) not in {name_key(n) for n in data["names"]}:
102 + data["names"].append(name)
103 + if account:
104 + account = account.strip().lower()
105 + if account not in data["accounts"]:
106 + data["accounts"].append(account)
107 + OPTOUT_PATH.parent.mkdir(parents=True, exist_ok=True)
108 + OPTOUT_PATH.write_text(
109 + json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
110 + from .schema import now_iso
111 + with OPTOUT_REQUESTS_PATH.open("a", encoding="utf-8") as fh:
112 + fh.write(json.dumps({"ts": now_iso(), "name": name, "account": account,
113 + "reason": reason[:500], "contact": contact[:200]},
114 + ensure_ascii=False) + "\n")
added creaka/identity.py +81 −0
@@ -0,0 +1,81 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/identity.py
4 +# Desc: Résolution d'identité — rattacher les bons comptes à la bonne personne
5 +# avec un score de confiance par signal (CLAUDE.md §12.1)
6 +# ==============================================================================
7 +"""Résolution d'identité : LE cœur du projet.
8 +
9 +Chaque rattachement de compte porte un score `confidence` selon le signal qui
10 +l'a produit (du plus fort au plus faible, §12.1). Sous `REVIEW_THRESHOLD`, le
11 +compte est marqué « à vérifier » et EXCLU de la fiche publique plutôt que
12 +rattaché à tort — mieux vaut un lien vérifié de moins qu'un lien erroné.
13 +"""
14 +from __future__ import annotations
15 +
16 +from .schema import PlatformAccount
17 +
18 +# signaux §12.1, du plus fort au plus faible
19 +SIGNAL_CONFIDENCE: dict[str, float] = {
20 + "profil_source": 0.98, # le compte DONT la fiche est issue (attribution inhérente)
21 + "link_in_bio": 0.95, # page Linktree/Beacons du créateur listant le compte
22 + "api_officielle": 0.95, # confirmé par l'API officielle de la plateforme
23 + "cross_link": 0.90, # un profil vérifié pointe vers l'autre (bio IG → TikTok)
24 + "listes_medias": 0.85, # listes/palmarès médias croisés (plusieurs sources concordantes)
25 + "base_publique": 0.85, # base de connaissances publique curée (Wikidata/Wikipédia)
26 + "handle_match": 0.75, # même handle + même nom d'affichage sur 2 plateformes
27 + "mention": 0.60, # mentions réciproques / mêmes liens externes
28 + "inconnu": 0.40,
29 +}
30 +
31 +# sous ce seuil : « à vérifier », jamais publié comme rattaché
32 +REVIEW_THRESHOLD = 0.50
33 +# à partir de ce seuil : compte considéré fiable pour la déduplication (§12.2)
34 +STRONG_THRESHOLD = 0.75
35 +
36 +
37 +def confidence_for(signal: str) -> float:
38 + return SIGNAL_CONFIDENCE.get(signal, SIGNAL_CONFIDENCE["inconnu"])
39 +
40 +
41 +def account(platform: str, handle: str, signal: str, *,
42 + followers: int | None = None, url: str = "",
43 + verified: bool | None = None) -> PlatformAccount:
44 + """Fabrique un compte avec la confiance du signal fourni (helper connecteurs)."""
45 + return PlatformAccount(platform=platform, handle=handle, url=url,
46 + followers=followers, verified=verified,
47 + confidence=confidence_for(signal), signal=signal)
48 +
49 +
50 +def needs_review(acc: PlatformAccount) -> bool:
51 + return (acc.confidence or 0.0) < REVIEW_THRESHOLD
52 +
53 +
54 +def is_strong(acc: PlatformAccount) -> bool:
55 + return (acc.confidence or 0.0) >= STRONG_THRESHOLD
56 +
57 +
58 +def merge_accounts(existing: list[PlatformAccount],
59 + incoming: list[PlatformAccount]) -> list[PlatformAccount]:
60 + """Union de deux listes de comptes — on ne perd AUCUN lien vérifié (§12.2).
61 +
62 + Même compte (plateforme+handle) : la meilleure confiance gagne ; les champs
63 + factuels (abonnés, badge) prennent la valeur la plus fraîche non nulle.
64 + """
65 + by_key: dict[str, PlatformAccount] = {a.key: a for a in existing}
66 + for acc in incoming:
67 + prev = by_key.get(acc.key)
68 + if prev is None:
69 + by_key[acc.key] = acc
70 + continue
71 + best = acc if (acc.confidence or 0) >= (prev.confidence or 0) else prev
72 + other = prev if best is acc else acc
73 + if best.followers is None:
74 + best.followers = other.followers
75 + if best.verified is None:
76 + best.verified = other.verified
77 + best.metrics = {**other.metrics, **best.metrics}
78 + best.last_checked = max(best.last_checked, other.last_checked)
79 + by_key[acc.key] = best
80 + return sorted(by_key.values(),
81 + key=lambda a: (a.followers is None, -(a.followers or 0)))
added creaka/ingest.py +100 −0
@@ -0,0 +1,100 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/ingest.py
4 +# Desc: Pipeline d'ingestion — découverte puis enrichissement par plateforme,
5 +# synchronisation de la base (ajouts / fusions / mises à jour)
6 +# ==============================================================================
7 +"""Pipeline Créa-Ka (calqué sur louka/ingest.py).
8 +
9 +Ordre d'un passage complet :
10 +1. connecteurs DÉCOUVERTE (listes médias, hashtags, agences…) → nouvelles fiches ;
11 +2. connecteurs ENRICHISSEMENT par plateforme (instagram, tiktok, youtube,
12 + twitch) → métriques fraîches + liens croisés, créateur par créateur ;
13 +3. link-in-bio EN DERNIER : il consomme les `link_in_bio_url` découvertes à
14 + l'étape 2 et révèle d'un coup tous les comptes restants (§8-9).
15 +
16 +Logging structuré par source : créateurs, comptes, confiance moyenne, durée,
17 +erreurs (§16) ; alerte si une source ne retourne plus rien (§18 monitoring).
18 +"""
19 +from __future__ import annotations
20 +
21 +import sys
22 +import time
23 +import traceback
24 +
25 +from . import db
26 +from .connectors import CONNECTORS
27 +from .dedup import dedupe
28 +
29 +# ordre d'exécution des connecteurs d'enrichissement (link-in-bio en dernier)
30 +ENRICH_ORDER = ["instagram-profil", "tiktok-profil", "youtube", "twitch",
31 + "link-in-bio"]
32 +
33 +
34 +def _finalize_batch(creators: list) -> tuple[list, int]:
35 + finalized, dropped = [], 0
36 + for cr in creators:
37 + try:
38 + finalized.append(cr.finalize())
39 + except Exception: # une fiche malformée ne bloque pas la source
40 + dropped += 1
41 + return finalized, dropped
42 +
43 +
44 +def load_active(con) -> list:
45 + """Fiches actives (jamais les opted_out : exclues des ré-agrégations, §15)."""
46 + rows = con.execute(
47 + "SELECT * FROM creators WHERE status='active' ORDER BY total_reach DESC"
48 + ).fetchall()
49 + return [db.creator_from_row(r) for r in rows]
50 +
51 +
52 +def run(sources: list[str] | None = None) -> list[dict]:
53 + """Exécute l'ingestion pour toutes les sources (ou celles demandées)."""
54 + con = db.connect()
55 + results: list[dict] = []
56 + discovery = [sid for sid, cls in CONNECTORS.items() if cls.kind == "discovery"]
57 + enrichment = [sid for sid in ENRICH_ORDER if sid in CONNECTORS]
58 + enrichment += [sid for sid, cls in CONNECTORS.items()
59 + if cls.kind == "enrichment" and sid not in enrichment]
60 + targets = sources or (discovery + enrichment)
61 +
62 + for sid in targets:
63 + cls = CONNECTORS.get(sid)
64 + if cls is None:
65 + print(f"[crea-ka] connecteur inconnu : {sid}", file=sys.stderr)
66 + continue
67 + t0 = time.time()
68 + print(f"[crea-ka] sync {sid} ({cls.kind}) ...")
69 + try:
70 + connector = cls()
71 + if cls.kind == "discovery":
72 + batch = connector.fetch()
73 + else:
74 + batch = connector.enrich(load_active(con))
75 + finalized, dropped = _finalize_batch(batch)
76 + finalized = dedupe(finalized) # dédup intra-lot avant la base
77 + stats = db.sync_source(con, sid, finalized)
78 + stats["seconds"] = round(time.time() - t0, 1)
79 + if dropped:
80 + stats["dropped"] = dropped
81 + if stats.get("alert") and cls.kind == "discovery":
82 + print(f"[crea-ka] ⚠ ALERTE {sid} : {stats['alert']}")
83 + print(f"[crea-ka] {stats}")
84 + results.append(stats)
85 + except Exception as exc: # robustesse : une source ne bloque pas les autres
86 + db.log_failure(con, sid, f"{exc}")
87 + traceback.print_exc()
88 + results.append({"source": sid, "error": str(exc)})
89 + return results
90 +
91 +
92 +def watch(interval_seconds: int = 86400) -> None:
93 + """Synchronisation en boucle (cadence §14 — défaut : quotidienne)."""
94 + while True:
95 + try:
96 + run()
97 + except Exception:
98 + traceback.print_exc()
99 + print(f"[crea-ka] prochain passage dans {interval_seconds}s")
100 + time.sleep(interval_seconds)
added creaka/normalize.py +362 −0
@@ -0,0 +1,362 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/normalize.py
4 +# 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.
8 +
9 +Chaque connecteur remplit des champs bruts ; `schema.Creator.finalize()`
10 +appelle ces fonctions pour produire une fiche canonique. Idempotent.
11 +"""
12 +from __future__ import annotations
13 +
14 +import re
15 +import unicodedata
16 +
17 +# --- taxonomies (CLAUDE.md §6) ------------------------------------------------
18 +NICHES = {
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 +}
24 +
25 +PLATFORMS = {
26 + "instagram", "tiktok", "youtube", "twitch", "kick", "x", "facebook",
27 + "snapchat", "substack", "patreon", "onlyfans", "linkedin", "threads",
28 + "podcast", "site-web", "autre",
29 +}
30 +
31 +CREATOR_TYPES = {
32 + "influenceur", "youtubeur", "streamer", "podcasteur", "createur-tiktok",
33 + "createur-ecrit", "musicien", "humoriste", "autre",
34 +}
35 +
36 +LANGUAGES = {"fr", "en", "bilingue"}
37 +
38 +REGIONS = {
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 +}
45 +
46 +# --- 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 + "linkedin": "https://www.linkedin.com/in/{h}",
61 + "threads": "https://www.threads.net/@{h}",
62 + "podcast": None,
63 + "site-web": None,
64 + "autre": None,
65 +}
66 +
67 +# détection de plateforme à partir d'une URL (pages link-in-bio, bios, etc.)
68 +_URL_PLATFORM_RES: list[tuple[str, re.Pattern]] = [
69 + ("instagram", re.compile(r"instagram\.com/([\w.\-]+)", re.I)),
70 + ("tiktok", re.compile(r"tiktok\.com/@([\w.\-]+)", re.I)),
71 + ("youtube", re.compile(r"youtube\.com/(?:@|c/|user/|channel/)([\w.\-]+)", re.I)),
72 + ("youtube", re.compile(r"youtu\.be/([\w.\-]+)", re.I)),
73 + ("twitch", re.compile(r"twitch\.tv/([\w.\-]+)", re.I)),
74 + ("kick", re.compile(r"kick\.com/([\w.\-]+)", re.I)),
75 + ("x", re.compile(r"(?:twitter|x)\.com/([\w.\-]+)", re.I)),
76 + ("facebook", re.compile(r"facebook\.com/([\w.\-]+)", re.I)),
77 + ("snapchat", re.compile(r"snapchat\.com/add/([\w.\-]+)", re.I)),
78 + ("substack", re.compile(r"https?://([\w\-]+)\.substack\.com", re.I)),
79 + ("patreon", re.compile(r"patreon\.com/([\w.\-]+)", re.I)),
80 + ("onlyfans", re.compile(r"onlyfans\.com/([\w.\-]+)", re.I)),
81 + ("linkedin", re.compile(r"linkedin\.com/in/([\w.\-]+)", re.I)),
82 + ("threads", re.compile(r"threads\.net/@?([\w.\-]+)", re.I)),
83 +]
84 +
85 +# chemins qui ne sont PAS des handles (pages génériques des plateformes)
86 +_NOT_HANDLES = {
87 + "p", "reel", "reels", "stories", "share", "watch", "video", "videos",
88 + "playlist", "shorts", "live", "intent", "hashtag", "explore", "home",
89 + "profile.php", "groups", "pages", "events", "posts", "status", "search",
90 +}
91 +
92 +
93 +def strip_accents(text: str) -> str:
94 + """« Bédard » → « Bedard » (clés de comparaison, jamais pour l'affichage)."""
95 + return "".join(c for c in unicodedata.normalize("NFD", text)
96 + if unicodedata.category(c) != "Mn")
97 +
98 +
99 +def slugify(text: str) -> str:
100 + """Nom d'affichage → slug d'identifiant interne (ex. « Enola Bédard » → enola-bedard)."""
101 + s = strip_accents(text or "").lower()
102 + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
103 + return s or "inconnu"
104 +
105 +
106 +def clean_handle(raw: str | None) -> str:
107 + """Nettoie un handle brut : retire @, URL, espaces, barre finale ; minuscules.
108 +
109 + Les handles des grandes plateformes sont insensibles à la casse — on
110 + normalise en minuscules pour la comparaison ET la forme canonique.
111 + """
112 + if not raw:
113 + return ""
114 + h = raw.strip()
115 + # si on a reçu une URL complète, garder le dernier segment utile
116 + if "://" in h or h.startswith("www."):
117 + h = h.rstrip("/").split("/")[-1]
118 + h = h.lstrip("@").split("?")[0].strip().strip("/")
119 + # ID de chaîne YouTube (UC…) : sensible à la casse — ne JAMAIS minusculiser
120 + if re.fullmatch(r"UC[0-9A-Za-z_-]{22}", h):
121 + return h
122 + return h.lower()
123 +
124 +
125 +def normalize_platform(raw: str | None) -> str:
126 + """Nom de plateforme brut → valeur canonique de §6.2 (défaut : « autre »)."""
127 + if not raw:
128 + return "autre"
129 + p = strip_accents(raw).lower().strip()
130 + aliases = {
131 + "ig": "instagram", "insta": "instagram", "instagram": "instagram",
132 + "tiktok": "tiktok", "tik-tok": "tiktok", "tik tok": "tiktok",
133 + "youtube": "youtube", "yt": "youtube",
134 + "twitch": "twitch", "kick": "kick",
135 + "twitter": "x", "x": "x",
136 + "facebook": "facebook", "fb": "facebook",
137 + "snapchat": "snapchat", "snap": "snapchat",
138 + "substack": "substack", "patreon": "patreon", "onlyfans": "onlyfans",
139 + "of": "onlyfans",
140 + "linkedin": "linkedin", "threads": "threads",
141 + "podcast": "podcast", "balado": "podcast",
142 + "site": "site-web", "site-web": "site-web", "website": "site-web",
143 + "web": "site-web", "blogue": "site-web", "blog": "site-web",
144 + }
145 + return aliases.get(p, p if p in PLATFORMS else "autre")
146 +
147 +
148 +def canonical_url(platform: str, handle: str, fallback: str = "") -> str:
149 + """Forme canonique de l'URL d'un compte (CLAUDE.md §11)."""
150 + h = clean_handle(handle)
151 + # chaîne YouTube identifiée par son ID (pas de @handle connu)
152 + if platform == "youtube" and re.fullmatch(r"UC[0-9A-Za-z_-]{22}", h):
153 + return f"https://www.youtube.com/channel/{h}"
154 + tpl = _URL_TEMPLATES.get(platform)
155 + if tpl and h:
156 + return tpl.format(h=h)
157 + return fallback or ""
158 +
159 +
160 +def platform_from_url(url: str) -> tuple[str, str] | None:
161 + """URL → (plateforme, handle) si reconnue, sinon None.
162 +
163 + Utilisé par le connecteur link-in-bio : chaque lien d'une page Linktree
164 + est classé ici. Les chemins génériques (posts, vidéos) sont rejetés.
165 + """
166 + if not url:
167 + return None
168 + for platform, rx in _URL_PLATFORM_RES:
169 + m = rx.search(url)
170 + if m:
171 + handle = clean_handle(m.group(1))
172 + if not handle or handle in _NOT_HANDLES:
173 + return None
174 + return platform, handle
175 + return None
176 +
177 +
178 +def clean_name(raw: str | None) -> str:
179 + """Nettoie un nom d'affichage (espaces, emojis décoratifs en bordure)."""
180 + if not raw:
181 + return ""
182 + name = re.sub(r"\s+", " ", raw).strip()
183 + # retirer les emojis/symboles en tête et en queue seulement (jamais au milieu :
184 + # certains noms de scène en contiennent volontairement)
185 + name = re.sub(r"^[\W_]+(?=\w)", "", name)
186 + name = re.sub(r"(?<=\w)[^\w).!?'’\"]+$", "", name)
187 + return name.strip()
188 +
189 +
190 +def clean_bio(raw: str | None) -> str:
191 + """Bio publique : espaces normalisés, longueur bornée."""
192 + if not raw:
193 + return ""
194 + bio = re.sub(r"[ \t]+", " ", raw).strip()
195 + return bio[:1200]
196 +
197 +
198 +def map_niche(raw: str | None) -> str:
199 + """Catégorie source → niche canonique (§6.1)."""
200 + if not raw:
201 + return "autre"
202 + c = strip_accents(raw).lower().strip()
203 + mapping = {
204 + "humour": "humour", "comedie": "humour", "comedy": "humour",
205 + "mode": "mode", "fashion": "mode",
206 + "beaute": "beaute", "beauty": "beaute", "makeup": "beaute",
207 + "lifestyle": "lifestyle", "tele-realite": "lifestyle",
208 + "famille": "famille-parentalite", "parentalite": "famille-parentalite",
209 + "famille-parentalite": "famille-parentalite", "maman": "famille-parentalite",
210 + "cuisine": "cuisine", "food": "bouffe-resto", "bouffe": "bouffe-resto",
211 + "bouffe-resto": "bouffe-resto", "resto": "bouffe-resto",
212 + "gaming": "gaming", "jeux video": "gaming", "jeux-video": "gaming",
213 + "tech": "tech", "technologie": "tech",
214 + "sport": "sport-fitness", "fitness": "sport-fitness",
215 + "sport-fitness": "sport-fitness", "entrainement": "sport-fitness",
216 + "plein air": "plein-air", "plein-air": "plein-air", "chasse": "plein-air",
217 + "peche": "plein-air", "outdoor": "plein-air",
218 + "voyage": "voyage", "travel": "voyage",
219 + "musique": "musique", "music": "musique",
220 + "arts": "arts", "art": "arts",
221 + "danse": "danse", "dance": "danse",
222 + "education": "education",
223 + "finance": "finance-affaires", "affaires": "finance-affaires",
224 + "finance-affaires": "finance-affaires", "entrepreneur": "finance-affaires",
225 + "business": "finance-affaires", "immobilier": "finance-affaires",
226 + "sante": "sante-mieux-etre", "mieux-etre": "sante-mieux-etre",
227 + "sante-mieux-etre": "sante-mieux-etre", "bien-etre": "sante-mieux-etre",
228 + "actualite": "actualite-opinion", "opinion": "actualite-opinion",
229 + "actualite-opinion": "actualite-opinion", "medias": "actualite-opinion",
230 + "podcast": "actualite-opinion",
231 + }
232 + return mapping.get(c, c if c in NICHES else "autre")
233 +
234 +
235 +# localisation publique déclarée → région administrative (§7).
236 +# ⚠️ On ne mappe QUE ce qui est sans ambiguïté ; « Québec » seul désigne la
237 +# PROVINCE dans les listes médias → region None. Jamais d'inférence privée.
238 +_CITY_REGION = {
239 + "montreal": "Montréal", "lachine": "Montréal", "verdun": "Montréal",
240 + "quebec (ville)": "Capitale-Nationale", "ville de quebec": "Capitale-Nationale",
241 + "charlevoix": "Capitale-Nationale",
242 + "laval": "Laval",
243 + "levis": "Chaudière-Appalaches", "beauce": "Chaudière-Appalaches",
244 + "gatineau": "Outaouais",
245 + "sherbrooke": "Estrie", "waterloo": "Estrie", # Haute-Yamaska → Estrie (2021)
246 + "granby": "Estrie",
247 + "trois-rivieres": "Mauricie", "shawinigan": "Mauricie",
248 + "saguenay": "Saguenay–Lac-Saint-Jean", "chicoutimi": "Saguenay–Lac-Saint-Jean",
249 + "alma": "Saguenay–Lac-Saint-Jean",
250 + "rimouski": "Bas-Saint-Laurent", "riviere-du-loup": "Bas-Saint-Laurent",
251 + "gaspe": "Gaspésie–Îles-de-la-Madeleine",
252 + "rouyn-noranda": "Abitibi-Témiscamingue", "val-d'or": "Abitibi-Témiscamingue",
253 + "drummondville": "Centre-du-Québec", "victoriaville": "Centre-du-Québec",
254 + "longueuil": "Montérégie", "brossard": "Montérégie",
255 + "saint-jean-sur-richelieu": "Montérégie",
256 + "blainville": "Laurentides", "pointe-calumet": "Laurentides",
257 + "saint-jerome": "Laurentides", "mont-tremblant": "Laurentides",
258 + "terrebonne": "Lanaudière", "repentigny": "Lanaudière", "joliette": "Lanaudière",
259 + "sept-iles": "Côte-Nord", "baie-comeau": "Côte-Nord",
260 +}
261 +
262 +
263 +def map_region(location: str | None) -> tuple[str | None, str | None]:
264 + """Localisation publique (texte libre) → (région §7, ville) ou (None, None).
265 +
266 + Prudence maximale : ne retourne une région que si la localisation déclarée
267 + est un lieu québécois reconnu sans ambiguïté. « Québec » seul = la province.
268 + """
269 + if not location:
270 + return None, None
271 + loc = strip_accents(location).lower().strip()
272 + # tronquer les précisions entre parenthèses SAUF « Québec (ville) »
273 + base = re.sub(r"\s*\(.*?\)\s*", " ", loc).strip().rstrip(",")
274 + if "quebec (ville)" in loc or base in ("ville de quebec",):
275 + return "Capitale-Nationale", "Québec"
276 + # retirer le suffixe province (« Blainville, Québec » → « blainville »)
277 + base = re.sub(r",?\s*(quebec|qc|canada)\s*$", "", base).strip().rstrip(",")
278 + if not base or base == "quebec":
279 + return None, None # province seule → région inconnue
280 + for city, region in _CITY_REGION.items():
281 + if base == city or base.startswith(city + ",") or base.endswith(" " + city):
282 + return region, location.split(",")[0].split("(")[0].strip()
283 + if base in {strip_accents(r).lower() for r in REGIONS}:
284 + for r in REGIONS:
285 + if strip_accents(r).lower() == base:
286 + return r, None
287 + return None, None
288 +
289 +
290 +def parse_count(raw) -> int | None:
291 + """« 1,2 M », « 12.3k », « 4 567 abonnés », 4567 → entier (None si inconnu)."""
292 + if raw is None:
293 + return None
294 + if isinstance(raw, (int, float)):
295 + return int(raw)
296 + if str(raw).strip().startswith("@"):
297 + return None # un handle (« @nom5b ») n'est pas un compteur
298 + # frontières de mot obligatoires — sinon « tw5bu » se lirait « 5 milliards »
299 + m = re.search(r"(?<![\w@.])([\d][\d\s .,]*)\s*([kKmMbB])?(?![\w])", str(raw))
300 + if not m:
301 + return None
302 + num = m.group(1).replace(" ", "").replace(" ", "")
303 + # « 1.234.567 » / « 1,234,567 » = séparateurs de milliers ; « 1,2 »/« 1.2 » = décimal
304 + if num.count(",") > 1 or (num.count(",") == 1 and len(num.split(",")[1]) == 3
305 + and not m.group(2)):
306 + num = num.replace(",", "")
307 + if num.count(".") > 1 or (num.count(".") == 1 and len(num.split(".")[1]) == 3
308 + and not m.group(2)):
309 + num = num.replace(".", "")
310 + num = num.replace(",", ".")
311 + try:
312 + value = float(num)
313 + except ValueError:
314 + return None
315 + mult = {"k": 1e3, "m": 1e6, "b": 1e9}.get((m.group(2) or "").lower(), 1)
316 + return int(value * mult)
317 +
318 +
319 +def audience_tier(followers: int | None) -> str:
320 + """Taille d'audience (§6.3), basée sur l'audience de la plateforme principale."""
321 + if not followers or followers <= 0:
322 + return "nano"
323 + if followers < 10_000:
324 + return "nano"
325 + if followers < 100_000:
326 + return "micro"
327 + if followers < 1_000_000:
328 + return "macro"
329 + return "mega"
330 +
331 +
332 +def infer_creator_type(platforms: list[str], niche: str) -> str:
333 + """Type de créateur par défaut selon sa plateforme dominante et sa niche."""
334 + first = platforms[0] if platforms else ""
335 + if first == "youtube":
336 + return "youtubeur"
337 + if first in ("twitch", "kick"):
338 + return "streamer"
339 + if first == "tiktok":
340 + return "createur-tiktok"
341 + if first in ("substack", "site-web"):
342 + return "createur-ecrit"
343 + if first == "podcast":
344 + return "podcasteur"
345 + if niche == "humour":
346 + return "humoriste"
347 + if niche == "musique":
348 + return "musicien"
349 + return "influenceur"
350 +
351 +
352 +def guess_language(*texts: str) -> str:
353 + """Langue de création (§6.4) à partir des textes publics. Défaut : fr (Québec)."""
354 + blob = strip_accents(" ".join(t for t in texts if t)).lower()
355 + if not blob:
356 + return "fr"
357 + if re.search(r"\b(bilingue|bilingual|fr/en|en/fr)\b", blob):
358 + return "bilingue"
359 + if re.search(r"\b(contenu|content)\s+(quotidien\s+)?(en\s+)?anglais\b", blob) \
360 + or re.search(r"\benglish(-|\s)?(only|content|speaking)\b", blob):
361 + return "en"
362 + return "fr"
added creaka/schema.py +191 −0
@@ -0,0 +1,191 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/schema.py
4 +# 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.
8 +
9 +Chaque connecteur, peu importe la source, doit produire des objets `Creator`
10 +conformes. `finalize()` applique la couche commune (creaka/normalize.py) :
11 +handles nettoyés, URLs canoniques, niches/région/langue/tier — les connecteurs
12 +restent simples et remplissent les champs bruts.
13 +
14 +Validation stricte (CLAUDE.md §18) : un compte sans `url` ou sans `confidence`
15 +est REJETÉ à la finalisation.
16 +"""
17 +from __future__ import annotations
18 +
19 +import hashlib
20 +import json
21 +from dataclasses import dataclass, field, asdict
22 +from datetime import datetime, timezone
23 +
24 +from .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 +)
39 +
40 +__all__ = ["Creator", "PlatformAccount", "now_iso"]
41 +
42 +
43 +def now_iso() -> str:
44 + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
45 +
46 +
47 +@dataclass
48 +class PlatformAccount:
49 + """Un compte public d'un créateur sur UNE plateforme (§5)."""
50 +
51 + platform: str # * canonique §6.2
52 + handle: str # * sans @, minuscules
53 + url: str = "" # * forme canonique
54 + 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-8601
58 + 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)
61 +
62 + 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 self
78 +
79 + @property
80 + def key(self) -> str:
81 + """Clé d'unicité d'un compte : plateforme + handle normalisé."""
82 + return f"{self.platform}:{self.handle}"
83 +
84 +
85 +@dataclass
86 +class Creator:
87 + """Fiche créateur canonique Créa-Ka (CLAUDE.md §5)."""
88 +
89 + source: str # id de la source (registre data/sources.json)
90 + external_id: str # identifiant chez la source
91 + display_name: str # * nom public / nom de scène
92 + legal_name: str | None = None # SEULEMENT si rendu public par le créateur
93 + 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 None
97 + city: str | None = None
98 + 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 = None
102 + avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)
103 + total_reach: int | None = None
104 + audience_tier: str = ""
105 + business_contact: str | None = None # courriel PRO affiché publiquement (§15)
106 + agency: str | None = None
107 + is_minor: bool = False
108 + status: str = "active" # active | inactive | opted_out
109 + notes: str = "" # contexte public (listes médias), affichable
110 + source_ids: list[str] = field(default_factory=list)
111 +
112 + @property
113 + def uid(self) -> str:
114 + return f"{self.source}:{self.external_id}"
115 +
116 + def content_hash(self) -> str:
117 + """Hash du contenu pour la détection de changements."""
118 + payload = asdict(self)
119 + for acc in payload["platforms"]:
120 + acc.pop("last_checked", None) # horodatage ≠ changement de contenu
121 + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
122 + return hashlib.sha256(blob.encode("utf-8")).hexdigest()
123 +
124 + def finalize(self) -> "Creator":
125 + """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
126 +
127 + Idempotent ; ne remplace jamais une valeur explicite du connecteur.
128 + Rejette la fiche si aucun compte valide (un créateur sans lien n'a pas
129 + sa place dans un annuaire de liens).
130 + """
131 + self.display_name = clean_name(self.display_name)
132 + if not self.display_name:
133 + raise ValueError("créateur sans nom d'affichage")
134 + if not self.external_id:
135 + self.external_id = slugify(self.display_name)
136 + self.bio = clean_bio(self.bio)
137 +
138 + # comptes : normaliser, valider, dédupliquer (meilleure confiance gagne)
139 + seen: dict[str, PlatformAccount] = {}
140 + for acc in self.platforms:
141 + try:
142 + acc = acc.finalize()
143 + except ValueError:
144 + continue # un compte invalide ne bloque pas la fiche (§18 : rejeté)
145 + prev = seen.get(acc.key)
146 + if prev is None or (acc.confidence or 0) > (prev.confidence or 0):
147 + if prev is not None and prev.followers and not acc.followers:
148 + acc.followers = prev.followers
149 + if prev is not None:
150 + acc.metrics = {**prev.metrics, **acc.metrics}
151 + seen[acc.key] = acc
152 + # tri : abonnés décroissants (None en dernier), pour primary_platform
153 + self.platforms = sorted(
154 + seen.values(), key=lambda a: (a.followers is None, -(a.followers or 0)))
155 + if not self.platforms:
156 + raise ValueError(f"{self.display_name}: aucun compte valide")
157 +
158 + self.niches = [map_niche(n) for n in (self.niches or ["autre"])]
159 + self.niches = sorted({n for n in self.niches if n in NICHES}) or ["autre"]
160 +
161 + if not self.primary_platform:
162 + self.primary_platform = self.platforms[0].platform
163 + self.primary_platform = normalize_platform(self.primary_platform)
164 + if self.primary_platform not in PLATFORMS:
165 + self.primary_platform = self.platforms[0].platform
166 +
167 + if not self.languages:
168 + self.languages = [guess_language(self.bio, self.notes)]
169 + self.languages = [l for l in self.languages if l in LANGUAGES] or ["fr"]
170 +
171 + if not self.creator_type:
172 + self.creator_type = infer_creator_type(
173 + [a.platform for a in self.platforms], self.niches[0])
174 +
175 + # portée totale : somme indicative des audiences connues
176 + known = [a.followers for a in self.platforms if a.followers]
177 + self.total_reach = sum(known) if known else None
178 + primary = next((a.followers for a in self.platforms
179 + if a.platform == self.primary_platform and a.followers), None)
180 + if not self.audience_tier:
181 + self.audience_tier = audience_tier(primary or (max(known) if known else None))
182 +
183 + if not self.source_ids:
184 + self.source_ids = [self.uid]
185 +
186 + # régime restreint pour les mineurs (§15) — appliqué aussi dans ethics.py
187 + if self.is_minor:
188 + self.business_contact = None
189 + self.city = None
190 + self.region = None
191 + return self
added creaka/web.py +130 −0
@@ -0,0 +1,130 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/web.py
4 +# Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) —
5 +# annuaire cherchable par niche, région, langue, plateforme, taille
6 +# ==============================================================================
7 +from __future__ import annotations
8 +
9 +import json
10 +from pathlib import Path
11 +
12 +from fastapi import FastAPI, HTTPException, Query
13 +from fastapi.middleware.cors import CORSMiddleware
14 +from fastapi.middleware.gzip import GZipMiddleware
15 +from fastapi.responses import FileResponse
16 +from pydantic import BaseModel, Field
17 +
18 +from . import db, ethics
19 +from .normalize import NICHES, PLATFORMS, REGIONS
20 +
21 +ROOT = Path(__file__).resolve().parent.parent
22 +FRONTEND_DIST = ROOT / "frontend" / "dist"
23 +SOURCES_PATH = ROOT / "data" / "sources.json"
24 +
25 +app = FastAPI(title="Créa-Ka API", version="1.0",
26 + description="Annuaire public des créateurs de contenu québécois "
27 + "— tous leurs comptes, toutes leurs plateformes.")
28 +app.add_middleware(CORSMiddleware, allow_origins=["*"],
29 + allow_methods=["*"], allow_headers=["*"])
30 +app.add_middleware(GZipMiddleware, minimum_size=1000)
31 +
32 +_con = db.connect()
33 +
34 +
35 +@app.get("/healthz")
36 +def healthz():
37 + return {"ok": True}
38 +
39 +
40 +@app.get("/api/health")
41 +def health():
42 + s = db.stats(_con)
43 + return {"ok": True, "creators": s["creators"], "accounts": s["accounts"],
44 + "last_sync": s["last_sync"]}
45 +
46 +
47 +@app.get("/api/creators")
48 +def list_creators(q: str = "", niche: str = "", region: str = "",
49 + langue: str = "", plateforme: str = "", tier: str = "",
50 + sort: str = "reach",
51 + limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):
52 + return db.search(_con, q=q.strip(), niche=niche, region=region,
53 + langue=langue, plateforme=plateforme, tier=tier,
54 + sort=sort, limit=limit, offset=offset)
55 +
56 +
57 +@app.get("/api/creators/{cid}")
58 +def get_creator(cid: str):
59 + creator = db.get_creator(_con, cid)
60 + if creator is None:
61 + raise HTTPException(404, "créateur introuvable")
62 + return creator
63 +
64 +
65 +@app.get("/api/stats")
66 +def get_stats():
67 + return db.stats(_con)
68 +
69 +
70 +@app.get("/api/taxonomies")
71 +def taxonomies():
72 + return {"niches": sorted(NICHES), "plateformes": sorted(PLATFORMS),
73 + "regions": sorted(REGIONS),
74 + "tiers": ["nano", "micro", "macro", "mega"],
75 + "langues": ["fr", "en", "bilingue"]}
76 +
77 +
78 +@app.get("/api/sources")
79 +def sources():
80 + if SOURCES_PATH.exists():
81 + return json.loads(SOURCES_PATH.read_text(encoding="utf-8"))
82 + return {"sources": []}
83 +
84 +
85 +class OptOutRequest(BaseModel):
86 + """Demande de retrait (§15 — opt-out obligatoire et respecté)."""
87 + name: str = Field("", max_length=200)
88 + account: str = Field("", max_length=200,
89 + description="plateforme:handle, ex. instagram:monnom")
90 + reason: str = Field("", max_length=500)
91 + contact: str = Field("", max_length=200)
92 +
93 +
94 +@app.post("/api/optout")
95 +def optout(req: OptOutRequest):
96 + """Retrait immédiat : la fiche est masquée et exclue des ré-agrégations.
97 +
98 + La vie privée prime sur la complétude (§15) : on masque d'abord, la
99 + demande est journalisée pour vérification humaine ensuite.
100 + """
101 + name = req.name.strip() or None
102 + acc = req.account.strip().lower() or None
103 + if not name and not acc:
104 + raise HTTPException(400, "préciser un nom ou un compte (plateforme:handle)")
105 + ethics.add_optout(name=name, account=acc, reason=req.reason,
106 + contact=req.contact)
107 + masked = db.apply_optout(_con, name=name, account=acc)
108 + return {"ok": True, "masked": masked,
109 + "message": "Fiche masquée. La demande sera vérifiée ; merci."}
110 +
111 +
112 +# --- frontend statique -----------------------------------------------------------
113 +
114 +@app.get("/")
115 +def index():
116 + idx = FRONTEND_DIST / "index.html"
117 + if idx.exists():
118 + return FileResponse(idx)
119 + raise HTTPException(404, "frontend non construit")
120 +
121 +
122 +@app.get("/{path:path}")
123 +def static_files(path: str):
124 + target = (FRONTEND_DIST / path).resolve()
125 + if target.is_file() and str(target).startswith(str(FRONTEND_DIST.resolve())):
126 + return FileResponse(target)
127 + idx = FRONTEND_DIST / "index.html"
128 + if idx.exists():
129 + return FileResponse(idx) # SPA fallback
130 + raise HTTPException(404, "introuvable")
added data/optout.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "_author": "Simon-Pierre Boucher <contact@spboucher.ai>",
3 + "_file": "data/optout.json",
4 + "_desc": "Liste de suppression persistante (opt-out §15) — fiches masquées et exclues des ré-agrégations futures",
5 + "names": [],
6 + "accounts": []
7 +}
added data/seed/qc_influenceurs.json +10732 −0
@@ -0,0 +1,10732 @@
1 +[
2 + {
3 + "name": "Enola Bédard",
4 + "handles": {
5 + "instagram": "enola.bedard",
6 + "tiktok": "enola.bedard",
7 + "youtube": "enola.bedard",
8 + "facebook": null,
9 + "x": null,
10 + "twitch": null
11 + },
12 + "metrics": {
13 + "instagram_followers": 2600000,
14 + "tiktok_followers": 17000000,
15 + "youtube_subscribers": 4900000,
16 + "facebook_followers": 1100000,
17 + "x_followers": null,
18 + "twitch_followers": null
19 + },
20 + "category": "danse",
21 + "location": "Los Angeles (originaire du Québec)",
22 + "notes": "danseuse TikTok originaire de la ville de Québec | Québécoise la plus suivie sur TikTok grâce à la danse; contenu quotidien en anglais, portée internationale. Chiffres ~2021-2022. | Danseuse/chorégraphe virale sur TikTok pendant la pandémie; #1 des tops frank, metricool et Favikon Québec (24,4M abonnés cumulés selon Favikon). Aussi actrice et chanteuse.",
23 + "sources": [
24 + "billie",
25 + "alexturcotte_tiktok",
26 + "frank",
27 + "metricool",
28 + "canadult",
29 + "favikon_qc"
30 + ],
31 + "total_followers": 25600000,
32 + "platform_count": 4,
33 + "tier": "méga (1M+)"
34 + },
35 + {
36 + "name": "Céline Dion",
37 + "handles": {
38 + "instagram": null,
39 + "tiktok": "celinedion",
40 + "youtube": "celinedion",
41 + "facebook": null,
42 + "x": null,
43 + "twitch": null
44 + },
45 + "metrics": {
46 + "instagram_followers": 4200000,
47 + "tiktok_followers": 5800000,
48 + "youtube_subscribers": 10200000,
49 + "facebook_followers": null,
50 + "x_followers": null,
51 + "twitch_followers": null
52 + },
53 + "category": "musique",
54 + "location": "Québec",
55 + "notes": "Mention comparative dans l'article (4.2M sur Instagram vs Enola Bédard). Chiffres ~2021-2022. | enrichi via recherche web 2026-08",
56 + "sources": [
57 + "alexturcotte_tiktok",
58 + "firecrawl_enrich"
59 + ],
60 + "total_followers": 20200000,
61 + "platform_count": 3,
62 + "tier": "méga (1M+)"
63 + },
64 + {
65 + "name": "Sarah-Jade Bleau",
66 + "handles": {
67 + "instagram": "sj.bleau",
68 + "tiktok": "sjbleau",
69 + "youtube": "sjbleau",
70 + "facebook": null,
71 + "x": null,
72 + "twitch": null
73 + },
74 + "metrics": {
75 + "instagram_followers": 1600000,
76 + "tiktok_followers": 14600000,
77 + "youtube_subscribers": 38200,
78 + "facebook_followers": null,
79 + "x_followers": null,
80 + "twitch_followers": null
81 + },
82 + "category": "danse",
83 + "location": "Québec",
84 + "notes": "carrière explosée sur TikTok en 2020 grâce à la danse | Vidéos virales amusantes, 2e Québécoise la plus suivie sur TikTok. Chiffres ~2021-2022. | Chorégraphies robotiques ultra précises sur TikTok (aussi @sjbleauellington); marque mode/beauté Champagnemami; partenaire L'Oréal Paris. 16,3M abonnés cumulés selon Favikon.",
85 + "sources": [
86 + "billie",
87 + "alexturcotte_tiktok",
88 + "frank",
89 + "metricool",
90 + "canadult",
91 + "favikon_qc"
92 + ],
93 + "total_followers": 16238200,
94 + "platform_count": 3,
95 + "tier": "méga (1M+)"
96 + },
97 + {
98 + "name": "Jordy Boulet-Viau",
99 + "handles": {
100 + "instagram": "jordybouletviau",
101 + "tiktok": null,
102 + "youtube": null,
103 + "facebook": null,
104 + "x": null,
105 + "twitch": null
106 + },
107 + "metrics": {
108 + "instagram_followers": 409700,
109 + "tiktok_followers": 6300000,
110 + "youtube_subscribers": 991000,
111 + "facebook_followers": 177000,
112 + "x_followers": null,
113 + "twitch_followers": null
114 + },
115 + "category": "humour",
116 + "location": "Montréal",
117 + "notes": "storytelling authentique, humour et santé mentale; 170K sur Snapchat",
118 + "sources": [
119 + "collabstr"
120 + ],
121 + "total_followers": 7877700,
122 + "platform_count": 4,
123 + "tier": "méga (1M+)"
124 + },
125 + {
126 + "name": "Shina Nova",
127 + "handles": {
128 + "instagram": "shinanova",
129 + "tiktok": "shinanova",
130 + "youtube": "shinanovayt",
131 + "facebook": null,
132 + "x": null,
133 + "twitch": null
134 + },
135 + "metrics": {
136 + "instagram_followers": 2000000,
137 + "tiktok_followers": 4400000,
138 + "youtube_subscribers": 254000,
139 + "facebook_followers": 415000,
140 + "x_followers": null,
141 + "twitch_followers": null
142 + },
143 + "category": "musique",
144 + "location": "Montréal",
145 + "notes": "no 1 du palmarès Billie; activiste et TikTokeuse inuite (chant de gorge) | Créatrice inuite, chant de gorge en duo avec sa mère; valorise la culture inuite et la santé mentale. #2 du top Favikon Québec (6,6M abonnés cumulés).",
146 + "sources": [
147 + "billie",
148 + "frank",
149 + "metricool",
150 + "favikon_qc"
151 + ],
152 + "total_followers": 7069000,
153 + "platform_count": 4,
154 + "tier": "méga (1M+)"
155 + },
156 + {
157 + "name": "Marc Fitt",
158 + "handles": {
159 + "instagram": "marc_fitt",
160 + "tiktok": null,
161 + "youtube": "marc fitt",
162 + "facebook": "marcfitt",
163 + "x": null,
164 + "twitch": null
165 + },
166 + "metrics": {
167 + "instagram_followers": 1200000,
168 + "tiktok_followers": null,
169 + "youtube_subscribers": 311000,
170 + "facebook_followers": 4100000,
171 + "x_followers": null,
172 + "twitch_followers": null
173 + },
174 + "category": "fitness",
175 + "location": "Montréal",
176 + "notes": "influenceur fitness, créateur de la marque Rise | Entraînement et fitness; 1M d'abonnés Instagram. Chiffres YouTube 2022. | Un des influenceurs fitness les plus suivis au Canada; programmes d'entraînement et podcast Fitt Talk. | enrichi via recherche web 2026-08; facebook = likes de page",
177 + "sources": [
178 + "billie",
179 + "billie_youtube",
180 + "frank",
181 + "firecrawl_enrich"
182 + ],
183 + "total_followers": 5611000,
184 + "platform_count": 3,
185 + "tier": "méga (1M+)"
186 + },
187 + {
188 + "name": "Adam Paradisio (ADAM)",
189 + "handles": {
190 + "instagram": "adam_de_youtube",
191 + "tiktok": "adamparadisio",
192 + "youtube": "adamparadisio",
193 + "facebook": null,
194 + "x": null,
195 + "twitch": null
196 + },
197 + "metrics": {
198 + "instagram_followers": null,
199 + "tiktok_followers": 3700000,
200 + "youtube_subscribers": 1855415,
201 + "facebook_followers": null,
202 + "x_followers": null,
203 + "twitch_followers": null
204 + },
205 + "category": "humour",
206 + "location": "Québec",
207 + "notes": "Gagnant Gala InfluenceCréation 2025 — Youtubeur de l'année et Vidéo de l'année, long format. Défis, pranks et vidéos de survie; 3,1M abonnés cumulés selon Favikon (#3). | enrichi via recherche web 2026-08",
208 + "sources": [
209 + "billie_gala",
210 + "grenier_gala",
211 + "favikon_qc",
212 + "firecrawl_enrich"
213 + ],
214 + "total_followers": 5555415,
215 + "platform_count": 3,
216 + "tier": "méga (1M+)"
217 + },
218 + {
219 + "name": "Denyzee",
220 + "handles": {
221 + "instagram": "denyzee_",
222 + "tiktok": "denyzee",
223 + "youtube": "denyzee",
224 + "facebook": null,
225 + "x": null,
226 + "twitch": null
227 + },
228 + "metrics": {
229 + "instagram_followers": 443000,
230 + "tiktok_followers": 898300,
231 + "youtube_subscribers": 2600000,
232 + "facebook_followers": 582000,
233 + "x_followers": null,
234 + "twitch_followers": null
235 + },
236 + "category": "humour",
237 + "location": "Québec (originaire de France)",
238 + "notes": "Vidéos humoristiques; retirée du classement Titan 2022 car s'identifie désormais en France. Chiffres 2022. | Figure incontournable du YouTube québécois; sketchs, expériences sociales et comparaisons France/Québec; installée au Canada depuis 2013. | enrichi via recherche web 2026-08",
239 + "sources": [
240 + "billie_youtube",
241 + "titan_youtube_2022",
242 + "metricool",
243 + "firecrawl_enrich"
244 + ],
245 + "total_followers": 4523300,
246 + "platform_count": 4,
247 + "tier": "méga (1M+)"
248 + },
249 + {
250 + "name": "Laurent Dagenais",
251 + "handles": {
252 + "instagram": "laurent.dagenais",
253 + "tiktok": "laurent.dagenais",
254 + "youtube": "laurentdagenais",
255 + "facebook": "cheflaurentdagenais",
256 + "x": null,
257 + "twitch": null
258 + },
259 + "metrics": {
260 + "instagram_followers": 1900000,
261 + "tiktok_followers": 2000000,
262 + "youtube_subscribers": 79200,
263 + "facebook_followers": 88000,
264 + "x_followers": null,
265 + "twitch_followers": null
266 + },
267 + "category": "food",
268 + "location": "Montréal",
269 + "notes": "Chef cuisinier de renom, recettes à portée internationale. Chiffres ~2021-2022. | Chef viral sur TikTok, livre Toujours Faim (2022), série Always Hungry. Gagnant Gala InfluenceCréation 2025 — Créateur de l'année, rayonnement international. | Chef #1 du top food Feedspot Québec (Mega); @toujourssoif, restaurant Limousine; 2M+ TikTok selon La Grappe",
270 + "sources": [
271 + "alexturcotte_tiktok",
272 + "frank",
273 + "metricool",
274 + "canadult",
275 + "billie_gala",
276 + "grenier_gala",
277 + "feedspot_food",
278 + "lagrappe_food"
279 + ],
280 + "total_followers": 4067200,
281 + "platform_count": 4,
282 + "tier": "méga (1M+)"
283 + },
284 + {
285 + "name": "Rachid Badouri",
286 + "handles": {
287 + "instagram": "rachidbadouri",
288 + "tiktok": "rachidbadouriofficiel",
289 + "youtube": "rachidbadouriofficiel",
290 + "facebook": null,
291 + "x": null,
292 + "twitch": null
293 + },
294 + "metrics": {
295 + "instagram_followers": 542000,
296 + "tiktok_followers": 2600000,
297 + "youtube_subscribers": null,
298 + "facebook_followers": null,
299 + "x_followers": null,
300 + "twitch_followers": null
301 + },
302 + "category": "humour",
303 + "location": "Montréal",
304 + "notes": "Humoriste, vedette québécoise la plus connue hors TikTok sur la plateforme. Chiffres ~2021-2022. | #1 humoriste QC sur les réseaux: 4 031 400 abonnés cumulés (FB+IG+TikTok+X, juin 2024); 2,6M sur TikTok seulement | enrichi via recherche web 2026-08",
305 + "sources": [
306 + "alexturcotte_tiktok",
307 + "jdm_humoristes",
308 + "firecrawl_enrich"
309 + ],
310 + "total_followers": 3142000,
311 + "platform_count": 3,
312 + "tier": "méga (1M+)"
313 + },
314 + {
315 + "name": "Polo (CB Games)",
316 + "handles": {
317 + "instagram": "polobleu_officiel",
318 + "tiktok": null,
319 + "youtube": "cbgamespolo",
320 + "facebook": null,
321 + "x": null,
322 + "twitch": null
323 + },
324 + "metrics": {
325 + "instagram_followers": 170000,
326 + "tiktok_followers": null,
327 + "youtube_subscribers": 2890000,
328 + "facebook_followers": null,
329 + "x_followers": null,
330 + "twitch_followers": null
331 + },
332 + "category": "gaming",
333 + "location": "Québec",
334 + "notes": "Gamer à l'énergie contagieuse; YouTubeur QC #1 avec le plus d'abonnés et #1 du top 100 Titan (depuis 2018). Chiffres 2022. | YouTubeur gaming (Fortnite, Pokémon, Minecraft), contenu bilingue FR/EN, aussi vie de père. 2,9M abonnés cumulés selon Favikon (#16). | enrichi via recherche web 2026-08",
335 + "sources": [
336 + "billie_youtube",
337 + "titan_youtube_2022",
338 + "favikon_qc",
339 + "firecrawl_enrich"
340 + ],
341 + "total_followers": 3060000,
342 + "platform_count": 2,
343 + "tier": "méga (1M+)"
344 + },
345 + {
346 + "name": "Willibed",
347 + "handles": {
348 + "instagram": "willibed",
349 + "tiktok": "willibed",
350 + "youtube": null,
351 + "facebook": null,
352 + "x": "willibed",
353 + "twitch": null
354 + },
355 + "metrics": {
356 + "instagram_followers": 76000,
357 + "tiktok_followers": 1600000,
358 + "youtube_subscribers": 1000000,
359 + "facebook_followers": null,
360 + "x_followers": null,
361 + "twitch_followers": null
362 + },
363 + "category": "humour",
364 + "location": "Québec",
365 + "notes": "Vidéos insolites et fun facts étranges. Listé deux fois (4e et 6e) dans l'article. Chiffres ~2021-2022. | YouTubeur de 21 ans, défis extrêmes et non conventionnels depuis 2013 (100K+ abonnés YouTube); collabs Sports Experts et Couche-Tard. 3M abonnés cumulés selon Favikon (#15). | enrichi via recherche web 2026-08; YouTube 1M+ selon post X",
366 + "sources": [
367 + "alexturcotte_tiktok",
368 + "favikon_qc",
369 + "firecrawl_enrich"
370 + ],
371 + "total_followers": 2676000,
372 + "platform_count": 4,
373 + "tier": "méga (1M+)"
374 + },
375 + {
376 + "name": "Cocotte (Arianne Vallières)",
377 + "handles": {
378 + "instagram": "cocottee._",
379 + "tiktok": null,
380 + "youtube": "cocotte",
381 + "facebook": null,
382 + "x": null,
383 + "twitch": "cocotte"
384 + },
385 + "metrics": {
386 + "instagram_followers": 1200000,
387 + "tiktok_followers": null,
388 + "youtube_subscribers": 1360000,
389 + "facebook_followers": null,
390 + "x_followers": null,
391 + "twitch_followers": null
392 + },
393 + "category": "gaming",
394 + "location": "Québec",
395 + "notes": "Streameuse Twitch/YouTube, millions d'abonnés cumulés; participation au GP Explorer. Top YouTubeurs QC 2026 (ONEWAY). | Streameuse Twitch à plein temps (547,4K abonnés Twitch); gagnante Gala InfluenceCréation 2025 — Streamer de l'année. #5 Favikon Québec (3,1M cumulés). | Streameuse gaming/Just Chatting, ex-pompière; large communauté en France, a participé au GP Explorer de Squeezie | enrichi via recherche web 2026-08",
396 + "sources": [
397 + "oneway_youtube",
398 + "metricool",
399 + "billie_gala",
400 + "grenier_gala",
401 + "favikon_qc",
402 + "leregard_twitch",
403 + "firecrawl_enrich"
404 + ],
405 + "total_followers": 2560000,
406 + "platform_count": 3,
407 + "tier": "méga (1M+)"
408 + },
409 + {
410 + "name": "tai_tl",
411 + "handles": {
412 + "instagram": "tai_tl",
413 + "tiktok": "tai_tl",
414 + "youtube": null,
415 + "facebook": null,
416 + "x": null,
417 + "twitch": null
418 + },
419 + "metrics": {
420 + "instagram_followers": 828000,
421 + "tiktok_followers": 1600000,
422 + "youtube_subscribers": null,
423 + "facebook_followers": null,
424 + "x_followers": null,
425 + "twitch_followers": null
426 + },
427 + "category": "humour",
428 + "location": "Québec",
429 + "notes": "Créatrice TikTok (1,1M+ abonnés TikTok): lip-sync, danse, humour; spectacles de comédie (Casino de Paris). 2,7M abonnés cumulés selon Favikon (#11). | enrichi via recherche web 2026-08; Tailaire Laguerre",
430 + "sources": [
431 + "favikon_qc",
432 + "firecrawl_enrich"
433 + ],
434 + "total_followers": 2428000,
435 + "platform_count": 2,
436 + "tier": "méga (1M+)"
437 + },
438 + {
439 + "name": "Jahan Tea",
440 + "handles": {
441 + "instagram": null,
442 + "tiktok": "iamjahantea",
443 + "youtube": null,
444 + "facebook": null,
445 + "x": null,
446 + "twitch": null
447 + },
448 + "metrics": {
449 + "instagram_followers": 282000,
450 + "tiktok_followers": 1300000,
451 + "youtube_subscribers": 721000,
452 + "facebook_followers": null,
453 + "x_followers": null,
454 + "twitch_followers": null
455 + },
456 + "category": "mode",
457 + "location": "Montréal",
458 + "notes": "Mode, lifestyle et humour; vibe « French girl from Montréal ». #9 Favikon Québec (1,8M cumulés).",
459 + "sources": [
460 + "metricool",
461 + "favikon_qc"
462 + ],
463 + "total_followers": 2303000,
464 + "platform_count": 3,
465 + "tier": "méga (1M+)"
466 + },
467 + {
468 + "name": "Nabil Lahrech",
469 + "handles": {
470 + "instagram": "aiequi",
471 + "tiktok": "nabil_lahrech",
472 + "youtube": "aiekillu",
473 + "facebook": null,
474 + "x": null,
475 + "twitch": null
476 + },
477 + "metrics": {
478 + "instagram_followers": 226000,
479 + "tiktok_followers": 71600,
480 + "youtube_subscribers": 1700000,
481 + "facebook_followers": null,
482 + "x_followers": 256600,
483 + "twitch_followers": null
484 + },
485 + "category": "gaming",
486 + "location": "Montréal",
487 + "notes": "youtubeur très populaire, coanimateur du podcast Sous Influence | Jeux vidéo et vie de papa. Chiffres 2022. Aussi #4 (gaming) au top 100 Titan 2022. | Figure du gaming francophone depuis 10+ ans; vidéos immersives de jeux de survie (7 Days to Die, The Forest, Contraband Police). | enrichi via recherche web 2026-08; alias Aiekillu",
488 + "sources": [
489 + "billie",
490 + "billie_youtube",
491 + "titan_youtube_2022",
492 + "metricool",
493 + "firecrawl_enrich"
494 + ],
495 + "total_followers": 2254200,
496 + "platform_count": 4,
497 + "tier": "méga (1M+)"
498 + },
499 + {
500 + "name": "Elisabeth Rioux",
501 + "handles": {
502 + "instagram": "elisabeth.rioux",
503 + "tiktok": "elisabeth.rioux",
504 + "youtube": "elisabeth rioux",
505 + "facebook": "elisabeth.rioux",
506 + "x": null,
507 + "twitch": null
508 + },
509 + "metrics": {
510 + "instagram_followers": 1800000,
511 + "tiktok_followers": 141200,
512 + "youtube_subscribers": 274000,
513 + "facebook_followers": 25100,
514 + "x_followers": null,
515 + "twitch_followers": null
516 + },
517 + "category": "entrepreneur",
518 + "location": "Blainville, Québec",
519 + "notes": "femme d'affaires, fondatrice de Hoaka Swimwear, Hoaka Apparel et Tasti | Vlogs sur sa vie, ses projets d'affaires et ses voyages. Chiffres 2022. | Fondatrice de Hoaka Swimwear à 18 ans; téléréalité Élisabeth Rioux: Unfiltered sur Prime Video (2024). #1 du top mainstream canadult (revenus estimés 1M$–5M$+). | Entrepreneure (Hoaka Swimwear); 1,8M abonnés IG selon l'article (embed récent: 1M); émission Influenceurs à VRAK",
520 + "sources": [
521 + "billie",
522 + "billie_youtube",
523 + "frank",
524 + "metricool",
525 + "canadult",
526 + "noovomoi_voyage"
527 + ],
528 + "total_followers": 2240300,
529 + "platform_count": 4,
530 + "tier": "méga (1M+)"
531 + },
532 + {
533 + "name": "Eugenie Bouchard",
534 + "handles": {
535 + "instagram": "geniebouchard",
536 + "tiktok": "geniebouchard",
537 + "youtube": null,
538 + "facebook": null,
539 + "x": null,
540 + "twitch": null
541 + },
542 + "metrics": {
543 + "instagram_followers": 2200000,
544 + "tiktok_followers": 24300,
545 + "youtube_subscribers": null,
546 + "facebook_followers": null,
547 + "x_followers": null,
548 + "twitch_followers": null
549 + },
550 + "category": "sport",
551 + "location": "Montréal, QC",
552 + "notes": "Joueuse de tennis; mention comparative dans l'article (2.1M sur Instagram). Chiffres ~2021-2022. | enrichi via recherche web 2026-08",
553 + "sources": [
554 + "alexturcotte_tiktok",
555 + "firecrawl_enrich"
556 + ],
557 + "total_followers": 2224300,
558 + "platform_count": 2,
559 + "tier": "méga (1M+)"
560 + },
561 + {
562 + "name": "Mahdi Ba",
563 + "handles": {
564 + "instagram": "mahdiba",
565 + "tiktok": "mahdibatv",
566 + "youtube": "mahdiba",
567 + "facebook": null,
568 + "x": null,
569 + "twitch": null
570 + },
571 + "metrics": {
572 + "instagram_followers": 564000,
573 + "tiktok_followers": null,
574 + "youtube_subscribers": 1640000,
575 + "facebook_followers": null,
576 + "x_followers": null,
577 + "twitch_followers": null
578 + },
579 + "category": "humour",
580 + "location": "Québec",
581 + "notes": "youtubeur populaire (orthographié « Madhi Ba » dans l'article Billie) | Ex-gamer devenu créateur humoristique, aussi production musicale. Chiffres 2022. | enrichi via recherche web 2026-08",
582 + "sources": [
583 + "billie",
584 + "billie_youtube",
585 + "firecrawl_enrich"
586 + ],
587 + "total_followers": 2204000,
588 + "platform_count": 3,
589 + "tier": "méga (1M+)"
590 + },
591 + {
592 + "name": "Domino Santantonio",
593 + "handles": {
594 + "instagram": "dominosantantonio",
595 + "tiktok": "dominosantantonio",
596 + "youtube": "dominosantantonio",
597 + "facebook": null,
598 + "x": null,
599 + "twitch": null
600 + },
601 + "metrics": {
602 + "instagram_followers": 526000,
603 + "tiktok_followers": 1400000,
604 + "youtube_subscribers": 264000,
605 + "facebook_followers": null,
606 + "x_followers": null,
607 + "twitch_followers": null
608 + },
609 + "category": "musique",
610 + "location": "Montréal, QC",
611 + "notes": "Batteure virtuose, contenu musical. Chiffres ~2021-2022. | enrichi via recherche web 2026-08",
612 + "sources": [
613 + "alexturcotte_tiktok",
614 + "firecrawl_enrich"
615 + ],
616 + "total_followers": 2190000,
617 + "platform_count": 3,
618 + "tier": "méga (1M+)"
619 + },
620 + {
621 + "name": "Emy Lalune",
622 + "handles": {
623 + "instagram": "emylalune",
624 + "tiktok": "emylalune",
625 + "youtube": "emy lalune",
626 + "facebook": null,
627 + "x": null,
628 + "twitch": null
629 + },
630 + "metrics": {
631 + "instagram_followers": 208000,
632 + "tiktok_followers": 1700000,
633 + "youtube_subscribers": 237000,
634 + "facebook_followers": null,
635 + "x_followers": null,
636 + "twitch_followers": null
637 + },
638 + "category": "humour",
639 + "location": "Québec",
640 + "notes": "Sketchs et humour décalé; projets en littérature jeunesse. TikTok chiffres ~2021-2022; classée top YouTubeurs QC 2026 par ONEWAY. | Gagnante Gala InfluenceCréation 2025 — TikTokeuse de l'année. Hauls, friperie et mode durable; 1,7M abonnés cumulés selon Favikon (#13). | enrichi via recherche web 2026-08",
641 + "sources": [
642 + "alexturcotte_tiktok",
643 + "oneway_youtube",
644 + "billie_youtube",
645 + "billie_gala",
646 + "grenier_gala",
647 + "favikon_qc",
648 + "firecrawl_enrich"
649 + ],
650 + "total_followers": 2145000,
651 + "platform_count": 3,
652 + "tier": "méga (1M+)"
653 + },
654 + {
655 + "name": "Carl is cooking",
656 + "handles": {
657 + "instagram": "carlarsenault",
658 + "tiktok": "carl.is.cooking",
659 + "youtube": "carl is cooking",
660 + "facebook": null,
661 + "x": null,
662 + "twitch": null
663 + },
664 + "metrics": {
665 + "instagram_followers": 177000,
666 + "tiktok_followers": 427300,
667 + "youtube_subscribers": 1400000,
668 + "facebook_followers": null,
669 + "x_followers": null,
670 + "twitch_followers": null
671 + },
672 + "category": "food",
673 + "location": "Québec",
674 + "notes": "Recettes originales publiées très régulièrement. Chiffres 2022. | Youtubeur pâtisserie et food hacks; finaliste du Meilleur Pâtissier (France); chiffres de 2019 | enrichi via recherche web 2026-08",
675 + "sources": [
676 + "billie_youtube",
677 + "clark_food",
678 + "firecrawl_enrich"
679 + ],
680 + "total_followers": 2004300,
681 + "platform_count": 3,
682 + "tier": "méga (1M+)"
683 + },
684 + {
685 + "name": "Audrey-Anne J",
686 + "handles": {
687 + "instagram": "audreyannej",
688 + "tiktok": "audreyanne.j",
689 + "youtube": null,
690 + "facebook": null,
691 + "x": null,
692 + "twitch": null
693 + },
694 + "metrics": {
695 + "instagram_followers": 1178875,
696 + "tiktok_followers": 768500,
697 + "youtube_subscribers": null,
698 + "facebook_followers": null,
699 + "x_followers": null,
700 + "twitch_followers": null
701 + },
702 + "category": "beauté",
703 + "location": "Île-du-Prince-Édouard (québécoise)",
704 + "notes": "idées de coiffures (hairstyles); Québécoise vivant à l'Î.-P.-É. | enrichi via recherche web 2026-08",
705 + "sources": [
706 + "billie",
707 + "firecrawl_enrich"
708 + ],
709 + "total_followers": 1947375,
710 + "platform_count": 2,
711 + "tier": "méga (1M+)"
712 + },
713 + {
714 + "name": "Claudie Mercier",
715 + "handles": {
716 + "instagram": "claudiemercier_",
717 + "tiktok": "claudiemercier",
718 + "youtube": "claudiemercier",
719 + "facebook": null,
720 + "x": null,
721 + "twitch": null
722 + },
723 + "metrics": {
724 + "instagram_followers": 384000,
725 + "tiktok_followers": 1300000,
726 + "youtube_subscribers": 141000,
727 + "facebook_followers": 9500,
728 + "x_followers": null,
729 + "twitch_followers": null
730 + },
731 + "category": "voyage",
732 + "location": "Montréal (originaire de la Beauce)",
733 + "notes": "ex-OD Afrique du Sud, contenu voyage inspirant; 315k dans le palmarès Billie, 384K sur l'embed plus récent; top 5 L'Hebdo 2026 | Ex-candidate d'Occupation Double devenue créatrice humoristique proche des jeunes. Chiffres TikTok ~2021-2022. Aussi mentionnée sur Billie (nouveau projet 2022). | Révélée à Occupation Double Afrique du Sud (2019); humour, couple et santé mentale; livre Pas de secret entre nous (2024). #14 Favikon Québec (1,7M cumulés).",
734 + "sources": [
735 + "billie",
736 + "lhebdo",
737 + "alexturcotte_tiktok",
738 + "billie_youtube",
739 + "frank",
740 + "favikon_qc"
741 + ],
742 + "total_followers": 1834500,
743 + "platform_count": 4,
744 + "tier": "méga (1M+)"
745 + },
746 + {
747 + "name": "Gurky",
748 + "handles": {
749 + "instagram": "legurky",
750 + "tiktok": "gurkytiktok",
751 + "youtube": "legurky",
752 + "facebook": null,
753 + "x": null,
754 + "twitch": null
755 + },
756 + "metrics": {
757 + "instagram_followers": 183000,
758 + "tiktok_followers": 195900,
759 + "youtube_subscribers": 1450000,
760 + "facebook_followers": null,
761 + "x_followers": null,
762 + "twitch_followers": null
763 + },
764 + "category": "food",
765 + "location": "Québec",
766 + "notes": "Défis culinaires et dégustations improbables. Top YouTubeurs QC 2026 (ONEWAY); #8 (vlog) au top 100 Titan 2022. | enrichi via recherche web 2026-08",
767 + "sources": [
768 + "oneway_youtube",
769 + "titan_youtube_2022",
770 + "firecrawl_enrich"
771 + ],
772 + "total_followers": 1828900,
773 + "platform_count": 3,
774 + "tier": "méga (1M+)"
775 + },
776 + {
777 + "name": "Claudia Tihan",
778 + "handles": {
779 + "instagram": "claudiatihan",
780 + "tiktok": null,
781 + "youtube": null,
782 + "facebook": null,
783 + "x": null,
784 + "twitch": null
785 + },
786 + "metrics": {
787 + "instagram_followers": 1400000,
788 + "tiktok_followers": 365400,
789 + "youtube_subscribers": null,
790 + "facebook_followers": null,
791 + "x_followers": null,
792 + "twitch_followers": null
793 + },
794 + "category": "mode",
795 + "location": "Montréal",
796 + "notes": "mannequin, voyages autour du monde | enrichi via recherche web 2026-08",
797 + "sources": [
798 + "billie",
799 + "firecrawl_enrich"
800 + ],
801 + "total_followers": 1765400,
802 + "platform_count": 2,
803 + "tier": "méga (1M+)"
804 + },
805 + {
806 + "name": "Franceska Fournier",
807 + "handles": {
808 + "instagram": "franceskafournier",
809 + "tiktok": "franceskafournier",
810 + "youtube": null,
811 + "facebook": null,
812 + "x": null,
813 + "twitch": null
814 + },
815 + "metrics": {
816 + "instagram_followers": 1200000,
817 + "tiktok_followers": 547000,
818 + "youtube_subscribers": null,
819 + "facebook_followers": null,
820 + "x_followers": null,
821 + "twitch_followers": null
822 + },
823 + "category": "mode",
824 + "location": "Québec",
825 + "notes": "mannequin et influenceuse voyage | enrichi via recherche web 2026-08",
826 + "sources": [
827 + "billie",
828 + "firecrawl_enrich"
829 + ],
830 + "total_followers": 1747000,
831 + "platform_count": 2,
832 + "tier": "méga (1M+)"
833 + },
834 + {
835 + "name": "Alex Centomo",
836 + "handles": {
837 + "instagram": "alexcentomo",
838 + "tiktok": "alex_centomo",
839 + "youtube": "alexcentomo",
840 + "facebook": null,
841 + "x": null,
842 + "twitch": null
843 + },
844 + "metrics": {
845 + "instagram_followers": 970000,
846 + "tiktok_followers": 80300,
847 + "youtube_subscribers": 578000,
848 + "facebook_followers": null,
849 + "x_followers": null,
850 + "twitch_followers": null
851 + },
852 + "category": "mode",
853 + "location": "Québec",
854 + "notes": "contenu mode, beauté et body positive; jeune maman | enrichi via recherche web 2026-08",
855 + "sources": [
856 + "billie",
857 + "firecrawl_enrich"
858 + ],
859 + "total_followers": 1628300,
860 + "platform_count": 3,
861 + "tier": "méga (1M+)"
862 + },
863 + {
864 + "name": "Frank Leblanc (No Fancy Shit)",
865 + "handles": {
866 + "instagram": "nofancyshit",
867 + "tiktok": "nofancyshit",
868 + "youtube": null,
869 + "facebook": null,
870 + "x": null,
871 + "twitch": null
872 + },
873 + "metrics": {
874 + "instagram_followers": null,
875 + "tiktok_followers": 1500000,
876 + "youtube_subscribers": null,
877 + "facebook_followers": null,
878 + "x_followers": null,
879 + "twitch_followers": null
880 + },
881 + "category": "food",
882 + "location": "Québec",
883 + "notes": "Cuisine « no fancy » humoristique, sans montrer son visage ni sa voix. Chiffres ~2021-2022. | enrichi via recherche web 2026-08",
884 + "sources": [
885 + "alexturcotte_tiktok",
886 + "firecrawl_enrich"
887 + ],
888 + "total_followers": 1500000,
889 + "platform_count": 2,
890 + "tier": "méga (1M+)"
891 + },
892 + {
893 + "name": "Victoria Charlton",
894 + "handles": {
895 + "instagram": "victoria.charlton",
896 + "tiktok": "victoriacharltonn",
897 + "youtube": "victoria charlton",
898 + "facebook": "victoriacharltonofficiel",
899 + "x": null,
900 + "twitch": null
901 + },
902 + "metrics": {
903 + "instagram_followers": 174000,
904 + "tiktok_followers": 47700,
905 + "youtube_subscribers": 755000,
906 + "facebook_followers": 374000,
907 + "x_followers": null,
908 + "twitch_followers": null
909 + },
910 + "category": "autre",
911 + "location": "Québec",
912 + "notes": "Pionnière du true crime au Québec (enquêtes, paranormal). Abonnés YouTube : chiffres 2022. Aussi #9 au top 100 Titan 2022 et top YouTubeurs 2026 (ONEWAY). | Référence du true crime francophone sur YouTube depuis 2014; autrice (Gardez l'œil ouvert), balados Post-Mortem et Trip de bouffe.",
913 + "sources": [
914 + "billie_youtube",
915 + "titan_youtube_2022",
916 + "oneway_youtube",
917 + "frank",
918 + "metricool",
919 + "canadult"
920 + ],
921 + "total_followers": 1350700,
922 + "platform_count": 4,
923 + "tier": "méga (1M+)"
924 + },
925 + {
926 + "name": "Mike Chabot",
927 + "handles": {
928 + "instagram": "mikechabotfitness",
929 + "tiktok": "rewiredbymike",
930 + "youtube": "mikechabot",
931 + "facebook": null,
932 + "x": null,
933 + "twitch": null
934 + },
935 + "metrics": {
936 + "instagram_followers": 1300000,
937 + "tiktok_followers": null,
938 + "youtube_subscribers": 14600,
939 + "facebook_followers": null,
940 + "x_followers": null,
941 + "twitch_followers": null
942 + },
943 + "category": "fitness",
944 + "location": "Québec",
945 + "notes": "ex-candidat de la télé-réalité XOXO, contenu fitness et inspirant | enrichi via recherche web 2026-08",
946 + "sources": [
947 + "billie",
948 + "firecrawl_enrich"
949 + ],
950 + "total_followers": 1314600,
951 + "platform_count": 3,
952 + "tier": "méga (1M+)"
953 + },
954 + {
955 + "name": "Marianne Plaisance (2e peau)",
956 + "handles": {
957 + "instagram": "marianne.plaisance",
958 + "tiktok": "marianneplaisance",
959 + "youtube": "2e peau",
960 + "facebook": "2epeau",
961 + "x": null,
962 + "twitch": null
963 + },
964 + "metrics": {
965 + "instagram_followers": 516000,
966 + "tiktok_followers": 514100,
967 + "youtube_subscribers": 240000,
968 + "facebook_followers": 38940,
969 + "x_followers": null,
970 + "twitch_followers": null
971 + },
972 + "category": "lifestyle",
973 + "location": "Québec",
974 + "notes": "Contenu créatif et original; le duo 2e peau s'est séparé mais Marianne poursuit la chaîne. Chiffres 2022. Aussi #17 (vlog) au top 100 Titan 2022. | enrichi via recherche web 2026-08; facebook = J'aime de page",
975 + "sources": [
976 + "billie_youtube",
977 + "titan_youtube_2022",
978 + "firecrawl_enrich"
979 + ],
980 + "total_followers": 1309040,
981 + "platform_count": 4,
982 + "tier": "méga (1M+)"
983 + },
984 + {
985 + "name": "Alicia Moffet",
986 + "handles": {
987 + "instagram": "aliciamoffet",
988 + "tiktok": "aliciamoffet",
989 + "youtube": "alicia moffet",
990 + "facebook": "aliciamoffetofficial",
991 + "x": null,
992 + "twitch": null
993 + },
994 + "metrics": {
995 + "instagram_followers": 475000,
996 + "tiktok_followers": 498300,
997 + "youtube_subscribers": 221000,
998 + "facebook_followers": 90000,
999 + "x_followers": null,
1000 + "twitch_followers": null
1001 + },
1002 + "category": "musique",
1003 + "location": "Québec",
1004 + "notes": "chanteuse et maman; citée dans le top 5 québécois 2026 de L'Hebdo | Chanteuse; vidéoclips sur YouTube. Chiffres 2022. | Chanteuse pop (The Next Star 2013, La Voix 2015), marque Silly Billy, coanime Occupation Double depuis 2023. Revenus estimés 500k$–2M$ (canadult).",
1005 + "sources": [
1006 + "billie",
1007 + "lhebdo",
1008 + "billie_youtube",
1009 + "frank",
1010 + "canadult"
1011 + ],
1012 + "total_followers": 1284300,
1013 + "platform_count": 4,
1014 + "tier": "méga (1M+)"
1015 + },
1016 + {
1017 + "name": "Lysandre Nadeau",
1018 + "handles": {
1019 + "instagram": "lysandrenadeau",
1020 + "tiktok": "lysandrenadeau",
1021 + "youtube": "lysandrenadeau",
1022 + "facebook": "nadeaulysandre",
1023 + "x": null,
1024 + "twitch": null
1025 + },
1026 + "metrics": {
1027 + "instagram_followers": 453000,
1028 + "tiktok_followers": 335400,
1029 + "youtube_subscribers": 420000,
1030 + "facebook_followers": 48000,
1031 + "x_followers": null,
1032 + "twitch_followers": null
1033 + },
1034 + "category": "lifestyle",
1035 + "location": "Québec",
1036 + "notes": "youtubeuse transparente sur sa vie; citée dans le top 5 québécois 2026 de L'Hebdo | Vlogs honnêtes; inactive depuis plus d'un an au moment de l'article. Chiffres 2022. | Vlogs YouTube (sexualité, santé mentale, féminisme); balados Sexe Oral et Parenthèse — Balado de l'année, Gala InfluenceCréation 2025 (avec Claude Bégin); Big Brother Célébrités 2021; copropriétaire Statera Spirits. Aussi listée par canadult en contenu hybride lifestyle/premium (OnlyFans).",
1037 + "sources": [
1038 + "billie",
1039 + "lhebdo",
1040 + "billie_youtube",
1041 + "frank",
1042 + "canadult",
1043 + "billie_gala",
1044 + "grenier_gala"
1045 + ],
1046 + "total_followers": 1256400,
1047 + "platform_count": 4,
1048 + "tier": "méga (1M+)"
1049 + },
1050 + {
1051 + "name": "Aly Brassard",
1052 + "handles": {
1053 + "instagram": "alybrassard",
1054 + "tiktok": "alybrassard",
1055 + "youtube": "aly brassard",
1056 + "facebook": null,
1057 + "x": null,
1058 + "twitch": null
1059 + },
1060 + "metrics": {
1061 + "instagram_followers": 253000,
1062 + "tiktok_followers": 745100,
1063 + "youtube_subscribers": 100000,
1064 + "facebook_followers": null,
1065 + "x_followers": null,
1066 + "twitch_followers": null
1067 + },
1068 + "category": "humour",
1069 + "location": "Québec",
1070 + "notes": "Sketchs, vlogs, magie et humour du quotidien. Top YouTubeurs QC 2026 (ONEWAY). | Gagnante Gala InfluenceCréation 2025 — Créatrice de l'année et Instagrameuse de l'année (prix remis par le ministre Mathieu Lacombe). Sketchs comiques, magie et vlogs sur YouTube/TikTok; 914,9K abonnés cumulés selon Favikon (#18). | Active sur YouTube, TikTok et Instagram; sketches comiques, moments de vie et tours de magie; top Twitch QC 2026 | enrichi via recherche web 2026-08",
1071 + "sources": [
1072 + "oneway_youtube",
1073 + "billie_gala",
1074 + "grenier_gala",
1075 + "favikon_qc",
1076 + "leregard_twitch",
1077 + "firecrawl_enrich"
1078 + ],
1079 + "total_followers": 1098100,
1080 + "platform_count": 3,
1081 + "tier": "méga (1M+)"
1082 + },
1083 + {
1084 + "name": "Catherine Francoeur (girlyaddict)",
1085 + "handles": {
1086 + "instagram": "girlyaddict",
1087 + "tiktok": null,
1088 + "youtube": "girlyaddict",
1089 + "facebook": null,
1090 + "x": null,
1091 + "twitch": null
1092 + },
1093 + "metrics": {
1094 + "instagram_followers": 151000,
1095 + "tiktok_followers": null,
1096 + "youtube_subscribers": 829000,
1097 + "facebook_followers": null,
1098 + "x_followers": null,
1099 + "twitch_followers": null
1100 + },
1101 + "category": "lifestyle",
1102 + "location": "Québec",
1103 + "notes": "DIY, défis et conseils. Chiffres 2022. Aussi #12 (beauté) au top 100 Titan 2022. | enrichi via recherche web 2026-08",
1104 + "sources": [
1105 + "billie_youtube",
1106 + "titan_youtube_2022",
1107 + "firecrawl_enrich"
1108 + ],
1109 + "total_followers": 980000,
1110 + "platform_count": 2,
1111 + "tier": "macro (100K+)"
1112 + },
1113 + {
1114 + "name": "Olivier Primeau",
1115 + "handles": {
1116 + "instagram": "olivierprimeaubc",
1117 + "tiktok": "oliprimeau",
1118 + "youtube": "olivierprimeau",
1119 + "facebook": null,
1120 + "x": null,
1121 + "twitch": null
1122 + },
1123 + "metrics": {
1124 + "instagram_followers": 654000,
1125 + "tiktok_followers": 203000,
1126 + "youtube_subscribers": 8540,
1127 + "facebook_followers": 94000,
1128 + "x_followers": null,
1129 + "twitch_followers": null
1130 + },
1131 + "category": "entrepreneur",
1132 + "location": "Pointe-Calumet",
1133 + "notes": "entrepreneur (Beach Club); ex-image de party boy devenue marque lifestyle complète; top 5 L'Hebdo 2026 | Entrepreneur derrière le Beachclub, festivals Métro Métro et Escapade, marque Beach Day Every Day, restos Slice Gang et Jack le Coq; critiques de poutines.",
1134 + "sources": [
1135 + "lhebdo",
1136 + "frank",
1137 + "canadult"
1138 + ],
1139 + "total_followers": 959540,
1140 + "platform_count": 4,
1141 + "tier": "macro (100K+)"
1142 + },
1143 + {
1144 + "name": "Emma Verde",
1145 + "handles": {
1146 + "instagram": "emmaverdeyt",
1147 + "tiktok": "emmaaaaverde",
1148 + "youtube": "emmaverde",
1149 + "facebook": null,
1150 + "x": null,
1151 + "twitch": null
1152 + },
1153 + "metrics": {
1154 + "instagram_followers": 285000,
1155 + "tiktok_followers": null,
1156 + "youtube_subscribers": 666000,
1157 + "facebook_followers": null,
1158 + "x_followers": null,
1159 + "twitch_followers": null
1160 + },
1161 + "category": "lifestyle",
1162 + "location": "Québec",
1163 + "notes": "youtubeuse, moins présente sur les réseaux qu'avant | Déco, voyages, mode et vie en appartement. Chiffres 2022. | enrichi via recherche web 2026-08",
1164 + "sources": [
1165 + "billie",
1166 + "billie_youtube",
1167 + "firecrawl_enrich"
1168 + ],
1169 + "total_followers": 951000,
1170 + "platform_count": 3,
1171 + "tier": "macro (100K+)"
1172 + },
1173 + {
1174 + "name": "Amine Laabi",
1175 + "handles": {
1176 + "instagram": "just.another.cook",
1177 + "tiktok": null,
1178 + "youtube": null,
1179 + "facebook": null,
1180 + "x": null,
1181 + "twitch": null
1182 + },
1183 + "metrics": {
1184 + "instagram_followers": 930000,
1185 + "tiktok_followers": null,
1186 + "youtube_subscribers": null,
1187 + "facebook_followers": null,
1188 + "x_followers": null,
1189 + "twitch_followers": null
1190 + },
1191 + "category": "food",
1192 + "location": "Québec",
1193 + "notes": "Cuisinier créateur (Macro); @loumimtl",
1194 + "sources": [
1195 + "feedspot_food"
1196 + ],
1197 + "total_followers": 930000,
1198 + "platform_count": 1,
1199 + "tier": "macro (100K+)"
1200 + },
1201 + {
1202 + "name": "Cynthia Dulude",
1203 + "handles": {
1204 + "instagram": "cynthiadulude",
1205 + "tiktok": "cynthia.dulude",
1206 + "youtube": "cynthiadulude",
1207 + "facebook": null,
1208 + "x": null,
1209 + "twitch": null
1210 + },
1211 + "metrics": {
1212 + "instagram_followers": 215000,
1213 + "tiktok_followers": null,
1214 + "youtube_subscribers": 685000,
1215 + "facebook_followers": null,
1216 + "x_followers": null,
1217 + "twitch_followers": null
1218 + },
1219 + "category": "beauté",
1220 + "location": "Montréal, QC",
1221 + "notes": "Maquilleuse : tutoriels et astuces depuis plus de 10 ans. Chiffres 2022. Aussi #13 (beauté) au top 100 Titan 2022. | Référence beauté francophone (canadult, revenus estimés 250k$–1M$); gagnante Gala InfluenceCréation 2025 — Mode & beauté. | enrichi via recherche web 2026-08",
1222 + "sources": [
1223 + "billie_youtube",
1224 + "titan_youtube_2022",
1225 + "canadult",
1226 + "billie_gala",
1227 + "grenier_gala",
1228 + "firecrawl_enrich"
1229 + ],
1230 + "total_followers": 900000,
1231 + "platform_count": 3,
1232 + "tier": "macro (100K+)"
1233 + },
1234 + {
1235 + "name": "Jess Megan",
1236 + "handles": {
1237 + "instagram": "jessmegan",
1238 + "tiktok": "_jessmegan",
1239 + "youtube": null,
1240 + "facebook": null,
1241 + "x": null,
1242 + "twitch": null
1243 + },
1244 + "metrics": {
1245 + "instagram_followers": 408000,
1246 + "tiktok_followers": 394100,
1247 + "youtube_subscribers": null,
1248 + "facebook_followers": null,
1249 + "x_followers": null,
1250 + "twitch_followers": null
1251 + },
1252 + "category": "lifestyle",
1253 + "location": "Montréal",
1254 + "notes": "Top 20 Modash Montréal, rang 9; maman de deux enfants, voyages et vie quotidienne; engagement 2.19% | enrichi via recherche web 2026-08",
1255 + "sources": [
1256 + "modash",
1257 + "firecrawl_enrich"
1258 + ],
1259 + "total_followers": 802100,
1260 + "platform_count": 2,
1261 + "tier": "macro (100K+)"
1262 + },
1263 + {
1264 + "name": "Sami Landri",
1265 + "handles": {
1266 + "instagram": "samilandri",
1267 + "tiktok": "samilandri",
1268 + "youtube": null,
1269 + "facebook": null,
1270 + "x": null,
1271 + "twitch": null
1272 + },
1273 + "metrics": {
1274 + "instagram_followers": 293700,
1275 + "tiktok_followers": 493900,
1276 + "youtube_subscribers": null,
1277 + "facebook_followers": null,
1278 + "x_followers": null,
1279 + "twitch_followers": null
1280 + },
1281 + "category": "humour",
1282 + "location": "Montréal",
1283 + "notes": "Top 20 Modash Montréal, rang 3; drag queen acadienne basée à Montréal (Canada's Drag Race); engagement 4.28% | enrichi via recherche web 2026-08",
1284 + "sources": [
1285 + "modash",
1286 + "firecrawl_enrich"
1287 + ],
1288 + "total_followers": 787600,
1289 + "platform_count": 2,
1290 + "tier": "macro (100K+)"
1291 + },
1292 + {
1293 + "name": "Marina Totino",
1294 + "handles": {
1295 + "instagram": "marinaminis",
1296 + "tiktok": null,
1297 + "youtube": null,
1298 + "facebook": "marinatotinophotography",
1299 + "x": null,
1300 + "twitch": null
1301 + },
1302 + "metrics": {
1303 + "instagram_followers": 288900,
1304 + "tiktok_followers": 460000,
1305 + "youtube_subscribers": null,
1306 + "facebook_followers": null,
1307 + "x_followers": null,
1308 + "twitch_followers": null
1309 + },
1310 + "category": "autre",
1311 + "location": "Montréal",
1312 + "notes": "Top 20 Modash Montréal, rang 4; créatrice de miniatures / DIY; engagement 2.7% | enrichi via recherche web 2026-08",
1313 + "sources": [
1314 + "modash",
1315 + "firecrawl_enrich"
1316 + ],
1317 + "total_followers": 748900,
1318 + "platform_count": 3,
1319 + "tier": "macro (100K+)"
1320 + },
1321 + {
1322 + "name": "Shahin Ouest",
1323 + "handles": {
1324 + "instagram": "shahinouest",
1325 + "tiktok": null,
1326 + "youtube": "shahin ouest",
1327 + "facebook": null,
1328 + "x": null,
1329 + "twitch": null
1330 + },
1331 + "metrics": {
1332 + "instagram_followers": 192000,
1333 + "tiktok_followers": null,
1334 + "youtube_subscribers": 550000,
1335 + "facebook_followers": null,
1336 + "x_followers": null,
1337 + "twitch_followers": null
1338 + },
1339 + "category": "lifestyle",
1340 + "location": "Montréal, Québec",
1341 + "notes": "Vidéos très produites sur Montréal et la culture francophone. Top YouTubeurs QC 2026 (ONEWAY). | Créateur humoristique sur YouTube et TikTok, contenus en France et au Canada. 601K abonnés cumulés selon Favikon (#17). | Vlogs, vidéos humoristiques et IRL; ligne de vêtements « J'comprends pas ton français » | enrichi via recherche web 2026-08",
1342 + "sources": [
1343 + "oneway_youtube",
1344 + "favikon_qc",
1345 + "leregard_twitch",
1346 + "firecrawl_enrich"
1347 + ],
1348 + "total_followers": 742000,
1349 + "platform_count": 2,
1350 + "tier": "macro (100K+)"
1351 + },
1352 + {
1353 + "name": "Rosalie Lessard",
1354 + "handles": {
1355 + "instagram": "rosalielessard_",
1356 + "tiktok": "rosalielessard_",
1357 + "youtube": "rosalie lessard",
1358 + "facebook": "rosalielessardd",
1359 + "x": null,
1360 + "twitch": null
1361 + },
1362 + "metrics": {
1363 + "instagram_followers": 151000,
1364 + "tiktok_followers": 59000,
1365 + "youtube_subscribers": 490000,
1366 + "facebook_followers": 4100,
1367 + "x_followers": null,
1368 + "twitch_followers": null
1369 + },
1370 + "category": "food",
1371 + "location": "Québec",
1372 + "notes": "Chaîne axée bouffe avec humour. Chiffres YouTube 2022. Aussi #14 (alimentation) au top 100 Titan 2022. | Créatrice culinaire diplômée de l'ITHQ; livre Survivre en appart; recettes simples et lifestyle authentique. | Youtubeuse food humoristique, recettes atypiques et challenges culinaires; chiffres de 2019",
1373 + "sources": [
1374 + "billie_youtube",
1375 + "titan_youtube_2022",
1376 + "frank",
1377 + "clark_food"
1378 + ],
1379 + "total_followers": 704100,
1380 + "platform_count": 4,
1381 + "tier": "macro (100K+)"
1382 + },
1383 + {
1384 + "name": "Andreane Chamberland",
1385 + "handles": {
1386 + "instagram": "andreanec",
1387 + "tiktok": "andreanec_",
1388 + "youtube": null,
1389 + "facebook": null,
1390 + "x": null,
1391 + "twitch": null
1392 + },
1393 + "metrics": {
1394 + "instagram_followers": 624000,
1395 + "tiktok_followers": null,
1396 + "youtube_subscribers": null,
1397 + "facebook_followers": null,
1398 + "x_followers": null,
1399 + "twitch_followers": null
1400 + },
1401 + "category": "mode",
1402 + "location": "Québec",
1403 + "notes": "mannequin, looks + secrets fitness et beauté | enrichi via recherche web 2026-08",
1404 + "sources": [
1405 + "billie",
1406 + "firecrawl_enrich"
1407 + ],
1408 + "total_followers": 624000,
1409 + "platform_count": 2,
1410 + "tier": "macro (100K+)"
1411 + },
1412 + {
1413 + "name": "Jérémie Latreille (Le Fit Cook)",
1414 + "handles": {
1415 + "instagram": "lefitcook",
1416 + "tiktok": "lefitcook",
1417 + "youtube": "lefitcook",
1418 + "facebook": "lefitcook",
1419 + "x": null,
1420 + "twitch": null
1421 + },
1422 + "metrics": {
1423 + "instagram_followers": 166900,
1424 + "tiktok_followers": 230000,
1425 + "youtube_subscribers": 7800,
1426 + "facebook_followers": 166000,
1427 + "x_followers": null,
1428 + "twitch_followers": null
1429 + },
1430 + "category": "food",
1431 + "location": "Montréal",
1432 + "notes": "Créateur culinaire santé (meal prep), marque Fitcook Foodz, révélé à Un souper presque parfait; coanimateur du Gala InfluenceCréation 2025 avec Olivia Leclerc. | Fondateur FitCook, auteur x5, animateur Crave Canada FR",
1433 + "sources": [
1434 + "frank",
1435 + "grenier_gala",
1436 + "feedspot_food"
1437 + ],
1438 + "total_followers": 570700,
1439 + "platform_count": 4,
1440 + "tier": "macro (100K+)"
1441 + },
1442 + {
1443 + "name": "Lucie Rhéaume",
1444 + "handles": {
1445 + "instagram": "lucierheaume",
1446 + "tiktok": "lucie_rheaume",
1447 + "youtube": "lucie rhéaume",
1448 + "facebook": null,
1449 + "x": null,
1450 + "twitch": null
1451 + },
1452 + "metrics": {
1453 + "instagram_followers": 252000,
1454 + "tiktok_followers": 134700,
1455 + "youtube_subscribers": 172000,
1456 + "facebook_followers": 3000,
1457 + "x_followers": null,
1458 + "twitch_followers": null
1459 + },
1460 + "category": "lifestyle",
1461 + "location": "Québec",
1462 + "notes": "youtubeuse, cofondatrice e-Influence, Girl Crush et du podcast En Privé SVP | Contenu lifestyle diversifié avec des montages remarquables. Abonnés YouTube : chiffres 2022 (article Billie). | Double gagnante Gala InfluenceCréation 2025 — Aventure & voyage et Style de vie. Cofondatrice de Girl Crush, coanime le podcast En Privé SVP; vanlife, bien-être, santé mentale. Aussi listée par canadult (croissance lifestyle/premium, OnlyFans).",
1463 + "sources": [
1464 + "billie",
1465 + "billie_youtube",
1466 + "frank",
1467 + "canadult",
1468 + "billie_gala",
1469 + "grenier_gala"
1470 + ],
1471 + "total_followers": 561700,
1472 + "platform_count": 4,
1473 + "tier": "macro (100K+)"
1474 + },
1475 + {
1476 + "name": "Thomas Gauthier",
1477 + "handles": {
1478 + "instagram": "thomasgauthierinsta",
1479 + "tiktok": null,
1480 + "youtube": "tomlivlogs",
1481 + "facebook": null,
1482 + "x": null,
1483 + "twitch": null
1484 + },
1485 + "metrics": {
1486 + "instagram_followers": 24900,
1487 + "tiktok_followers": null,
1488 + "youtube_subscribers": 464000,
1489 + "facebook_followers": null,
1490 + "x_followers": null,
1491 + "twitch_followers": null
1492 + },
1493 + "category": "humour",
1494 + "location": "Montréal",
1495 + "notes": "Sketches humoristiques, vidéos d'opinion et vidéoclips. Chiffres 2022. Aussi #20 (vlog) au top 100 Titan 2022. | enrichi via recherche web 2026-08",
1496 + "sources": [
1497 + "billie_youtube",
1498 + "titan_youtube_2022",
1499 + "firecrawl_enrich"
1500 + ],
1501 + "total_followers": 488900,
1502 + "platform_count": 2,
1503 + "tier": "macro (100K+)"
1504 + },
1505 + {
1506 + "name": "Rosalie Vaillancourt",
1507 + "handles": {
1508 + "instagram": "rosalievaillancourt",
1509 + "tiktok": "rosalievaillancourt",
1510 + "youtube": null,
1511 + "facebook": null,
1512 + "x": null,
1513 + "twitch": null
1514 + },
1515 + "metrics": {
1516 + "instagram_followers": 234900,
1517 + "tiktok_followers": 251200,
1518 + "youtube_subscribers": null,
1519 + "facebook_followers": null,
1520 + "x_followers": null,
1521 + "twitch_followers": null
1522 + },
1523 + "category": "humour",
1524 + "location": "Montréal",
1525 + "notes": "Top 20 Modash Montréal, rang 6; humoriste; engagement 2.74% | #9 humoriste QC: 636 359 abonnés cumulés (FB+IG+TikTok+X, 2024) | enrichi via recherche web 2026-08",
1526 + "sources": [
1527 + "modash",
1528 + "jdm_humoristes",
1529 + "firecrawl_enrich"
1530 + ],
1531 + "total_followers": 486100,
1532 + "platform_count": 2,
1533 + "tier": "macro (100K+)"
1534 + },
1535 + {
1536 + "name": "PL Cloutier",
1537 + "handles": {
1538 + "instagram": "plcloutier",
1539 + "tiktok": "plcloutier",
1540 + "youtube": "pl cloutier",
1541 + "facebook": null,
1542 + "x": "plcloutier",
1543 + "twitch": null
1544 + },
1545 + "metrics": {
1546 + "instagram_followers": 81000,
1547 + "tiktok_followers": 55800,
1548 + "youtube_subscribers": 280000,
1549 + "facebook_followers": null,
1550 + "x_followers": 41128,
1551 + "twitch_followers": null
1552 + },
1553 + "category": "humour",
1554 + "location": "Montréal, Québec",
1555 + "notes": "youtubeur; « règne sur YouTube Québec » selon le top 5 de L'Hebdo 2026 | Vidéos humoristiques et d'opinion. Chiffres 2022. | Icône YouTube Québec selon canadult; audience Québec francophone, revenus estimés 300k$–1M$. | enrichi via recherche web 2026-08",
1556 + "sources": [
1557 + "lhebdo",
1558 + "billie_youtube",
1559 + "canadult",
1560 + "firecrawl_enrich"
1561 + ],
1562 + "total_followers": 457928,
1563 + "platform_count": 4,
1564 + "tier": "macro (100K+)"
1565 + },
1566 + {
1567 + "name": "Lewis le fou",
1568 + "handles": {
1569 + "instagram": "lewislefou",
1570 + "tiktok": "lewislefou",
1571 + "youtube": "lewis le fou",
1572 + "facebook": null,
1573 + "x": null,
1574 + "twitch": "lewislefou"
1575 + },
1576 + "metrics": {
1577 + "instagram_followers": 151000,
1578 + "tiktok_followers": 260000,
1579 + "youtube_subscribers": 44400,
1580 + "facebook_followers": null,
1581 + "x_followers": null,
1582 + "twitch_followers": null
1583 + },
1584 + "category": "humour",
1585 + "location": "Charlevoix, Québec",
1586 + "notes": "Divertissement spontané entre Twitch et YouTube. Top YouTubeurs QC 2026 (ONEWAY). | Streamer IRL #1 du top Twitch Québec 2026; subathon de 30 jours, records d'abonnements Twitch au Québec | enrichi via recherche web 2026-08",
1587 + "sources": [
1588 + "oneway_youtube",
1589 + "leregard_twitch",
1590 + "firecrawl_enrich"
1591 + ],
1592 + "total_followers": 455400,
1593 + "platform_count": 4,
1594 + "tier": "macro (100K+)"
1595 + },
1596 + {
1597 + "name": "Frederike Lachance-Brulotte (Folks and Forks)",
1598 + "handles": {
1599 + "instagram": "folksandforks",
1600 + "tiktok": null,
1601 + "youtube": null,
1602 + "facebook": null,
1603 + "x": null,
1604 + "twitch": null
1605 + },
1606 + "metrics": {
1607 + "instagram_followers": 179700,
1608 + "tiktok_followers": 260900,
1609 + "youtube_subscribers": null,
1610 + "facebook_followers": null,
1611 + "x_followers": null,
1612 + "twitch_followers": null
1613 + },
1614 + "category": "food",
1615 + "location": "Québec",
1616 + "notes": "Auteure Folks and Forks (2 tomes), prêts-à-manger chez IGA; TikTok 260 900 selon La Grappe",
1617 + "sources": [
1618 + "feedspot_food",
1619 + "lagrappe_food"
1620 + ],
1621 + "total_followers": 440600,
1622 + "platform_count": 2,
1623 + "tier": "macro (100K+)"
1624 + },
1625 + {
1626 + "name": "Jessie Gélinas",
1627 + "handles": {
1628 + "instagram": "jessiegelinas_",
1629 + "tiktok": "jessiegelinas_",
1630 + "youtube": "jessiieg999",
1631 + "facebook": null,
1632 + "x": null,
1633 + "twitch": null
1634 + },
1635 + "metrics": {
1636 + "instagram_followers": 50800,
1637 + "tiktok_followers": null,
1638 + "youtube_subscribers": 355000,
1639 + "facebook_followers": null,
1640 + "x_followers": null,
1641 + "twitch_followers": null
1642 + },
1643 + "category": "lifestyle",
1644 + "location": "Québec",
1645 + "notes": "Mode, beauté, déco et humour; publie environ une fois par mois. Chiffres 2022. | enrichi via recherche web 2026-08",
1646 + "sources": [
1647 + "billie_youtube",
1648 + "firecrawl_enrich"
1649 + ],
1650 + "total_followers": 405800,
1651 + "platform_count": 3,
1652 + "tier": "macro (100K+)"
1653 + },
1654 + {
1655 + "name": "Rym Nebbak",
1656 + "handles": {
1657 + "instagram": "rymnebbak",
1658 + "tiktok": "rymnebbak",
1659 + "youtube": null,
1660 + "facebook": null,
1661 + "x": null,
1662 + "twitch": null
1663 + },
1664 + "metrics": {
1665 + "instagram_followers": 220000,
1666 + "tiktok_followers": 168900,
1667 + "youtube_subscribers": null,
1668 + "facebook_followers": null,
1669 + "x_followers": null,
1670 + "twitch_followers": null
1671 + },
1672 + "category": "télé-réalité",
1673 + "location": "Québec",
1674 + "notes": "ex-Occupation Double Afrique du Sud | enrichi via recherche web 2026-08",
1675 + "sources": [
1676 + "billie",
1677 + "firecrawl_enrich"
1678 + ],
1679 + "total_followers": 388900,
1680 + "platform_count": 2,
1681 + "tier": "macro (100K+)"
1682 + },
1683 + {
1684 + "name": "Catherine Belle",
1685 + "handles": {
1686 + "instagram": "cath_belle",
1687 + "tiktok": "cath.belle",
1688 + "youtube": null,
1689 + "facebook": null,
1690 + "x": null,
1691 + "twitch": null
1692 + },
1693 + "metrics": {
1694 + "instagram_followers": 368000,
1695 + "tiktok_followers": 14100,
1696 + "youtube_subscribers": null,
1697 + "facebook_followers": null,
1698 + "x_followers": null,
1699 + "twitch_followers": null
1700 + },
1701 + "category": "lifestyle",
1702 + "location": "Québec",
1703 + "notes": "contenu voyage et bien-être | enrichi via recherche web 2026-08",
1704 + "sources": [
1705 + "billie",
1706 + "firecrawl_enrich"
1707 + ],
1708 + "total_followers": 382100,
1709 + "platform_count": 2,
1710 + "tier": "macro (100K+)"
1711 + },
1712 + {
1713 + "name": "Dom Babin",
1714 + "handles": {
1715 + "instagram": "dombabin",
1716 + "tiktok": "dominicbabinn",
1717 + "youtube": null,
1718 + "facebook": null,
1719 + "x": null,
1720 + "twitch": null
1721 + },
1722 + "metrics": {
1723 + "instagram_followers": 180600,
1724 + "tiktok_followers": 197600,
1725 + "youtube_subscribers": null,
1726 + "facebook_followers": null,
1727 + "x_followers": null,
1728 + "twitch_followers": null
1729 + },
1730 + "category": "humour",
1731 + "location": "Montréal",
1732 + "notes": "Top 20 Modash Montréal, rang 10; humoriste, spectacle « Américain du Nord français »; engagement 8.61% | enrichi via recherche web 2026-08",
1733 + "sources": [
1734 + "modash",
1735 + "firecrawl_enrich"
1736 + ],
1737 + "total_followers": 378200,
1738 + "platform_count": 2,
1739 + "tier": "macro (100K+)"
1740 + },
1741 + {
1742 + "name": "Alanis Désilets",
1743 + "handles": {
1744 + "instagram": "alanis.desilets",
1745 + "tiktok": "alanisdesilets",
1746 + "youtube": null,
1747 + "facebook": "desiletsalanis",
1748 + "x": null,
1749 + "twitch": null
1750 + },
1751 + "metrics": {
1752 + "instagram_followers": 278000,
1753 + "tiktok_followers": 89500,
1754 + "youtube_subscribers": null,
1755 + "facebook_followers": null,
1756 + "x_followers": null,
1757 + "twitch_followers": null
1758 + },
1759 + "category": "entrepreneur",
1760 + "location": "Montréal, QC",
1761 + "notes": "plusieurs entreprises (La campagne d'ici, La Jasette), podcast « Faut qu'on s'appelle », maman | enrichi via recherche web 2026-08",
1762 + "sources": [
1763 + "billie",
1764 + "firecrawl_enrich"
1765 + ],
1766 + "total_followers": 367500,
1767 + "platform_count": 3,
1768 + "tier": "macro (100K+)"
1769 + },
1770 + {
1771 + "name": "Hey Raion",
1772 + "handles": {
1773 + "instagram": null,
1774 + "tiktok": null,
1775 + "youtube": null,
1776 + "facebook": null,
1777 + "x": null,
1778 + "twitch": "heyraion"
1779 + },
1780 + "metrics": {
1781 + "instagram_followers": null,
1782 + "tiktok_followers": 189000,
1783 + "youtube_subscribers": 135000,
1784 + "facebook_followers": null,
1785 + "x_followers": null,
1786 + "twitch_followers": 37900
1787 + },
1788 + "category": "gaming",
1789 + "location": "Montréal",
1790 + "notes": "Streamer gaming (gacha), culture japonaise/chinoise, fitness et tech",
1791 + "sources": [
1792 + "collabstr_twitch"
1793 + ],
1794 + "total_followers": 361900,
1795 + "platform_count": 3,
1796 + "tier": "macro (100K+)"
1797 + },
1798 + {
1799 + "name": "Beatrice Bouchard",
1800 + "handles": {
1801 + "instagram": "beatricebouchard",
1802 + "tiktok": "beatricebouchard001",
1803 + "youtube": null,
1804 + "facebook": null,
1805 + "x": null,
1806 + "twitch": null
1807 + },
1808 + "metrics": {
1809 + "instagram_followers": 359000,
1810 + "tiktok_followers": 1554,
1811 + "youtube_subscribers": null,
1812 + "facebook_followers": null,
1813 + "x_followers": null,
1814 + "twitch_followers": null
1815 + },
1816 + "category": "lifestyle",
1817 + "location": "Montréal",
1818 + "notes": "créatrice de contenu, sœur jumelle de la joueuse de tennis Eugenie Bouchard | enrichi via recherche web 2026-08",
1819 + "sources": [
1820 + "billie",
1821 + "firecrawl_enrich"
1822 + ],
1823 + "total_followers": 360554,
1824 + "platform_count": 2,
1825 + "tier": "macro (100K+)"
1826 + },
1827 + {
1828 + "name": "Carey Price",
1829 + "handles": {
1830 + "instagram": "cp0031",
1831 + "tiktok": null,
1832 + "youtube": null,
1833 + "facebook": null,
1834 + "x": null,
1835 + "twitch": null
1836 + },
1837 + "metrics": {
1838 + "instagram_followers": 360500,
1839 + "tiktok_followers": null,
1840 + "youtube_subscribers": null,
1841 + "facebook_followers": null,
1842 + "x_followers": null,
1843 + "twitch_followers": null
1844 + },
1845 + "category": "sport",
1846 + "location": "Montréal",
1847 + "notes": "Top 20 Modash Montréal, rang 1; gardien de but des Canadiens de Montréal; engagement 3.22%",
1848 + "sources": [
1849 + "modash"
1850 + ],
1851 + "total_followers": 360500,
1852 + "platform_count": 1,
1853 + "tier": "macro (100K+)"
1854 + },
1855 + {
1856 + "name": "Marilou Bélanger",
1857 + "handles": {
1858 + "instagram": "mariloublg_",
1859 + "tiktok": "mariloublg_",
1860 + "youtube": null,
1861 + "facebook": null,
1862 + "x": null,
1863 + "twitch": null
1864 + },
1865 + "metrics": {
1866 + "instagram_followers": 262000,
1867 + "tiktok_followers": 94200,
1868 + "youtube_subscribers": null,
1869 + "facebook_followers": null,
1870 + "x_followers": null,
1871 + "twitch_followers": null
1872 + },
1873 + "category": "télé-réalité",
1874 + "location": "Montréal",
1875 + "notes": "mannequin, popularité après Occupation Double dans l'Ouest | enrichi via recherche web 2026-08",
1876 + "sources": [
1877 + "billie",
1878 + "firecrawl_enrich"
1879 + ],
1880 + "total_followers": 356200,
1881 + "platform_count": 2,
1882 + "tier": "macro (100K+)"
1883 + },
1884 + {
1885 + "name": "Lisa Homsy",
1886 + "handles": {
1887 + "instagram": "lisahomsy",
1888 + "tiktok": "lisahomsy_",
1889 + "youtube": null,
1890 + "facebook": "homsy.lisa",
1891 + "x": "lisahomsy",
1892 + "twitch": null
1893 + },
1894 + "metrics": {
1895 + "instagram_followers": 296000,
1896 + "tiktok_followers": 59300,
1897 + "youtube_subscribers": null,
1898 + "facebook_followers": null,
1899 + "x_followers": null,
1900 + "twitch_followers": null
1901 + },
1902 + "category": "voyage",
1903 + "location": "Montréal",
1904 + "notes": "photos de voyage, publie en anglais mais bilingue | enrichi via recherche web 2026-08",
1905 + "sources": [
1906 + "billie",
1907 + "firecrawl_enrich"
1908 + ],
1909 + "total_followers": 355300,
1910 + "platform_count": 4,
1911 + "tier": "macro (100K+)"
1912 + },
1913 + {
1914 + "name": "Nick Faber",
1915 + "handles": {
1916 + "instagram": "nickfaberr",
1917 + "tiktok": null,
1918 + "youtube": null,
1919 + "facebook": null,
1920 + "x": null,
1921 + "twitch": null
1922 + },
1923 + "metrics": {
1924 + "instagram_followers": 198000,
1925 + "tiktok_followers": 153000,
1926 + "youtube_subscribers": null,
1927 + "facebook_followers": null,
1928 + "x_followers": null,
1929 + "twitch_followers": null
1930 + },
1931 + "category": "food",
1932 + "location": "Montréal",
1933 + "notes": "#1 du top 7 influenceurs food La Grappe 2026; découvertes de restos montréalais",
1934 + "sources": [
1935 + "lagrappe_food"
1936 + ],
1937 + "total_followers": 351000,
1938 + "platform_count": 2,
1939 + "tier": "macro (100K+)"
1940 + },
1941 + {
1942 + "name": "Brendan Mikan",
1943 + "handles": {
1944 + "instagram": "brendanmikan",
1945 + "tiktok": "brendanmikan",
1946 + "youtube": null,
1947 + "facebook": null,
1948 + "x": null,
1949 + "twitch": null
1950 + },
1951 + "metrics": {
1952 + "instagram_followers": 152600,
1953 + "tiktok_followers": 173900,
1954 + "youtube_subscribers": null,
1955 + "facebook_followers": null,
1956 + "x_followers": null,
1957 + "twitch_followers": null
1958 + },
1959 + "category": "lifestyle",
1960 + "location": "Montréal",
1961 + "notes": "Top 20 Modash Montréal, rang 14; reviewer et foodie (agence Dulcedo); engagement 2.04% | enrichi via recherche web 2026-08",
1962 + "sources": [
1963 + "modash",
1964 + "firecrawl_enrich"
1965 + ],
1966 + "total_followers": 326500,
1967 + "platform_count": 2,
1968 + "tier": "macro (100K+)"
1969 + },
1970 + {
1971 + "name": "Catherine Paquin",
1972 + "handles": {
1973 + "instagram": "catherinepeachh",
1974 + "tiktok": null,
1975 + "youtube": null,
1976 + "facebook": null,
1977 + "x": null,
1978 + "twitch": null
1979 + },
1980 + "metrics": {
1981 + "instagram_followers": 323000,
1982 + "tiktok_followers": null,
1983 + "youtube_subscribers": null,
1984 + "facebook_followers": null,
1985 + "x_followers": null,
1986 + "twitch_followers": null
1987 + },
1988 + "category": "télé-réalité",
1989 + "location": "Québec",
1990 + "notes": "« Peach », ex-OD; gamme de bijoux Appy | enrichi via recherche web 2026-08",
1991 + "sources": [
1992 + "billie",
1993 + "firecrawl_enrich"
1994 + ],
1995 + "total_followers": 323000,
1996 + "platform_count": 1,
1997 + "tier": "macro (100K+)"
1998 + },
1999 + {
2000 + "name": "Milaydie Bujold",
2001 + "handles": {
2002 + "instagram": "milaydie",
2003 + "tiktok": "milaydiebujold",
2004 + "youtube": null,
2005 + "facebook": null,
2006 + "x": null,
2007 + "twitch": null
2008 + },
2009 + "metrics": {
2010 + "instagram_followers": 263000,
2011 + "tiktok_followers": 58000,
2012 + "youtube_subscribers": null,
2013 + "facebook_followers": null,
2014 + "x_followers": null,
2015 + "twitch_followers": null
2016 + },
2017 + "category": "lifestyle",
2018 + "location": "Québec",
2019 + "notes": "looks mode, voyages et quotidien avec ses chats | enrichi via recherche web 2026-08",
2020 + "sources": [
2021 + "billie",
2022 + "firecrawl_enrich"
2023 + ],
2024 + "total_followers": 321000,
2025 + "platform_count": 2,
2026 + "tier": "macro (100K+)"
2027 + },
2028 + {
2029 + "name": "Mathieu Dufour (Math Duff)",
2030 + "handles": {
2031 + "instagram": "mathduff",
2032 + "tiktok": "mathduff",
2033 + "youtube": "mathduff.mathieudufour",
2034 + "facebook": "mathduff",
2035 + "x": null,
2036 + "twitch": null
2037 + },
2038 + "metrics": {
2039 + "instagram_followers": 304500,
2040 + "tiktok_followers": null,
2041 + "youtube_subscribers": null,
2042 + "facebook_followers": null,
2043 + "x_followers": null,
2044 + "twitch_followers": null
2045 + },
2046 + "category": "humour",
2047 + "location": "Montréal",
2048 + "notes": "Humoriste; gagnant Gala InfluenceCréation 2025 — Humour & divertissement. | Top 20 Modash Montréal, rang 2; humoriste (Math Duff); engagement 9.6% | #12 humoriste QC: 572 347 abonnés cumulés (FB+IG+TikTok+X, 2024) | enrichi via recherche web 2026-08; handles seulement, aucun compte d'abonnés trouvé",
2049 + "sources": [
2050 + "billie_gala",
2051 + "grenier_gala",
2052 + "modash",
2053 + "jdm_humoristes",
2054 + "firecrawl_enrich"
2055 + ],
2056 + "total_followers": 304500,
2057 + "platform_count": 4,
2058 + "tier": "macro (100K+)"
2059 + },
2060 + {
2061 + "name": "Marilou Bourdon (Trois fois par jour)",
2062 + "handles": {
2063 + "instagram": "troisfoisparjour",
2064 + "tiktok": null,
2065 + "youtube": null,
2066 + "facebook": null,
2067 + "x": null,
2068 + "twitch": null
2069 + },
2070 + "metrics": {
2071 + "instagram_followers": 287000,
2072 + "tiktok_followers": null,
2073 + "youtube_subscribers": null,
2074 + "facebook_followers": null,
2075 + "x_followers": null,
2076 + "twitch_followers": null
2077 + },
2078 + "category": "food",
2079 + "location": "Québec",
2080 + "notes": "Star de la cuisine au Québec; livres Trois fois par jour (tomes 1-2 + Desserts), boutique en ligne; chiffres de 2019",
2081 + "sources": [
2082 + "clark_food"
2083 + ],
2084 + "total_followers": 287000,
2085 + "platform_count": 1,
2086 + "tier": "macro (100K+)"
2087 + },
2088 + {
2089 + "name": "Emy-Jade Greaves",
2090 + "handles": {
2091 + "instagram": "emyjade.g",
2092 + "tiktok": "emyjade.g",
2093 + "youtube": null,
2094 + "facebook": null,
2095 + "x": null,
2096 + "twitch": null
2097 + },
2098 + "metrics": {
2099 + "instagram_followers": 208000,
2100 + "tiktok_followers": 45100,
2101 + "youtube_subscribers": 32700,
2102 + "facebook_followers": null,
2103 + "x_followers": null,
2104 + "twitch_followers": null
2105 + },
2106 + "category": "fitness",
2107 + "location": "Montréal, QC",
2108 + "notes": "prof de yoga, marque de loungewear Reckless Minds | Créatrice de contenu adulte (OnlyFans), populaire sur Instagram/TikTok; revenus estimés 100k$–300k$ (canadult). | enrichi via recherche web 2026-08",
2109 + "sources": [
2110 + "billie",
2111 + "canadult",
2112 + "firecrawl_enrich"
2113 + ],
2114 + "total_followers": 285800,
2115 + "platform_count": 3,
2116 + "tier": "macro (100K+)"
2117 + },
2118 + {
2119 + "name": "Esther Brzezinski",
2120 + "handles": {
2121 + "instagram": "estieb",
2122 + "tiktok": "estherbrzezinski",
2123 + "youtube": null,
2124 + "facebook": null,
2125 + "x": "estieb",
2126 + "twitch": null
2127 + },
2128 + "metrics": {
2129 + "instagram_followers": 108200,
2130 + "tiktok_followers": 165400,
2131 + "youtube_subscribers": null,
2132 + "facebook_followers": null,
2133 + "x_followers": null,
2134 + "twitch_followers": null
2135 + },
2136 + "category": "lifestyle",
2137 + "location": "Montréal",
2138 + "notes": "« Age Is Not My Cage », actrice/voix/podcasteuse de 65 ans, engagement 10% | enrichi via recherche web 2026-08",
2139 + "sources": [
2140 + "collabstr",
2141 + "firecrawl_enrich"
2142 + ],
2143 + "total_followers": 273600,
2144 + "platform_count": 3,
2145 + "tier": "macro (100K+)"
2146 + },
2147 + {
2148 + "name": "Amélie Barbeau",
2149 + "handles": {
2150 + "instagram": "amelie.barbeau",
2151 + "tiktok": null,
2152 + "youtube": "amyfashionimpact",
2153 + "facebook": null,
2154 + "x": null,
2155 + "twitch": null
2156 + },
2157 + "metrics": {
2158 + "instagram_followers": 59200,
2159 + "tiktok_followers": 8540,
2160 + "youtube_subscribers": 202000,
2161 + "facebook_followers": null,
2162 + "x_followers": null,
2163 + "twitch_followers": null
2164 + },
2165 + "category": "humour",
2166 + "location": "Québec",
2167 + "notes": "DIY et vidéos humoristiques, plus de 10 ans sur YouTube. Chiffres 2022. Aussi #44 (beauté/mode) au top 100 Titan 2022. | enrichi via recherche web 2026-08",
2168 + "sources": [
2169 + "billie_youtube",
2170 + "titan_youtube_2022",
2171 + "firecrawl_enrich"
2172 + ],
2173 + "total_followers": 269740,
2174 + "platform_count": 3,
2175 + "tier": "macro (100K+)"
2176 + },
2177 + {
2178 + "name": "Julie-Anne Ho",
2179 + "handles": {
2180 + "instagram": "julieanneho",
2181 + "tiktok": "julieanneho",
2182 + "youtube": null,
2183 + "facebook": null,
2184 + "x": null,
2185 + "twitch": null
2186 + },
2187 + "metrics": {
2188 + "instagram_followers": 237000,
2189 + "tiktok_followers": 30900,
2190 + "youtube_subscribers": null,
2191 + "facebook_followers": null,
2192 + "x_followers": null,
2193 + "twitch_followers": null
2194 + },
2195 + "category": "mode",
2196 + "location": "Montréal",
2197 + "notes": "ex-OD Grèce et OD Chez Nous; marque d'accessoires Essaim | Créatrice de contenu ayant lancé la ligne d'accessoires Essaim; mentionnée dans un article connexe de Billie (aucune métrique dans la source). | enrichi via recherche web 2026-08",
2198 + "sources": [
2199 + "billie",
2200 + "billie_youtube",
2201 + "firecrawl_enrich"
2202 + ],
2203 + "total_followers": 267900,
2204 + "platform_count": 2,
2205 + "tier": "macro (100K+)"
2206 + },
2207 + {
2208 + "name": "Nick Suzuki",
2209 + "handles": {
2210 + "instagram": "nsuzuki_37",
2211 + "tiktok": null,
2212 + "youtube": null,
2213 + "facebook": null,
2214 + "x": null,
2215 + "twitch": null
2216 + },
2217 + "metrics": {
2218 + "instagram_followers": 258000,
2219 + "tiktok_followers": null,
2220 + "youtube_subscribers": null,
2221 + "facebook_followers": null,
2222 + "x_followers": null,
2223 + "twitch_followers": null
2224 + },
2225 + "category": "sport",
2226 + "location": "Montréal",
2227 + "notes": "Top 20 Modash Montréal, rang 5; capitaine des Canadiens de Montréal; engagement 3% | enrichi via recherche web 2026-08",
2228 + "sources": [
2229 + "modash",
2230 + "firecrawl_enrich"
2231 + ],
2232 + "total_followers": 258000,
2233 + "platform_count": 1,
2234 + "tier": "macro (100K+)"
2235 + },
2236 + {
2237 + "name": "Christine Morency",
2238 + "handles": {
2239 + "instagram": "morenclic",
2240 + "tiktok": "christinemorency",
2241 + "youtube": "christinemorencycm",
2242 + "facebook": null,
2243 + "x": null,
2244 + "twitch": null
2245 + },
2246 + "metrics": {
2247 + "instagram_followers": 156500,
2248 + "tiktok_followers": 93400,
2249 + "youtube_subscribers": 2430,
2250 + "facebook_followers": null,
2251 + "x_followers": null,
2252 + "twitch_followers": null
2253 + },
2254 + "category": "humour",
2255 + "location": "Montréal",
2256 + "notes": "Top 20 Modash Montréal, rang 13; humoriste à temps plein; engagement 4.78% | enrichi via recherche web 2026-08",
2257 + "sources": [
2258 + "modash",
2259 + "firecrawl_enrich"
2260 + ],
2261 + "total_followers": 252330,
2262 + "platform_count": 3,
2263 + "tier": "macro (100K+)"
2264 + },
2265 + {
2266 + "name": "Maude Lavoie",
2267 + "handles": {
2268 + "instagram": "maudelavoie",
2269 + "tiktok": "maude.lavoie",
2270 + "youtube": null,
2271 + "facebook": null,
2272 + "x": null,
2273 + "twitch": null
2274 + },
2275 + "metrics": {
2276 + "instagram_followers": 207000,
2277 + "tiktok_followers": 34300,
2278 + "youtube_subscribers": null,
2279 + "facebook_followers": null,
2280 + "x_followers": null,
2281 + "twitch_followers": null
2282 + },
2283 + "category": "télé-réalité",
2284 + "location": "Québec",
2285 + "notes": "ex-candidate OD Grèce, maintenant maman (embed plus récent: 188K) | enrichi via recherche web 2026-08",
2286 + "sources": [
2287 + "billie",
2288 + "firecrawl_enrich"
2289 + ],
2290 + "total_followers": 241300,
2291 + "platform_count": 2,
2292 + "tier": "macro (100K+)"
2293 + },
2294 + {
2295 + "name": "Myriam Lemay",
2296 + "handles": {
2297 + "instagram": "myriamlemaay",
2298 + "tiktok": null,
2299 + "youtube": null,
2300 + "facebook": null,
2301 + "x": null,
2302 + "twitch": null
2303 + },
2304 + "metrics": {
2305 + "instagram_followers": 219000,
2306 + "tiktok_followers": null,
2307 + "youtube_subscribers": null,
2308 + "facebook_followers": null,
2309 + "x_followers": null,
2310 + "twitch_followers": null
2311 + },
2312 + "category": "lifestyle",
2313 + "location": "Québec (ville)",
2314 + "notes": "créatrice de contenu, maman depuis octobre 2021 | enrichi via recherche web 2026-08",
2315 + "sources": [
2316 + "billie",
2317 + "firecrawl_enrich"
2318 + ],
2319 + "total_followers": 219000,
2320 + "platform_count": 1,
2321 + "tier": "macro (100K+)"
2322 + },
2323 + {
2324 + "name": "Marina Bastarache",
2325 + "handles": {
2326 + "instagram": "marinabastarache",
2327 + "tiktok": null,
2328 + "youtube": null,
2329 + "facebook": null,
2330 + "x": null,
2331 + "twitch": null
2332 + },
2333 + "metrics": {
2334 + "instagram_followers": 210000,
2335 + "tiktok_followers": null,
2336 + "youtube_subscribers": null,
2337 + "facebook_followers": null,
2338 + "x_followers": null,
2339 + "twitch_followers": null
2340 + },
2341 + "category": "voyage",
2342 + "location": "Québec",
2343 + "notes": "Comédienne et grande voyageuse (liste NoovoMoi des Québécois qui voyagent)",
2344 + "sources": [
2345 + "noovomoi_voyage"
2346 + ],
2347 + "total_followers": 210000,
2348 + "platform_count": 1,
2349 + "tier": "macro (100K+)"
2350 + },
2351 + {
2352 + "name": "Émilie Rossignol",
2353 + "handles": {
2354 + "instagram": "emilierossignolarts",
2355 + "tiktok": null,
2356 + "youtube": null,
2357 + "facebook": null,
2358 + "x": null,
2359 + "twitch": null
2360 + },
2361 + "metrics": {
2362 + "instagram_followers": 112200,
2363 + "tiktok_followers": 97000,
2364 + "youtube_subscribers": null,
2365 + "facebook_followers": null,
2366 + "x_followers": null,
2367 + "twitch_followers": null
2368 + },
2369 + "category": "food",
2370 + "location": "Montréal",
2371 + "notes": "Créatrice food, connexion et voyage; TikTok 97 000 selon La Grappe",
2372 + "sources": [
2373 + "feedspot_food",
2374 + "lagrappe_food"
2375 + ],
2376 + "total_followers": 209200,
2377 + "platform_count": 2,
2378 + "tier": "macro (100K+)"
2379 + },
2380 + {
2381 + "name": "Célin Do (Crazy Broke Asian)",
2382 + "handles": {
2383 + "instagram": "crazybr0keasian",
2384 + "tiktok": null,
2385 + "youtube": "crazybrokeasian",
2386 + "facebook": null,
2387 + "x": null,
2388 + "twitch": null
2389 + },
2390 + "metrics": {
2391 + "instagram_followers": 208600,
2392 + "tiktok_followers": null,
2393 + "youtube_subscribers": null,
2394 + "facebook_followers": null,
2395 + "x_followers": null,
2396 + "twitch_followers": null
2397 + },
2398 + "category": "autre",
2399 + "location": "Montréal",
2400 + "notes": "Top 20 Modash Montréal, rang 7; docu-série et animation Crazy Broke Asian; engagement 2.43% | enrichi via recherche web 2026-08",
2401 + "sources": [
2402 + "modash",
2403 + "firecrawl_enrich"
2404 + ],
2405 + "total_followers": 208600,
2406 + "platform_count": 2,
2407 + "tier": "macro (100K+)"
2408 + },
2409 + {
2410 + "name": "Lane Hutson",
2411 + "handles": {
2412 + "instagram": "lanehutson_",
2413 + "tiktok": null,
2414 + "youtube": null,
2415 + "facebook": null,
2416 + "x": null,
2417 + "twitch": null
2418 + },
2419 + "metrics": {
2420 + "instagram_followers": 196500,
2421 + "tiktok_followers": null,
2422 + "youtube_subscribers": null,
2423 + "facebook_followers": null,
2424 + "x_followers": null,
2425 + "twitch_followers": null
2426 + },
2427 + "category": "sport",
2428 + "location": "Montréal",
2429 + "notes": "Top 20 Modash Montréal, rang 8; défenseur des Canadiens de Montréal; engagement 3.3%",
2430 + "sources": [
2431 + "modash"
2432 + ],
2433 + "total_followers": 196500,
2434 + "platform_count": 1,
2435 + "tier": "macro (100K+)"
2436 + },
2437 + {
2438 + "name": "Vanessa Larche",
2439 + "handles": {
2440 + "instagram": "vanessa.andrea.beauty",
2441 + "tiktok": null,
2442 + "youtube": null,
2443 + "facebook": null,
2444 + "x": null,
2445 + "twitch": null
2446 + },
2447 + "metrics": {
2448 + "instagram_followers": 178000,
2449 + "tiktok_followers": null,
2450 + "youtube_subscribers": null,
2451 + "facebook_followers": null,
2452 + "x_followers": null,
2453 + "twitch_followers": null
2454 + },
2455 + "category": "beauté",
2456 + "location": "Montréal",
2457 + "notes": "beauté et lifestyle, maman de 2 garçons; 178K sur le compte embed @vanessa.larche | enrichi via recherche web 2026-08",
2458 + "sources": [
2459 + "embold",
2460 + "firecrawl_enrich"
2461 + ],
2462 + "total_followers": 178000,
2463 + "platform_count": 1,
2464 + "tier": "macro (100K+)"
2465 + },
2466 + {
2467 + "name": "Ivan Demidov",
2468 + "handles": {
2469 + "instagram": "_i.demidov",
2470 + "tiktok": null,
2471 + "youtube": null,
2472 + "facebook": null,
2473 + "x": null,
2474 + "twitch": null
2475 + },
2476 + "metrics": {
2477 + "instagram_followers": 159400,
2478 + "tiktok_followers": null,
2479 + "youtube_subscribers": null,
2480 + "facebook_followers": null,
2481 + "x_followers": null,
2482 + "twitch_followers": null
2483 + },
2484 + "category": "sport",
2485 + "location": "Montréal",
2486 + "notes": "Top 20 Modash Montréal, rang 11; attaquant des Canadiens de Montréal (#93); engagement 12.39%",
2487 + "sources": [
2488 + "modash"
2489 + ],
2490 + "total_followers": 159400,
2491 + "platform_count": 1,
2492 + "tier": "macro (100K+)"
2493 + },
2494 + {
2495 + "name": "Shamit Shome",
2496 + "handles": {
2497 + "instagram": "shamit_shome07",
2498 + "tiktok": null,
2499 + "youtube": null,
2500 + "facebook": null,
2501 + "x": null,
2502 + "twitch": null
2503 + },
2504 + "metrics": {
2505 + "instagram_followers": 157400,
2506 + "tiktok_followers": null,
2507 + "youtube_subscribers": null,
2508 + "facebook_followers": null,
2509 + "x_followers": null,
2510 + "twitch_followers": null
2511 + },
2512 + "category": "sport",
2513 + "location": "Montréal",
2514 + "notes": "Top 20 Modash Montréal, rang 12; footballeur (soccer), audience surtout Montréal/Edmonton; engagement 17.77% | enrichi via recherche web 2026-08",
2515 + "sources": [
2516 + "modash",
2517 + "firecrawl_enrich"
2518 + ],
2519 + "total_followers": 157400,
2520 + "platform_count": 1,
2521 + "tier": "macro (100K+)"
2522 + },
2523 + {
2524 + "name": "Math Pellerin",
2525 + "handles": {
2526 + "instagram": "mathieupellerin._",
2527 + "tiktok": null,
2528 + "youtube": null,
2529 + "facebook": null,
2530 + "x": null,
2531 + "twitch": null
2532 + },
2533 + "metrics": {
2534 + "instagram_followers": 155000,
2535 + "tiktok_followers": null,
2536 + "youtube_subscribers": null,
2537 + "facebook_followers": null,
2538 + "x_followers": null,
2539 + "twitch_followers": null
2540 + },
2541 + "category": "gaming",
2542 + "location": "Québec",
2543 + "notes": "Finaliste Occupation Double Afrique du Sud 2019, devenu streamer Twitch (gaming, discussions)",
2544 + "sources": [
2545 + "leregard_twitch"
2546 + ],
2547 + "total_followers": 155000,
2548 + "platform_count": 1,
2549 + "tier": "macro (100K+)"
2550 + },
2551 + {
2552 + "name": "Félix-Antoine Tremblay",
2553 + "handles": {
2554 + "instagram": "phoenixantoine",
2555 + "tiktok": null,
2556 + "youtube": null,
2557 + "facebook": null,
2558 + "x": null,
2559 + "twitch": null
2560 + },
2561 + "metrics": {
2562 + "instagram_followers": 151900,
2563 + "tiktok_followers": null,
2564 + "youtube_subscribers": null,
2565 + "facebook_followers": null,
2566 + "x_followers": null,
2567 + "twitch_followers": null
2568 + },
2569 + "category": "autre",
2570 + "location": "Montréal",
2571 + "notes": "Top 20 Modash Montréal, rang 15; comédien / créateur de contenu; engagement 2.32%",
2572 + "sources": [
2573 + "modash"
2574 + ],
2575 + "total_followers": 151900,
2576 + "platform_count": 1,
2577 + "tier": "macro (100K+)"
2578 + },
2579 + {
2580 + "name": "Murphy Cooper",
2581 + "handles": {
2582 + "instagram": "murphycooper",
2583 + "tiktok": null,
2584 + "youtube": null,
2585 + "facebook": null,
2586 + "x": null,
2587 + "twitch": null
2588 + },
2589 + "metrics": {
2590 + "instagram_followers": 144200,
2591 + "tiktok_followers": null,
2592 + "youtube_subscribers": null,
2593 + "facebook_followers": null,
2594 + "x_followers": null,
2595 + "twitch_followers": null
2596 + },
2597 + "category": "mode",
2598 + "location": "Montréal",
2599 + "notes": "Gagnant Gala InfluenceCréation 2025 — Expression artistique. | Top 20 Modash Montréal, rang 16; surcycleur de vêtements (upcycling), #CONNULIFE; engagement 3.13%",
2600 + "sources": [
2601 + "billie_gala",
2602 + "grenier_gala",
2603 + "modash"
2604 + ],
2605 + "total_followers": 144200,
2606 + "platform_count": 1,
2607 + "tier": "macro (100K+)"
2608 + },
2609 + {
2610 + "name": "Yann Enrich",
2611 + "handles": {
2612 + "instagram": "samples.fr",
2613 + "tiktok": null,
2614 + "youtube": null,
2615 + "facebook": null,
2616 + "x": null,
2617 + "twitch": null
2618 + },
2619 + "metrics": {
2620 + "instagram_followers": 137500,
2621 + "tiktok_followers": 5500,
2622 + "youtube_subscribers": null,
2623 + "facebook_followers": null,
2624 + "x_followers": null,
2625 + "twitch_followers": null
2626 + },
2627 + "category": "musique",
2628 + "location": "Montréal",
2629 + "notes": "vidéos sur les samples musicaux et la techno (vinyle, DJ, micro); X entre 10k et 50k",
2630 + "sources": [
2631 + "collabstr"
2632 + ],
2633 + "total_followers": 143000,
2634 + "platform_count": 2,
2635 + "tier": "macro (100K+)"
2636 + },
2637 + {
2638 + "name": "Joanie Grenier",
2639 + "handles": {
2640 + "instagram": "joaniegrenier69",
2641 + "tiktok": null,
2642 + "youtube": null,
2643 + "facebook": null,
2644 + "x": null,
2645 + "twitch": null
2646 + },
2647 + "metrics": {
2648 + "instagram_followers": 142900,
2649 + "tiktok_followers": null,
2650 + "youtube_subscribers": null,
2651 + "facebook_followers": null,
2652 + "x_followers": null,
2653 + "twitch_followers": null
2654 + },
2655 + "category": "humour",
2656 + "location": "Montréal",
2657 + "notes": "Top 20 Modash Montréal, rang 17; animatrice de podcast, contenu bien-être/sexualité; engagement 2.6%",
2658 + "sources": [
2659 + "modash"
2660 + ],
2661 + "total_followers": 142900,
2662 + "platform_count": 1,
2663 + "tier": "macro (100K+)"
2664 + },
2665 + {
2666 + "name": "Pézie Beaudin",
2667 + "handles": {
2668 + "instagram": "pezie.beaudin",
2669 + "tiktok": null,
2670 + "youtube": "pézie beaudin",
2671 + "facebook": null,
2672 + "x": null,
2673 + "twitch": null
2674 + },
2675 + "metrics": {
2676 + "instagram_followers": 137000,
2677 + "tiktok_followers": null,
2678 + "youtube_subscribers": null,
2679 + "facebook_followers": null,
2680 + "x_followers": null,
2681 + "twitch_followers": null
2682 + },
2683 + "category": "lifestyle",
2684 + "location": "Québec",
2685 + "notes": "Design d'intérieur à approche humaine; podcast « On s'connaît pas tant que ça ». Top YouTubeurs QC 2026 (ONEWAY).",
2686 + "sources": [
2687 + "oneway_youtube"
2688 + ],
2689 + "total_followers": 137000,
2690 + "platform_count": 2,
2691 + "tier": "macro (100K+)"
2692 + },
2693 + {
2694 + "name": "Tectalks",
2695 + "handles": {
2696 + "instagram": "tectalks.io",
2697 + "tiktok": null,
2698 + "youtube": null,
2699 + "facebook": null,
2700 + "x": null,
2701 + "twitch": null
2702 + },
2703 + "metrics": {
2704 + "instagram_followers": 132600,
2705 + "tiktok_followers": null,
2706 + "youtube_subscribers": 156,
2707 + "facebook_followers": null,
2708 + "x_followers": null,
2709 + "twitch_followers": null
2710 + },
2711 + "category": "autre",
2712 + "location": "Montréal",
2713 + "notes": "compte collectif tech et éducation (IA, robotique)",
2714 + "sources": [
2715 + "collabstr"
2716 + ],
2717 + "total_followers": 132756,
2718 + "platform_count": 2,
2719 + "tier": "macro (100K+)"
2720 + },
2721 + {
2722 + "name": "Wassim Abdul Khalek",
2723 + "handles": {
2724 + "instagram": "ak.wassim",
2725 + "tiktok": null,
2726 + "youtube": null,
2727 + "facebook": null,
2728 + "x": null,
2729 + "twitch": null
2730 + },
2731 + "metrics": {
2732 + "instagram_followers": 82900,
2733 + "tiktok_followers": 48800,
2734 + "youtube_subscribers": null,
2735 + "facebook_followers": null,
2736 + "x_followers": null,
2737 + "twitch_followers": null
2738 + },
2739 + "category": "food",
2740 + "location": "Montréal",
2741 + "notes": "Histoires humaines à travers la bouffe et la culture; +9% de croissance IG en 3 mois",
2742 + "sources": [
2743 + "feedspot_food",
2744 + "lagrappe_food"
2745 + ],
2746 + "total_followers": 131700,
2747 + "platform_count": 2,
2748 + "tier": "macro (100K+)"
2749 + },
2750 + {
2751 + "name": "Alex Mentink",
2752 + "handles": {
2753 + "instagram": "menthink",
2754 + "tiktok": null,
2755 + "youtube": null,
2756 + "facebook": null,
2757 + "x": null,
2758 + "twitch": null
2759 + },
2760 + "metrics": {
2761 + "instagram_followers": 126900,
2762 + "tiktok_followers": null,
2763 + "youtube_subscribers": null,
2764 + "facebook_followers": null,
2765 + "x_followers": null,
2766 + "twitch_followers": null
2767 + },
2768 + "category": "entrepreneur",
2769 + "location": "Montréal",
2770 + "notes": "Top 20 Modash Montréal, rang 18; « The Happy Dad », fondateur @carryu.ca et @lockup.ca; engagement 3.85%",
2771 + "sources": [
2772 + "modash"
2773 + ],
2774 + "total_followers": 126900,
2775 + "platform_count": 1,
2776 + "tier": "macro (100K+)"
2777 + },
2778 + {
2779 + "name": "Antonio Park",
2780 + "handles": {
2781 + "instagram": "chefantoniopark",
2782 + "tiktok": null,
2783 + "youtube": null,
2784 + "facebook": null,
2785 + "x": null,
2786 + "twitch": null
2787 + },
2788 + "metrics": {
2789 + "instagram_followers": 126400,
2790 + "tiktok_followers": null,
2791 + "youtube_subscribers": null,
2792 + "facebook_followers": null,
2793 + "x_followers": null,
2794 + "twitch_followers": null
2795 + },
2796 + "category": "food",
2797 + "location": "Montréal",
2798 + "notes": "Chef-propriétaire (Park, Café Bazin, Jatoba, Le Cathcart)",
2799 + "sources": [
2800 + "feedspot_food"
2801 + ],
2802 + "total_followers": 126400,
2803 + "platform_count": 1,
2804 + "tier": "macro (100K+)"
2805 + },
2806 + {
2807 + "name": "Antoine Olivier Pilon",
2808 + "handles": {
2809 + "instagram": "antoineolivierpilon",
2810 + "tiktok": null,
2811 + "youtube": null,
2812 + "facebook": null,
2813 + "x": null,
2814 + "twitch": null
2815 + },
2816 + "metrics": {
2817 + "instagram_followers": 125200,
2818 + "tiktok_followers": null,
2819 + "youtube_subscribers": null,
2820 + "facebook_followers": null,
2821 + "x_followers": null,
2822 + "twitch_followers": null
2823 + },
2824 + "category": "autre",
2825 + "location": "Montréal",
2826 + "notes": "Top 20 Modash Montréal, rang 19; acteur (Mommy de Xavier Dolan); engagement 2.22%",
2827 + "sources": [
2828 + "modash"
2829 + ],
2830 + "total_followers": 125200,
2831 + "platform_count": 1,
2832 + "tier": "macro (100K+)"
2833 + },
2834 + {
2835 + "name": "Max Gourgues",
2836 + "handles": {
2837 + "instagram": "maxgourgues",
2838 + "tiktok": "maxgourgues",
2839 + "youtube": "maxgourgues7062",
2840 + "facebook": null,
2841 + "x": null,
2842 + "twitch": null
2843 + },
2844 + "metrics": {
2845 + "instagram_followers": 114700,
2846 + "tiktok_followers": 1700,
2847 + "youtube_subscribers": 700,
2848 + "facebook_followers": 600,
2849 + "x_followers": null,
2850 + "twitch_followers": null
2851 + },
2852 + "category": "beauté",
2853 + "location": "Montréal",
2854 + "notes": "Coloriste et entrepreneur (salon fondé en 2019), ambassadeur Redken; transformations capillaires virales. | Top 20 Modash Montréal, rang 20; coloriste capillaire, propriétaire @salonmaxgourgues, ambassadeur Redken; engagement 2.35%",
2855 + "sources": [
2856 + "frank",
2857 + "modash"
2858 + ],
2859 + "total_followers": 117700,
2860 + "platform_count": 4,
2861 + "tier": "macro (100K+)"
2862 + },
2863 + {
2864 + "name": "Audrey-Lyne Ferlatte",
2865 + "handles": {
2866 + "instagram": "ferlattez",
2867 + "tiktok": "ferlattez",
2868 + "youtube": null,
2869 + "facebook": null,
2870 + "x": null,
2871 + "twitch": null
2872 + },
2873 + "metrics": {
2874 + "instagram_followers": 79000,
2875 + "tiktok_followers": 17400,
2876 + "youtube_subscribers": null,
2877 + "facebook_followers": 21000,
2878 + "x_followers": null,
2879 + "twitch_followers": null
2880 + },
2881 + "category": "voyage",
2882 + "location": "Québec",
2883 + "notes": "randonnée, roadtrips, chalets et tourisme famille; 21K sur Facebook | Maman aventurière : voyage, randonnée et paysages du Québec.",
2884 + "sources": [
2885 + "feedspot",
2886 + "embold",
2887 + "collabstr_tiktok"
2888 + ],
2889 + "total_followers": 117400,
2890 + "platform_count": 3,
2891 + "tier": "macro (100K+)"
2892 + },
2893 + {
2894 + "name": "Solène Lucas",
2895 + "handles": {
2896 + "instagram": "_solenelucas",
2897 + "tiktok": null,
2898 + "youtube": null,
2899 + "facebook": null,
2900 + "x": null,
2901 + "twitch": null
2902 + },
2903 + "metrics": {
2904 + "instagram_followers": 116300,
2905 + "tiktok_followers": null,
2906 + "youtube_subscribers": null,
2907 + "facebook_followers": null,
2908 + "x_followers": null,
2909 + "twitch_followers": null
2910 + },
2911 + "category": "fitness",
2912 + "location": "Québec",
2913 + "notes": "coach fitness (@aura.trainingclub), athlète AYBL",
2914 + "sources": [
2915 + "feedspot"
2916 + ],
2917 + "total_followers": 116300,
2918 + "platform_count": 1,
2919 + "tier": "macro (100K+)"
2920 + },
2921 + {
2922 + "name": "Melissa Soldera",
2923 + "handles": {
2924 + "instagram": "melissasoldera",
2925 + "tiktok": null,
2926 + "youtube": null,
2927 + "facebook": null,
2928 + "x": null,
2929 + "twitch": null
2930 + },
2931 + "metrics": {
2932 + "instagram_followers": 112200,
2933 + "tiktok_followers": null,
2934 + "youtube_subscribers": null,
2935 + "facebook_followers": null,
2936 + "x_followers": null,
2937 + "twitch_followers": null
2938 + },
2939 + "category": "lifestyle",
2940 + "location": "Montréal",
2941 + "notes": "maman montréalaise, food/mode/voyage, marketing numérique",
2942 + "sources": [
2943 + "feedspot"
2944 + ],
2945 + "total_followers": 112200,
2946 + "platform_count": 1,
2947 + "tier": "macro (100K+)"
2948 + },
2949 + {
2950 + "name": "Helli Patel",
2951 + "handles": {
2952 + "instagram": null,
2953 + "tiktok": "hellipatell",
2954 + "youtube": null,
2955 + "facebook": null,
2956 + "x": null,
2957 + "twitch": null
2958 + },
2959 + "metrics": {
2960 + "instagram_followers": 25400,
2961 + "tiktok_followers": 82200,
2962 + "youtube_subscribers": null,
2963 + "facebook_followers": null,
2964 + "x_followers": null,
2965 + "twitch_followers": null
2966 + },
2967 + "category": "fitness",
2968 + "location": "Québec",
2969 + "notes": "Lifestyle, fitness et bien-être. YouTube 50k-100k.",
2970 + "sources": [
2971 + "collabstr_tiktok"
2972 + ],
2973 + "total_followers": 107600,
2974 + "platform_count": 2,
2975 + "tier": "macro (100K+)"
2976 + },
2977 + {
2978 + "name": "Claude Alicia",
2979 + "handles": {
2980 + "instagram": "consciencecoupable",
2981 + "tiktok": null,
2982 + "youtube": null,
2983 + "facebook": null,
2984 + "x": null,
2985 + "twitch": null
2986 + },
2987 + "metrics": {
2988 + "instagram_followers": 103200,
2989 + "tiktok_followers": null,
2990 + "youtube_subscribers": null,
2991 + "facebook_followers": null,
2992 + "x_followers": null,
2993 + "twitch_followers": null
2994 + },
2995 + "category": "lifestyle",
2996 + "location": "Montréal",
2997 + "notes": "bébés, sacs, Disney World",
2998 + "sources": [
2999 + "feedspot"
3000 + ],
3001 + "total_followers": 103200,
3002 + "platform_count": 1,
3003 + "tier": "macro (100K+)"
3004 + },
3005 + {
3006 + "name": "Erica Syl",
3007 + "handles": {
3008 + "instagram": "so.sylly",
3009 + "tiktok": null,
3010 + "youtube": null,
3011 + "facebook": null,
3012 + "x": null,
3013 + "twitch": null
3014 + },
3015 + "metrics": {
3016 + "instagram_followers": 103000,
3017 + "tiktok_followers": null,
3018 + "youtube_subscribers": null,
3019 + "facebook_followers": null,
3020 + "x_followers": null,
3021 + "twitch_followers": null
3022 + },
3023 + "category": "mode",
3024 + "location": "Montréal",
3025 + "notes": "mode DIY durable; compte secondaire @erica.syl (103K sur l'embed)",
3026 + "sources": [
3027 + "embold"
3028 + ],
3029 + "total_followers": 103000,
3030 + "platform_count": 1,
3031 + "tier": "macro (100K+)"
3032 + },
3033 + {
3034 + "name": "Bonne Bouffe Montréal",
3035 + "handles": {
3036 + "instagram": "bonnebouffemontreal",
3037 + "tiktok": null,
3038 + "youtube": null,
3039 + "facebook": null,
3040 + "x": null,
3041 + "twitch": null
3042 + },
3043 + "metrics": {
3044 + "instagram_followers": 47600,
3045 + "tiktok_followers": 55200,
3046 + "youtube_subscribers": null,
3047 + "facebook_followers": null,
3048 + "x_followers": null,
3049 + "twitch_followers": null
3050 + },
3051 + "category": "food",
3052 + "location": "Montréal",
3053 + "notes": "Compte de recommandations de restaurants montréalais; top 7 food La Grappe 2026",
3054 + "sources": [
3055 + "lagrappe_food"
3056 + ],
3057 + "total_followers": 102800,
3058 + "platform_count": 2,
3059 + "tier": "macro (100K+)"
3060 + },
3061 + {
3062 + "name": "Victoria Rodriguez Carnevale",
3063 + "handles": {
3064 + "instagram": "vic_carnevale",
3065 + "tiktok": null,
3066 + "youtube": null,
3067 + "facebook": null,
3068 + "x": null,
3069 + "twitch": null
3070 + },
3071 + "metrics": {
3072 + "instagram_followers": 98300,
3073 + "tiktok_followers": null,
3074 + "youtube_subscribers": null,
3075 + "facebook_followers": null,
3076 + "x_followers": null,
3077 + "twitch_followers": null
3078 + },
3079 + "category": "mode",
3080 + "location": "Montréal",
3081 + "notes": "créatrice mode grande taille (plus size), beauté et lifestyle",
3082 + "sources": [
3083 + "feedspot",
3084 + "embold"
3085 + ],
3086 + "total_followers": 98300,
3087 + "platform_count": 1,
3088 + "tier": "micro (10K+)"
3089 + },
3090 + {
3091 + "name": "Bob Le Chef (Robert James Penny)",
3092 + "handles": {
3093 + "instagram": "boblechef",
3094 + "tiktok": null,
3095 + "youtube": "boblechef",
3096 + "facebook": null,
3097 + "x": null,
3098 + "twitch": null
3099 + },
3100 + "metrics": {
3101 + "instagram_followers": 94600,
3102 + "tiktok_followers": null,
3103 + "youtube_subscribers": null,
3104 + "facebook_followers": null,
3105 + "x_followers": null,
3106 + "twitch_followers": null
3107 + },
3108 + "category": "food",
3109 + "location": "Québec",
3110 + "notes": "L'Anarchie Culinaire depuis 2005; auteur de 3 livres, lives du dimanche; restaurant Normandin",
3111 + "sources": [
3112 + "feedspot_food",
3113 + "clark_food"
3114 + ],
3115 + "total_followers": 94600,
3116 + "platform_count": 2,
3117 + "tier": "micro (10K+)"
3118 + },
3119 + {
3120 + "name": "Noura Agrebi",
3121 + "handles": {
3122 + "instagram": null,
3123 + "tiktok": "noura_agrebi",
3124 + "youtube": null,
3125 + "facebook": null,
3126 + "x": null,
3127 + "twitch": null
3128 + },
3129 + "metrics": {
3130 + "instagram_followers": null,
3131 + "tiktok_followers": 90600,
3132 + "youtube_subscribers": null,
3133 + "facebook_followers": null,
3134 + "x_followers": null,
3135 + "twitch_followers": null
3136 + },
3137 + "category": "beauté",
3138 + "location": "Québec",
3139 + "notes": "Créatrice basée au Québec : skincare, beauté et lifestyle, UGC.",
3140 + "sources": [
3141 + "collabstr_tiktok"
3142 + ],
3143 + "total_followers": 90600,
3144 + "platform_count": 1,
3145 + "tier": "micro (10K+)"
3146 + },
3147 + {
3148 + "name": "Stéphanie Bureau",
3149 + "handles": {
3150 + "instagram": "stephaniebureau",
3151 + "tiktok": null,
3152 + "youtube": null,
3153 + "facebook": null,
3154 + "x": null,
3155 + "twitch": null
3156 + },
3157 + "metrics": {
3158 + "instagram_followers": 89000,
3159 + "tiktok_followers": null,
3160 + "youtube_subscribers": null,
3161 + "facebook_followers": null,
3162 + "x_followers": null,
3163 + "twitch_followers": null
3164 + },
3165 + "category": "voyage",
3166 + "location": "Québec",
3167 + "notes": "randonnée et inspiration voyage, croissance +16% (3 mois)",
3168 + "sources": [
3169 + "feedspot"
3170 + ],
3171 + "total_followers": 89000,
3172 + "platform_count": 1,
3173 + "tier": "micro (10K+)"
3174 + },
3175 + {
3176 + "name": "Alexane Pelletier",
3177 + "handles": {
3178 + "instagram": "alexane_pelletier",
3179 + "tiktok": null,
3180 + "youtube": "alexane pelletier",
3181 + "facebook": null,
3182 + "x": null,
3183 + "twitch": null
3184 + },
3185 + "metrics": {
3186 + "instagram_followers": 87000,
3187 + "tiktok_followers": null,
3188 + "youtube_subscribers": null,
3189 + "facebook_followers": null,
3190 + "x_followers": null,
3191 + "twitch_followers": null
3192 + },
3193 + "category": "lifestyle",
3194 + "location": "Québec",
3195 + "notes": "Créatrice autodidacte (idée au montage); connue pour son Vlogmas (24 vidéos en 24 jours). Top YouTubeurs QC 2026 (ONEWAY).",
3196 + "sources": [
3197 + "oneway_youtube"
3198 + ],
3199 + "total_followers": 87000,
3200 + "platform_count": 2,
3201 + "tier": "micro (10K+)"
3202 + },
3203 + {
3204 + "name": "Sarah (Une famille au Canada)",
3205 + "handles": {
3206 + "instagram": "unefamilleaucanada",
3207 + "tiktok": null,
3208 + "youtube": null,
3209 + "facebook": null,
3210 + "x": null,
3211 + "twitch": null
3212 + },
3213 + "metrics": {
3214 + "instagram_followers": 79000,
3215 + "tiktok_followers": null,
3216 + "youtube_subscribers": 2600,
3217 + "facebook_followers": null,
3218 + "x_followers": null,
3219 + "twitch_followers": null
3220 + },
3221 + "category": "voyage",
3222 + "location": "Montréal",
3223 + "notes": "famille française expatriée à Montréal, voyage et randonnée",
3224 + "sources": [
3225 + "collabstr"
3226 + ],
3227 + "total_followers": 81600,
3228 + "platform_count": 2,
3229 + "tier": "micro (10K+)"
3230 + },
3231 + {
3232 + "name": "Alexia Colantuono",
3233 + "handles": {
3234 + "instagram": "alecolantuono",
3235 + "tiktok": null,
3236 + "youtube": null,
3237 + "facebook": null,
3238 + "x": null,
3239 + "twitch": null
3240 + },
3241 + "metrics": {
3242 + "instagram_followers": 79100,
3243 + "tiktok_followers": null,
3244 + "youtube_subscribers": null,
3245 + "facebook_followers": null,
3246 + "x_followers": null,
3247 + "twitch_followers": null
3248 + },
3249 + "category": "lifestyle",
3250 + "location": "Montréal",
3251 + "notes": "routines bien-être et vie de maman",
3252 + "sources": [
3253 + "feedspot"
3254 + ],
3255 + "total_followers": 79100,
3256 + "platform_count": 1,
3257 + "tier": "micro (10K+)"
3258 + },
3259 + {
3260 + "name": "Lydiane St-Onge (Lydiane autour du monde)",
3261 + "handles": {
3262 + "instagram": "lydianeautourdumonde",
3263 + "tiktok": null,
3264 + "youtube": null,
3265 + "facebook": null,
3266 + "x": null,
3267 + "twitch": null
3268 + },
3269 + "metrics": {
3270 + "instagram_followers": 78900,
3271 + "tiktok_followers": null,
3272 + "youtube_subscribers": null,
3273 + "facebook_followers": null,
3274 + "x_followers": null,
3275 + "twitch_followers": null
3276 + },
3277 + "category": "voyage",
3278 + "location": "Québec",
3279 + "notes": "Blogueuse voyage incontournable, passe une grande partie de l'année à l'étranger",
3280 + "sources": [
3281 + "noovomoi_voyage"
3282 + ],
3283 + "total_followers": 78900,
3284 + "platform_count": 1,
3285 + "tier": "micro (10K+)"
3286 + },
3287 + {
3288 + "name": "Emma Bossé",
3289 + "handles": {
3290 + "instagram": "emma04044",
3291 + "tiktok": null,
3292 + "youtube": "emma04044",
3293 + "facebook": null,
3294 + "x": null,
3295 + "twitch": null
3296 + },
3297 + "metrics": {
3298 + "instagram_followers": 74700,
3299 + "tiktok_followers": null,
3300 + "youtube_subscribers": null,
3301 + "facebook_followers": null,
3302 + "x_followers": null,
3303 + "twitch_followers": null
3304 + },
3305 + "category": "autre",
3306 + "location": "Québec",
3307 + "notes": "Chaîne axée astrologie et tarot; nombre d'abonnés gardé secret en 2022 (~300 000 en 2021).",
3308 + "sources": [
3309 + "billie_youtube"
3310 + ],
3311 + "total_followers": 74700,
3312 + "platform_count": 2,
3313 + "tier": "micro (10K+)"
3314 + },
3315 + {
3316 + "name": "Cassandra Loignon",
3317 + "handles": {
3318 + "instagram": "cassandraloignon",
3319 + "tiktok": null,
3320 + "youtube": null,
3321 + "facebook": null,
3322 + "x": null,
3323 + "twitch": null
3324 + },
3325 + "metrics": {
3326 + "instagram_followers": 73000,
3327 + "tiktok_followers": null,
3328 + "youtube_subscribers": null,
3329 + "facebook_followers": null,
3330 + "x_followers": null,
3331 + "twitch_followers": null
3332 + },
3333 + "category": "food",
3334 + "location": "Québec",
3335 + "notes": "Entrepreneure (institut de beauté Les Précieuses); recettes quotidiennes en stories; chiffres de 2019",
3336 + "sources": [
3337 + "clark_food"
3338 + ],
3339 + "total_followers": 73000,
3340 + "platform_count": 1,
3341 + "tier": "micro (10K+)"
3342 + },
3343 + {
3344 + "name": "Maxime Tremblay (Max L'Affamé)",
3345 + "handles": {
3346 + "instagram": "max_laffame",
3347 + "tiktok": null,
3348 + "youtube": null,
3349 + "facebook": null,
3350 + "x": null,
3351 + "twitch": null
3352 + },
3353 + "metrics": {
3354 + "instagram_followers": 70400,
3355 + "tiktok_followers": null,
3356 + "youtube_subscribers": null,
3357 + "facebook_followers": null,
3358 + "x_followers": null,
3359 + "twitch_followers": null
3360 + },
3361 + "category": "food",
3362 + "location": "Montréal",
3363 + "notes": "Livre et série « AirFryer tout-en-un », série sur NoovoMoi.ca",
3364 + "sources": [
3365 + "feedspot_food"
3366 + ],
3367 + "total_followers": 70400,
3368 + "platform_count": 1,
3369 + "tier": "micro (10K+)"
3370 + },
3371 + {
3372 + "name": "Logan Medeiros",
3373 + "handles": {
3374 + "instagram": "loganmedeiros",
3375 + "tiktok": null,
3376 + "youtube": null,
3377 + "facebook": null,
3378 + "x": null,
3379 + "twitch": null
3380 + },
3381 + "metrics": {
3382 + "instagram_followers": 69300,
3383 + "tiktok_followers": null,
3384 + "youtube_subscribers": null,
3385 + "facebook_followers": null,
3386 + "x_followers": null,
3387 + "twitch_followers": null
3388 + },
3389 + "category": "lifestyle",
3390 + "location": "Montréal",
3391 + "notes": "lifestyle, bien-être, beauté et mode; croissance +32% (3 mois)",
3392 + "sources": [
3393 + "feedspot"
3394 + ],
3395 + "total_followers": 69300,
3396 + "platform_count": 1,
3397 + "tier": "micro (10K+)"
3398 + },
3399 + {
3400 + "name": "AchilleFPS",
3401 + "handles": {
3402 + "instagram": "achillefps",
3403 + "tiktok": null,
3404 + "youtube": null,
3405 + "facebook": null,
3406 + "x": null,
3407 + "twitch": "achillefps"
3408 + },
3409 + "metrics": {
3410 + "instagram_followers": 69200,
3411 + "tiktok_followers": null,
3412 + "youtube_subscribers": null,
3413 + "facebook_followers": null,
3414 + "x_followers": null,
3415 + "twitch_followers": null
3416 + },
3417 + "category": "gaming",
3418 + "location": "Québec",
3419 + "notes": "Streamer FPS/Battle Royale depuis 2016 (H1Z1, PUBG, Apex, Fortnite, Warzone)",
3420 + "sources": [
3421 + "leregard_twitch"
3422 + ],
3423 + "total_followers": 69200,
3424 + "platform_count": 2,
3425 + "tier": "micro (10K+)"
3426 + },
3427 + {
3428 + "name": "Prêts pour la route",
3429 + "handles": {
3430 + "instagram": null,
3431 + "tiktok": null,
3432 + "youtube": "prêts pour la route",
3433 + "facebook": null,
3434 + "x": null,
3435 + "twitch": null
3436 + },
3437 + "metrics": {
3438 + "instagram_followers": null,
3439 + "tiktok_followers": null,
3440 + "youtube_subscribers": 69100,
3441 + "facebook_followers": null,
3442 + "x_followers": null,
3443 + "twitch_followers": null
3444 + },
3445 + "category": "voyage",
3446 + "location": "Québec",
3447 + "notes": "Couple qui vit en roulotte à l'année et filme ses aventures (vanlife); #5 du top voyage VoyageurQc",
3448 + "sources": [
3449 + "voyageurqc"
3450 + ],
3451 + "total_followers": 69100,
3452 + "platform_count": 1,
3453 + "tier": "micro (10K+)"
3454 + },
3455 + {
3456 + "name": "Maya (MTL Foodie)",
3457 + "handles": {
3458 + "instagram": "mtl.foodie",
3459 + "tiktok": null,
3460 + "youtube": null,
3461 + "facebook": null,
3462 + "x": null,
3463 + "twitch": null
3464 + },
3465 + "metrics": {
3466 + "instagram_followers": 59700,
3467 + "tiktok_followers": null,
3468 + "youtube_subscribers": null,
3469 + "facebook_followers": null,
3470 + "x_followers": null,
3471 + "twitch_followers": null
3472 + },
3473 + "category": "food",
3474 + "location": "Montréal",
3475 + "notes": "Créatrice food montréalaise",
3476 + "sources": [
3477 + "feedspot_food"
3478 + ],
3479 + "total_followers": 59700,
3480 + "platform_count": 1,
3481 + "tier": "micro (10K+)"
3482 + },
3483 + {
3484 + "name": "Ariane Toussaint",
3485 + "handles": {
3486 + "instagram": "arianetouss",
3487 + "tiktok": null,
3488 + "youtube": null,
3489 + "facebook": null,
3490 + "x": null,
3491 + "twitch": null
3492 + },
3493 + "metrics": {
3494 + "instagram_followers": 57400,
3495 + "tiktok_followers": null,
3496 + "youtube_subscribers": null,
3497 + "facebook_followers": null,
3498 + "x_followers": null,
3499 + "twitch_followers": null
3500 + },
3501 + "category": "lifestyle",
3502 + "location": "Québec",
3503 + "notes": "lifestyle western, infirmière clinique, ambassadrice @missrodeoqc",
3504 + "sources": [
3505 + "feedspot"
3506 + ],
3507 + "total_followers": 57400,
3508 + "platform_count": 1,
3509 + "tier": "micro (10K+)"
3510 + },
3511 + {
3512 + "name": "Latifah Yene (Badgalatii)",
3513 + "handles": {
3514 + "instagram": null,
3515 + "tiktok": "misslatii",
3516 + "youtube": null,
3517 + "facebook": null,
3518 + "x": null,
3519 + "twitch": null
3520 + },
3521 + "metrics": {
3522 + "instagram_followers": 13200,
3523 + "tiktok_followers": 43200,
3524 + "youtube_subscribers": null,
3525 + "facebook_followers": null,
3526 + "x_followers": null,
3527 + "twitch_followers": null
3528 + },
3529 + "category": "mode",
3530 + "location": "Québec",
3531 + "notes": "Mode, beauté et lifestyle avec une esthétique féminine affirmée.",
3532 + "sources": [
3533 + "collabstr_tiktok"
3534 + ],
3535 + "total_followers": 56400,
3536 + "platform_count": 2,
3537 + "tier": "micro (10K+)"
3538 + },
3539 + {
3540 + "name": "Narissara Dangjean",
3541 + "handles": {
3542 + "instagram": null,
3543 + "tiktok": "makimild01",
3544 + "youtube": null,
3545 + "facebook": null,
3546 + "x": null,
3547 + "twitch": null
3548 + },
3549 + "metrics": {
3550 + "instagram_followers": null,
3551 + "tiktok_followers": 55700,
3552 + "youtube_subscribers": null,
3553 + "facebook_followers": null,
3554 + "x_followers": null,
3555 + "twitch_followers": null
3556 + },
3557 + "category": "beauté",
3558 + "location": "Québec",
3559 + "notes": "Skincare, maquillage, bien-être, fitness et plein air.",
3560 + "sources": [
3561 + "collabstr_tiktok"
3562 + ],
3563 + "total_followers": 55700,
3564 + "platform_count": 1,
3565 + "tier": "micro (10K+)"
3566 + },
3567 + {
3568 + "name": "Mica Peng",
3569 + "handles": {
3570 + "instagram": "micapeng",
3571 + "tiktok": null,
3572 + "youtube": null,
3573 + "facebook": null,
3574 + "x": null,
3575 + "twitch": null
3576 + },
3577 + "metrics": {
3578 + "instagram_followers": 55300,
3579 + "tiktok_followers": null,
3580 + "youtube_subscribers": null,
3581 + "facebook_followers": null,
3582 + "x_followers": null,
3583 + "twitch_followers": null
3584 + },
3585 + "category": "beauté",
3586 + "location": "Montréal",
3587 + "notes": "beauté, bien-être et storytelling",
3588 + "sources": [
3589 + "feedspot"
3590 + ],
3591 + "total_followers": 55300,
3592 + "platform_count": 1,
3593 + "tier": "micro (10K+)"
3594 + },
3595 + {
3596 + "name": "Arnaud Nadeau",
3597 + "handles": {
3598 + "instagram": "arnaud.od_officiel",
3599 + "tiktok": null,
3600 + "youtube": null,
3601 + "facebook": null,
3602 + "x": null,
3603 + "twitch": null
3604 + },
3605 + "metrics": {
3606 + "instagram_followers": 55300,
3607 + "tiktok_followers": null,
3608 + "youtube_subscribers": null,
3609 + "facebook_followers": null,
3610 + "x_followers": null,
3611 + "twitch_followers": null
3612 + },
3613 + "category": "télé-réalité",
3614 + "location": "Montréal",
3615 + "notes": "Entrepreneur montréalais, gagnant d'Occupation Double Chypre 2025, a lancé sa chaîne Twitch",
3616 + "sources": [
3617 + "leregard_twitch"
3618 + ],
3619 + "total_followers": 55300,
3620 + "platform_count": 1,
3621 + "tier": "micro (10K+)"
3622 + },
3623 + {
3624 + "name": "Papi Melv",
3625 + "handles": {
3626 + "instagram": "papimelv",
3627 + "tiktok": null,
3628 + "youtube": null,
3629 + "facebook": null,
3630 + "x": null,
3631 + "twitch": "papimelv"
3632 + },
3633 + "metrics": {
3634 + "instagram_followers": 53000,
3635 + "tiktok_followers": null,
3636 + "youtube_subscribers": null,
3637 + "facebook_followers": null,
3638 + "x_followers": null,
3639 + "twitch_followers": null
3640 + },
3641 + "category": "gaming",
3642 + "location": "Québec",
3643 + "notes": "Streaming IRL, lives de voyages à l'étranger (Inde, Italie)",
3644 + "sources": [
3645 + "leregard_twitch"
3646 + ],
3647 + "total_followers": 53000,
3648 + "platform_count": 2,
3649 + "tier": "micro (10K+)"
3650 + },
3651 + {
3652 + "name": "Zainab (zai.eats)",
3653 + "handles": {
3654 + "instagram": "zai.eats",
3655 + "tiktok": null,
3656 + "youtube": null,
3657 + "facebook": null,
3658 + "x": null,
3659 + "twitch": null
3660 + },
3661 + "metrics": {
3662 + "instagram_followers": 51900,
3663 + "tiktok_followers": null,
3664 + "youtube_subscribers": null,
3665 + "facebook_followers": null,
3666 + "x_followers": null,
3667 + "twitch_followers": null
3668 + },
3669 + "category": "food",
3670 + "location": "Montréal",
3671 + "notes": "Quoi manger et où sortir à Montréal; vlogs de voyage",
3672 + "sources": [
3673 + "feedspot_food"
3674 + ],
3675 + "total_followers": 51900,
3676 + "platform_count": 1,
3677 + "tier": "micro (10K+)"
3678 + },
3679 + {
3680 + "name": "Manvi Gangwani",
3681 + "handles": {
3682 + "instagram": "yourbossgirll",
3683 + "tiktok": null,
3684 + "youtube": null,
3685 + "facebook": null,
3686 + "x": null,
3687 + "twitch": null
3688 + },
3689 + "metrics": {
3690 + "instagram_followers": 28600,
3691 + "tiktok_followers": null,
3692 + "youtube_subscribers": 23000,
3693 + "facebook_followers": null,
3694 + "x_followers": null,
3695 + "twitch_followers": null
3696 + },
3697 + "category": "autre",
3698 + "location": "Montréal",
3699 + "notes": "artiste muraliste (@bossgirldecors), contenu desi au Canada, 23K sur YouTube",
3700 + "sources": [
3701 + "feedspot"
3702 + ],
3703 + "total_followers": 51600,
3704 + "platform_count": 2,
3705 + "tier": "micro (10K+)"
3706 + },
3707 + {
3708 + "name": "Joe Beef",
3709 + "handles": {
3710 + "instagram": "joebeef",
3711 + "tiktok": null,
3712 + "youtube": null,
3713 + "facebook": null,
3714 + "x": null,
3715 + "twitch": null
3716 + },
3717 + "metrics": {
3718 + "instagram_followers": 46100,
3719 + "tiktok_followers": null,
3720 + "youtube_subscribers": null,
3721 + "facebook_followers": null,
3722 + "x_followers": null,
3723 + "twitch_followers": null
3724 + },
3725 + "category": "food",
3726 + "location": "Montréal",
3727 + "notes": "Compte du restaurant emblématique montréalais Joe Beef",
3728 + "sources": [
3729 + "feedspot_food"
3730 + ],
3731 + "total_followers": 46100,
3732 + "platform_count": 1,
3733 + "tier": "micro (10K+)"
3734 + },
3735 + {
3736 + "name": "Shaielle Oliveira",
3737 + "handles": {
3738 + "instagram": null,
3739 + "tiktok": "queenz_shaii",
3740 + "youtube": null,
3741 + "facebook": null,
3742 + "x": null,
3743 + "twitch": null
3744 + },
3745 + "metrics": {
3746 + "instagram_followers": 34500,
3747 + "tiktok_followers": 8600,
3748 + "youtube_subscribers": null,
3749 + "facebook_followers": null,
3750 + "x_followers": null,
3751 + "twitch_followers": null
3752 + },
3753 + "category": "beauté",
3754 + "location": "Québec",
3755 + "notes": "Maman créatrice UGC : skincare, beauté, mode, DIY.",
3756 + "sources": [
3757 + "collabstr_tiktok"
3758 + ],
3759 + "total_followers": 43100,
3760 + "platform_count": 2,
3761 + "tier": "micro (10K+)"
3762 + },
3763 + {
3764 + "name": "Helen & Billie (Mia Kouppa)",
3765 + "handles": {
3766 + "instagram": "miakouppa",
3767 + "tiktok": null,
3768 + "youtube": null,
3769 + "facebook": null,
3770 + "x": null,
3771 + "twitch": null
3772 + },
3773 + "metrics": {
3774 + "instagram_followers": 41000,
3775 + "tiktok_followers": null,
3776 + "youtube_subscribers": null,
3777 + "facebook_followers": null,
3778 + "x_followers": null,
3779 + "twitch_followers": null
3780 + },
3781 + "category": "food",
3782 + "location": "Québec",
3783 + "notes": "Recettes grecques authentiques familiales; miakouppa.com",
3784 + "sources": [
3785 + "feedspot_food"
3786 + ],
3787 + "total_followers": 41000,
3788 + "platform_count": 1,
3789 + "tier": "micro (10K+)"
3790 + },
3791 + {
3792 + "name": "Sarah Maamari",
3793 + "handles": {
3794 + "instagram": "sisimaamari",
3795 + "tiktok": null,
3796 + "youtube": null,
3797 + "facebook": null,
3798 + "x": null,
3799 + "twitch": null
3800 + },
3801 + "metrics": {
3802 + "instagram_followers": 32800,
3803 + "tiktok_followers": 7600,
3804 + "youtube_subscribers": null,
3805 + "facebook_followers": null,
3806 + "x_followers": null,
3807 + "twitch_followers": null
3808 + },
3809 + "category": "mode",
3810 + "location": "Montréal",
3811 + "notes": "mode, lifestyle, beauté, bien-être et voyage de luxe",
3812 + "sources": [
3813 + "collabstr"
3814 + ],
3815 + "total_followers": 40400,
3816 + "platform_count": 2,
3817 + "tier": "micro (10K+)"
3818 + },
3819 + {
3820 + "name": "Jasmine Delia",
3821 + "handles": {
3822 + "instagram": "jazzdelia",
3823 + "tiktok": null,
3824 + "youtube": null,
3825 + "facebook": null,
3826 + "x": null,
3827 + "twitch": null
3828 + },
3829 + "metrics": {
3830 + "instagram_followers": 37400,
3831 + "tiktok_followers": null,
3832 + "youtube_subscribers": null,
3833 + "facebook_followers": null,
3834 + "x_followers": null,
3835 + "twitch_followers": null
3836 + },
3837 + "category": "fitness",
3838 + "location": "Montréal",
3839 + "notes": "fitness, mode, lifestyle",
3840 + "sources": [
3841 + "feedspot"
3842 + ],
3843 + "total_followers": 37400,
3844 + "platform_count": 1,
3845 + "tier": "micro (10K+)"
3846 + },
3847 + {
3848 + "name": "Martin Juneau",
3849 + "handles": {
3850 + "instagram": "martin_juneau",
3851 + "tiktok": null,
3852 + "youtube": null,
3853 + "facebook": null,
3854 + "x": null,
3855 + "twitch": null
3856 + },
3857 + "metrics": {
3858 + "instagram_followers": 35500,
3859 + "tiktok_followers": null,
3860 + "youtube_subscribers": null,
3861 + "facebook_followers": null,
3862 + "x_followers": null,
3863 + "twitch_followers": null
3864 + },
3865 + "category": "food",
3866 + "location": "Montréal",
3867 + "notes": "Chef et entrepreneur, passionné d'œnologie; chiffres de 2019",
3868 + "sources": [
3869 + "clark_food"
3870 + ],
3871 + "total_followers": 35500,
3872 + "platform_count": 1,
3873 + "tier": "micro (10K+)"
3874 + },
3875 + {
3876 + "name": "Kim Dandurand",
3877 + "handles": {
3878 + "instagram": "xkimy23",
3879 + "tiktok": "xkimy23",
3880 + "youtube": null,
3881 + "facebook": null,
3882 + "x": null,
3883 + "twitch": null
3884 + },
3885 + "metrics": {
3886 + "instagram_followers": 14600,
3887 + "tiktok_followers": 20400,
3888 + "youtube_subscribers": null,
3889 + "facebook_followers": null,
3890 + "x_followers": null,
3891 + "twitch_followers": null
3892 + },
3893 + "category": "lifestyle",
3894 + "location": "Québec",
3895 + "notes": "lifestyle et animaux (Golden Retrievers, chats); Top Creator Collabstr | Contenu lifestyle et animaux (Golden Retrievers et chats). Top Creator Collabstr, note 5.0.",
3896 + "sources": [
3897 + "collabstr",
3898 + "collabstr_tiktok"
3899 + ],
3900 + "total_followers": 35000,
3901 + "platform_count": 2,
3902 + "tier": "micro (10K+)"
3903 + },
3904 + {
3905 + "name": "Alicia Waid",
3906 + "handles": {
3907 + "instagram": "aliciawaid",
3908 + "tiktok": null,
3909 + "youtube": null,
3910 + "facebook": null,
3911 + "x": null,
3912 + "twitch": null
3913 + },
3914 + "metrics": {
3915 + "instagram_followers": 32300,
3916 + "tiktok_followers": null,
3917 + "youtube_subscribers": null,
3918 + "facebook_followers": null,
3919 + "x_followers": null,
3920 + "twitch_followers": null
3921 + },
3922 + "category": "lifestyle",
3923 + "location": "Montréal",
3924 + "notes": "slow living, photographie (@aliciawaidphotography)",
3925 + "sources": [
3926 + "feedspot"
3927 + ],
3928 + "total_followers": 32300,
3929 + "platform_count": 1,
3930 + "tier": "micro (10K+)"
3931 + },
3932 + {
3933 + "name": "Gigi Madanipour",
3934 + "handles": {
3935 + "instagram": "gigimadanipour",
3936 + "tiktok": null,
3937 + "youtube": null,
3938 + "facebook": null,
3939 + "x": null,
3940 + "twitch": null
3941 + },
3942 + "metrics": {
3943 + "instagram_followers": 30500,
3944 + "tiktok_followers": null,
3945 + "youtube_subscribers": null,
3946 + "facebook_followers": null,
3947 + "x_followers": null,
3948 + "twitch_followers": null
3949 + },
3950 + "category": "food",
3951 + "location": "Montréal",
3952 + "notes": "Soupers faciles et plats pour recevoir; contributrice Flavour Network; maman de 2",
3953 + "sources": [
3954 + "feedspot_food"
3955 + ],
3956 + "total_followers": 30500,
3957 + "platform_count": 1,
3958 + "tier": "micro (10K+)"
3959 + },
3960 + {
3961 + "name": "Steve Daniel",
3962 + "handles": {
3963 + "instagram": "iamstevedaniel",
3964 + "tiktok": null,
3965 + "youtube": null,
3966 + "facebook": null,
3967 + "x": null,
3968 + "twitch": null
3969 + },
3970 + "metrics": {
3971 + "instagram_followers": 28600,
3972 + "tiktok_followers": null,
3973 + "youtube_subscribers": null,
3974 + "facebook_followers": null,
3975 + "x_followers": null,
3976 + "twitch_followers": null
3977 + },
3978 + "category": "food",
3979 + "location": "Montréal",
3980 + "notes": "Food & expériences à Montréal; +25% de croissance IG en 3 mois",
3981 + "sources": [
3982 + "feedspot_food"
3983 + ],
3984 + "total_followers": 28600,
3985 + "platform_count": 1,
3986 + "tier": "micro (10K+)"
3987 + },
3988 + {
3989 + "name": "Audrey Daigneault",
3990 + "handles": {
3991 + "instagram": "audrey_qc",
3992 + "tiktok": null,
3993 + "youtube": null,
3994 + "facebook": null,
3995 + "x": null,
3996 + "twitch": null
3997 + },
3998 + "metrics": {
3999 + "instagram_followers": 28400,
4000 + "tiktok_followers": null,
4001 + "youtube_subscribers": null,
4002 + "facebook_followers": null,
4003 + "x_followers": null,
4004 + "twitch_followers": null
4005 + },
4006 + "category": "lifestyle",
4007 + "location": "Québec",
4008 + "notes": "créatrice québécoise ex-prof, maman, contenu sans filtre",
4009 + "sources": [
4010 + "feedspot"
4011 + ],
4012 + "total_followers": 28400,
4013 + "platform_count": 1,
4014 + "tier": "micro (10K+)"
4015 + },
4016 + {
4017 + "name": "Gigi (Gigi in MTL)",
4018 + "handles": {
4019 + "instagram": "gigi.in.mtl",
4020 + "tiktok": null,
4021 + "youtube": null,
4022 + "facebook": null,
4023 + "x": null,
4024 + "twitch": null
4025 + },
4026 + "metrics": {
4027 + "instagram_followers": 28100,
4028 + "tiktok_followers": null,
4029 + "youtube_subscribers": null,
4030 + "facebook_followers": null,
4031 + "x_followers": null,
4032 + "twitch_followers": null
4033 + },
4034 + "category": "food",
4035 + "location": "Montréal",
4036 + "notes": "Food, voyage et lifestyle FR/EN; 27 500 abonnés IG selon La Grappe",
4037 + "sources": [
4038 + "feedspot_food",
4039 + "lagrappe_food"
4040 + ],
4041 + "total_followers": 28100,
4042 + "platform_count": 1,
4043 + "tier": "micro (10K+)"
4044 + },
4045 + {
4046 + "name": "Jérémie Falissard",
4047 + "handles": {
4048 + "instagram": "jeremie_barroco",
4049 + "tiktok": null,
4050 + "youtube": null,
4051 + "facebook": null,
4052 + "x": null,
4053 + "twitch": null
4054 + },
4055 + "metrics": {
4056 + "instagram_followers": 27600,
4057 + "tiktok_followers": null,
4058 + "youtube_subscribers": null,
4059 + "facebook_followers": null,
4060 + "x_followers": null,
4061 + "twitch_followers": null
4062 + },
4063 + "category": "food",
4064 + "location": "Québec",
4065 + "notes": "Chef/restaurateur, co-fondateur du Groupe Barroco, Top Chef France S14",
4066 + "sources": [
4067 + "feedspot_food"
4068 + ],
4069 + "total_followers": 27600,
4070 + "platform_count": 1,
4071 + "tier": "micro (10K+)"
4072 + },
4073 + {
4074 + "name": "Natacha (Natacha Eats)",
4075 + "handles": {
4076 + "instagram": "natachaeats",
4077 + "tiktok": null,
4078 + "youtube": null,
4079 + "facebook": null,
4080 + "x": null,
4081 + "twitch": null
4082 + },
4083 + "metrics": {
4084 + "instagram_followers": 27400,
4085 + "tiktok_followers": null,
4086 + "youtube_subscribers": null,
4087 + "facebook_followers": null,
4088 + "x_followers": null,
4089 + "twitch_followers": null
4090 + },
4091 + "category": "food",
4092 + "location": "Québec",
4093 + "notes": "Foodie, voyage et lifestyle; @shareyourplate.ca",
4094 + "sources": [
4095 + "feedspot_food"
4096 + ],
4097 + "total_followers": 27400,
4098 + "platform_count": 1,
4099 + "tier": "micro (10K+)"
4100 + },
4101 + {
4102 + "name": "Nirina Andreana",
4103 + "handles": {
4104 + "instagram": "itisnirina",
4105 + "tiktok": null,
4106 + "youtube": null,
4107 + "facebook": null,
4108 + "x": null,
4109 + "twitch": null
4110 + },
4111 + "metrics": {
4112 + "instagram_followers": 27200,
4113 + "tiktok_followers": null,
4114 + "youtube_subscribers": null,
4115 + "facebook_followers": null,
4116 + "x_followers": null,
4117 + "twitch_followers": null
4118 + },
4119 + "category": "lifestyle",
4120 + "location": "Montréal",
4121 + "notes": "lifestyle et shopping",
4122 + "sources": [
4123 + "feedspot"
4124 + ],
4125 + "total_followers": 27200,
4126 + "platform_count": 1,
4127 + "tier": "micro (10K+)"
4128 + },
4129 + {
4130 + "name": "Salma Bousnina",
4131 + "handles": {
4132 + "instagram": "tmavy_salma",
4133 + "tiktok": null,
4134 + "youtube": null,
4135 + "facebook": null,
4136 + "x": null,
4137 + "twitch": null
4138 + },
4139 + "metrics": {
4140 + "instagram_followers": 26900,
4141 + "tiktok_followers": null,
4142 + "youtube_subscribers": null,
4143 + "facebook_followers": null,
4144 + "x_followers": null,
4145 + "twitch_followers": null
4146 + },
4147 + "category": "mode",
4148 + "location": "Québec",
4149 + "notes": "maternité, mode, beauté; traductrice/interprète le jour",
4150 + "sources": [
4151 + "feedspot"
4152 + ],
4153 + "total_followers": 26900,
4154 + "platform_count": 1,
4155 + "tier": "micro (10K+)"
4156 + },
4157 + {
4158 + "name": "Maire de Laval",
4159 + "handles": {
4160 + "instagram": "mairedelaval",
4161 + "tiktok": null,
4162 + "youtube": "maire de laval",
4163 + "facebook": null,
4164 + "x": null,
4165 + "twitch": null
4166 + },
4167 + "metrics": {
4168 + "instagram_followers": 26800,
4169 + "tiktok_followers": null,
4170 + "youtube_subscribers": null,
4171 + "facebook_followers": null,
4172 + "x_followers": null,
4173 + "twitch_followers": null
4174 + },
4175 + "category": "autre",
4176 + "location": "Laval, Québec",
4177 + "notes": "Analyses culturelles et politiques au ton brut. Top YouTubeurs QC 2026 (ONEWAY).",
4178 + "sources": [
4179 + "oneway_youtube"
4180 + ],
4181 + "total_followers": 26800,
4182 + "platform_count": 2,
4183 + "tier": "micro (10K+)"
4184 + },
4185 + {
4186 + "name": "Mathilde Chamberland",
4187 + "handles": {
4188 + "instagram": "mathildechamberland",
4189 + "tiktok": null,
4190 + "youtube": null,
4191 + "facebook": null,
4192 + "x": null,
4193 + "twitch": null
4194 + },
4195 + "metrics": {
4196 + "instagram_followers": 26500,
4197 + "tiktok_followers": null,
4198 + "youtube_subscribers": null,
4199 + "facebook_followers": null,
4200 + "x_followers": null,
4201 + "twitch_followers": null
4202 + },
4203 + "category": "food",
4204 + "location": "Québec",
4205 + "notes": "Slow motherhood et vie à la campagne",
4206 + "sources": [
4207 + "feedspot_food"
4208 + ],
4209 + "total_followers": 26500,
4210 + "platform_count": 1,
4211 + "tier": "micro (10K+)"
4212 + },
4213 + {
4214 + "name": "Jeremy Fillion",
4215 + "handles": {
4216 + "instagram": "jeremyfillion",
4217 + "tiktok": "jeremyfillion",
4218 + "youtube": null,
4219 + "facebook": null,
4220 + "x": null,
4221 + "twitch": null
4222 + },
4223 + "metrics": {
4224 + "instagram_followers": 3300,
4225 + "tiktok_followers": 21800,
4226 + "youtube_subscribers": null,
4227 + "facebook_followers": null,
4228 + "x_followers": null,
4229 + "twitch_followers": null
4230 + },
4231 + "category": "lifestyle",
4232 + "location": "Québec",
4233 + "notes": "créateur franco-canadien, vlogs « Day in My Life » et beauté | Créateur franco-canadien : vlogs quotidiens, beauté (maquillage, soins, parfums). Apparaît deux fois sur Collabstr (21.8k et 21.5k).",
4234 + "sources": [
4235 + "collabstr",
4236 + "collabstr_tiktok"
4237 + ],
4238 + "total_followers": 25100,
4239 + "platform_count": 2,
4240 + "tier": "micro (10K+)"
4241 + },
4242 + {
4243 + "name": "Cassidy Neves",
4244 + "handles": {
4245 + "instagram": "cassidyneves",
4246 + "tiktok": null,
4247 + "youtube": null,
4248 + "facebook": null,
4249 + "x": null,
4250 + "twitch": null
4251 + },
4252 + "metrics": {
4253 + "instagram_followers": 24400,
4254 + "tiktok_followers": null,
4255 + "youtube_subscribers": null,
4256 + "facebook_followers": null,
4257 + "x_followers": null,
4258 + "twitch_followers": null
4259 + },
4260 + "category": "lifestyle",
4261 + "location": "Montréal",
4262 + "notes": "outfits, livres et sports; e-commerce chez YSL Beauty / Armani Beauty",
4263 + "sources": [
4264 + "feedspot"
4265 + ],
4266 + "total_followers": 24400,
4267 + "platform_count": 1,
4268 + "tier": "micro (10K+)"
4269 + },
4270 + {
4271 + "name": "Aretha (arethaeats)",
4272 + "handles": {
4273 + "instagram": "arethaeats",
4274 + "tiktok": null,
4275 + "youtube": null,
4276 + "facebook": null,
4277 + "x": null,
4278 + "twitch": null
4279 + },
4280 + "metrics": {
4281 + "instagram_followers": 23900,
4282 + "tiktok_followers": null,
4283 + "youtube_subscribers": null,
4284 + "facebook_followers": null,
4285 + "x_followers": null,
4286 + "twitch_followers": null
4287 + },
4288 + "category": "food",
4289 + "location": "Montréal",
4290 + "notes": "Restaurants et activités à Montréal",
4291 + "sources": [
4292 + "feedspot_food"
4293 + ],
4294 + "total_followers": 23900,
4295 + "platform_count": 1,
4296 + "tier": "micro (10K+)"
4297 + },
4298 + {
4299 + "name": "Nath (fulltimefoooodie)",
4300 + "handles": {
4301 + "instagram": "fulltimefoooodie",
4302 + "tiktok": null,
4303 + "youtube": null,
4304 + "facebook": null,
4305 + "x": null,
4306 + "twitch": null
4307 + },
4308 + "metrics": {
4309 + "instagram_followers": 23700,
4310 + "tiktok_followers": null,
4311 + "youtube_subscribers": null,
4312 + "facebook_followers": null,
4313 + "x_followers": null,
4314 + "twitch_followers": null
4315 + },
4316 + "category": "food",
4317 + "location": "Montréal",
4318 + "notes": "Bouffe, drinks et activités partout à Montréal",
4319 + "sources": [
4320 + "feedspot_food"
4321 + ],
4322 + "total_followers": 23700,
4323 + "platform_count": 1,
4324 + "tier": "micro (10K+)"
4325 + },
4326 + {
4327 + "name": "Molly Paquin",
4328 + "handles": {
4329 + "instagram": null,
4330 + "tiktok": "mollypaquin_sobre",
4331 + "youtube": null,
4332 + "facebook": null,
4333 + "x": null,
4334 + "twitch": null
4335 + },
4336 + "metrics": {
4337 + "instagram_followers": null,
4338 + "tiktok_followers": 22600,
4339 + "youtube_subscribers": null,
4340 + "facebook_followers": null,
4341 + "x_followers": null,
4342 + "twitch_followers": null
4343 + },
4344 + "category": "lifestyle",
4345 + "location": "Québec",
4346 + "notes": "Créatrice lifestyle : vie de famille, astuces beauté et recettes.",
4347 + "sources": [
4348 + "collabstr_tiktok"
4349 + ],
4350 + "total_followers": 22600,
4351 + "platform_count": 1,
4352 + "tier": "micro (10K+)"
4353 + },
4354 + {
4355 + "name": "Annie Ferland",
4356 + "handles": {
4357 + "instagram": "sciencefourchette",
4358 + "tiktok": null,
4359 + "youtube": null,
4360 + "facebook": null,
4361 + "x": null,
4362 + "twitch": null
4363 + },
4364 + "metrics": {
4365 + "instagram_followers": 22400,
4366 + "tiktok_followers": null,
4367 + "youtube_subscribers": null,
4368 + "facebook_followers": null,
4369 + "x_followers": null,
4370 + "twitch_followers": null
4371 + },
4372 + "category": "food",
4373 + "location": "Montréal",
4374 + "notes": "Nutritionniste Dt.P., Ph.D., fondatrice de Science & Fourchette",
4375 + "sources": [
4376 + "feedspot_food"
4377 + ],
4378 + "total_followers": 22400,
4379 + "platform_count": 1,
4380 + "tier": "micro (10K+)"
4381 + },
4382 + {
4383 + "name": "Murielle Banackissa",
4384 + "handles": {
4385 + "instagram": "muriellebanackissa",
4386 + "tiktok": null,
4387 + "youtube": null,
4388 + "facebook": null,
4389 + "x": null,
4390 + "twitch": null
4391 + },
4392 + "metrics": {
4393 + "instagram_followers": 21600,
4394 + "tiktok_followers": null,
4395 + "youtube_subscribers": null,
4396 + "facebook_followers": null,
4397 + "x_followers": null,
4398 + "twitch_followers": null
4399 + },
4400 + "category": "food",
4401 + "location": "Montréal",
4402 + "notes": "Auteure de livre de cuisine végane (Savoring), photographe et styliste culinaire",
4403 + "sources": [
4404 + "feedspot_food"
4405 + ],
4406 + "total_followers": 21600,
4407 + "platform_count": 1,
4408 + "tier": "micro (10K+)"
4409 + },
4410 + {
4411 + "name": "Genevieve Cusson",
4412 + "handles": {
4413 + "instagram": null,
4414 + "tiktok": "genevieve.geteste",
4415 + "youtube": null,
4416 + "facebook": null,
4417 + "x": null,
4418 + "twitch": null
4419 + },
4420 + "metrics": {
4421 + "instagram_followers": null,
4422 + "tiktok_followers": 20800,
4423 + "youtube_subscribers": null,
4424 + "facebook_followers": null,
4425 + "x_followers": null,
4426 + "twitch_followers": null
4427 + },
4428 + "category": "lifestyle",
4429 + "location": "Québec",
4430 + "notes": "Tests et revues de produits, découvertes lifestyle, UGC.",
4431 + "sources": [
4432 + "collabstr_tiktok"
4433 + ],
4434 + "total_followers": 20800,
4435 + "platform_count": 1,
4436 + "tier": "micro (10K+)"
4437 + },
4438 + {
4439 + "name": "Food Guy Montreal",
4440 + "handles": {
4441 + "instagram": "foodguymtl",
4442 + "tiktok": null,
4443 + "youtube": null,
4444 + "facebook": null,
4445 + "x": null,
4446 + "twitch": null
4447 + },
4448 + "metrics": {
4449 + "instagram_followers": 20400,
4450 + "tiktok_followers": null,
4451 + "youtube_subscribers": null,
4452 + "facebook_followers": null,
4453 + "x_followers": null,
4454 + "twitch_followers": null
4455 + },
4456 + "category": "food",
4457 + "location": "Montréal",
4458 + "notes": "Source de tout ce qui est bouffe à Montréal",
4459 + "sources": [
4460 + "feedspot_food"
4461 + ],
4462 + "total_followers": 20400,
4463 + "platform_count": 1,
4464 + "tier": "micro (10K+)"
4465 + },
4466 + {
4467 + "name": "Stefano Faita",
4468 + "handles": {
4469 + "instagram": "sfaita",
4470 + "tiktok": null,
4471 + "youtube": null,
4472 + "facebook": null,
4473 + "x": null,
4474 + "twitch": null
4475 + },
4476 + "metrics": {
4477 + "instagram_followers": 20000,
4478 + "tiktok_followers": null,
4479 + "youtube_subscribers": null,
4480 + "facebook_followers": null,
4481 + "x_followers": null,
4482 + "twitch_followers": null
4483 + },
4484 + "category": "food",
4485 + "location": "Montréal",
4486 + "notes": "Roi de la cuisine italienne à Montréal; 3 restaurants avec Michele Forgione, pizzas et sauces; chiffres de 2019 (~20 000)",
4487 + "sources": [
4488 + "clark_food"
4489 + ],
4490 + "total_followers": 20000,
4491 + "platform_count": 1,
4492 + "tier": "micro (10K+)"
4493 + },
4494 + {
4495 + "name": "Emily Doung",
4496 + "handles": {
4497 + "instagram": "ewilydee",
4498 + "tiktok": null,
4499 + "youtube": null,
4500 + "facebook": null,
4501 + "x": null,
4502 + "twitch": null
4503 + },
4504 + "metrics": {
4505 + "instagram_followers": 19800,
4506 + "tiktok_followers": null,
4507 + "youtube_subscribers": null,
4508 + "facebook_followers": null,
4509 + "x_followers": null,
4510 + "twitch_followers": null
4511 + },
4512 + "category": "food",
4513 + "location": "Montréal",
4514 + "notes": "Médecin la semaine, créatrice de contenu la fin de semaine; posts quotidiens EN/FR",
4515 + "sources": [
4516 + "feedspot_food"
4517 + ],
4518 + "total_followers": 19800,
4519 + "platform_count": 1,
4520 + "tier": "micro (10K+)"
4521 + },
4522 + {
4523 + "name": "Jessica (toutedansmesfesses)",
4524 + "handles": {
4525 + "instagram": "toutedansmesfesses",
4526 + "tiktok": null,
4527 + "youtube": null,
4528 + "facebook": null,
4529 + "x": null,
4530 + "twitch": null
4531 + },
4532 + "metrics": {
4533 + "instagram_followers": 19600,
4534 + "tiktok_followers": null,
4535 + "youtube_subscribers": null,
4536 + "facebook_followers": null,
4537 + "x_followers": null,
4538 + "twitch_followers": null
4539 + },
4540 + "category": "food",
4541 + "location": "Montréal",
4542 + "notes": "Archives des munchies de @jessicaprdnc",
4543 + "sources": [
4544 + "feedspot_food"
4545 + ],
4546 + "total_followers": 19600,
4547 + "platform_count": 1,
4548 + "tier": "micro (10K+)"
4549 + },
4550 + {
4551 + "name": "Parneet Dhillon",
4552 + "handles": {
4553 + "instagram": "parneeetk_",
4554 + "tiktok": null,
4555 + "youtube": null,
4556 + "facebook": null,
4557 + "x": null,
4558 + "twitch": null
4559 + },
4560 + "metrics": {
4561 + "instagram_followers": 19100,
4562 + "tiktok_followers": null,
4563 + "youtube_subscribers": null,
4564 + "facebook_followers": null,
4565 + "x_followers": null,
4566 + "twitch_followers": null
4567 + },
4568 + "category": "lifestyle",
4569 + "location": "Montréal",
4570 + "notes": "micro-influenceuse montréalaise",
4571 + "sources": [
4572 + "feedspot"
4573 + ],
4574 + "total_followers": 19100,
4575 + "platform_count": 1,
4576 + "tier": "micro (10K+)"
4577 + },
4578 + {
4579 + "name": "Karolane Rondeau",
4580 + "handles": {
4581 + "instagram": "karolanerondeau",
4582 + "tiktok": null,
4583 + "youtube": null,
4584 + "facebook": null,
4585 + "x": null,
4586 + "twitch": null
4587 + },
4588 + "metrics": {
4589 + "instagram_followers": 18600,
4590 + "tiktok_followers": null,
4591 + "youtube_subscribers": null,
4592 + "facebook_followers": null,
4593 + "x_followers": null,
4594 + "twitch_followers": null
4595 + },
4596 + "category": "food",
4597 + "location": "Montréal",
4598 + "notes": "Créatrice voyage + food",
4599 + "sources": [
4600 + "feedspot_food"
4601 + ],
4602 + "total_followers": 18600,
4603 + "platform_count": 1,
4604 + "tier": "micro (10K+)"
4605 + },
4606 + {
4607 + "name": "Audrey Roberge",
4608 + "handles": {
4609 + "instagram": "audreyy_x",
4610 + "tiktok": null,
4611 + "youtube": null,
4612 + "facebook": null,
4613 + "x": null,
4614 + "twitch": null
4615 + },
4616 + "metrics": {
4617 + "instagram_followers": 18100,
4618 + "tiktok_followers": null,
4619 + "youtube_subscribers": null,
4620 + "facebook_followers": null,
4621 + "x_followers": null,
4622 + "twitch_followers": null
4623 + },
4624 + "category": "lifestyle",
4625 + "location": "Québec",
4626 + "notes": "travaille en assurances",
4627 + "sources": [
4628 + "feedspot"
4629 + ],
4630 + "total_followers": 18100,
4631 + "platform_count": 1,
4632 + "tier": "micro (10K+)"
4633 + },
4634 + {
4635 + "name": "Amanda Quirion",
4636 + "handles": {
4637 + "instagram": null,
4638 + "tiktok": "amandaquirion",
4639 + "youtube": null,
4640 + "facebook": null,
4641 + "x": null,
4642 + "twitch": null
4643 + },
4644 + "metrics": {
4645 + "instagram_followers": 4600,
4646 + "tiktok_followers": 13300,
4647 + "youtube_subscribers": null,
4648 + "facebook_followers": null,
4649 + "x_followers": null,
4650 + "twitch_followers": null
4651 + },
4652 + "category": "lifestyle",
4653 + "location": "Québec",
4654 + "notes": "Maman de jumeaux et enseignante : slow motherhood, cuisine, beauté, sport.",
4655 + "sources": [
4656 + "collabstr_tiktok"
4657 + ],
4658 + "total_followers": 17900,
4659 + "platform_count": 2,
4660 + "tier": "micro (10K+)"
4661 + },
4662 + {
4663 + "name": "Abby Rose Dar Juan",
4664 + "handles": {
4665 + "instagram": "abbyyxxrose",
4666 + "tiktok": "abbyyxxrose",
4667 + "youtube": null,
4668 + "facebook": null,
4669 + "x": null,
4670 + "twitch": null
4671 + },
4672 + "metrics": {
4673 + "instagram_followers": 4400,
4674 + "tiktok_followers": 13400,
4675 + "youtube_subscribers": null,
4676 + "facebook_followers": null,
4677 + "x_followers": null,
4678 + "twitch_followers": null
4679 + },
4680 + "category": "lifestyle",
4681 + "location": "Montréal",
4682 + "notes": "Philippine à Montréal, travail, culture et vie après les heures de bureau",
4683 + "sources": [
4684 + "feedspot"
4685 + ],
4686 + "total_followers": 17800,
4687 + "platform_count": 2,
4688 + "tier": "micro (10K+)"
4689 + },
4690 + {
4691 + "name": "Amelie Montambeault",
4692 + "handles": {
4693 + "instagram": null,
4694 + "tiktok": "amey.newera",
4695 + "youtube": null,
4696 + "facebook": null,
4697 + "x": null,
4698 + "twitch": null
4699 + },
4700 + "metrics": {
4701 + "instagram_followers": 8200,
4702 + "tiktok_followers": 9600,
4703 + "youtube_subscribers": null,
4704 + "facebook_followers": null,
4705 + "x_followers": null,
4706 + "twitch_followers": null
4707 + },
4708 + "category": "beauté",
4709 + "location": "Québec",
4710 + "notes": "Créatrice UGC de luxe : skincare, voyage, food et marketing; maman de trois enfants.",
4711 + "sources": [
4712 + "collabstr_tiktok"
4713 + ],
4714 + "total_followers": 17800,
4715 + "platform_count": 2,
4716 + "tier": "micro (10K+)"
4717 + },
4718 + {
4719 + "name": "Myka (mykababyxx)",
4720 + "handles": {
4721 + "instagram": "mykababyxx",
4722 + "tiktok": null,
4723 + "youtube": null,
4724 + "facebook": null,
4725 + "x": null,
4726 + "twitch": null
4727 + },
4728 + "metrics": {
4729 + "instagram_followers": 17500,
4730 + "tiktok_followers": null,
4731 + "youtube_subscribers": null,
4732 + "facebook_followers": null,
4733 + "x_followers": null,
4734 + "twitch_followers": null
4735 + },
4736 + "category": "beauté",
4737 + "location": "Québec",
4738 + "notes": "Top 20 Modash Québec, rang 1; propriétaire @nailzzbymyka (ongles); engagement 3.44%",
4739 + "sources": [
4740 + "modash"
4741 + ],
4742 + "total_followers": 17500,
4743 + "platform_count": 1,
4744 + "tier": "micro (10K+)"
4745 + },
4746 + {
4747 + "name": "Victor (Random Cuisine)",
4748 + "handles": {
4749 + "instagram": "randomcuisine",
4750 + "tiktok": null,
4751 + "youtube": null,
4752 + "facebook": null,
4753 + "x": null,
4754 + "twitch": null
4755 + },
4756 + "metrics": {
4757 + "instagram_followers": 17300,
4758 + "tiktok_followers": null,
4759 + "youtube_subscribers": null,
4760 + "facebook_followers": null,
4761 + "x_followers": null,
4762 + "twitch_followers": null
4763 + },
4764 + "category": "food",
4765 + "location": "Montréal",
4766 + "notes": "Physicien quantique, sommelier de thé, chroniqueur bouffe asiatique; finaliste Prix Lauriers 2023",
4767 + "sources": [
4768 + "feedspot_food"
4769 + ],
4770 + "total_followers": 17300,
4771 + "platform_count": 1,
4772 + "tier": "micro (10K+)"
4773 + },
4774 + {
4775 + "name": "Nitika",
4776 + "handles": {
4777 + "instagram": "heyitsnitika",
4778 + "tiktok": null,
4779 + "youtube": null,
4780 + "facebook": null,
4781 + "x": null,
4782 + "twitch": null
4783 + },
4784 + "metrics": {
4785 + "instagram_followers": 17100,
4786 + "tiktok_followers": null,
4787 + "youtube_subscribers": null,
4788 + "facebook_followers": null,
4789 + "x_followers": null,
4790 + "twitch_followers": null
4791 + },
4792 + "category": "lifestyle",
4793 + "location": "Montréal",
4794 + "notes": "maman montréalaise de 40 ans, aventures/mode/bien-être (listée « Nikita » sur Feedspot)",
4795 + "sources": [
4796 + "feedspot"
4797 + ],
4798 + "total_followers": 17100,
4799 + "platform_count": 1,
4800 + "tier": "micro (10K+)"
4801 + },
4802 + {
4803 + "name": "Evan (evanvanya)",
4804 + "handles": {
4805 + "instagram": "evanvanya",
4806 + "tiktok": null,
4807 + "youtube": null,
4808 + "facebook": null,
4809 + "x": null,
4810 + "twitch": null
4811 + },
4812 + "metrics": {
4813 + "instagram_followers": 17100,
4814 + "tiktok_followers": null,
4815 + "youtube_subscribers": null,
4816 + "facebook_followers": null,
4817 + "x_followers": null,
4818 + "twitch_followers": null
4819 + },
4820 + "category": "food",
4821 + "location": "Montréal",
4822 + "notes": "Ne publie que ce qu'il a aimé",
4823 + "sources": [
4824 + "feedspot_food"
4825 + ],
4826 + "total_followers": 17100,
4827 + "platform_count": 1,
4828 + "tier": "micro (10K+)"
4829 + },
4830 + {
4831 + "name": "Kenza Elkad",
4832 + "handles": {
4833 + "instagram": "kenzaelkad",
4834 + "tiktok": null,
4835 + "youtube": null,
4836 + "facebook": null,
4837 + "x": null,
4838 + "twitch": null
4839 + },
4840 + "metrics": {
4841 + "instagram_followers": 17000,
4842 + "tiktok_followers": null,
4843 + "youtube_subscribers": null,
4844 + "facebook_followers": null,
4845 + "x_followers": null,
4846 + "twitch_followers": null
4847 + },
4848 + "category": "mode",
4849 + "location": "Montréal",
4850 + "notes": "ingénieure en bâtiment passionnée de mode",
4851 + "sources": [
4852 + "feedspot"
4853 + ],
4854 + "total_followers": 17000,
4855 + "platform_count": 1,
4856 + "tier": "micro (10K+)"
4857 + },
4858 + {
4859 + "name": "Kim Williams",
4860 + "handles": {
4861 + "instagram": "kimoune",
4862 + "tiktok": null,
4863 + "youtube": null,
4864 + "facebook": null,
4865 + "x": null,
4866 + "twitch": null
4867 + },
4868 + "metrics": {
4869 + "instagram_followers": 16900,
4870 + "tiktok_followers": null,
4871 + "youtube_subscribers": null,
4872 + "facebook_followers": null,
4873 + "x_followers": null,
4874 + "twitch_followers": null
4875 + },
4876 + "category": "lifestyle",
4877 + "location": "Montréal",
4878 + "notes": "animatrice radio (Énergie 94.3, Rouge 107.3), maman; lifestyle/mode/maternité",
4879 + "sources": [
4880 + "feedspot",
4881 + "embold"
4882 + ],
4883 + "total_followers": 16900,
4884 + "platform_count": 1,
4885 + "tier": "micro (10K+)"
4886 + },
4887 + {
4888 + "name": "Johanna Dalmeida (Ayélé)",
4889 + "handles": {
4890 + "instagram": null,
4891 + "tiktok": "ayl.d",
4892 + "youtube": null,
4893 + "facebook": null,
4894 + "x": null,
4895 + "twitch": null
4896 + },
4897 + "metrics": {
4898 + "instagram_followers": 1300,
4899 + "tiktok_followers": 15500,
4900 + "youtube_subscribers": null,
4901 + "facebook_followers": null,
4902 + "x_followers": null,
4903 + "twitch_followers": null
4904 + },
4905 + "category": "beauté",
4906 + "location": "Québec",
4907 + "notes": "Créatrice de contenu beauté, lifestyle et découverte, 25 ans.",
4908 + "sources": [
4909 + "collabstr_tiktok"
4910 + ],
4911 + "total_followers": 16800,
4912 + "platform_count": 2,
4913 + "tier": "micro (10K+)"
4914 + },
4915 + {
4916 + "name": "Shaida Rouamba",
4917 + "handles": {
4918 + "instagram": null,
4919 + "tiktok": "urfavshxida",
4920 + "youtube": null,
4921 + "facebook": null,
4922 + "x": null,
4923 + "twitch": null
4924 + },
4925 + "metrics": {
4926 + "instagram_followers": 14700,
4927 + "tiktok_followers": 1900,
4928 + "youtube_subscribers": null,
4929 + "facebook_followers": null,
4930 + "x_followers": null,
4931 + "twitch_followers": null
4932 + },
4933 + "category": "mode",
4934 + "location": "Québec",
4935 + "notes": "Créatrice mode axée sur la confiance en soi et les tendances.",
4936 + "sources": [
4937 + "collabstr_tiktok"
4938 + ],
4939 + "total_followers": 16600,
4940 + "platform_count": 2,
4941 + "tier": "micro (10K+)"
4942 + },
4943 + {
4944 + "name": "Aya Hayek",
4945 + "handles": {
4946 + "instagram": "ayahayek_",
4947 + "tiktok": null,
4948 + "youtube": null,
4949 + "facebook": null,
4950 + "x": null,
4951 + "twitch": null
4952 + },
4953 + "metrics": {
4954 + "instagram_followers": 16200,
4955 + "tiktok_followers": null,
4956 + "youtube_subscribers": null,
4957 + "facebook_followers": null,
4958 + "x_followers": null,
4959 + "twitch_followers": null
4960 + },
4961 + "category": "food",
4962 + "location": "Montréal",
4963 + "notes": "créatrice lifestyle & food, UGC (@ayas.feedd)",
4964 + "sources": [
4965 + "feedspot"
4966 + ],
4967 + "total_followers": 16200,
4968 + "platform_count": 1,
4969 + "tier": "micro (10K+)"
4970 + },
4971 + {
4972 + "name": "Caroline Elie",
4973 + "handles": {
4974 + "instagram": "caroline.elie",
4975 + "tiktok": null,
4976 + "youtube": null,
4977 + "facebook": null,
4978 + "x": null,
4979 + "twitch": null
4980 + },
4981 + "metrics": {
4982 + "instagram_followers": 16000,
4983 + "tiktok_followers": null,
4984 + "youtube_subscribers": null,
4985 + "facebook_followers": null,
4986 + "x_followers": null,
4987 + "twitch_followers": null
4988 + },
4989 + "category": "lifestyle",
4990 + "location": "Montréal",
4991 + "notes": "experte luxe au quotidien, fondatrice @theeverydayluxury",
4992 + "sources": [
4993 + "feedspot"
4994 + ],
4995 + "total_followers": 16000,
4996 + "platform_count": 1,
4997 + "tier": "micro (10K+)"
4998 + },
4999 + {
5000 + "name": "Jess (j3ss.food)",
5001 + "handles": {
5002 + "instagram": "j3ss.food",
5003 + "tiktok": null,
5004 + "youtube": null,
5005 + "facebook": null,
5006 + "x": null,
5007 + "twitch": null
5008 + },
5009 + "metrics": {
5010 + "instagram_followers": 15700,
5011 + "tiktok_followers": null,
5012 + "youtube_subscribers": null,
5013 + "facebook_followers": null,
5014 + "x_followers": null,
5015 + "twitch_followers": null
5016 + },
5017 + "category": "food",
5018 + "location": "Montréal",
5019 + "notes": "Food et voyage entre Manchester et Montréal",
5020 + "sources": [
5021 + "feedspot_food"
5022 + ],
5023 + "total_followers": 15700,
5024 + "platform_count": 1,
5025 + "tier": "micro (10K+)"
5026 + },
5027 + {
5028 + "name": "Ishman Kaur",
5029 + "handles": {
5030 + "instagram": "ishman.in.mtl",
5031 + "tiktok": null,
5032 + "youtube": null,
5033 + "facebook": null,
5034 + "x": null,
5035 + "twitch": null
5036 + },
5037 + "metrics": {
5038 + "instagram_followers": 15600,
5039 + "tiktok_followers": null,
5040 + "youtube_subscribers": null,
5041 + "facebook_followers": null,
5042 + "x_followers": null,
5043 + "twitch_followers": null
5044 + },
5045 + "category": "mode",
5046 + "location": "Montréal",
5047 + "notes": "sciences de la vie + mode et lifestyle, recherche en oncologie",
5048 + "sources": [
5049 + "feedspot"
5050 + ],
5051 + "total_followers": 15600,
5052 + "platform_count": 1,
5053 + "tier": "micro (10K+)"
5054 + },
5055 + {
5056 + "name": "Julia (julia_tastes)",
5057 + "handles": {
5058 + "instagram": "julia_tastes",
5059 + "tiktok": null,
5060 + "youtube": null,
5061 + "facebook": null,
5062 + "x": null,
5063 + "twitch": null
5064 + },
5065 + "metrics": {
5066 + "instagram_followers": 15400,
5067 + "tiktok_followers": null,
5068 + "youtube_subscribers": null,
5069 + "facebook_followers": null,
5070 + "x_followers": null,
5071 + "twitch_followers": null
5072 + },
5073 + "category": "food",
5074 + "location": "Montréal",
5075 + "notes": "Latina à Montréal; food, lifestyle, cuisine EN/FR/ESP; créatrice UGC",
5076 + "sources": [
5077 + "feedspot_food"
5078 + ],
5079 + "total_followers": 15400,
5080 + "platform_count": 1,
5081 + "tier": "micro (10K+)"
5082 + },
5083 + {
5084 + "name": "Dwyane101",
5085 + "handles": {
5086 + "instagram": null,
5087 + "tiktok": null,
5088 + "youtube": null,
5089 + "facebook": null,
5090 + "x": null,
5091 + "twitch": "dwyane101"
5092 + },
5093 + "metrics": {
5094 + "instagram_followers": 1600,
5095 + "tiktok_followers": 13100,
5096 + "youtube_subscribers": 316,
5097 + "facebook_followers": null,
5098 + "x_followers": null,
5099 + "twitch_followers": 380
5100 + },
5101 + "category": "gaming",
5102 + "location": "Lachine, Québec",
5103 + "notes": "Skits Roblox et clips Twitch, 19 ans",
5104 + "sources": [
5105 + "collabstr_twitch"
5106 + ],
5107 + "total_followers": 15396,
5108 + "platform_count": 4,
5109 + "tier": "micro (10K+)"
5110 + },
5111 + {
5112 + "name": "Massimo Piedimonte",
5113 + "handles": {
5114 + "instagram": "massimoagogo",
5115 + "tiktok": null,
5116 + "youtube": null,
5117 + "facebook": null,
5118 + "x": null,
5119 + "twitch": null
5120 + },
5121 + "metrics": {
5122 + "instagram_followers": 15300,
5123 + "tiktok_followers": null,
5124 + "youtube_subscribers": null,
5125 + "facebook_followers": null,
5126 + "x_followers": null,
5127 + "twitch_followers": null
5128 + },
5129 + "category": "food",
5130 + "location": "Montréal",
5131 + "notes": "Demi-finaliste Top Chef (Bravo US), proprio Cabaret l'Enfer; #2 Best New Restaurant Canada's 100 Best",
5132 + "sources": [
5133 + "feedspot_food"
5134 + ],
5135 + "total_followers": 15300,
5136 + "platform_count": 1,
5137 + "tier": "micro (10K+)"
5138 + },
5139 + {
5140 + "name": "Serena Kani",
5141 + "handles": {
5142 + "instagram": "serenakani",
5143 + "tiktok": null,
5144 + "youtube": null,
5145 + "facebook": null,
5146 + "x": null,
5147 + "twitch": null
5148 + },
5149 + "metrics": {
5150 + "instagram_followers": 14400,
5151 + "tiktok_followers": null,
5152 + "youtube_subscribers": null,
5153 + "facebook_followers": null,
5154 + "x_followers": null,
5155 + "twitch_followers": null
5156 + },
5157 + "category": "lifestyle",
5158 + "location": "Montréal",
5159 + "notes": "créatrice UGC, lifestyle et fitness",
5160 + "sources": [
5161 + "feedspot"
5162 + ],
5163 + "total_followers": 14400,
5164 + "platform_count": 1,
5165 + "tier": "micro (10K+)"
5166 + },
5167 + {
5168 + "name": "Marie Morrissette",
5169 + "handles": {
5170 + "instagram": null,
5171 + "tiktok": "mmorrissette16",
5172 + "youtube": null,
5173 + "facebook": null,
5174 + "x": null,
5175 + "twitch": null
5176 + },
5177 + "metrics": {
5178 + "instagram_followers": null,
5179 + "tiktok_followers": 14100,
5180 + "youtube_subscribers": null,
5181 + "facebook_followers": null,
5182 + "x_followers": null,
5183 + "twitch_followers": null
5184 + },
5185 + "category": "lifestyle",
5186 + "location": "Québec",
5187 + "notes": "Lifestyle, beauté et voyage; Instagram entre 10k et 50k abonnés.",
5188 + "sources": [
5189 + "collabstr_tiktok"
5190 + ],
5191 + "total_followers": 14100,
5192 + "platform_count": 1,
5193 + "tier": "micro (10K+)"
5194 + },
5195 + {
5196 + "name": "Pascal Barriault",
5197 + "handles": {
5198 + "instagram": "pascalbarriault",
5199 + "tiktok": null,
5200 + "youtube": null,
5201 + "facebook": null,
5202 + "x": null,
5203 + "twitch": null
5204 + },
5205 + "metrics": {
5206 + "instagram_followers": 14000,
5207 + "tiktok_followers": null,
5208 + "youtube_subscribers": null,
5209 + "facebook_followers": null,
5210 + "x_followers": null,
5211 + "twitch_followers": null
5212 + },
5213 + "category": "voyage",
5214 + "location": "Québec",
5215 + "notes": "Comédien; voyage entre tournages et spectacles (périple au Nicaragua)",
5216 + "sources": [
5217 + "noovomoi_voyage"
5218 + ],
5219 + "total_followers": 14000,
5220 + "platform_count": 1,
5221 + "tier": "micro (10K+)"
5222 + },
5223 + {
5224 + "name": "Pascale Robitaille",
5225 + "handles": {
5226 + "instagram": "pascalerobitaille",
5227 + "tiktok": null,
5228 + "youtube": null,
5229 + "facebook": null,
5230 + "x": null,
5231 + "twitch": null
5232 + },
5233 + "metrics": {
5234 + "instagram_followers": 13700,
5235 + "tiktok_followers": null,
5236 + "youtube_subscribers": null,
5237 + "facebook_followers": null,
5238 + "x_followers": null,
5239 + "twitch_followers": null
5240 + },
5241 + "category": "autre",
5242 + "location": "Québec",
5243 + "notes": "Top 20 Modash Québec, rang 2; journaliste TVA Nouvelles / Salut Bonjour; engagement 4.14%",
5244 + "sources": [
5245 + "modash"
5246 + ],
5247 + "total_followers": 13700,
5248 + "platform_count": 1,
5249 + "tier": "micro (10K+)"
5250 + },
5251 + {
5252 + "name": "Soufiane Benrqiq",
5253 + "handles": {
5254 + "instagram": "soufiane",
5255 + "tiktok": null,
5256 + "youtube": null,
5257 + "facebook": null,
5258 + "x": null,
5259 + "twitch": null
5260 + },
5261 + "metrics": {
5262 + "instagram_followers": 13300,
5263 + "tiktok_followers": null,
5264 + "youtube_subscribers": null,
5265 + "facebook_followers": null,
5266 + "x_followers": null,
5267 + "twitch_followers": null
5268 + },
5269 + "category": "musique",
5270 + "location": "Québec",
5271 + "notes": "Top 20 Modash Québec, rang 3; réalisateur / producteur musical (@upawasessions); engagement 1.09%",
5272 + "sources": [
5273 + "modash"
5274 + ],
5275 + "total_followers": 13300,
5276 + "platform_count": 1,
5277 + "tier": "micro (10K+)"
5278 + },
5279 + {
5280 + "name": "Jeannie (jeanniesfood)",
5281 + "handles": {
5282 + "instagram": "jeanniesfood",
5283 + "tiktok": null,
5284 + "youtube": null,
5285 + "facebook": null,
5286 + "x": null,
5287 + "twitch": null
5288 + },
5289 + "metrics": {
5290 + "instagram_followers": 13300,
5291 + "tiktok_followers": null,
5292 + "youtube_subscribers": null,
5293 + "facebook_followers": null,
5294 + "x_followers": null,
5295 + "twitch_followers": null
5296 + },
5297 + "category": "food",
5298 + "location": "Montréal",
5299 + "notes": "Meilleurs restos et activités à Montréal",
5300 + "sources": [
5301 + "feedspot_food"
5302 + ],
5303 + "total_followers": 13300,
5304 + "platform_count": 1,
5305 + "tier": "micro (10K+)"
5306 + },
5307 + {
5308 + "name": "Cynthia Marcotte",
5309 + "handles": {
5310 + "instagram": "cynthia.marcotte.dtp",
5311 + "tiktok": null,
5312 + "youtube": null,
5313 + "facebook": null,
5314 + "x": null,
5315 + "twitch": null
5316 + },
5317 + "metrics": {
5318 + "instagram_followers": null,
5319 + "tiktok_followers": null,
5320 + "youtube_subscribers": 13300,
5321 + "facebook_followers": null,
5322 + "x_followers": null,
5323 + "twitch_followers": null
5324 + },
5325 + "category": "food",
5326 + "location": "Québec",
5327 + "notes": "Nutritionniste; prix pour ses vidéos diététiques au Gala DUX de Montréal; chiffres de 2019",
5328 + "sources": [
5329 + "clark_food"
5330 + ],
5331 + "total_followers": 13300,
5332 + "platform_count": 2,
5333 + "tier": "micro (10K+)"
5334 + },
5335 + {
5336 + "name": "Marie-Julie Gagnon",
5337 + "handles": {
5338 + "instagram": "mariejuliega",
5339 + "tiktok": null,
5340 + "youtube": null,
5341 + "facebook": null,
5342 + "x": null,
5343 + "twitch": null
5344 + },
5345 + "metrics": {
5346 + "instagram_followers": 13000,
5347 + "tiktok_followers": null,
5348 + "youtube_subscribers": null,
5349 + "facebook_followers": null,
5350 + "x_followers": null,
5351 + "twitch_followers": null
5352 + },
5353 + "category": "voyage",
5354 + "location": "Québec",
5355 + "notes": "Journaliste et chroniqueuse voyage (télé/radio); aussi @technomade",
5356 + "sources": [
5357 + "noovomoi_voyage"
5358 + ],
5359 + "total_followers": 13000,
5360 + "platform_count": 1,
5361 + "tier": "micro (10K+)"
5362 + },
5363 + {
5364 + "name": "Mary Swaydan",
5365 + "handles": {
5366 + "instagram": "mary.swaydan",
5367 + "tiktok": null,
5368 + "youtube": null,
5369 + "facebook": null,
5370 + "x": null,
5371 + "twitch": null
5372 + },
5373 + "metrics": {
5374 + "instagram_followers": 12900,
5375 + "tiktok_followers": null,
5376 + "youtube_subscribers": null,
5377 + "facebook_followers": null,
5378 + "x_followers": null,
5379 + "twitch_followers": null
5380 + },
5381 + "category": "lifestyle",
5382 + "location": "Québec",
5383 + "notes": "maman, vie à Montréal et au Saguenay–Lac-Saint-Jean, slow living et tourisme local",
5384 + "sources": [
5385 + "feedspot"
5386 + ],
5387 + "total_followers": 12900,
5388 + "platform_count": 1,
5389 + "tier": "micro (10K+)"
5390 + },
5391 + {
5392 + "name": "Elodie Taylor",
5393 + "handles": {
5394 + "instagram": "elodie_taylor",
5395 + "tiktok": "elodie_taylor",
5396 + "youtube": null,
5397 + "facebook": null,
5398 + "x": null,
5399 + "twitch": null
5400 + },
5401 + "metrics": {
5402 + "instagram_followers": 12700,
5403 + "tiktok_followers": null,
5404 + "youtube_subscribers": null,
5405 + "facebook_followers": null,
5406 + "x_followers": null,
5407 + "twitch_followers": null
5408 + },
5409 + "category": "beauté",
5410 + "location": "Montréal",
5411 + "notes": "beauté, lifestyle, voyage",
5412 + "sources": [
5413 + "feedspot"
5414 + ],
5415 + "total_followers": 12700,
5416 + "platform_count": 2,
5417 + "tier": "micro (10K+)"
5418 + },
5419 + {
5420 + "name": "Pierrick Dubé",
5421 + "handles": {
5422 + "instagram": "duber72_",
5423 + "tiktok": null,
5424 + "youtube": null,
5425 + "facebook": null,
5426 + "x": null,
5427 + "twitch": null
5428 + },
5429 + "metrics": {
5430 + "instagram_followers": 12700,
5431 + "tiktok_followers": null,
5432 + "youtube_subscribers": null,
5433 + "facebook_followers": null,
5434 + "x_followers": null,
5435 + "twitch_followers": null
5436 + },
5437 + "category": "sport",
5438 + "location": "Québec",
5439 + "notes": "Top 20 Modash Québec, rang 4; hockeyeur (organisation Washington Capitals); engagement 4.88%",
5440 + "sources": [
5441 + "modash"
5442 + ],
5443 + "total_followers": 12700,
5444 + "platform_count": 1,
5445 + "tier": "micro (10K+)"
5446 + },
5447 + {
5448 + "name": "Kathy Brousseau",
5449 + "handles": {
5450 + "instagram": "kayttykat",
5451 + "tiktok": null,
5452 + "youtube": null,
5453 + "facebook": null,
5454 + "x": null,
5455 + "twitch": null
5456 + },
5457 + "metrics": {
5458 + "instagram_followers": 12100,
5459 + "tiktok_followers": null,
5460 + "youtube_subscribers": null,
5461 + "facebook_followers": null,
5462 + "x_followers": null,
5463 + "twitch_followers": null
5464 + },
5465 + "category": "fitness",
5466 + "location": "Québec",
5467 + "notes": "Top 20 Modash Québec, rang 5; athlète bikini et coach fitness, infirmière; engagement 1.36%",
5468 + "sources": [
5469 + "modash"
5470 + ],
5471 + "total_followers": 12100,
5472 + "platform_count": 1,
5473 + "tier": "micro (10K+)"
5474 + },
5475 + {
5476 + "name": "Eric Martel-Bahoeli",
5477 + "handles": {
5478 + "instagram": "bahoboxing",
5479 + "tiktok": null,
5480 + "youtube": null,
5481 + "facebook": null,
5482 + "x": null,
5483 + "twitch": null
5484 + },
5485 + "metrics": {
5486 + "instagram_followers": 11800,
5487 + "tiktok_followers": null,
5488 + "youtube_subscribers": null,
5489 + "facebook_followers": null,
5490 + "x_followers": null,
5491 + "twitch_followers": null
5492 + },
5493 + "category": "sport",
5494 + "location": "Québec",
5495 + "notes": "Top 20 Modash Québec, rang 6; ex-boxeur pro, champion canadien poids lourd; engagement 1.24%",
5496 + "sources": [
5497 + "modash"
5498 + ],
5499 + "total_followers": 11800,
5500 + "platform_count": 1,
5501 + "tier": "micro (10K+)"
5502 + },
5503 + {
5504 + "name": "Foodie Quebec",
5505 + "handles": {
5506 + "instagram": "foodiequebec",
5507 + "tiktok": null,
5508 + "youtube": null,
5509 + "facebook": null,
5510 + "x": null,
5511 + "twitch": null
5512 + },
5513 + "metrics": {
5514 + "instagram_followers": 11800,
5515 + "tiktok_followers": null,
5516 + "youtube_subscribers": null,
5517 + "facebook_followers": null,
5518 + "x_followers": null,
5519 + "twitch_followers": null
5520 + },
5521 + "category": "food",
5522 + "location": "Québec",
5523 + "notes": "Sélection de découvertes culinaires au Québec (#foodieqc)",
5524 + "sources": [
5525 + "feedspot_food"
5526 + ],
5527 + "total_followers": 11800,
5528 + "platform_count": 1,
5529 + "tier": "micro (10K+)"
5530 + },
5531 + {
5532 + "name": "Chanel-Océane Tardif",
5533 + "handles": {
5534 + "instagram": null,
5535 + "tiktok": "chanel.octt",
5536 + "youtube": null,
5537 + "facebook": null,
5538 + "x": null,
5539 + "twitch": "chanou__"
5540 + },
5541 + "metrics": {
5542 + "instagram_followers": 2800,
5543 + "tiktok_followers": 8500,
5544 + "youtube_subscribers": null,
5545 + "facebook_followers": null,
5546 + "x_followers": null,
5547 + "twitch_followers": 491
5548 + },
5549 + "category": "beauté",
5550 + "location": "Québec (ville), Québec",
5551 + "notes": "Beauté, mode, lifestyle, voyage et danse. YouTube 10k-50k, Twitch 491. | Créatrice beauté, mode, lifestyle, voyage et danse; YouTube 10k-50k (fourchette)",
5552 + "sources": [
5553 + "collabstr_tiktok",
5554 + "collabstr_twitch"
5555 + ],
5556 + "total_followers": 11791,
5557 + "platform_count": 3,
5558 + "tier": "micro (10K+)"
5559 + },
5560 + {
5561 + "name": "Phil Gagné",
5562 + "handles": {
5563 + "instagram": "kaikupanda",
5564 + "tiktok": null,
5565 + "youtube": null,
5566 + "facebook": null,
5567 + "x": null,
5568 + "twitch": null
5569 + },
5570 + "metrics": {
5571 + "instagram_followers": 11600,
5572 + "tiktok_followers": null,
5573 + "youtube_subscribers": null,
5574 + "facebook_followers": null,
5575 + "x_followers": null,
5576 + "twitch_followers": null
5577 + },
5578 + "category": "sport",
5579 + "location": "Québec",
5580 + "notes": "Top 20 Modash Québec, rang 7; vélo (@velocartel), musique et photo; engagement 2.59%",
5581 + "sources": [
5582 + "modash"
5583 + ],
5584 + "total_followers": 11600,
5585 + "platform_count": 1,
5586 + "tier": "micro (10K+)"
5587 + },
5588 + {
5589 + "name": "Yanick Turcotte",
5590 + "handles": {
5591 + "instagram": "yturcotte17",
5592 + "tiktok": null,
5593 + "youtube": null,
5594 + "facebook": null,
5595 + "x": null,
5596 + "twitch": null
5597 + },
5598 + "metrics": {
5599 + "instagram_followers": 11400,
5600 + "tiktok_followers": null,
5601 + "youtube_subscribers": null,
5602 + "facebook_followers": null,
5603 + "x_followers": null,
5604 + "twitch_followers": null
5605 + },
5606 + "category": "lifestyle",
5607 + "location": "Québec",
5608 + "notes": "Top 20 Modash Québec, rang 8; engagement 10.17%",
5609 + "sources": [
5610 + "modash"
5611 + ],
5612 + "total_followers": 11400,
5613 + "platform_count": 1,
5614 + "tier": "micro (10K+)"
5615 + },
5616 + {
5617 + "name": "Émilie Gaillet (Émilie Murmure)",
5618 + "handles": {
5619 + "instagram": "emilie_murmure",
5620 + "tiktok": null,
5621 + "youtube": null,
5622 + "facebook": null,
5623 + "x": null,
5624 + "twitch": null
5625 + },
5626 + "metrics": {
5627 + "instagram_followers": 11300,
5628 + "tiktok_followers": null,
5629 + "youtube_subscribers": null,
5630 + "facebook_followers": null,
5631 + "x_followers": null,
5632 + "twitch_followers": null
5633 + },
5634 + "category": "food",
5635 + "location": "Montréal",
5636 + "notes": "Photographe et styliste culinaire, création de recettes; blogue primé Taste Canada Awards 2018",
5637 + "sources": [
5638 + "feedspot_food",
5639 + "clark_food"
5640 + ],
5641 + "total_followers": 11300,
5642 + "platform_count": 1,
5643 + "tier": "micro (10K+)"
5644 + },
5645 + {
5646 + "name": "Genevieve Wong",
5647 + "handles": {
5648 + "instagram": "goodgirlgigi_",
5649 + "tiktok": null,
5650 + "youtube": null,
5651 + "facebook": null,
5652 + "x": null,
5653 + "twitch": null
5654 + },
5655 + "metrics": {
5656 + "instagram_followers": 11200,
5657 + "tiktok_followers": null,
5658 + "youtube_subscribers": null,
5659 + "facebook_followers": null,
5660 + "x_followers": null,
5661 + "twitch_followers": null
5662 + },
5663 + "category": "lifestyle",
5664 + "location": "Montréal",
5665 + "notes": "lifestyle, maternité et mode; maman de jumeaux",
5666 + "sources": [
5667 + "collabstr"
5668 + ],
5669 + "total_followers": 11200,
5670 + "platform_count": 1,
5671 + "tier": "micro (10K+)"
5672 + },
5673 + {
5674 + "name": "V. (verolagachette)",
5675 + "handles": {
5676 + "instagram": "verolagachette",
5677 + "tiktok": null,
5678 + "youtube": null,
5679 + "facebook": null,
5680 + "x": null,
5681 + "twitch": null
5682 + },
5683 + "metrics": {
5684 + "instagram_followers": 11200,
5685 + "tiktok_followers": null,
5686 + "youtube_subscribers": null,
5687 + "facebook_followers": null,
5688 + "x_followers": null,
5689 + "twitch_followers": null
5690 + },
5691 + "category": "autre",
5692 + "location": "Québec",
5693 + "notes": "Top 20 Modash Québec, rang 9; tatoueuse retraitée (@blackxlfc); engagement 1.46%",
5694 + "sources": [
5695 + "modash"
5696 + ],
5697 + "total_followers": 11200,
5698 + "platform_count": 1,
5699 + "tier": "micro (10K+)"
5700 + },
5701 + {
5702 + "name": "Émilie Brun-Cormier",
5703 + "handles": {
5704 + "instagram": "emiliebruncormier",
5705 + "tiktok": "emiliebruncormier",
5706 + "youtube": null,
5707 + "facebook": null,
5708 + "x": null,
5709 + "twitch": null
5710 + },
5711 + "metrics": {
5712 + "instagram_followers": 10900,
5713 + "tiktok_followers": null,
5714 + "youtube_subscribers": null,
5715 + "facebook_followers": null,
5716 + "x_followers": null,
5717 + "twitch_followers": null
5718 + },
5719 + "category": "lifestyle",
5720 + "location": "Québec",
5721 + "notes": "Top 20 Modash Québec, rang 10; plein air, ambassadrice Sports Experts Laurier; engagement 3.9%",
5722 + "sources": [
5723 + "modash"
5724 + ],
5725 + "total_followers": 10900,
5726 + "platform_count": 2,
5727 + "tier": "micro (10K+)"
5728 + },
5729 + {
5730 + "name": "Lauréan Tardif",
5731 + "handles": {
5732 + "instagram": "laureantardif",
5733 + "tiktok": null,
5734 + "youtube": null,
5735 + "facebook": null,
5736 + "x": null,
5737 + "twitch": null
5738 + },
5739 + "metrics": {
5740 + "instagram_followers": 10900,
5741 + "tiktok_followers": null,
5742 + "youtube_subscribers": null,
5743 + "facebook_followers": null,
5744 + "x_followers": null,
5745 + "twitch_followers": null
5746 + },
5747 + "category": "voyage",
5748 + "location": "Québec",
5749 + "notes": "Top 20 Modash Québec, rang 11; vidéaste d'expédition / plein air; engagement 2.8%",
5750 + "sources": [
5751 + "modash"
5752 + ],
5753 + "total_followers": 10900,
5754 + "platform_count": 1,
5755 + "tier": "micro (10K+)"
5756 + },
5757 + {
5758 + "name": "École de cirque de Québec",
5759 + "handles": {
5760 + "instagram": "ecoledecirquedequebec",
5761 + "tiktok": null,
5762 + "youtube": null,
5763 + "facebook": null,
5764 + "x": null,
5765 + "twitch": null
5766 + },
5767 + "metrics": {
5768 + "instagram_followers": 10800,
5769 + "tiktok_followers": null,
5770 + "youtube_subscribers": null,
5771 + "facebook_followers": null,
5772 + "x_followers": null,
5773 + "twitch_followers": null
5774 + },
5775 + "category": "autre",
5776 + "location": "Québec",
5777 + "notes": "Top 20 Modash Québec, rang 12; école de cirque contemporain (organisation); engagement 1.41%",
5778 + "sources": [
5779 + "modash"
5780 + ],
5781 + "total_followers": 10800,
5782 + "platform_count": 1,
5783 + "tier": "micro (10K+)"
5784 + },
5785 + {
5786 + "name": "Olivier Lohman",
5787 + "handles": {
5788 + "instagram": "speedbird3000",
5789 + "tiktok": null,
5790 + "youtube": null,
5791 + "facebook": null,
5792 + "x": null,
5793 + "twitch": null
5794 + },
5795 + "metrics": {
5796 + "instagram_followers": 10600,
5797 + "tiktok_followers": null,
5798 + "youtube_subscribers": null,
5799 + "facebook_followers": null,
5800 + "x_followers": null,
5801 + "twitch_followers": null
5802 + },
5803 + "category": "autre",
5804 + "location": "Québec",
5805 + "notes": "Top 20 Modash Québec, rang 13; contenu aviation / pilote; engagement 10.96%",
5806 + "sources": [
5807 + "modash"
5808 + ],
5809 + "total_followers": 10600,
5810 + "platform_count": 1,
5811 + "tier": "micro (10K+)"
5812 + },
5813 + {
5814 + "name": "Katia Curadeau",
5815 + "handles": {
5816 + "instagram": "katiacuradeau",
5817 + "tiktok": null,
5818 + "youtube": null,
5819 + "facebook": null,
5820 + "x": null,
5821 + "twitch": null
5822 + },
5823 + "metrics": {
5824 + "instagram_followers": 10500,
5825 + "tiktok_followers": null,
5826 + "youtube_subscribers": null,
5827 + "facebook_followers": null,
5828 + "x_followers": null,
5829 + "twitch_followers": null
5830 + },
5831 + "category": "mode",
5832 + "location": "Québec",
5833 + "notes": "Top 20 Modash Québec, rang 14; modèle photo / photographie (@photokurado); engagement 1.65%",
5834 + "sources": [
5835 + "modash"
5836 + ],
5837 + "total_followers": 10500,
5838 + "platform_count": 1,
5839 + "tier": "micro (10K+)"
5840 + },
5841 + {
5842 + "name": "Kristeena Saint",
5843 + "handles": {
5844 + "instagram": "kristeenamichelle",
5845 + "tiktok": null,
5846 + "youtube": null,
5847 + "facebook": null,
5848 + "x": null,
5849 + "twitch": null
5850 + },
5851 + "metrics": {
5852 + "instagram_followers": 10400,
5853 + "tiktok_followers": null,
5854 + "youtube_subscribers": null,
5855 + "facebook_followers": null,
5856 + "x_followers": null,
5857 + "twitch_followers": null
5858 + },
5859 + "category": "food",
5860 + "location": "Montréal",
5861 + "notes": "Foodie pigiste",
5862 + "sources": [
5863 + "feedspot_food"
5864 + ],
5865 + "total_followers": 10400,
5866 + "platform_count": 1,
5867 + "tier": "micro (10K+)"
5868 + },
5869 + {
5870 + "name": "Annabelle Gervais-Tanguay (anna)",
5871 + "handles": {
5872 + "instagram": "annagervaiss",
5873 + "tiktok": null,
5874 + "youtube": null,
5875 + "facebook": null,
5876 + "x": null,
5877 + "twitch": null
5878 + },
5879 + "metrics": {
5880 + "instagram_followers": 10100,
5881 + "tiktok_followers": null,
5882 + "youtube_subscribers": null,
5883 + "facebook_followers": null,
5884 + "x_followers": null,
5885 + "twitch_followers": null
5886 + },
5887 + "category": "lifestyle",
5888 + "location": "Québec",
5889 + "notes": "Top 20 Modash Québec, rang 15; engagement 9.12%",
5890 + "sources": [
5891 + "modash"
5892 + ],
5893 + "total_followers": 10100,
5894 + "platform_count": 1,
5895 + "tier": "micro (10K+)"
5896 + },
5897 + {
5898 + "name": "Cynthia (cl_blog)",
5899 + "handles": {
5900 + "instagram": "cl_blog",
5901 + "tiktok": null,
5902 + "youtube": null,
5903 + "facebook": null,
5904 + "x": null,
5905 + "twitch": null
5906 + },
5907 + "metrics": {
5908 + "instagram_followers": 10100,
5909 + "tiktok_followers": null,
5910 + "youtube_subscribers": null,
5911 + "facebook_followers": null,
5912 + "x_followers": null,
5913 + "twitch_followers": null
5914 + },
5915 + "category": "food",
5916 + "location": "Montréal",
5917 + "notes": "Blogueuse MTL: food, lifestyle, activités, voyage | Aventures culinaires et contenu UGC",
5918 + "sources": [
5919 + "feedspot_food"
5920 + ],
5921 + "total_followers": 10100,
5922 + "platform_count": 1,
5923 + "tier": "micro (10K+)"
5924 + },
5925 + {
5926 + "name": "Girlie Owusu",
5927 + "handles": {
5928 + "instagram": "blackasiian",
5929 + "tiktok": null,
5930 + "youtube": null,
5931 + "facebook": null,
5932 + "x": null,
5933 + "twitch": null
5934 + },
5935 + "metrics": {
5936 + "instagram_followers": 9900,
5937 + "tiktok_followers": null,
5938 + "youtube_subscribers": null,
5939 + "facebook_followers": null,
5940 + "x_followers": null,
5941 + "twitch_followers": null
5942 + },
5943 + "category": "mode",
5944 + "location": "Montréal",
5945 + "notes": "mode, cheveux bouclés, beauté et skincare; mannequin",
5946 + "sources": [
5947 + "collabstr"
5948 + ],
5949 + "total_followers": 9900,
5950 + "platform_count": 1,
5951 + "tier": "nano/inconnu"
5952 + },
5953 + {
5954 + "name": "Eben Gomat",
5955 + "handles": {
5956 + "instagram": "gomateben",
5957 + "tiktok": null,
5958 + "youtube": null,
5959 + "facebook": null,
5960 + "x": null,
5961 + "twitch": null
5962 + },
5963 + "metrics": {
5964 + "instagram_followers": 9800,
5965 + "tiktok_followers": null,
5966 + "youtube_subscribers": null,
5967 + "facebook_followers": null,
5968 + "x_followers": null,
5969 + "twitch_followers": null
5970 + },
5971 + "category": "fitness",
5972 + "location": "Montréal",
5973 + "notes": "santé, fitness et mode masculine",
5974 + "sources": [
5975 + "feedspot"
5976 + ],
5977 + "total_followers": 9800,
5978 + "platform_count": 1,
5979 + "tier": "nano/inconnu"
5980 + },
5981 + {
5982 + "name": "Lisa Cote",
5983 + "handles": {
5984 + "instagram": "liisacotee__",
5985 + "tiktok": null,
5986 + "youtube": null,
5987 + "facebook": null,
5988 + "x": null,
5989 + "twitch": null
5990 + },
5991 + "metrics": {
5992 + "instagram_followers": 9500,
5993 + "tiktok_followers": null,
5994 + "youtube_subscribers": null,
5995 + "facebook_followers": null,
5996 + "x_followers": null,
5997 + "twitch_followers": null
5998 + },
5999 + "category": "autre",
6000 + "location": "Québec",
6001 + "notes": "UGC et photographie de produit",
6002 + "sources": [
6003 + "collabstr"
6004 + ],
6005 + "total_followers": 9500,
6006 + "platform_count": 1,
6007 + "tier": "nano/inconnu"
6008 + },
6009 + {
6010 + "name": "Kelly Ann Turcot",
6011 + "handles": {
6012 + "instagram": "kellyannturcot",
6013 + "tiktok": null,
6014 + "youtube": null,
6015 + "facebook": null,
6016 + "x": null,
6017 + "twitch": null
6018 + },
6019 + "metrics": {
6020 + "instagram_followers": 9400,
6021 + "tiktok_followers": null,
6022 + "youtube_subscribers": null,
6023 + "facebook_followers": null,
6024 + "x_followers": null,
6025 + "twitch_followers": null
6026 + },
6027 + "category": "lifestyle",
6028 + "location": "Québec",
6029 + "notes": "Top 20 Modash Québec, rang 16; partenariat @undz; engagement 6%",
6030 + "sources": [
6031 + "modash"
6032 + ],
6033 + "total_followers": 9400,
6034 + "platform_count": 1,
6035 + "tier": "nano/inconnu"
6036 + },
6037 + {
6038 + "name": "Tracy Vee",
6039 + "handles": {
6040 + "instagram": "misstracyvee",
6041 + "tiktok": null,
6042 + "youtube": null,
6043 + "facebook": null,
6044 + "x": null,
6045 + "twitch": null
6046 + },
6047 + "metrics": {
6048 + "instagram_followers": 9300,
6049 + "tiktok_followers": null,
6050 + "youtube_subscribers": null,
6051 + "facebook_followers": null,
6052 + "x_followers": null,
6053 + "twitch_followers": null
6054 + },
6055 + "category": "beauté",
6056 + "location": "Montréal",
6057 + "notes": "maquilleuse (MUA) pour cinéma, télé et pubs (@tracyveemakeup)",
6058 + "sources": [
6059 + "feedspot"
6060 + ],
6061 + "total_followers": 9300,
6062 + "platform_count": 1,
6063 + "tier": "nano/inconnu"
6064 + },
6065 + {
6066 + "name": "Laurence L. Lepage",
6067 + "handles": {
6068 + "instagram": "laurence_l._lepage",
6069 + "tiktok": null,
6070 + "youtube": null,
6071 + "facebook": null,
6072 + "x": null,
6073 + "twitch": null
6074 + },
6075 + "metrics": {
6076 + "instagram_followers": 9216,
6077 + "tiktok_followers": null,
6078 + "youtube_subscribers": null,
6079 + "facebook_followers": null,
6080 + "x_followers": null,
6081 + "twitch_followers": null
6082 + },
6083 + "category": "voyage",
6084 + "location": "Québec",
6085 + "notes": "Photographe de paysages et grands espaces, couchers de soleil",
6086 + "sources": [
6087 + "noovomoi_voyage"
6088 + ],
6089 + "total_followers": 9216,
6090 + "platform_count": 1,
6091 + "tier": "nano/inconnu"
6092 + },
6093 + {
6094 + "name": "Fernanda Gonzalez",
6095 + "handles": {
6096 + "instagram": null,
6097 + "tiktok": "ferchi0802",
6098 + "youtube": null,
6099 + "facebook": null,
6100 + "x": null,
6101 + "twitch": null
6102 + },
6103 + "metrics": {
6104 + "instagram_followers": null,
6105 + "tiktok_followers": 8700,
6106 + "youtube_subscribers": null,
6107 + "facebook_followers": null,
6108 + "x_followers": null,
6109 + "twitch_followers": null
6110 + },
6111 + "category": "lifestyle",
6112 + "location": "Québec",
6113 + "notes": "Créatrice latina en espagnol pour la communauté latino-américaine au Canada. Taux d'engagement 73.35%.",
6114 + "sources": [
6115 + "collabstr_tiktok"
6116 + ],
6117 + "total_followers": 8700,
6118 + "platform_count": 1,
6119 + "tier": "nano/inconnu"
6120 + },
6121 + {
6122 + "name": "Juliette Painchaud",
6123 + "handles": {
6124 + "instagram": "juliette_painchaud",
6125 + "tiktok": "juliettepainchaud",
6126 + "youtube": null,
6127 + "facebook": null,
6128 + "x": null,
6129 + "twitch": null
6130 + },
6131 + "metrics": {
6132 + "instagram_followers": 8700,
6133 + "tiktok_followers": null,
6134 + "youtube_subscribers": null,
6135 + "facebook_followers": null,
6136 + "x_followers": null,
6137 + "twitch_followers": null
6138 + },
6139 + "category": "sport",
6140 + "location": "Québec",
6141 + "notes": "Top 20 Modash Québec, rang 17; marathonienne (Boston Marathon); engagement 9.16%",
6142 + "sources": [
6143 + "modash"
6144 + ],
6145 + "total_followers": 8700,
6146 + "platform_count": 2,
6147 + "tier": "nano/inconnu"
6148 + },
6149 + {
6150 + "name": "Benoit Boulanger",
6151 + "handles": {
6152 + "instagram": "ben.boulanger",
6153 + "tiktok": null,
6154 + "youtube": null,
6155 + "facebook": null,
6156 + "x": null,
6157 + "twitch": null
6158 + },
6159 + "metrics": {
6160 + "instagram_followers": 8600,
6161 + "tiktok_followers": null,
6162 + "youtube_subscribers": null,
6163 + "facebook_followers": null,
6164 + "x_followers": null,
6165 + "twitch_followers": null
6166 + },
6167 + "category": "fitness",
6168 + "location": "Québec",
6169 + "notes": "Top 20 Modash Québec, rang 18; athlète CrossFit et nutritionniste Dt.P; engagement 3.52%",
6170 + "sources": [
6171 + "modash"
6172 + ],
6173 + "total_followers": 8600,
6174 + "platform_count": 1,
6175 + "tier": "nano/inconnu"
6176 + },
6177 + {
6178 + "name": "Abi Rodier",
6179 + "handles": {
6180 + "instagram": "aaabirodier",
6181 + "tiktok": null,
6182 + "youtube": null,
6183 + "facebook": null,
6184 + "x": null,
6185 + "twitch": null
6186 + },
6187 + "metrics": {
6188 + "instagram_followers": 8500,
6189 + "tiktok_followers": null,
6190 + "youtube_subscribers": null,
6191 + "facebook_followers": null,
6192 + "x_followers": null,
6193 + "twitch_followers": null
6194 + },
6195 + "category": "lifestyle",
6196 + "location": "Québec",
6197 + "notes": "Top 20 Modash Québec, rang 19; engagement 17.59%",
6198 + "sources": [
6199 + "modash"
6200 + ],
6201 + "total_followers": 8500,
6202 + "platform_count": 1,
6203 + "tier": "nano/inconnu"
6204 + },
6205 + {
6206 + "name": "Charles Nguyen",
6207 + "handles": {
6208 + "instagram": "mtlcharles",
6209 + "tiktok": null,
6210 + "youtube": null,
6211 + "facebook": null,
6212 + "x": null,
6213 + "twitch": null
6214 + },
6215 + "metrics": {
6216 + "instagram_followers": 8400,
6217 + "tiktok_followers": null,
6218 + "youtube_subscribers": null,
6219 + "facebook_followers": null,
6220 + "x_followers": null,
6221 + "twitch_followers": null
6222 + },
6223 + "category": "food",
6224 + "location": "Montréal",
6225 + "notes": "Créateur, photographe, auteur; culture vietnamienne (@chodemmtl)",
6226 + "sources": [
6227 + "feedspot_food"
6228 + ],
6229 + "total_followers": 8400,
6230 + "platform_count": 1,
6231 + "tier": "nano/inconnu"
6232 + },
6233 + {
6234 + "name": "Claudia Passalacqua",
6235 + "handles": {
6236 + "instagram": "claudiapassa",
6237 + "tiktok": null,
6238 + "youtube": null,
6239 + "facebook": null,
6240 + "x": null,
6241 + "twitch": null
6242 + },
6243 + "metrics": {
6244 + "instagram_followers": 8400,
6245 + "tiktok_followers": null,
6246 + "youtube_subscribers": null,
6247 + "facebook_followers": null,
6248 + "x_followers": null,
6249 + "twitch_followers": null
6250 + },
6251 + "category": "food",
6252 + "location": "Montréal",
6253 + "notes": "Stratège de contenu et gestionnaire de réseaux sociaux",
6254 + "sources": [
6255 + "feedspot_food"
6256 + ],
6257 + "total_followers": 8400,
6258 + "platform_count": 1,
6259 + "tier": "nano/inconnu"
6260 + },
6261 + {
6262 + "name": "Géraldine Drolet",
6263 + "handles": {
6264 + "instagram": "geraldinedrolet",
6265 + "tiktok": null,
6266 + "youtube": null,
6267 + "facebook": null,
6268 + "x": null,
6269 + "twitch": null
6270 + },
6271 + "metrics": {
6272 + "instagram_followers": 8200,
6273 + "tiktok_followers": null,
6274 + "youtube_subscribers": null,
6275 + "facebook_followers": null,
6276 + "x_followers": null,
6277 + "twitch_followers": null
6278 + },
6279 + "category": "lifestyle",
6280 + "location": "Québec",
6281 + "notes": "Top 20 Modash Québec, rang 20; collab Red Bull Canada; engagement 1.23%",
6282 + "sources": [
6283 + "modash"
6284 + ],
6285 + "total_followers": 8200,
6286 + "platform_count": 1,
6287 + "tier": "nano/inconnu"
6288 + },
6289 + {
6290 + "name": "Jonika (Xx.Jojo_Ac)",
6291 + "handles": {
6292 + "instagram": "xx.jojo_ac",
6293 + "tiktok": null,
6294 + "youtube": null,
6295 + "facebook": null,
6296 + "x": null,
6297 + "twitch": null
6298 + },
6299 + "metrics": {
6300 + "instagram_followers": 5500,
6301 + "tiktok_followers": 2400,
6302 + "youtube_subscribers": null,
6303 + "facebook_followers": null,
6304 + "x_followers": null,
6305 + "twitch_followers": null
6306 + },
6307 + "category": "beauté",
6308 + "location": "Montréal",
6309 + "notes": "beauté et lifestyle, UGC; collabs Hoaka Swimwear entre autres",
6310 + "sources": [
6311 + "collabstr"
6312 + ],
6313 + "total_followers": 7900,
6314 + "platform_count": 2,
6315 + "tier": "nano/inconnu"
6316 + },
6317 + {
6318 + "name": "Philippe Marquis",
6319 + "handles": {
6320 + "instagram": "philmarquis",
6321 + "tiktok": null,
6322 + "youtube": null,
6323 + "facebook": null,
6324 + "x": null,
6325 + "twitch": null
6326 + },
6327 + "metrics": {
6328 + "instagram_followers": 7800,
6329 + "tiktok_followers": null,
6330 + "youtube_subscribers": null,
6331 + "facebook_followers": null,
6332 + "x_followers": null,
6333 + "twitch_followers": null
6334 + },
6335 + "category": "autre",
6336 + "location": "Québec",
6337 + "notes": "olympien en ski acrobatique (Sochi 2014, PyeongChang 2018), défenseur du climat",
6338 + "sources": [
6339 + "feedspot"
6340 + ],
6341 + "total_followers": 7800,
6342 + "platform_count": 1,
6343 + "tier": "nano/inconnu"
6344 + },
6345 + {
6346 + "name": "Priyanka (pri_mtlfoodie)",
6347 + "handles": {
6348 + "instagram": "pri_mtlfoodie",
6349 + "tiktok": null,
6350 + "youtube": null,
6351 + "facebook": null,
6352 + "x": null,
6353 + "twitch": null
6354 + },
6355 + "metrics": {
6356 + "instagram_followers": 7600,
6357 + "tiktok_followers": null,
6358 + "youtube_subscribers": null,
6359 + "facebook_followers": null,
6360 + "x_followers": null,
6361 + "twitch_followers": null
6362 + },
6363 + "category": "food",
6364 + "location": "Montréal",
6365 + "notes": "Food, activités et voyage EN/FR",
6366 + "sources": [
6367 + "feedspot_food"
6368 + ],
6369 + "total_followers": 7600,
6370 + "platform_count": 1,
6371 + "tier": "nano/inconnu"
6372 + },
6373 + {
6374 + "name": "Amy Fraser",
6375 + "handles": {
6376 + "instagram": "amyfraserxox",
6377 + "tiktok": null,
6378 + "youtube": null,
6379 + "facebook": null,
6380 + "x": null,
6381 + "twitch": null
6382 + },
6383 + "metrics": {
6384 + "instagram_followers": 7500,
6385 + "tiktok_followers": null,
6386 + "youtube_subscribers": null,
6387 + "facebook_followers": null,
6388 + "x_followers": null,
6389 + "twitch_followers": null
6390 + },
6391 + "category": "beauté",
6392 + "location": "Montréal",
6393 + "notes": "beauté, mode et lifestyle; engagement 8.79%; Top Creator Collabstr",
6394 + "sources": [
6395 + "collabstr"
6396 + ],
6397 + "total_followers": 7500,
6398 + "platform_count": 1,
6399 + "tier": "nano/inconnu"
6400 + },
6401 + {
6402 + "name": "Béatrice Bernard-Poulin",
6403 + "handles": {
6404 + "instagram": "beatricebp",
6405 + "tiktok": null,
6406 + "youtube": null,
6407 + "facebook": null,
6408 + "x": null,
6409 + "twitch": null
6410 + },
6411 + "metrics": {
6412 + "instagram_followers": 7000,
6413 + "tiktok_followers": null,
6414 + "youtube_subscribers": null,
6415 + "facebook_followers": null,
6416 + "x_followers": null,
6417 + "twitch_followers": null
6418 + },
6419 + "category": "voyage",
6420 + "location": "Québec",
6421 + "notes": "Photos de voyages, nourriture et cocktails",
6422 + "sources": [
6423 + "noovomoi_voyage"
6424 + ],
6425 + "total_followers": 7000,
6426 + "platform_count": 1,
6427 + "tier": "nano/inconnu"
6428 + },
6429 + {
6430 + "name": "Alexa Flynn",
6431 + "handles": {
6432 + "instagram": "shlex",
6433 + "tiktok": null,
6434 + "youtube": null,
6435 + "facebook": null,
6436 + "x": null,
6437 + "twitch": null
6438 + },
6439 + "metrics": {
6440 + "instagram_followers": 6500,
6441 + "tiktok_followers": null,
6442 + "youtube_subscribers": null,
6443 + "facebook_followers": null,
6444 + "x_followers": null,
6445 + "twitch_followers": null
6446 + },
6447 + "category": "fitness",
6448 + "location": "Montréal",
6449 + "notes": "prof de Pilates et entraîneuse personnelle, cofondatrice @tonebyshab",
6450 + "sources": [
6451 + "feedspot"
6452 + ],
6453 + "total_followers": 6500,
6454 + "platform_count": 1,
6455 + "tier": "nano/inconnu"
6456 + },
6457 + {
6458 + "name": "Marion",
6459 + "handles": {
6460 + "instagram": "marion.csrd",
6461 + "tiktok": null,
6462 + "youtube": null,
6463 + "facebook": null,
6464 + "x": null,
6465 + "twitch": null
6466 + },
6467 + "metrics": {
6468 + "instagram_followers": 6400,
6469 + "tiktok_followers": null,
6470 + "youtube_subscribers": null,
6471 + "facebook_followers": null,
6472 + "x_followers": null,
6473 + "twitch_followers": null
6474 + },
6475 + "category": "lifestyle",
6476 + "location": "Montréal",
6477 + "notes": "yoga et matcha, Montréal/Paris",
6478 + "sources": [
6479 + "feedspot"
6480 + ],
6481 + "total_followers": 6400,
6482 + "platform_count": 1,
6483 + "tier": "nano/inconnu"
6484 + },
6485 + {
6486 + "name": "Himani Jadeja",
6487 + "handles": {
6488 + "instagram": "himani_jadeja",
6489 + "tiktok": null,
6490 + "youtube": null,
6491 + "facebook": null,
6492 + "x": null,
6493 + "twitch": null
6494 + },
6495 + "metrics": {
6496 + "instagram_followers": 6200,
6497 + "tiktok_followers": null,
6498 + "youtube_subscribers": null,
6499 + "facebook_followers": null,
6500 + "x_followers": null,
6501 + "twitch_followers": null
6502 + },
6503 + "category": "mode",
6504 + "location": "Montréal",
6505 + "notes": "designer de mode et styliste (Canada/Koweït/Inde)",
6506 + "sources": [
6507 + "feedspot"
6508 + ],
6509 + "total_followers": 6200,
6510 + "platform_count": 1,
6511 + "tier": "nano/inconnu"
6512 + },
6513 + {
6514 + "name": "Maude Robichaud Godin",
6515 + "handles": {
6516 + "instagram": null,
6517 + "tiktok": "smiti.maude",
6518 + "youtube": null,
6519 + "facebook": null,
6520 + "x": null,
6521 + "twitch": null
6522 + },
6523 + "metrics": {
6524 + "instagram_followers": 3700,
6525 + "tiktok_followers": 1900,
6526 + "youtube_subscribers": null,
6527 + "facebook_followers": null,
6528 + "x_followers": null,
6529 + "twitch_followers": null
6530 + },
6531 + "category": "food",
6532 + "location": "Québec",
6533 + "notes": "Cuisine et culture marocaines, apprentissage du darija, vie de couple.",
6534 + "sources": [
6535 + "collabstr_tiktok"
6536 + ],
6537 + "total_followers": 5600,
6538 + "platform_count": 2,
6539 + "tier": "nano/inconnu"
6540 + },
6541 + {
6542 + "name": "Shivani Bansal",
6543 + "handles": {
6544 + "instagram": "__shivanibansal__",
6545 + "tiktok": null,
6546 + "youtube": null,
6547 + "facebook": null,
6548 + "x": null,
6549 + "twitch": null
6550 + },
6551 + "metrics": {
6552 + "instagram_followers": 5500,
6553 + "tiktok_followers": null,
6554 + "youtube_subscribers": null,
6555 + "facebook_followers": null,
6556 + "x_followers": null,
6557 + "twitch_followers": null
6558 + },
6559 + "category": "food",
6560 + "location": "Montréal",
6561 + "notes": "food, beauté, lifestyle et expériences",
6562 + "sources": [
6563 + "feedspot"
6564 + ],
6565 + "total_followers": 5500,
6566 + "platform_count": 1,
6567 + "tier": "nano/inconnu"
6568 + },
6569 + {
6570 + "name": "Jennifer Doré Dallas",
6571 + "handles": {
6572 + "instagram": "moimessouliers",
6573 + "tiktok": null,
6574 + "youtube": null,
6575 + "facebook": null,
6576 + "x": null,
6577 + "twitch": null
6578 + },
6579 + "metrics": {
6580 + "instagram_followers": 5438,
6581 + "tiktok_followers": null,
6582 + "youtube_subscribers": null,
6583 + "facebook_followers": null,
6584 + "x_followers": null,
6585 + "twitch_followers": null
6586 + },
6587 + "category": "voyage",
6588 + "location": "Québec",
6589 + "notes": "Voyageuse gourmande, blogue Moi, mes souliers",
6590 + "sources": [
6591 + "noovomoi_voyage"
6592 + ],
6593 + "total_followers": 5438,
6594 + "platform_count": 1,
6595 + "tier": "nano/inconnu"
6596 + },
6597 + {
6598 + "name": "Rémi Lemieux",
6599 + "handles": {
6600 + "instagram": "chef.remi.lemieux",
6601 + "tiktok": null,
6602 + "youtube": null,
6603 + "facebook": null,
6604 + "x": null,
6605 + "twitch": null
6606 + },
6607 + "metrics": {
6608 + "instagram_followers": 5200,
6609 + "tiktok_followers": null,
6610 + "youtube_subscribers": null,
6611 + "facebook_followers": null,
6612 + "x_followers": null,
6613 + "twitch_followers": null
6614 + },
6615 + "category": "food",
6616 + "location": "Montréal",
6617 + "notes": "Chef, Guide Michelin 2025; projet resto centre-ville été 2026",
6618 + "sources": [
6619 + "feedspot_food"
6620 + ],
6621 + "total_followers": 5200,
6622 + "platform_count": 1,
6623 + "tier": "nano/inconnu"
6624 + },
6625 + {
6626 + "name": "Angela Rasile",
6627 + "handles": {
6628 + "instagram": "angelarasile",
6629 + "tiktok": null,
6630 + "youtube": null,
6631 + "facebook": null,
6632 + "x": null,
6633 + "twitch": null
6634 + },
6635 + "metrics": {
6636 + "instagram_followers": 5000,
6637 + "tiktok_followers": null,
6638 + "youtube_subscribers": null,
6639 + "facebook_followers": null,
6640 + "x_followers": null,
6641 + "twitch_followers": null
6642 + },
6643 + "category": "mode",
6644 + "location": "Montréal",
6645 + "notes": "mode, beauté et lifestyle",
6646 + "sources": [
6647 + "collabstr"
6648 + ],
6649 + "total_followers": 5000,
6650 + "platform_count": 1,
6651 + "tier": "nano/inconnu"
6652 + },
6653 + {
6654 + "name": "Marine Trudel",
6655 + "handles": {
6656 + "instagram": "marineemmaa",
6657 + "tiktok": null,
6658 + "youtube": null,
6659 + "facebook": null,
6660 + "x": null,
6661 + "twitch": null
6662 + },
6663 + "metrics": {
6664 + "instagram_followers": 4900,
6665 + "tiktok_followers": null,
6666 + "youtube_subscribers": null,
6667 + "facebook_followers": null,
6668 + "x_followers": null,
6669 + "twitch_followers": null
6670 + },
6671 + "category": "lifestyle",
6672 + "location": "Montréal",
6673 + "notes": "lifestyle et bien-être",
6674 + "sources": [
6675 + "feedspot"
6676 + ],
6677 + "total_followers": 4900,
6678 + "platform_count": 1,
6679 + "tier": "nano/inconnu"
6680 + },
6681 + {
6682 + "name": "Carla Isin",
6683 + "handles": {
6684 + "instagram": "carlaisin",
6685 + "tiktok": null,
6686 + "youtube": null,
6687 + "facebook": null,
6688 + "x": null,
6689 + "twitch": null
6690 + },
6691 + "metrics": {
6692 + "instagram_followers": 4800,
6693 + "tiktok_followers": null,
6694 + "youtube_subscribers": null,
6695 + "facebook_followers": null,
6696 + "x_followers": null,
6697 + "twitch_followers": null
6698 + },
6699 + "category": "beauté",
6700 + "location": "Montréal",
6701 + "notes": "beauté, lifestyle, foodie; cofondatrice @xpose.marketing",
6702 + "sources": [
6703 + "feedspot"
6704 + ],
6705 + "total_followers": 4800,
6706 + "platform_count": 1,
6707 + "tier": "nano/inconnu"
6708 + },
6709 + {
6710 + "name": "Mirelle Lupovici",
6711 + "handles": {
6712 + "instagram": "mtlelle",
6713 + "tiktok": null,
6714 + "youtube": null,
6715 + "facebook": null,
6716 + "x": null,
6717 + "twitch": null
6718 + },
6719 + "metrics": {
6720 + "instagram_followers": 4800,
6721 + "tiktok_followers": null,
6722 + "youtube_subscribers": null,
6723 + "facebook_followers": null,
6724 + "x_followers": null,
6725 + "twitch_followers": null
6726 + },
6727 + "category": "lifestyle",
6728 + "location": "Montréal",
6729 + "notes": "#scoliosiswarrior",
6730 + "sources": [
6731 + "feedspot"
6732 + ],
6733 + "total_followers": 4800,
6734 + "platform_count": 1,
6735 + "tier": "nano/inconnu"
6736 + },
6737 + {
6738 + "name": "Anthony",
6739 + "handles": {
6740 + "instagram": "anthony.mtl.creator",
6741 + "tiktok": null,
6742 + "youtube": null,
6743 + "facebook": null,
6744 + "x": null,
6745 + "twitch": null
6746 + },
6747 + "metrics": {
6748 + "instagram_followers": 4700,
6749 + "tiktok_followers": null,
6750 + "youtube_subscribers": null,
6751 + "facebook_followers": null,
6752 + "x_followers": null,
6753 + "twitch_followers": null
6754 + },
6755 + "category": "lifestyle",
6756 + "location": "Montréal",
6757 + "notes": "food, beauté et lifestyle; membre @acrea.officiel | Food, beauté, lifestyle; membre ACREA; +14% de croissance IG en 3 mois",
6758 + "sources": [
6759 + "feedspot",
6760 + "feedspot_food"
6761 + ],
6762 + "total_followers": 4700,
6763 + "platform_count": 1,
6764 + "tier": "nano/inconnu"
6765 + },
6766 + {
6767 + "name": "Kevarine (ultimate.foodiemtl)",
6768 + "handles": {
6769 + "instagram": "ultimate.foodiemtl",
6770 + "tiktok": null,
6771 + "youtube": null,
6772 + "facebook": null,
6773 + "x": null,
6774 + "twitch": null
6775 + },
6776 + "metrics": {
6777 + "instagram_followers": 4400,
6778 + "tiktok_followers": null,
6779 + "youtube_subscribers": null,
6780 + "facebook_followers": null,
6781 + "x_followers": null,
6782 + "twitch_followers": null
6783 + },
6784 + "category": "food",
6785 + "location": "Montréal",
6786 + "notes": "Guide food MTL; 11M de vues sur Google (niveau 7)",
6787 + "sources": [
6788 + "feedspot_food"
6789 + ],
6790 + "total_followers": 4400,
6791 + "platform_count": 1,
6792 + "tier": "nano/inconnu"
6793 + },
6794 + {
6795 + "name": "Jessika Hall",
6796 + "handles": {
6797 + "instagram": "bunny_ta",
6798 + "tiktok": null,
6799 + "youtube": null,
6800 + "facebook": null,
6801 + "x": null,
6802 + "twitch": null
6803 + },
6804 + "metrics": {
6805 + "instagram_followers": 4200,
6806 + "tiktok_followers": null,
6807 + "youtube_subscribers": null,
6808 + "facebook_followers": null,
6809 + "x_followers": null,
6810 + "twitch_followers": null
6811 + },
6812 + "category": "voyage",
6813 + "location": "Montréal",
6814 + "notes": "voyage de luxe abordable, hôtels chics",
6815 + "sources": [
6816 + "feedspot"
6817 + ],
6818 + "total_followers": 4200,
6819 + "platform_count": 1,
6820 + "tier": "nano/inconnu"
6821 + },
6822 + {
6823 + "name": "Marie-Pier Leduc",
6824 + "handles": {
6825 + "instagram": "marieisaway",
6826 + "tiktok": null,
6827 + "youtube": null,
6828 + "facebook": null,
6829 + "x": null,
6830 + "twitch": null
6831 + },
6832 + "metrics": {
6833 + "instagram_followers": 4000,
6834 + "tiktok_followers": null,
6835 + "youtube_subscribers": null,
6836 + "facebook_followers": null,
6837 + "x_followers": null,
6838 + "twitch_followers": null
6839 + },
6840 + "category": "voyage",
6841 + "location": "Québec",
6842 + "notes": "Co-fondatrice de Nomade Magazine; moins de 4 000 abonnés IG (2020); #3 du top VoyageurQc",
6843 + "sources": [
6844 + "voyageurqc"
6845 + ],
6846 + "total_followers": 4000,
6847 + "platform_count": 1,
6848 + "tier": "nano/inconnu"
6849 + },
6850 + {
6851 + "name": "Jessica Campoli",
6852 + "handles": {
6853 + "instagram": "jessica_campolii",
6854 + "tiktok": null,
6855 + "youtube": null,
6856 + "facebook": null,
6857 + "x": null,
6858 + "twitch": null
6859 + },
6860 + "metrics": {
6861 + "instagram_followers": 3900,
6862 + "tiktok_followers": null,
6863 + "youtube_subscribers": null,
6864 + "facebook_followers": null,
6865 + "x_followers": null,
6866 + "twitch_followers": null
6867 + },
6868 + "category": "mode",
6869 + "location": "Montréal",
6870 + "notes": "mode et lifestyle; bio mentionne Fort Lauderdale mais listée à Montréal",
6871 + "sources": [
6872 + "feedspot"
6873 + ],
6874 + "total_followers": 3900,
6875 + "platform_count": 1,
6876 + "tier": "nano/inconnu"
6877 + },
6878 + {
6879 + "name": "Hanieh Ghasemi",
6880 + "handles": {
6881 + "instagram": "honeyrose.mademoiselle",
6882 + "tiktok": null,
6883 + "youtube": null,
6884 + "facebook": null,
6885 + "x": null,
6886 + "twitch": null
6887 + },
6888 + "metrics": {
6889 + "instagram_followers": 3900,
6890 + "tiktok_followers": null,
6891 + "youtube_subscribers": null,
6892 + "facebook_followers": null,
6893 + "x_followers": null,
6894 + "twitch_followers": null
6895 + },
6896 + "category": "mode",
6897 + "location": "Montréal",
6898 + "notes": "mode, nature, voyage, études",
6899 + "sources": [
6900 + "feedspot"
6901 + ],
6902 + "total_followers": 3900,
6903 + "platform_count": 1,
6904 + "tier": "nano/inconnu"
6905 + },
6906 + {
6907 + "name": "Chef Kai Gannon",
6908 + "handles": {
6909 + "instagram": "chefkai___",
6910 + "tiktok": null,
6911 + "youtube": null,
6912 + "facebook": null,
6913 + "x": null,
6914 + "twitch": null
6915 + },
6916 + "metrics": {
6917 + "instagram_followers": 3800,
6918 + "tiktok_followers": null,
6919 + "youtube_subscribers": null,
6920 + "facebook_followers": null,
6921 + "x_followers": null,
6922 + "twitch_followers": null
6923 + },
6924 + "category": "food",
6925 + "location": "Québec",
6926 + "notes": "Chef privé, cuisine fusion, traiteur, mariages et événements",
6927 + "sources": [
6928 + "feedspot_food"
6929 + ],
6930 + "total_followers": 3800,
6931 + "platform_count": 1,
6932 + "tier": "nano/inconnu"
6933 + },
6934 + {
6935 + "name": "Meghan",
6936 + "handles": {
6937 + "instagram": "meghanproofficial",
6938 + "tiktok": null,
6939 + "youtube": null,
6940 + "facebook": null,
6941 + "x": null,
6942 + "twitch": null
6943 + },
6944 + "metrics": {
6945 + "instagram_followers": 3600,
6946 + "tiktok_followers": null,
6947 + "youtube_subscribers": null,
6948 + "facebook_followers": null,
6949 + "x_followers": null,
6950 + "twitch_followers": null
6951 + },
6952 + "category": "mode",
6953 + "location": "Québec",
6954 + "notes": "créatrice mode et lifestyle",
6955 + "sources": [
6956 + "feedspot"
6957 + ],
6958 + "total_followers": 3600,
6959 + "platform_count": 1,
6960 + "tier": "nano/inconnu"
6961 + },
6962 + {
6963 + "name": "Emily (emilyhungrylab)",
6964 + "handles": {
6965 + "instagram": "emilyhungrylab",
6966 + "tiktok": null,
6967 + "youtube": null,
6968 + "facebook": null,
6969 + "x": null,
6970 + "twitch": null
6971 + },
6972 + "metrics": {
6973 + "instagram_followers": 3400,
6974 + "tiktok_followers": null,
6975 + "youtube_subscribers": null,
6976 + "facebook_followers": null,
6977 + "x_followers": null,
6978 + "twitch_followers": null
6979 + },
6980 + "category": "food",
6981 + "location": "Québec",
6982 + "notes": "Explore les saveurs à Singapour, Montréal et ailleurs | Food worth your time; aventures culinaires en voyage",
6983 + "sources": [
6984 + "feedspot_food"
6985 + ],
6986 + "total_followers": 3400,
6987 + "platform_count": 1,
6988 + "tier": "nano/inconnu"
6989 + },
6990 + {
6991 + "name": "Sarah-Émilie Nault",
6992 + "handles": {
6993 + "instagram": "sarahemilienault",
6994 + "tiktok": null,
6995 + "youtube": null,
6996 + "facebook": null,
6997 + "x": null,
6998 + "twitch": null
6999 + },
7000 + "metrics": {
7001 + "instagram_followers": 3000,
7002 + "tiktok_followers": null,
7003 + "youtube_subscribers": null,
7004 + "facebook_followers": null,
7005 + "x_followers": null,
7006 + "twitch_followers": null
7007 + },
7008 + "category": "voyage",
7009 + "location": "Québec",
7010 + "notes": "Journaliste voyage constamment en déplacement",
7011 + "sources": [
7012 + "noovomoi_voyage"
7013 + ],
7014 + "total_followers": 3000,
7015 + "platform_count": 1,
7016 + "tier": "nano/inconnu"
7017 + },
7018 + {
7019 + "name": "Julie Labrecque",
7020 + "handles": {
7021 + "instagram": "the.sweetest.chef",
7022 + "tiktok": null,
7023 + "youtube": null,
7024 + "facebook": null,
7025 + "x": null,
7026 + "twitch": null
7027 + },
7028 + "metrics": {
7029 + "instagram_followers": 2900,
7030 + "tiktok_followers": null,
7031 + "youtube_subscribers": null,
7032 + "facebook_followers": null,
7033 + "x_followers": null,
7034 + "twitch_followers": null
7035 + },
7036 + "category": "food",
7037 + "location": "Waterloo, Québec",
7038 + "notes": "Compte pâtisserie (fermé au moment du recensement)",
7039 + "sources": [
7040 + "feedspot_food"
7041 + ],
7042 + "total_followers": 2900,
7043 + "platform_count": 1,
7044 + "tier": "nano/inconnu"
7045 + },
7046 + {
7047 + "name": "Ariane Ménard",
7048 + "handles": {
7049 + "instagram": "ariane.mnrd",
7050 + "tiktok": null,
7051 + "youtube": null,
7052 + "facebook": null,
7053 + "x": null,
7054 + "twitch": null
7055 + },
7056 + "metrics": {
7057 + "instagram_followers": 2800,
7058 + "tiktok_followers": null,
7059 + "youtube_subscribers": null,
7060 + "facebook_followers": null,
7061 + "x_followers": null,
7062 + "twitch_followers": null
7063 + },
7064 + "category": "mode",
7065 + "location": "Montréal",
7066 + "notes": "mode, lifestyle, développement personnel",
7067 + "sources": [
7068 + "feedspot"
7069 + ],
7070 + "total_followers": 2800,
7071 + "platform_count": 1,
7072 + "tier": "nano/inconnu"
7073 + },
7074 + {
7075 + "name": "Shalom A Campbell",
7076 + "handles": {
7077 + "instagram": "shalomcampbelll",
7078 + "tiktok": null,
7079 + "youtube": null,
7080 + "facebook": null,
7081 + "x": null,
7082 + "twitch": null
7083 + },
7084 + "metrics": {
7085 + "instagram_followers": 2700,
7086 + "tiktok_followers": null,
7087 + "youtube_subscribers": null,
7088 + "facebook_followers": null,
7089 + "x_followers": null,
7090 + "twitch_followers": null
7091 + },
7092 + "category": "lifestyle",
7093 + "location": "Montréal",
7094 + "notes": "vlogs, foodie et self-care; acteur de formation classique",
7095 + "sources": [
7096 + "collabstr"
7097 + ],
7098 + "total_followers": 2700,
7099 + "platform_count": 1,
7100 + "tier": "nano/inconnu"
7101 + },
7102 + {
7103 + "name": "Ashley (mtlmarz)",
7104 + "handles": {
7105 + "instagram": "mtlmarz",
7106 + "tiktok": null,
7107 + "youtube": null,
7108 + "facebook": null,
7109 + "x": null,
7110 + "twitch": null
7111 + },
7112 + "metrics": {
7113 + "instagram_followers": 2700,
7114 + "tiktok_followers": null,
7115 + "youtube_subscribers": null,
7116 + "facebook_followers": null,
7117 + "x_followers": null,
7118 + "twitch_followers": null
7119 + },
7120 + "category": "food",
7121 + "location": "Montréal",
7122 + "notes": "Partage ses spots préférés pour manger et explorer",
7123 + "sources": [
7124 + "feedspot_food"
7125 + ],
7126 + "total_followers": 2700,
7127 + "platform_count": 1,
7128 + "tier": "nano/inconnu"
7129 + },
7130 + {
7131 + "name": "Sneha",
7132 + "handles": {
7133 + "instagram": "sneha.travels.journey",
7134 + "tiktok": null,
7135 + "youtube": null,
7136 + "facebook": null,
7137 + "x": null,
7138 + "twitch": null
7139 + },
7140 + "metrics": {
7141 + "instagram_followers": 2600,
7142 + "tiktok_followers": null,
7143 + "youtube_subscribers": null,
7144 + "facebook_followers": null,
7145 + "x_followers": null,
7146 + "twitch_followers": null
7147 + },
7148 + "category": "voyage",
7149 + "location": "Montréal",
7150 + "notes": "guides de voyage et bonnes adresses à Montréal, partenaire MTL Blog/Narcity",
7151 + "sources": [
7152 + "feedspot"
7153 + ],
7154 + "total_followers": 2600,
7155 + "platform_count": 1,
7156 + "tier": "nano/inconnu"
7157 + },
7158 + {
7159 + "name": "Sheila Luong",
7160 + "handles": {
7161 + "instagram": "sheilaluongxo",
7162 + "tiktok": null,
7163 + "youtube": null,
7164 + "facebook": null,
7165 + "x": null,
7166 + "twitch": null
7167 + },
7168 + "metrics": {
7169 + "instagram_followers": 2600,
7170 + "tiktok_followers": null,
7171 + "youtube_subscribers": null,
7172 + "facebook_followers": null,
7173 + "x_followers": null,
7174 + "twitch_followers": null
7175 + },
7176 + "category": "autre",
7177 + "location": "Montréal",
7178 + "notes": "conseils de vie et relations, fitness",
7179 + "sources": [
7180 + "collabstr"
7181 + ],
7182 + "total_followers": 2600,
7183 + "platform_count": 1,
7184 + "tier": "nano/inconnu"
7185 + },
7186 + {
7187 + "name": "Carmen Li",
7188 + "handles": {
7189 + "instagram": "carmennlii",
7190 + "tiktok": null,
7191 + "youtube": null,
7192 + "facebook": null,
7193 + "x": null,
7194 + "twitch": null
7195 + },
7196 + "metrics": {
7197 + "instagram_followers": 2500,
7198 + "tiktok_followers": null,
7199 + "youtube_subscribers": null,
7200 + "facebook_followers": null,
7201 + "x_followers": null,
7202 + "twitch_followers": null
7203 + },
7204 + "category": "lifestyle",
7205 + "location": "Montréal",
7206 + "notes": "nano-influenceuse lifestyle",
7207 + "sources": [
7208 + "feedspot"
7209 + ],
7210 + "total_followers": 2500,
7211 + "platform_count": 1,
7212 + "tier": "nano/inconnu"
7213 + },
7214 + {
7215 + "name": "ElijahDes12",
7216 + "handles": {
7217 + "instagram": "elijah_des",
7218 + "tiktok": null,
7219 + "youtube": null,
7220 + "facebook": null,
7221 + "x": null,
7222 + "twitch": "elijahdes12"
7223 + },
7224 + "metrics": {
7225 + "instagram_followers": null,
7226 + "tiktok_followers": null,
7227 + "youtube_subscribers": null,
7228 + "facebook_followers": null,
7229 + "x_followers": null,
7230 + "twitch_followers": 2400
7231 + },
7232 + "category": "gaming",
7233 + "location": "Québec",
7234 + "notes": "Streamer émergent, contenu varié, slogan GO CRAZY; ~2 400 abonnés Twitch",
7235 + "sources": [
7236 + "leregard_twitch"
7237 + ],
7238 + "total_followers": 2400,
7239 + "platform_count": 2,
7240 + "tier": "nano/inconnu"
7241 + },
7242 + {
7243 + "name": "Kate Hume",
7244 + "handles": {
7245 + "instagram": "katereads95",
7246 + "tiktok": null,
7247 + "youtube": null,
7248 + "facebook": null,
7249 + "x": null,
7250 + "twitch": null
7251 + },
7252 + "metrics": {
7253 + "instagram_followers": 1500,
7254 + "tiktok_followers": 533,
7255 + "youtube_subscribers": null,
7256 + "facebook_followers": null,
7257 + "x_followers": null,
7258 + "twitch_followers": null
7259 + },
7260 + "category": "lifestyle",
7261 + "location": "Montréal",
7262 + "notes": "livres (bookstagram) et lifestyle, UGC",
7263 + "sources": [
7264 + "collabstr"
7265 + ],
7266 + "total_followers": 2033,
7267 + "platform_count": 2,
7268 + "tier": "nano/inconnu"
7269 + },
7270 + {
7271 + "name": "Diana Vanegas",
7272 + "handles": {
7273 + "instagram": "bulcari",
7274 + "tiktok": null,
7275 + "youtube": null,
7276 + "facebook": null,
7277 + "x": null,
7278 + "twitch": null
7279 + },
7280 + "metrics": {
7281 + "instagram_followers": 2000,
7282 + "tiktok_followers": null,
7283 + "youtube_subscribers": null,
7284 + "facebook_followers": null,
7285 + "x_followers": null,
7286 + "twitch_followers": null
7287 + },
7288 + "category": "lifestyle",
7289 + "location": "Montréal",
7290 + "notes": "nano-influenceuse montréalaise",
7291 + "sources": [
7292 + "feedspot"
7293 + ],
7294 + "total_followers": 2000,
7295 + "platform_count": 1,
7296 + "tier": "nano/inconnu"
7297 + },
7298 + {
7299 + "name": "Sophie Vixamar Victor",
7300 + "handles": {
7301 + "instagram": null,
7302 + "tiktok": "vixsophie",
7303 + "youtube": null,
7304 + "facebook": null,
7305 + "x": null,
7306 + "twitch": null
7307 + },
7308 + "metrics": {
7309 + "instagram_followers": null,
7310 + "tiktok_followers": 2000,
7311 + "youtube_subscribers": null,
7312 + "facebook_followers": null,
7313 + "x_followers": null,
7314 + "twitch_followers": null
7315 + },
7316 + "category": "lifestyle",
7317 + "location": "Québec",
7318 + "notes": "Créatrice UGC bilingue (français/anglais) : lifestyle, beauté, produits du quotidien.",
7319 + "sources": [
7320 + "collabstr_tiktok"
7321 + ],
7322 + "total_followers": 2000,
7323 + "platform_count": 1,
7324 + "tier": "nano/inconnu"
7325 + },
7326 + {
7327 + "name": "GFZ Parfait",
7328 + "handles": {
7329 + "instagram": "gfz.parfait",
7330 + "tiktok": "parfait.clip",
7331 + "youtube": null,
7332 + "facebook": null,
7333 + "x": null,
7334 + "twitch": "gfz_parfait"
7335 + },
7336 + "metrics": {
7337 + "instagram_followers": 2000,
7338 + "tiktok_followers": null,
7339 + "youtube_subscribers": null,
7340 + "facebook_followers": null,
7341 + "x_followers": null,
7342 + "twitch_followers": null
7343 + },
7344 + "category": "gaming",
7345 + "location": "Québec",
7346 + "notes": "Prank calls et vidéos de réaction sur Twitch; chaîne TikTok parfait.clip virale",
7347 + "sources": [
7348 + "leregard_twitch"
7349 + ],
7350 + "total_followers": 2000,
7351 + "platform_count": 3,
7352 + "tier": "nano/inconnu"
7353 + },
7354 + {
7355 + "name": "Fabio Sperduti",
7356 + "handles": {
7357 + "instagram": "chefsperduti",
7358 + "tiktok": null,
7359 + "youtube": null,
7360 + "facebook": null,
7361 + "x": null,
7362 + "twitch": null
7363 + },
7364 + "metrics": {
7365 + "instagram_followers": 1900,
7366 + "tiktok_followers": null,
7367 + "youtube_subscribers": null,
7368 + "facebook_followers": null,
7369 + "x_followers": null,
7370 + "twitch_followers": null
7371 + },
7372 + "category": "food",
7373 + "location": "Montréal",
7374 + "notes": "Directeur culinaire Bossa MTL",
7375 + "sources": [
7376 + "feedspot_food"
7377 + ],
7378 + "total_followers": 1900,
7379 + "platform_count": 1,
7380 + "tier": "nano/inconnu"
7381 + },
7382 + {
7383 + "name": "Alex (alexfoodi)",
7384 + "handles": {
7385 + "instagram": "alexfoodi",
7386 + "tiktok": null,
7387 + "youtube": null,
7388 + "facebook": null,
7389 + "x": null,
7390 + "twitch": null
7391 + },
7392 + "metrics": {
7393 + "instagram_followers": 1900,
7394 + "tiktok_followers": null,
7395 + "youtube_subscribers": null,
7396 + "facebook_followers": null,
7397 + "x_followers": null,
7398 + "twitch_followers": null
7399 + },
7400 + "category": "food",
7401 + "location": "Montréal",
7402 + "notes": "Pharmacien le jour, créateur de contenu le soir; voyage et food",
7403 + "sources": [
7404 + "feedspot_food"
7405 + ],
7406 + "total_followers": 1900,
7407 + "platform_count": 1,
7408 + "tier": "nano/inconnu"
7409 + },
7410 + {
7411 + "name": "Maria Morari",
7412 + "handles": {
7413 + "instagram": "morari.mm",
7414 + "tiktok": null,
7415 + "youtube": null,
7416 + "facebook": null,
7417 + "x": null,
7418 + "twitch": null
7419 + },
7420 + "metrics": {
7421 + "instagram_followers": 1200,
7422 + "tiktok_followers": 189,
7423 + "youtube_subscribers": 193,
7424 + "facebook_followers": null,
7425 + "x_followers": null,
7426 + "twitch_followers": null
7427 + },
7428 + "category": "fitness",
7429 + "location": "Montréal",
7430 + "notes": "ex-ballerine professionnelle, fitness postpartum et Pilates; Top Creator Collabstr",
7431 + "sources": [
7432 + "collabstr"
7433 + ],
7434 + "total_followers": 1582,
7435 + "platform_count": 3,
7436 + "tier": "nano/inconnu"
7437 + },
7438 + {
7439 + "name": "Carole-Ann Tavano",
7440 + "handles": {
7441 + "instagram": "ca_tavano",
7442 + "tiktok": null,
7443 + "youtube": null,
7444 + "facebook": null,
7445 + "x": null,
7446 + "twitch": null
7447 + },
7448 + "metrics": {
7449 + "instagram_followers": 1500,
7450 + "tiktok_followers": null,
7451 + "youtube_subscribers": null,
7452 + "facebook_followers": null,
7453 + "x_followers": null,
7454 + "twitch_followers": null
7455 + },
7456 + "category": "voyage",
7457 + "location": "Québec",
7458 + "notes": "voyage et lifestyle bien-être; Top Creator Collabstr",
7459 + "sources": [
7460 + "collabstr"
7461 + ],
7462 + "total_followers": 1500,
7463 + "platform_count": 1,
7464 + "tier": "nano/inconnu"
7465 + },
7466 + {
7467 + "name": "Valérie Gagné",
7468 + "handles": {
7469 + "instagram": "_therunningpanda",
7470 + "tiktok": null,
7471 + "youtube": null,
7472 + "facebook": null,
7473 + "x": null,
7474 + "twitch": null
7475 + },
7476 + "metrics": {
7477 + "instagram_followers": 1400,
7478 + "tiktok_followers": null,
7479 + "youtube_subscribers": null,
7480 + "facebook_followers": null,
7481 + "x_followers": null,
7482 + "twitch_followers": null
7483 + },
7484 + "category": "voyage",
7485 + "location": "Québec",
7486 + "notes": "guide d'aventure et exploratrice (Arctique canadien, Atacama, Baïkal)",
7487 + "sources": [
7488 + "collabstr"
7489 + ],
7490 + "total_followers": 1400,
7491 + "platform_count": 1,
7492 + "tier": "nano/inconnu"
7493 + },
7494 + {
7495 + "name": "Ben Schmautz",
7496 + "handles": {
7497 + "instagram": "ben_schmautz",
7498 + "tiktok": null,
7499 + "youtube": null,
7500 + "facebook": null,
7501 + "x": null,
7502 + "twitch": null
7503 + },
7504 + "metrics": {
7505 + "instagram_followers": 860,
7506 + "tiktok_followers": null,
7507 + "youtube_subscribers": null,
7508 + "facebook_followers": null,
7509 + "x_followers": null,
7510 + "twitch_followers": null
7511 + },
7512 + "category": "voyage",
7513 + "location": "Québec",
7514 + "notes": "Photographe et journaliste professionnel de voyage",
7515 + "sources": [
7516 + "noovomoi_voyage"
7517 + ],
7518 + "total_followers": 860,
7519 + "platform_count": 1,
7520 + "tier": "nano/inconnu"
7521 + },
7522 + {
7523 + "name": "Chris Woo",
7524 + "handles": {
7525 + "instagram": "therealchefwoo",
7526 + "tiktok": null,
7527 + "youtube": null,
7528 + "facebook": null,
7529 + "x": null,
7530 + "twitch": null
7531 + },
7532 + "metrics": {
7533 + "instagram_followers": 557,
7534 + "tiktok_followers": null,
7535 + "youtube_subscribers": null,
7536 + "facebook_followers": null,
7537 + "x_followers": null,
7538 + "twitch_followers": null
7539 + },
7540 + "category": "food",
7541 + "location": "Québec",
7542 + "notes": "Chef, maman, blogueuse culinaire et jardinière",
7543 + "sources": [
7544 + "feedspot_food"
7545 + ],
7546 + "total_followers": 557,
7547 + "platform_count": 1,
7548 + "tier": "nano/inconnu"
7549 + },
7550 + {
7551 + "name": "Cath Bastien",
7552 + "handles": {
7553 + "instagram": null,
7554 + "tiktok": null,
7555 + "youtube": null,
7556 + "facebook": null,
7557 + "x": null,
7558 + "twitch": null
7559 + },
7560 + "metrics": {
7561 + "instagram_followers": null,
7562 + "tiktok_followers": null,
7563 + "youtube_subscribers": null,
7564 + "facebook_followers": null,
7565 + "x_followers": null,
7566 + "twitch_followers": null
7567 + },
7568 + "category": "fitness",
7569 + "location": "Québec",
7570 + "notes": "influenceuse fitness mentionnée dans l'article Billie (ex-copine de Marc Fitt)",
7571 + "sources": [
7572 + "billie"
7573 + ],
7574 + "total_followers": null,
7575 + "platform_count": 0,
7576 + "tier": "nano/inconnu"
7577 + },
7578 + {
7579 + "name": "Noémie Lacerte",
7580 + "handles": {
7581 + "instagram": null,
7582 + "tiktok": null,
7583 + "youtube": null,
7584 + "facebook": null,
7585 + "x": null,
7586 + "twitch": null
7587 + },
7588 + "metrics": {
7589 + "instagram_followers": null,
7590 + "tiktok_followers": null,
7591 + "youtube_subscribers": null,
7592 + "facebook_followers": null,
7593 + "x_followers": null,
7594 + "twitch_followers": null
7595 + },
7596 + "category": "mode",
7597 + "location": "Québec",
7598 + "notes": "influenceuse mode mentionnée sur Billie (marque Noble) | Créatrice de contenu adulte (OnlyFans), forte présence sociale; revenus estimés 100k$–400k$ (canadult).",
7599 + "sources": [
7600 + "billie",
7601 + "canadult"
7602 + ],
7603 + "total_followers": null,
7604 + "platform_count": 0,
7605 + "tier": "nano/inconnu"
7606 + },
7607 + {
7608 + "name": "Zoé Duval",
7609 + "handles": {
7610 + "instagram": null,
7611 + "tiktok": null,
7612 + "youtube": null,
7613 + "facebook": null,
7614 + "x": null,
7615 + "twitch": null
7616 + },
7617 + "metrics": {
7618 + "instagram_followers": null,
7619 + "tiktok_followers": null,
7620 + "youtube_subscribers": null,
7621 + "facebook_followers": null,
7622 + "x_followers": null,
7623 + "twitch_followers": null
7624 + },
7625 + "category": "autre",
7626 + "location": "Québec",
7627 + "notes": "personnalité web apparaissant dans les vidéos Billie (greenflag/redflag) | Créatrice mentionnée sur Billie (vidéo « Est-ce un greenflag ou un redflag ? »); mention périphérique, peu de données.",
7628 + "sources": [
7629 + "billie",
7630 + "billie_gala"
7631 + ],
7632 + "total_followers": null,
7633 + "platform_count": 0,
7634 + "tier": "nano/inconnu"
7635 + },
7636 + {
7637 + "name": "Teagan Cain",
7638 + "handles": {
7639 + "instagram": "teagan.cain",
7640 + "tiktok": null,
7641 + "youtube": null,
7642 + "facebook": null,
7643 + "x": null,
7644 + "twitch": null
7645 + },
7646 + "metrics": {
7647 + "instagram_followers": null,
7648 + "tiktok_followers": null,
7649 + "youtube_subscribers": null,
7650 + "facebook_followers": null,
7651 + "x_followers": null,
7652 + "twitch_followers": null
7653 + },
7654 + "category": "mode",
7655 + "location": "Montréal",
7656 + "notes": "mannequin, étudiante en économie et foodie; skincare et marques locales",
7657 + "sources": [
7658 + "embold"
7659 + ],
7660 + "total_followers": null,
7661 + "platform_count": 1,
7662 + "tier": "nano/inconnu"
7663 + },
7664 + {
7665 + "name": "Priscila Sanches",
7666 + "handles": {
7667 + "instagram": "pr_scila",
7668 + "tiktok": null,
7669 + "youtube": null,
7670 + "facebook": null,
7671 + "x": null,
7672 + "twitch": null
7673 + },
7674 + "metrics": {
7675 + "instagram_followers": null,
7676 + "tiktok_followers": null,
7677 + "youtube_subscribers": null,
7678 + "facebook_followers": null,
7679 + "x_followers": null,
7680 + "twitch_followers": null
7681 + },
7682 + "category": "voyage",
7683 + "location": "Montréal",
7684 + "notes": "Brésilienne de 28 ans; voyage, beauté, lifestyle et mode",
7685 + "sources": [
7686 + "embold"
7687 + ],
7688 + "total_followers": null,
7689 + "platform_count": 1,
7690 + "tier": "nano/inconnu"
7691 + },
7692 + {
7693 + "name": "Katie DiCaprio",
7694 + "handles": {
7695 + "instagram": "mtlkatie",
7696 + "tiktok": null,
7697 + "youtube": null,
7698 + "facebook": null,
7699 + "x": null,
7700 + "twitch": null
7701 + },
7702 + "metrics": {
7703 + "instagram_followers": null,
7704 + "tiktok_followers": null,
7705 + "youtube_subscribers": null,
7706 + "facebook_followers": null,
7707 + "x_followers": null,
7708 + "twitch_followers": null
7709 + },
7710 + "category": "autre",
7711 + "location": "Montréal",
7712 + "notes": "marketing sportif, conférences, mode et podcast",
7713 + "sources": [
7714 + "embold"
7715 + ],
7716 + "total_followers": null,
7717 + "platform_count": 1,
7718 + "tier": "nano/inconnu"
7719 + },
7720 + {
7721 + "name": "Sarah Nahle",
7722 + "handles": {
7723 + "instagram": "sarahnahlee",
7724 + "tiktok": null,
7725 + "youtube": null,
7726 + "facebook": null,
7727 + "x": null,
7728 + "twitch": null
7729 + },
7730 + "metrics": {
7731 + "instagram_followers": null,
7732 + "tiktok_followers": null,
7733 + "youtube_subscribers": null,
7734 + "facebook_followers": null,
7735 + "x_followers": null,
7736 + "twitch_followers": null
7737 + },
7738 + "category": "lifestyle",
7739 + "location": "Montréal",
7740 + "notes": "maman de 2 filles de moins de 2 ans",
7741 + "sources": [
7742 + "embold"
7743 + ],
7744 + "total_followers": null,
7745 + "platform_count": 1,
7746 + "tier": "nano/inconnu"
7747 + },
7748 + {
7749 + "name": "Melisa",
7750 + "handles": {
7751 + "instagram": "meligatorr",
7752 + "tiktok": null,
7753 + "youtube": null,
7754 + "facebook": null,
7755 + "x": null,
7756 + "twitch": null
7757 + },
7758 + "metrics": {
7759 + "instagram_followers": null,
7760 + "tiktok_followers": null,
7761 + "youtube_subscribers": null,
7762 + "facebook_followers": null,
7763 + "x_followers": null,
7764 + "twitch_followers": null
7765 + },
7766 + "category": "lifestyle",
7767 + "location": "Montréal",
7768 + "notes": "blogueuse lifestyle montréalaise",
7769 + "sources": [
7770 + "embold"
7771 + ],
7772 + "total_followers": null,
7773 + "platform_count": 1,
7774 + "tier": "nano/inconnu"
7775 + },
7776 + {
7777 + "name": "Gabrielle Marion",
7778 + "handles": {
7779 + "instagram": null,
7780 + "tiktok": null,
7781 + "youtube": "gabrielle marion",
7782 + "facebook": null,
7783 + "x": null,
7784 + "twitch": null
7785 + },
7786 + "metrics": {
7787 + "instagram_followers": null,
7788 + "tiktok_followers": null,
7789 + "youtube_subscribers": null,
7790 + "facebook_followers": null,
7791 + "x_followers": null,
7792 + "twitch_followers": null
7793 + },
7794 + "category": "lifestyle",
7795 + "location": "Québec",
7796 + "notes": "YouTubeuse québécoise mentionnée dans des articles connexes de Billie (aucune métrique dans la source).",
7797 + "sources": [
7798 + "billie_youtube"
7799 + ],
7800 + "total_followers": null,
7801 + "platform_count": 1,
7802 + "tier": "nano/inconnu"
7803 + },
7804 + {
7805 + "name": "Mila Taillefer",
7806 + "handles": {
7807 + "instagram": null,
7808 + "tiktok": null,
7809 + "youtube": "mila taillefer",
7810 + "facebook": null,
7811 + "x": null,
7812 + "twitch": null
7813 + },
7814 + "metrics": {
7815 + "instagram_followers": null,
7816 + "tiktok_followers": null,
7817 + "youtube_subscribers": null,
7818 + "facebook_followers": null,
7819 + "x_followers": null,
7820 + "twitch_followers": null
7821 + },
7822 + "category": "lifestyle",
7823 + "location": "Québec",
7824 + "notes": "Ex-YouTubeuse; mentionnée dans un article connexe de Billie sur son nouveau projet après YouTube (aucune métrique dans la source).",
7825 + "sources": [
7826 + "billie_youtube"
7827 + ],
7828 + "total_followers": null,
7829 + "platform_count": 1,
7830 + "tier": "nano/inconnu"
7831 + },
7832 + {
7833 + "name": "Cassandra Bouchard",
7834 + "handles": {
7835 + "instagram": null,
7836 + "tiktok": null,
7837 + "youtube": null,
7838 + "facebook": null,
7839 + "x": null,
7840 + "twitch": null
7841 + },
7842 + "metrics": {
7843 + "instagram_followers": null,
7844 + "tiktok_followers": null,
7845 + "youtube_subscribers": null,
7846 + "facebook_followers": null,
7847 + "x_followers": null,
7848 + "twitch_followers": null
7849 + },
7850 + "category": "lifestyle",
7851 + "location": "Québec",
7852 + "notes": "Influenceuse québécoise mentionnée dans un article connexe de Billie (aucune métrique dans la source).",
7853 + "sources": [
7854 + "billie_youtube"
7855 + ],
7856 + "total_followers": null,
7857 + "platform_count": 0,
7858 + "tier": "nano/inconnu"
7859 + },
7860 + {
7861 + "name": "Catherine Bastien",
7862 + "handles": {
7863 + "instagram": null,
7864 + "tiktok": null,
7865 + "youtube": null,
7866 + "facebook": null,
7867 + "x": null,
7868 + "twitch": null
7869 + },
7870 + "metrics": {
7871 + "instagram_followers": null,
7872 + "tiktok_followers": null,
7873 + "youtube_subscribers": null,
7874 + "facebook_followers": null,
7875 + "x_followers": null,
7876 + "twitch_followers": null
7877 + },
7878 + "category": "lifestyle",
7879 + "location": "Québec",
7880 + "notes": "Influenceuse québécoise mentionnée dans un article connexe de Billie (aucune métrique dans la source).",
7881 + "sources": [
7882 + "billie_youtube"
7883 + ],
7884 + "total_followers": null,
7885 + "platform_count": 0,
7886 + "tier": "nano/inconnu"
7887 + },
7888 + {
7889 + "name": "Maud Poulin",
7890 + "handles": {
7891 + "instagram": null,
7892 + "tiktok": null,
7893 + "youtube": null,
7894 + "facebook": null,
7895 + "x": null,
7896 + "twitch": null
7897 + },
7898 + "metrics": {
7899 + "instagram_followers": null,
7900 + "tiktok_followers": null,
7901 + "youtube_subscribers": null,
7902 + "facebook_followers": null,
7903 + "x_followers": null,
7904 + "twitch_followers": null
7905 + },
7906 + "category": "lifestyle",
7907 + "location": "Québec",
7908 + "notes": "Influenceuse québécoise mentionnée dans un article connexe de Billie (aucune métrique dans la source).",
7909 + "sources": [
7910 + "billie_youtube"
7911 + ],
7912 + "total_followers": null,
7913 + "platform_count": 0,
7914 + "tier": "nano/inconnu"
7915 + },
7916 + {
7917 + "name": "WatchMojo Français",
7918 + "handles": {
7919 + "instagram": null,
7920 + "tiktok": null,
7921 + "youtube": "watchmojofr",
7922 + "facebook": null,
7923 + "x": null,
7924 + "twitch": null
7925 + },
7926 + "metrics": {
7927 + "instagram_followers": null,
7928 + "tiktok_followers": null,
7929 + "youtube_subscribers": null,
7930 + "facebook_followers": null,
7931 + "x_followers": null,
7932 + "twitch_followers": null
7933 + },
7934 + "category": "autre",
7935 + "location": "Québec",
7936 + "notes": "Divertissement (tops/classements); #2 du top 100 YouTubers QC Titan, chiffres 2022 (classement par vues moyennes mensuelles, abonnés non précisés).",
7937 + "sources": [
7938 + "titan_youtube_2022"
7939 + ],
7940 + "total_followers": null,
7941 + "platform_count": 1,
7942 + "tier": "nano/inconnu"
7943 + },
7944 + {
7945 + "name": "PoloBleu",
7946 + "handles": {
7947 + "instagram": null,
7948 + "tiktok": null,
7949 + "youtube": "polobleu",
7950 + "facebook": null,
7951 + "x": null,
7952 + "twitch": null
7953 + },
7954 + "metrics": {
7955 + "instagram_followers": null,
7956 + "tiktok_followers": null,
7957 + "youtube_subscribers": null,
7958 + "facebook_followers": null,
7959 + "x_followers": null,
7960 + "twitch_followers": null
7961 + },
7962 + "category": "gaming",
7963 + "location": "Québec",
7964 + "notes": "Deuxième chaîne gaming de Polo; #3 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
7965 + "sources": [
7966 + "titan_youtube_2022"
7967 + ],
7968 + "total_followers": null,
7969 + "platform_count": 1,
7970 + "tier": "nano/inconnu"
7971 + },
7972 + {
7973 + "name": "Têtes à claques",
7974 + "handles": {
7975 + "instagram": null,
7976 + "tiktok": null,
7977 + "youtube": "salamboproductions",
7978 + "facebook": null,
7979 + "x": null,
7980 + "twitch": null
7981 + },
7982 + "metrics": {
7983 + "instagram_followers": null,
7984 + "tiktok_followers": null,
7985 + "youtube_subscribers": null,
7986 + "facebook_followers": null,
7987 + "x_followers": null,
7988 + "twitch_followers": null
7989 + },
7990 + "category": "humour",
7991 + "location": "Québec",
7992 + "notes": "Chaîne humoristique culte; #5 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
7993 + "sources": [
7994 + "titan_youtube_2022"
7995 + ],
7996 + "total_followers": null,
7997 + "platform_count": 1,
7998 + "tier": "nano/inconnu"
7999 + },
8000 + {
8001 + "name": "Steelorse",
8002 + "handles": {
8003 + "instagram": null,
8004 + "tiktok": null,
8005 + "youtube": "steelorse",
8006 + "facebook": null,
8007 + "x": null,
8008 + "twitch": null
8009 + },
8010 + "metrics": {
8011 + "instagram_followers": null,
8012 + "tiktok_followers": null,
8013 + "youtube_subscribers": null,
8014 + "facebook_followers": null,
8015 + "x_followers": null,
8016 + "twitch_followers": null
8017 + },
8018 + "category": "gaming",
8019 + "location": "Québec",
8020 + "notes": "Gaming; #6 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8021 + "sources": [
8022 + "titan_youtube_2022"
8023 + ],
8024 + "total_followers": null,
8025 + "platform_count": 1,
8026 + "tier": "nano/inconnu"
8027 + },
8028 + {
8029 + "name": "Raynold Boudreau",
8030 + "handles": {
8031 + "instagram": null,
8032 + "tiktok": null,
8033 + "youtube": "boudreaurj",
8034 + "facebook": null,
8035 + "x": null,
8036 + "twitch": null
8037 + },
8038 + "metrics": {
8039 + "instagram_followers": null,
8040 + "tiktok_followers": null,
8041 + "youtube_subscribers": null,
8042 + "facebook_followers": null,
8043 + "x_followers": null,
8044 + "twitch_followers": null
8045 + },
8046 + "category": "autre",
8047 + "location": "Québec",
8048 + "notes": "Contenu religieux; #7 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8049 + "sources": [
8050 + "titan_youtube_2022"
8051 + ],
8052 + "total_followers": null,
8053 + "platform_count": 1,
8054 + "tier": "nano/inconnu"
8055 + },
8056 + {
8057 + "name": "Chef Michel Dumas",
8058 + "handles": {
8059 + "instagram": null,
8060 + "tiktok": null,
8061 + "youtube": "chefmicheldumas",
8062 + "facebook": null,
8063 + "x": null,
8064 + "twitch": null
8065 + },
8066 + "metrics": {
8067 + "instagram_followers": null,
8068 + "tiktok_followers": null,
8069 + "youtube_subscribers": null,
8070 + "facebook_followers": null,
8071 + "x_followers": null,
8072 + "twitch_followers": null
8073 + },
8074 + "category": "food",
8075 + "location": "Québec",
8076 + "notes": "Cuisine; #10 du top 100 Titan, chiffres 2022 (abonnés non précisés). | Chef franco-canadien, vidéos de cuisine humoristiques sur TikTok/YouTube; boutique en ligne de produits dérivés. 2M abonnés cumulés selon Favikon (#12).",
8077 + "sources": [
8078 + "titan_youtube_2022",
8079 + "favikon_qc"
8080 + ],
8081 + "total_followers": null,
8082 + "platform_count": 1,
8083 + "tier": "nano/inconnu"
8084 + },
8085 + {
8086 + "name": "Cyril Chauquet",
8087 + "handles": {
8088 + "instagram": null,
8089 + "tiktok": null,
8090 + "youtube": "cyrilchauquettv",
8091 + "facebook": null,
8092 + "x": null,
8093 + "twitch": null
8094 + },
8095 + "metrics": {
8096 + "instagram_followers": null,
8097 + "tiktok_followers": null,
8098 + "youtube_subscribers": null,
8099 + "facebook_followers": null,
8100 + "x_followers": null,
8101 + "twitch_followers": null
8102 + },
8103 + "category": "autre",
8104 + "location": "Québec",
8105 + "notes": "Pêche (Mordu de la pêche); #11 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8106 + "sources": [
8107 + "titan_youtube_2022"
8108 + ],
8109 + "total_followers": null,
8110 + "platform_count": 1,
8111 + "tier": "nano/inconnu"
8112 + },
8113 + {
8114 + "name": "Loud Officiel",
8115 + "handles": {
8116 + "instagram": null,
8117 + "tiktok": null,
8118 + "youtube": "loud officiel",
8119 + "facebook": null,
8120 + "x": null,
8121 + "twitch": null
8122 + },
8123 + "metrics": {
8124 + "instagram_followers": null,
8125 + "tiktok_followers": null,
8126 + "youtube_subscribers": null,
8127 + "facebook_followers": null,
8128 + "x_followers": null,
8129 + "twitch_followers": null
8130 + },
8131 + "category": "musique",
8132 + "location": "Québec",
8133 + "notes": "Rappeur québécois (chaîne officielle); #15 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8134 + "sources": [
8135 + "titan_youtube_2022"
8136 + ],
8137 + "total_followers": null,
8138 + "platform_count": 1,
8139 + "tier": "nano/inconnu"
8140 + },
8141 + {
8142 + "name": "Light Pollux",
8143 + "handles": {
8144 + "instagram": null,
8145 + "tiktok": null,
8146 + "youtube": "light pollux",
8147 + "facebook": null,
8148 + "x": null,
8149 + "twitch": null
8150 + },
8151 + "metrics": {
8152 + "instagram_followers": null,
8153 + "tiktok_followers": null,
8154 + "youtube_subscribers": null,
8155 + "facebook_followers": null,
8156 + "x_followers": null,
8157 + "twitch_followers": null
8158 + },
8159 + "category": "gaming",
8160 + "location": "Québec",
8161 + "notes": "Gaming; #26 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8162 + "sources": [
8163 + "titan_youtube_2022"
8164 + ],
8165 + "total_followers": null,
8166 + "platform_count": 1,
8167 + "tier": "nano/inconnu"
8168 + },
8169 + {
8170 + "name": "Cath & Jay",
8171 + "handles": {
8172 + "instagram": null,
8173 + "tiktok": null,
8174 + "youtube": "cath & jay",
8175 + "facebook": null,
8176 + "x": null,
8177 + "twitch": null
8178 + },
8179 + "metrics": {
8180 + "instagram_followers": null,
8181 + "tiktok_followers": null,
8182 + "youtube_subscribers": null,
8183 + "facebook_followers": null,
8184 + "x_followers": null,
8185 + "twitch_followers": null
8186 + },
8187 + "category": "lifestyle",
8188 + "location": "Québec",
8189 + "notes": "Duo vlog; #19 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8190 + "sources": [
8191 + "titan_youtube_2022"
8192 + ],
8193 + "total_followers": null,
8194 + "platform_count": 1,
8195 + "tier": "nano/inconnu"
8196 + },
8197 + {
8198 + "name": "Gilles pis la p'tite",
8199 + "handles": {
8200 + "instagram": null,
8201 + "tiktok": null,
8202 + "youtube": "gilles pis la p'tite",
8203 + "facebook": null,
8204 + "x": null,
8205 + "twitch": null
8206 + },
8207 + "metrics": {
8208 + "instagram_followers": null,
8209 + "tiktok_followers": null,
8210 + "youtube_subscribers": null,
8211 + "facebook_followers": null,
8212 + "x_followers": null,
8213 + "twitch_followers": null
8214 + },
8215 + "category": "humour",
8216 + "location": "Québec",
8217 + "notes": "Humour; #40 du top 100 Titan (nouvellement sondé), chiffres 2022 (abonnés non précisés).",
8218 + "sources": [
8219 + "titan_youtube_2022"
8220 + ],
8221 + "total_followers": null,
8222 + "platform_count": 1,
8223 + "tier": "nano/inconnu"
8224 + },
8225 + {
8226 + "name": "AndrewTche",
8227 + "handles": {
8228 + "instagram": null,
8229 + "tiktok": null,
8230 + "youtube": "andrewtche",
8231 + "facebook": null,
8232 + "x": null,
8233 + "twitch": null
8234 + },
8235 + "metrics": {
8236 + "instagram_followers": null,
8237 + "tiktok_followers": null,
8238 + "youtube_subscribers": null,
8239 + "facebook_followers": null,
8240 + "x_followers": null,
8241 + "twitch_followers": null
8242 + },
8243 + "category": "humour",
8244 + "location": "Québec",
8245 + "notes": "Humour; #42 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8246 + "sources": [
8247 + "titan_youtube_2022"
8248 + ],
8249 + "total_followers": null,
8250 + "platform_count": 1,
8251 + "tier": "nano/inconnu"
8252 + },
8253 + {
8254 + "name": "Chest Bras",
8255 + "handles": {
8256 + "instagram": null,
8257 + "tiktok": null,
8258 + "youtube": "chest bras",
8259 + "facebook": null,
8260 + "x": null,
8261 + "twitch": null
8262 + },
8263 + "metrics": {
8264 + "instagram_followers": null,
8265 + "tiktok_followers": null,
8266 + "youtube_subscribers": null,
8267 + "facebook_followers": null,
8268 + "x_followers": null,
8269 + "twitch_followers": null
8270 + },
8271 + "category": "humour",
8272 + "location": "Québec",
8273 + "notes": "Humour; #44 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8274 + "sources": [
8275 + "titan_youtube_2022"
8276 + ],
8277 + "total_followers": null,
8278 + "platform_count": 1,
8279 + "tier": "nano/inconnu"
8280 + },
8281 + {
8282 + "name": "2 Filles Ordinaires",
8283 + "handles": {
8284 + "instagram": null,
8285 + "tiktok": null,
8286 + "youtube": "2 filles ordinaires",
8287 + "facebook": null,
8288 + "x": null,
8289 + "twitch": null
8290 + },
8291 + "metrics": {
8292 + "instagram_followers": null,
8293 + "tiktok_followers": null,
8294 + "youtube_subscribers": null,
8295 + "facebook_followers": null,
8296 + "x_followers": null,
8297 + "twitch_followers": null
8298 + },
8299 + "category": "beauté",
8300 + "location": "Québec",
8301 + "notes": "Beauté et mode; #46 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8302 + "sources": [
8303 + "titan_youtube_2022"
8304 + ],
8305 + "total_followers": null,
8306 + "platform_count": 1,
8307 + "tier": "nano/inconnu"
8308 + },
8309 + {
8310 + "name": "Billie",
8311 + "handles": {
8312 + "instagram": null,
8313 + "tiktok": null,
8314 + "youtube": "billie",
8315 + "facebook": null,
8316 + "x": null,
8317 + "twitch": null
8318 + },
8319 + "metrics": {
8320 + "instagram_followers": null,
8321 + "tiktok_followers": null,
8322 + "youtube_subscribers": null,
8323 + "facebook_followers": null,
8324 + "x_followers": null,
8325 + "twitch_followers": null
8326 + },
8327 + "category": "beauté",
8328 + "location": "Québec",
8329 + "notes": "Chaîne beauté/mode (Québecor); #48 du top 100 Titan, chiffres 2022 (abonnés non précisés).",
8330 + "sources": [
8331 + "titan_youtube_2022"
8332 + ],
8333 + "total_followers": null,
8334 + "platform_count": 1,
8335 + "tier": "nano/inconnu"
8336 + },
8337 + {
8338 + "name": "Hélène Boudreau",
8339 + "handles": {
8340 + "instagram": null,
8341 + "tiktok": null,
8342 + "youtube": null,
8343 + "facebook": null,
8344 + "x": null,
8345 + "twitch": null
8346 + },
8347 + "metrics": {
8348 + "instagram_followers": null,
8349 + "tiktok_followers": null,
8350 + "youtube_subscribers": null,
8351 + "facebook_followers": null,
8352 + "x_followers": null,
8353 + "twitch_followers": null
8354 + },
8355 + "category": "autre",
8356 + "location": "Québec",
8357 + "notes": "Créatrice de contenu adulte (OnlyFans), #1 du top créatrices adultes du Québec selon canadult; visibilité internationale (USA + Canada), revenus estimés 1M$–3M$+.",
8358 + "sources": [
8359 + "canadult"
8360 + ],
8361 + "total_followers": null,
8362 + "platform_count": 0,
8363 + "tier": "nano/inconnu"
8364 + },
8365 + {
8366 + "name": "Jade Lavoie",
8367 + "handles": {
8368 + "instagram": null,
8369 + "tiktok": null,
8370 + "youtube": null,
8371 + "facebook": null,
8372 + "x": null,
8373 + "twitch": null
8374 + },
8375 + "metrics": {
8376 + "instagram_followers": null,
8377 + "tiktok_followers": null,
8378 + "youtube_subscribers": null,
8379 + "facebook_followers": null,
8380 + "x_followers": null,
8381 + "twitch_followers": null
8382 + },
8383 + "category": "autre",
8384 + "location": "Québec",
8385 + "notes": "Créatrice de contenu adulte (OnlyFans), star adulte internationale selon canadult; revenus estimés 1M$+.",
8386 + "sources": [
8387 + "canadult"
8388 + ],
8389 + "total_followers": null,
8390 + "platform_count": 0,
8391 + "tier": "nano/inconnu"
8392 + },
8393 + {
8394 + "name": "Aïcha Black",
8395 + "handles": {
8396 + "instagram": null,
8397 + "tiktok": null,
8398 + "youtube": null,
8399 + "facebook": null,
8400 + "x": null,
8401 + "twitch": null
8402 + },
8403 + "metrics": {
8404 + "instagram_followers": null,
8405 + "tiktok_followers": null,
8406 + "youtube_subscribers": null,
8407 + "facebook_followers": null,
8408 + "x_followers": null,
8409 + "twitch_followers": null
8410 + },
8411 + "category": "autre",
8412 + "location": "Québec",
8413 + "notes": "Créatrice de contenu adulte (OnlyFans), niche premium; audience Québec + France, revenus estimés 100k$–500k$ (canadult).",
8414 + "sources": [
8415 + "canadult"
8416 + ],
8417 + "total_followers": null,
8418 + "platform_count": 0,
8419 + "tier": "nano/inconnu"
8420 + },
8421 + {
8422 + "name": "Eva Prevost",
8423 + "handles": {
8424 + "instagram": null,
8425 + "tiktok": null,
8426 + "youtube": null,
8427 + "facebook": null,
8428 + "x": null,
8429 + "twitch": null
8430 + },
8431 + "metrics": {
8432 + "instagram_followers": null,
8433 + "tiktok_followers": null,
8434 + "youtube_subscribers": null,
8435 + "facebook_followers": null,
8436 + "x_followers": null,
8437 + "twitch_followers": null
8438 + },
8439 + "category": "autre",
8440 + "location": "Québec",
8441 + "notes": "Créatrice de contenu adulte (OnlyFans) en forte croissance sociale; audience Canada francophone, revenus estimés 100k$–500k$ (canadult).",
8442 + "sources": [
8443 + "canadult"
8444 + ],
8445 + "total_followers": null,
8446 + "platform_count": 0,
8447 + "tier": "nano/inconnu"
8448 + },
8449 + {
8450 + "name": "Peachjars",
8451 + "handles": {
8452 + "instagram": null,
8453 + "tiktok": null,
8454 + "youtube": null,
8455 + "facebook": null,
8456 + "x": null,
8457 + "twitch": null
8458 + },
8459 + "metrics": {
8460 + "instagram_followers": null,
8461 + "tiktok_followers": null,
8462 + "youtube_subscribers": null,
8463 + "facebook_followers": null,
8464 + "x_followers": null,
8465 + "twitch_followers": null
8466 + },
8467 + "category": "autre",
8468 + "location": "Québec",
8469 + "notes": "Créatrice gaming + contenu adulte (OnlyFans), audience internationale, revenus estimés 500k$+ (canadult).",
8470 + "sources": [
8471 + "canadult"
8472 + ],
8473 + "total_followers": null,
8474 + "platform_count": 0,
8475 + "tier": "nano/inconnu"
8476 + },
8477 + {
8478 + "name": "Cindy Cournoyer",
8479 + "handles": {
8480 + "instagram": null,
8481 + "tiktok": null,
8482 + "youtube": null,
8483 + "facebook": null,
8484 + "x": null,
8485 + "twitch": null
8486 + },
8487 + "metrics": {
8488 + "instagram_followers": null,
8489 + "tiktok_followers": null,
8490 + "youtube_subscribers": null,
8491 + "facebook_followers": null,
8492 + "x_followers": null,
8493 + "twitch_followers": null
8494 + },
8495 + "category": "autre",
8496 + "location": "Québec",
8497 + "notes": "Créatrice lifestyle + contenu premium (OnlyFans); revenus estimés 100k$–300k$ (canadult).",
8498 + "sources": [
8499 + "canadult"
8500 + ],
8501 + "total_followers": null,
8502 + "platform_count": 0,
8503 + "tier": "nano/inconnu"
8504 + },
8505 + {
8506 + "name": "Xavier Dolan",
8507 + "handles": {
8508 + "instagram": null,
8509 + "tiktok": null,
8510 + "youtube": null,
8511 + "facebook": null,
8512 + "x": null,
8513 + "twitch": null
8514 + },
8515 + "metrics": {
8516 + "instagram_followers": null,
8517 + "tiktok_followers": null,
8518 + "youtube_subscribers": null,
8519 + "facebook_followers": null,
8520 + "x_followers": null,
8521 + "twitch_followers": null
8522 + },
8523 + "category": "autre",
8524 + "location": "Montréal",
8525 + "notes": "Cinéaste cité par canadult comme exemple de personnalité québécoise au rayonnement international; pas un influenceur des réseaux sociaux au sens strict. | Cinéaste; 8e du top Twitter QC 2018, a quitté Twitter en 2018",
8526 + "sources": [
8527 + "canadult",
8528 + "rythme_twitter"
8529 + ],
8530 + "total_followers": null,
8531 + "platform_count": 0,
8532 + "tier": "nano/inconnu"
8533 + },
8534 + {
8535 + "name": "Farnell Morisset",
8536 + "handles": {
8537 + "instagram": null,
8538 + "tiktok": null,
8539 + "youtube": null,
8540 + "facebook": null,
8541 + "x": null,
8542 + "twitch": null
8543 + },
8544 + "metrics": {
8545 + "instagram_followers": null,
8546 + "tiktok_followers": null,
8547 + "youtube_subscribers": null,
8548 + "facebook_followers": null,
8549 + "x_followers": null,
8550 + "twitch_followers": null
8551 + },
8552 + "category": "autre",
8553 + "location": "Québec",
8554 + "notes": "Gagnant Gala InfluenceCréation 2025 — Prix hommage, influence positive et rayonnement social.",
8555 + "sources": [
8556 + "billie_gala",
8557 + "grenier_gala"
8558 + ],
8559 + "total_followers": null,
8560 + "platform_count": 0,
8561 + "tier": "nano/inconnu"
8562 + },
8563 + {
8564 + "name": "Laura L. Marchand",
8565 + "handles": {
8566 + "instagram": "laulauthecoconut",
8567 + "tiktok": null,
8568 + "youtube": null,
8569 + "facebook": null,
8570 + "x": null,
8571 + "twitch": null
8572 + },
8573 + "metrics": {
8574 + "instagram_followers": null,
8575 + "tiktok_followers": null,
8576 + "youtube_subscribers": null,
8577 + "facebook_followers": null,
8578 + "x_followers": null,
8579 + "twitch_followers": null
8580 + },
8581 + "category": "autre",
8582 + "location": "Québec",
8583 + "notes": "Gagnante Gala InfluenceCréation 2025 — Découverte de l'année (@laulauthecoconut).",
8584 + "sources": [
8585 + "billie_gala",
8586 + "grenier_gala"
8587 + ],
8588 + "total_followers": null,
8589 + "platform_count": 1,
8590 + "tier": "nano/inconnu"
8591 + },
8592 + {
8593 + "name": "Annie Archambault",
8594 + "handles": {
8595 + "instagram": "surleborddelaligne",
8596 + "tiktok": null,
8597 + "youtube": null,
8598 + "facebook": null,
8599 + "x": null,
8600 + "twitch": null
8601 + },
8602 + "metrics": {
8603 + "instagram_followers": null,
8604 + "tiktok_followers": null,
8605 + "youtube_subscribers": null,
8606 + "facebook_followers": null,
8607 + "x_followers": null,
8608 + "twitch_followers": null
8609 + },
8610 + "category": "autre",
8611 + "location": "Québec",
8612 + "notes": "Gagnante Gala InfluenceCréation 2025 — Sensibilisation (@surleborddelaligne).",
8613 + "sources": [
8614 + "billie_gala",
8615 + "grenier_gala"
8616 + ],
8617 + "total_followers": null,
8618 + "platform_count": 1,
8619 + "tier": "nano/inconnu"
8620 + },
8621 + {
8622 + "name": "Vicky Powell",
8623 + "handles": {
8624 + "instagram": "vp.law",
8625 + "tiktok": null,
8626 + "youtube": null,
8627 + "facebook": null,
8628 + "x": null,
8629 + "twitch": null
8630 + },
8631 + "metrics": {
8632 + "instagram_followers": null,
8633 + "tiktok_followers": null,
8634 + "youtube_subscribers": null,
8635 + "facebook_followers": null,
8636 + "x_followers": null,
8637 + "twitch_followers": null
8638 + },
8639 + "category": "autre",
8640 + "location": "Québec",
8641 + "notes": "Gagnante Gala InfluenceCréation 2025 — Éducation (@vp.law, vulgarisation juridique).",
8642 + "sources": [
8643 + "billie_gala",
8644 + "grenier_gala"
8645 + ],
8646 + "total_followers": null,
8647 + "platform_count": 1,
8648 + "tier": "nano/inconnu"
8649 + },
8650 + {
8651 + "name": "Laurent Turcot",
8652 + "handles": {
8653 + "instagram": null,
8654 + "tiktok": null,
8655 + "youtube": null,
8656 + "facebook": null,
8657 + "x": null,
8658 + "twitch": null
8659 + },
8660 + "metrics": {
8661 + "instagram_followers": null,
8662 + "tiktok_followers": null,
8663 + "youtube_subscribers": null,
8664 + "facebook_followers": null,
8665 + "x_followers": null,
8666 + "twitch_followers": null
8667 + },
8668 + "category": "autre",
8669 + "location": "Québec",
8670 + "notes": "Historien derrière la chaîne L'Histoire nous le dira; gagnant Gala InfluenceCréation 2025 — Vidéo de l'année, format court.",
8671 + "sources": [
8672 + "billie_gala",
8673 + "grenier_gala"
8674 + ],
8675 + "total_followers": null,
8676 + "platform_count": 0,
8677 + "tier": "nano/inconnu"
8678 + },
8679 + {
8680 + "name": "Geneviève O'Gleman",
8681 + "handles": {
8682 + "instagram": null,
8683 + "tiktok": null,
8684 + "youtube": null,
8685 + "facebook": null,
8686 + "x": null,
8687 + "twitch": null
8688 + },
8689 + "metrics": {
8690 + "instagram_followers": null,
8691 + "tiktok_followers": null,
8692 + "youtube_subscribers": null,
8693 + "facebook_followers": null,
8694 + "x_followers": null,
8695 + "twitch_followers": null
8696 + },
8697 + "category": "food",
8698 + "location": "Québec",
8699 + "notes": "Nutritionniste et créatrice culinaire; gagnante Gala InfluenceCréation 2025 — Chef, foodie & nutrition.",
8700 + "sources": [
8701 + "billie_gala",
8702 + "grenier_gala"
8703 + ],
8704 + "total_followers": null,
8705 + "platform_count": 0,
8706 + "tier": "nano/inconnu"
8707 + },
8708 + {
8709 + "name": "Félix Daigle",
8710 + "handles": {
8711 + "instagram": null,
8712 + "tiktok": null,
8713 + "youtube": null,
8714 + "facebook": null,
8715 + "x": null,
8716 + "twitch": null
8717 + },
8718 + "metrics": {
8719 + "instagram_followers": null,
8720 + "tiktok_followers": null,
8721 + "youtube_subscribers": null,
8722 + "facebook_followers": null,
8723 + "x_followers": null,
8724 + "twitch_followers": null
8725 + },
8726 + "category": "fitness",
8727 + "location": "Québec",
8728 + "notes": "Gagnant Gala InfluenceCréation 2025 — Sport & fitness.",
8729 + "sources": [
8730 + "billie_gala",
8731 + "grenier_gala"
8732 + ],
8733 + "total_followers": null,
8734 + "platform_count": 0,
8735 + "tier": "nano/inconnu"
8736 + },
8737 + {
8738 + "name": "Sarah Hamel",
8739 + "handles": {
8740 + "instagram": null,
8741 + "tiktok": null,
8742 + "youtube": null,
8743 + "facebook": null,
8744 + "x": null,
8745 + "twitch": null
8746 + },
8747 + "metrics": {
8748 + "instagram_followers": null,
8749 + "tiktok_followers": null,
8750 + "youtube_subscribers": null,
8751 + "facebook_followers": null,
8752 + "x_followers": null,
8753 + "twitch_followers": null
8754 + },
8755 + "category": "autre",
8756 + "location": "Québec",
8757 + "notes": "Gagnante Gala InfluenceCréation 2025 — Famille & parentalité.",
8758 + "sources": [
8759 + "billie_gala",
8760 + "grenier_gala"
8761 + ],
8762 + "total_followers": null,
8763 + "platform_count": 0,
8764 + "tier": "nano/inconnu"
8765 + },
8766 + {
8767 + "name": "Hellfrank",
8768 + "handles": {
8769 + "instagram": null,
8770 + "tiktok": null,
8771 + "youtube": null,
8772 + "facebook": null,
8773 + "x": null,
8774 + "twitch": null
8775 + },
8776 + "metrics": {
8777 + "instagram_followers": null,
8778 + "tiktok_followers": null,
8779 + "youtube_subscribers": null,
8780 + "facebook_followers": null,
8781 + "x_followers": null,
8782 + "twitch_followers": null
8783 + },
8784 + "category": "gaming",
8785 + "location": "Québec",
8786 + "notes": "Gagnant Gala InfluenceCréation 2025 — Créateur émergent, catégorie Streamer.",
8787 + "sources": [
8788 + "grenier_gala"
8789 + ],
8790 + "total_followers": null,
8791 + "platform_count": 0,
8792 + "tier": "nano/inconnu"
8793 + },
8794 + {
8795 + "name": "Mon bro",
8796 + "handles": {
8797 + "instagram": null,
8798 + "tiktok": null,
8799 + "youtube": null,
8800 + "facebook": null,
8801 + "x": null,
8802 + "twitch": null
8803 + },
8804 + "metrics": {
8805 + "instagram_followers": null,
8806 + "tiktok_followers": null,
8807 + "youtube_subscribers": null,
8808 + "facebook_followers": null,
8809 + "x_followers": null,
8810 + "twitch_followers": null
8811 + },
8812 + "category": "autre",
8813 + "location": "Québec",
8814 + "notes": "Gagnant Gala InfluenceCréation 2025 — Créateur émergent, catégorie Créateur.",
8815 + "sources": [
8816 + "grenier_gala"
8817 + ],
8818 + "total_followers": null,
8819 + "platform_count": 0,
8820 + "tier": "nano/inconnu"
8821 + },
8822 + {
8823 + "name": "Olivia Leclerc",
8824 + "handles": {
8825 + "instagram": null,
8826 + "tiktok": null,
8827 + "youtube": null,
8828 + "facebook": null,
8829 + "x": null,
8830 + "twitch": null
8831 + },
8832 + "metrics": {
8833 + "instagram_followers": null,
8834 + "tiktok_followers": null,
8835 + "youtube_subscribers": null,
8836 + "facebook_followers": null,
8837 + "x_followers": null,
8838 + "twitch_followers": null
8839 + },
8840 + "category": "autre",
8841 + "location": "Québec",
8842 + "notes": "Coanimatrice du Gala InfluenceCréation 2025 (avec Jérémie Latreille / Le Fit Cook).",
8843 + "sources": [
8844 + "grenier_gala"
8845 + ],
8846 + "total_followers": null,
8847 + "platform_count": 0,
8848 + "tier": "nano/inconnu"
8849 + },
8850 + {
8851 + "name": "Claude Bégin",
8852 + "handles": {
8853 + "instagram": null,
8854 + "tiktok": null,
8855 + "youtube": null,
8856 + "facebook": null,
8857 + "x": null,
8858 + "twitch": null
8859 + },
8860 + "metrics": {
8861 + "instagram_followers": null,
8862 + "tiktok_followers": null,
8863 + "youtube_subscribers": null,
8864 + "facebook_followers": null,
8865 + "x_followers": null,
8866 + "twitch_followers": null
8867 + },
8868 + "category": "musique",
8869 + "location": "Québec",
8870 + "notes": "Musicien; coanime le balado Parenthèse avec Lysandre Nadeau — Balado de l'année, Gala InfluenceCréation 2025; coauteur d'un livre jeunesse avec elle.",
8871 + "sources": [
8872 + "frank",
8873 + "billie_gala",
8874 + "grenier_gala"
8875 + ],
8876 + "total_followers": null,
8877 + "platform_count": 0,
8878 + "tier": "nano/inconnu"
8879 + },
8880 + {
8881 + "name": "Martin et Caroline",
8882 + "handles": {
8883 + "instagram": null,
8884 + "tiktok": null,
8885 + "youtube": null,
8886 + "facebook": null,
8887 + "x": null,
8888 + "twitch": null
8889 + },
8890 + "metrics": {
8891 + "instagram_followers": null,
8892 + "tiktok_followers": null,
8893 + "youtube_subscribers": null,
8894 + "facebook_followers": null,
8895 + "x_followers": null,
8896 + "twitch_followers": null
8897 + },
8898 + "category": "humour",
8899 + "location": "Montréal",
8900 + "notes": "Couple franco-canadien; humour et lifestyle sur les différences culturelles France/Québec, conseils d'immigration. 1,2M abonnés cumulés selon Favikon (#20).",
8901 + "sources": [
8902 + "favikon_qc"
8903 + ],
8904 + "total_followers": null,
8905 + "platform_count": 0,
8906 + "tier": "nano/inconnu"
8907 + },
8908 + {
8909 + "name": "Fredz",
8910 + "handles": {
8911 + "instagram": null,
8912 + "tiktok": null,
8913 + "youtube": null,
8914 + "facebook": null,
8915 + "x": null,
8916 + "twitch": null
8917 + },
8918 + "metrics": {
8919 + "instagram_followers": null,
8920 + "tiktok_followers": null,
8921 + "youtube_subscribers": null,
8922 + "facebook_followers": null,
8923 + "x_followers": null,
8924 + "twitch_followers": null
8925 + },
8926 + "category": "musique",
8927 + "location": "Québec",
8928 + "notes": "Musicien et performeur (chanson « Ce soir (j'suis dans ma tête) »), en tournée au Canada. 1M abonnés cumulés selon Favikon (#19).",
8929 + "sources": [
8930 + "favikon_qc"
8931 + ],
8932 + "total_followers": null,
8933 + "platform_count": 0,
8934 + "tier": "nano/inconnu"
8935 + },
8936 + {
8937 + "name": "Zac Bulle",
8938 + "handles": {
8939 + "instagram": null,
8940 + "tiktok": null,
8941 + "youtube": null,
8942 + "facebook": null,
8943 + "x": null,
8944 + "twitch": null
8945 + },
8946 + "metrics": {
8947 + "instagram_followers": null,
8948 + "tiktok_followers": null,
8949 + "youtube_subscribers": null,
8950 + "facebook_followers": null,
8951 + "x_followers": null,
8952 + "twitch_followers": null
8953 + },
8954 + "category": "food",
8955 + "location": "Québec",
8956 + "notes": "YouTubeur connu pour ses critiques de poutine et son contenu comique. 624,7K abonnés cumulés selon Favikon (#10).",
8957 + "sources": [
8958 + "favikon_qc"
8959 + ],
8960 + "total_followers": null,
8961 + "platform_count": 0,
8962 + "tier": "nano/inconnu"
8963 + },
8964 + {
8965 + "name": "Romy Victory",
8966 + "handles": {
8967 + "instagram": null,
8968 + "tiktok": null,
8969 + "youtube": null,
8970 + "facebook": null,
8971 + "x": null,
8972 + "twitch": null
8973 + },
8974 + "metrics": {
8975 + "instagram_followers": null,
8976 + "tiktok_followers": null,
8977 + "youtube_subscribers": null,
8978 + "facebook_followers": null,
8979 + "x_followers": null,
8980 + "twitch_followers": null
8981 + },
8982 + "category": "mode",
8983 + "location": "Montréal",
8984 + "notes": "YouTubeuse mode vintage et lifestyle, aussi actrice et streameuse Twitch. 1,9M abonnés cumulés selon Favikon (#8).",
8985 + "sources": [
8986 + "favikon_qc"
8987 + ],
8988 + "total_followers": null,
8989 + "platform_count": 0,
8990 + "tier": "nano/inconnu"
8991 + },
8992 + {
8993 + "name": "Zequoiza",
8994 + "handles": {
8995 + "instagram": null,
8996 + "tiktok": null,
8997 + "youtube": null,
8998 + "facebook": null,
8999 + "x": null,
9000 + "twitch": null
9001 + },
9002 + "metrics": {
9003 + "instagram_followers": null,
9004 + "tiktok_followers": null,
9005 + "youtube_subscribers": null,
9006 + "facebook_followers": null,
9007 + "x_followers": null,
9008 + "twitch_followers": null
9009 + },
9010 + "category": "humour",
9011 + "location": "Québec",
9012 + "notes": "Personnalité humoristique des réseaux sociaux, blagues et anecdotes. 1,3M abonnés cumulés selon Favikon (#7).",
9013 + "sources": [
9014 + "favikon_qc"
9015 + ],
9016 + "total_followers": null,
9017 + "platform_count": 0,
9018 + "tier": "nano/inconnu"
9019 + },
9020 + {
9021 + "name": "Redazere",
9022 + "handles": {
9023 + "instagram": null,
9024 + "tiktok": null,
9025 + "youtube": null,
9026 + "facebook": null,
9027 + "x": null,
9028 + "twitch": null
9029 + },
9030 + "metrics": {
9031 + "instagram_followers": null,
9032 + "tiktok_followers": null,
9033 + "youtube_subscribers": null,
9034 + "facebook_followers": null,
9035 + "x_followers": null,
9036 + "twitch_followers": null
9037 + },
9038 + "category": "autre",
9039 + "location": "Québec",
9040 + "notes": "Créateur de contenu sur l'islam (TikTok, Snapchat), partage sa vie de musulman au Japon. 5,3M abonnés cumulés selon Favikon (#4).",
9041 + "sources": [
9042 + "favikon_qc"
9043 + ],
9044 + "total_followers": null,
9045 + "platform_count": 0,
9046 + "tier": "nano/inconnu"
9047 + },
9048 + {
9049 + "name": "Ceffi (CeffiCappi)",
9050 + "handles": {
9051 + "instagram": null,
9052 + "tiktok": null,
9053 + "youtube": null,
9054 + "facebook": null,
9055 + "x": null,
9056 + "twitch": "cefficappi"
9057 + },
9058 + "metrics": {
9059 + "instagram_followers": null,
9060 + "tiktok_followers": null,
9061 + "youtube_subscribers": null,
9062 + "facebook_followers": null,
9063 + "x_followers": null,
9064 + "twitch_followers": null
9065 + },
9066 + "category": "gaming",
9067 + "location": "Québec",
9068 + "notes": "Créateur francophone multiplateforme, lives gaming et Just Chatting",
9069 + "sources": [
9070 + "leregard_twitch"
9071 + ],
9072 + "total_followers": null,
9073 + "platform_count": 1,
9074 + "tier": "nano/inconnu"
9075 + },
9076 + {
9077 + "name": "On Déjeune",
9078 + "handles": {
9079 + "instagram": "ondejeune",
9080 + "tiktok": null,
9081 + "youtube": null,
9082 + "facebook": null,
9083 + "x": null,
9084 + "twitch": null
9085 + },
9086 + "metrics": {
9087 + "instagram_followers": null,
9088 + "tiktok_followers": null,
9089 + "youtube_subscribers": null,
9090 + "facebook_followers": null,
9091 + "x_followers": null,
9092 + "twitch_followers": null
9093 + },
9094 + "category": "food",
9095 + "location": "Montréal",
9096 + "notes": "Compte de bonnes adresses à Montréal animé par @maybewats et @michelfleurs",
9097 + "sources": [
9098 + "clark_food"
9099 + ],
9100 + "total_followers": null,
9101 + "platform_count": 1,
9102 + "tier": "nano/inconnu"
9103 + },
9104 + {
9105 + "name": "Ève Martel (Top Yummy)",
9106 + "handles": {
9107 + "instagram": "topyummy",
9108 + "tiktok": null,
9109 + "youtube": null,
9110 + "facebook": null,
9111 + "x": null,
9112 + "twitch": null
9113 + },
9114 + "metrics": {
9115 + "instagram_followers": null,
9116 + "tiktok_followers": null,
9117 + "youtube_subscribers": null,
9118 + "facebook_followers": null,
9119 + "x_followers": null,
9120 + "twitch_followers": null
9121 + },
9122 + "category": "food",
9123 + "location": "Québec",
9124 + "notes": "Recettes santé, salades et meal preps sur Instagram et YouTube",
9125 + "sources": [
9126 + "clark_food"
9127 + ],
9128 + "total_followers": null,
9129 + "platform_count": 1,
9130 + "tier": "nano/inconnu"
9131 + },
9132 + {
9133 + "name": "Christelle (Christelle is Flabbergasting)",
9134 + "handles": {
9135 + "instagram": "christellerocks",
9136 + "tiktok": null,
9137 + "youtube": null,
9138 + "facebook": null,
9139 + "x": null,
9140 + "twitch": null
9141 + },
9142 + "metrics": {
9143 + "instagram_followers": null,
9144 + "tiktok_followers": null,
9145 + "youtube_subscribers": null,
9146 + "facebook_followers": null,
9147 + "x_followers": null,
9148 + "twitch_followers": null
9149 + },
9150 + "category": "food",
9151 + "location": "Québec",
9152 + "notes": "Photographe culinaire et auteure (Ensemble, cuisine gourmande et colorée)",
9153 + "sources": [
9154 + "clark_food"
9155 + ],
9156 + "total_followers": null,
9157 + "platform_count": 1,
9158 + "tier": "nano/inconnu"
9159 + },
9160 + {
9161 + "name": "Kerrie Ahern",
9162 + "handles": {
9163 + "instagram": "kerrie_ahern",
9164 + "tiktok": null,
9165 + "youtube": null,
9166 + "facebook": null,
9167 + "x": null,
9168 + "twitch": null
9169 + },
9170 + "metrics": {
9171 + "instagram_followers": null,
9172 + "tiktok_followers": null,
9173 + "youtube_subscribers": null,
9174 + "facebook_followers": null,
9175 + "x_followers": null,
9176 + "twitch_followers": null
9177 + },
9178 + "category": "food",
9179 + "location": "Québec",
9180 + "notes": "Recettes, mises en scène de repas et stylisme photo culinaire",
9181 + "sources": [
9182 + "clark_food"
9183 + ],
9184 + "total_followers": null,
9185 + "platform_count": 1,
9186 + "tier": "nano/inconnu"
9187 + },
9188 + {
9189 + "name": "Chloé Crane-Leroux",
9190 + "handles": {
9191 + "instagram": "chloecleroux",
9192 + "tiktok": null,
9193 + "youtube": null,
9194 + "facebook": null,
9195 + "x": null,
9196 + "twitch": null
9197 + },
9198 + "metrics": {
9199 + "instagram_followers": null,
9200 + "tiktok_followers": null,
9201 + "youtube_subscribers": null,
9202 + "facebook_followers": null,
9203 + "x_followers": null,
9204 + "twitch_followers": null
9205 + },
9206 + "category": "food",
9207 + "location": "Montréal",
9208 + "notes": "Photographe professionnelle entre New York et Montréal; co-auteure Les Saisons, La Table Vegane",
9209 + "sources": [
9210 + "clark_food"
9211 + ],
9212 + "total_followers": null,
9213 + "platform_count": 1,
9214 + "tier": "nano/inconnu"
9215 + },
9216 + {
9217 + "name": "Gabrielle Pellerin",
9218 + "handles": {
9219 + "instagram": "gabepllrn",
9220 + "tiktok": null,
9221 + "youtube": null,
9222 + "facebook": "chefgabriellepellerin",
9223 + "x": null,
9224 + "twitch": null
9225 + },
9226 + "metrics": {
9227 + "instagram_followers": null,
9228 + "tiktok_followers": null,
9229 + "youtube_subscribers": null,
9230 + "facebook_followers": null,
9231 + "x_followers": null,
9232 + "twitch_followers": null
9233 + },
9234 + "category": "food",
9235 + "location": "Québec",
9236 + "notes": "Chef cuisinière; recettes pour la box Cook It",
9237 + "sources": [
9238 + "clark_food"
9239 + ],
9240 + "total_followers": null,
9241 + "platform_count": 2,
9242 + "tier": "nano/inconnu"
9243 + },
9244 + {
9245 + "name": "Sugar Sammy",
9246 + "handles": {
9247 + "instagram": null,
9248 + "tiktok": null,
9249 + "youtube": null,
9250 + "facebook": null,
9251 + "x": null,
9252 + "twitch": null
9253 + },
9254 + "metrics": {
9255 + "instagram_followers": null,
9256 + "tiktok_followers": null,
9257 + "youtube_subscribers": null,
9258 + "facebook_followers": null,
9259 + "x_followers": null,
9260 + "twitch_followers": null
9261 + },
9262 + "category": "humour",
9263 + "location": "Québec",
9264 + "notes": "#2 humoriste QC: 1 790 900 abonnés cumulés (FB+IG+TikTok+X, 2024); retiré du top Twitter 2019 (ne vivait plus principalement au Québec)",
9265 + "sources": [
9266 + "jdm_humoristes",
9267 + "rythme_twitter"
9268 + ],
9269 + "total_followers": null,
9270 + "platform_count": 0,
9271 + "tier": "nano/inconnu"
9272 + },
9273 + {
9274 + "name": "Martin Matte",
9275 + "handles": {
9276 + "instagram": null,
9277 + "tiktok": null,
9278 + "youtube": null,
9279 + "facebook": null,
9280 + "x": null,
9281 + "twitch": null
9282 + },
9283 + "metrics": {
9284 + "instagram_followers": null,
9285 + "tiktok_followers": null,
9286 + "youtube_subscribers": null,
9287 + "facebook_followers": null,
9288 + "x_followers": null,
9289 + "twitch_followers": null
9290 + },
9291 + "category": "humour",
9292 + "location": "Québec",
9293 + "notes": "#3 humoriste QC: 957 000 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9294 + "sources": [
9295 + "jdm_humoristes"
9296 + ],
9297 + "total_followers": null,
9298 + "platform_count": 0,
9299 + "tier": "nano/inconnu"
9300 + },
9301 + {
9302 + "name": "Arnaud Soly",
9303 + "handles": {
9304 + "instagram": null,
9305 + "tiktok": null,
9306 + "youtube": null,
9307 + "facebook": null,
9308 + "x": null,
9309 + "twitch": null
9310 + },
9311 + "metrics": {
9312 + "instagram_followers": null,
9313 + "tiktok_followers": null,
9314 + "youtube_subscribers": null,
9315 + "facebook_followers": null,
9316 + "x_followers": null,
9317 + "twitch_followers": null
9318 + },
9319 + "category": "humour",
9320 + "location": "Québec",
9321 + "notes": "#4 humoriste QC: 840 510 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9322 + "sources": [
9323 + "jdm_humoristes"
9324 + ],
9325 + "total_followers": null,
9326 + "platform_count": 0,
9327 + "tier": "nano/inconnu"
9328 + },
9329 + {
9330 + "name": "Mike Ward",
9331 + "handles": {
9332 + "instagram": null,
9333 + "tiktok": null,
9334 + "youtube": null,
9335 + "facebook": null,
9336 + "x": "mikewardca",
9337 + "twitch": null
9338 + },
9339 + "metrics": {
9340 + "instagram_followers": null,
9341 + "tiktok_followers": null,
9342 + "youtube_subscribers": null,
9343 + "facebook_followers": null,
9344 + "x_followers": null,
9345 + "twitch_followers": null
9346 + },
9347 + "category": "humour",
9348 + "location": "Québec",
9349 + "notes": "#5 humoriste QC: 820 394 abonnés cumulés (FB+IG+TikTok+X, 2024); parrain des podcasts d'humour; top 25 Twitter 2019 (>120K)",
9350 + "sources": [
9351 + "jdm_humoristes",
9352 + "rythme_twitter"
9353 + ],
9354 + "total_followers": null,
9355 + "platform_count": 1,
9356 + "tier": "nano/inconnu"
9357 + },
9358 + {
9359 + "name": "Mariana Mazza",
9360 + "handles": {
9361 + "instagram": null,
9362 + "tiktok": null,
9363 + "youtube": null,
9364 + "facebook": null,
9365 + "x": null,
9366 + "twitch": null
9367 + },
9368 + "metrics": {
9369 + "instagram_followers": null,
9370 + "tiktok_followers": null,
9371 + "youtube_subscribers": null,
9372 + "facebook_followers": null,
9373 + "x_followers": null,
9374 + "twitch_followers": null
9375 + },
9376 + "category": "humour",
9377 + "location": "Québec",
9378 + "notes": "#6 humoriste QC: 738 700 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9379 + "sources": [
9380 + "jdm_humoristes"
9381 + ],
9382 + "total_followers": null,
9383 + "platform_count": 0,
9384 + "tier": "nano/inconnu"
9385 + },
9386 + {
9387 + "name": "Anthony Kavanagh",
9388 + "handles": {
9389 + "instagram": null,
9390 + "tiktok": null,
9391 + "youtube": null,
9392 + "facebook": null,
9393 + "x": null,
9394 + "twitch": null
9395 + },
9396 + "metrics": {
9397 + "instagram_followers": null,
9398 + "tiktok_followers": null,
9399 + "youtube_subscribers": null,
9400 + "facebook_followers": null,
9401 + "x_followers": null,
9402 + "twitch_followers": null
9403 + },
9404 + "category": "humour",
9405 + "location": "Québec",
9406 + "notes": "#7 humoriste QC: 713 832 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9407 + "sources": [
9408 + "jdm_humoristes"
9409 + ],
9410 + "total_followers": null,
9411 + "platform_count": 0,
9412 + "tier": "nano/inconnu"
9413 + },
9414 + {
9415 + "name": "Jérémy Demay",
9416 + "handles": {
9417 + "instagram": null,
9418 + "tiktok": null,
9419 + "youtube": null,
9420 + "facebook": null,
9421 + "x": null,
9422 + "twitch": null
9423 + },
9424 + "metrics": {
9425 + "instagram_followers": null,
9426 + "tiktok_followers": null,
9427 + "youtube_subscribers": null,
9428 + "facebook_followers": null,
9429 + "x_followers": null,
9430 + "twitch_followers": null
9431 + },
9432 + "category": "humour",
9433 + "location": "Québec",
9434 + "notes": "#8 humoriste QC: 690 217 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9435 + "sources": [
9436 + "jdm_humoristes"
9437 + ],
9438 + "total_followers": null,
9439 + "platform_count": 0,
9440 + "tier": "nano/inconnu"
9441 + },
9442 + {
9443 + "name": "Jay Du Temple",
9444 + "handles": {
9445 + "instagram": null,
9446 + "tiktok": null,
9447 + "youtube": null,
9448 + "facebook": null,
9449 + "x": null,
9450 + "twitch": null
9451 + },
9452 + "metrics": {
9453 + "instagram_followers": null,
9454 + "tiktok_followers": null,
9455 + "youtube_subscribers": null,
9456 + "facebook_followers": null,
9457 + "x_followers": null,
9458 + "twitch_followers": null
9459 + },
9460 + "category": "humour",
9461 + "location": "Québec",
9462 + "notes": "#10 humoriste QC: 632 203 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9463 + "sources": [
9464 + "jdm_humoristes"
9465 + ],
9466 + "total_followers": null,
9467 + "platform_count": 0,
9468 + "tier": "nano/inconnu"
9469 + },
9470 + {
9471 + "name": "Louis-José Houde",
9472 + "handles": {
9473 + "instagram": null,
9474 + "tiktok": null,
9475 + "youtube": null,
9476 + "facebook": null,
9477 + "x": null,
9478 + "twitch": null
9479 + },
9480 + "metrics": {
9481 + "instagram_followers": null,
9482 + "tiktok_followers": null,
9483 + "youtube_subscribers": null,
9484 + "facebook_followers": null,
9485 + "x_followers": null,
9486 + "twitch_followers": null
9487 + },
9488 + "category": "humour",
9489 + "location": "Québec",
9490 + "notes": "#11 humoriste QC: 581 400 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9491 + "sources": [
9492 + "jdm_humoristes"
9493 + ],
9494 + "total_followers": null,
9495 + "platform_count": 0,
9496 + "tier": "nano/inconnu"
9497 + },
9498 + {
9499 + "name": "François Bellefeuille",
9500 + "handles": {
9501 + "instagram": null,
9502 + "tiktok": null,
9503 + "youtube": null,
9504 + "facebook": null,
9505 + "x": null,
9506 + "twitch": null
9507 + },
9508 + "metrics": {
9509 + "instagram_followers": null,
9510 + "tiktok_followers": null,
9511 + "youtube_subscribers": null,
9512 + "facebook_followers": null,
9513 + "x_followers": null,
9514 + "twitch_followers": null
9515 + },
9516 + "category": "humour",
9517 + "location": "Québec",
9518 + "notes": "#13 humoriste QC: 526 900 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9519 + "sources": [
9520 + "jdm_humoristes"
9521 + ],
9522 + "total_followers": null,
9523 + "platform_count": 0,
9524 + "tier": "nano/inconnu"
9525 + },
9526 + {
9527 + "name": "Philippe Laprise",
9528 + "handles": {
9529 + "instagram": null,
9530 + "tiktok": null,
9531 + "youtube": null,
9532 + "facebook": null,
9533 + "x": null,
9534 + "twitch": null
9535 + },
9536 + "metrics": {
9537 + "instagram_followers": null,
9538 + "tiktok_followers": null,
9539 + "youtube_subscribers": null,
9540 + "facebook_followers": null,
9541 + "x_followers": null,
9542 + "twitch_followers": null
9543 + },
9544 + "category": "humour",
9545 + "location": "Québec",
9546 + "notes": "#14 humoriste QC: 517 100 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9547 + "sources": [
9548 + "jdm_humoristes"
9549 + ],
9550 + "total_followers": null,
9551 + "platform_count": 0,
9552 + "tier": "nano/inconnu"
9553 + },
9554 + {
9555 + "name": "Pierre-Yves Roy-Desmarais",
9556 + "handles": {
9557 + "instagram": null,
9558 + "tiktok": null,
9559 + "youtube": null,
9560 + "facebook": null,
9561 + "x": null,
9562 + "twitch": null
9563 + },
9564 + "metrics": {
9565 + "instagram_followers": null,
9566 + "tiktok_followers": null,
9567 + "youtube_subscribers": null,
9568 + "facebook_followers": null,
9569 + "x_followers": null,
9570 + "twitch_followers": null
9571 + },
9572 + "category": "humour",
9573 + "location": "Québec",
9574 + "notes": "#15 humoriste QC: 480 700 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9575 + "sources": [
9576 + "jdm_humoristes"
9577 + ],
9578 + "total_followers": null,
9579 + "platform_count": 0,
9580 + "tier": "nano/inconnu"
9581 + },
9582 + {
9583 + "name": "Katherine Levac",
9584 + "handles": {
9585 + "instagram": null,
9586 + "tiktok": null,
9587 + "youtube": null,
9588 + "facebook": null,
9589 + "x": null,
9590 + "twitch": null
9591 + },
9592 + "metrics": {
9593 + "instagram_followers": null,
9594 + "tiktok_followers": null,
9595 + "youtube_subscribers": null,
9596 + "facebook_followers": null,
9597 + "x_followers": null,
9598 + "twitch_followers": null
9599 + },
9600 + "category": "humour",
9601 + "location": "Québec",
9602 + "notes": "#16 humoriste QC: 479 060 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9603 + "sources": [
9604 + "jdm_humoristes"
9605 + ],
9606 + "total_followers": null,
9607 + "platform_count": 0,
9608 + "tier": "nano/inconnu"
9609 + },
9610 + {
9611 + "name": "Louis Morissette",
9612 + "handles": {
9613 + "instagram": null,
9614 + "tiktok": null,
9615 + "youtube": null,
9616 + "facebook": null,
9617 + "x": null,
9618 + "twitch": null
9619 + },
9620 + "metrics": {
9621 + "instagram_followers": null,
9622 + "tiktok_followers": null,
9623 + "youtube_subscribers": null,
9624 + "facebook_followers": null,
9625 + "x_followers": null,
9626 + "twitch_followers": null
9627 + },
9628 + "category": "humour",
9629 + "location": "Québec",
9630 + "notes": "#17 humoriste QC: 475 800 abonnés cumulés (FB+IG+TikTok+X, 2024); aussi au top 25 Twitter 2019 (>120K)",
9631 + "sources": [
9632 + "jdm_humoristes",
9633 + "rythme_twitter"
9634 + ],
9635 + "total_followers": null,
9636 + "platform_count": 0,
9637 + "tier": "nano/inconnu"
9638 + },
9639 + {
9640 + "name": "Liliane Blanco-Binette",
9641 + "handles": {
9642 + "instagram": null,
9643 + "tiktok": null,
9644 + "youtube": null,
9645 + "facebook": null,
9646 + "x": null,
9647 + "twitch": null
9648 + },
9649 + "metrics": {
9650 + "instagram_followers": null,
9651 + "tiktok_followers": null,
9652 + "youtube_subscribers": null,
9653 + "facebook_followers": null,
9654 + "x_followers": null,
9655 + "twitch_followers": null
9656 + },
9657 + "category": "humour",
9658 + "location": "Québec",
9659 + "notes": "#18 humoriste QC: 471 800 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9660 + "sources": [
9661 + "jdm_humoristes"
9662 + ],
9663 + "total_followers": null,
9664 + "platform_count": 0,
9665 + "tier": "nano/inconnu"
9666 + },
9667 + {
9668 + "name": "Stéphane Rousseau",
9669 + "handles": {
9670 + "instagram": null,
9671 + "tiktok": null,
9672 + "youtube": null,
9673 + "facebook": null,
9674 + "x": null,
9675 + "twitch": null
9676 + },
9677 + "metrics": {
9678 + "instagram_followers": null,
9679 + "tiktok_followers": null,
9680 + "youtube_subscribers": null,
9681 + "facebook_followers": null,
9682 + "x_followers": null,
9683 + "twitch_followers": null
9684 + },
9685 + "category": "humour",
9686 + "location": "Québec",
9687 + "notes": "#19 humoriste QC: 465 100 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9688 + "sources": [
9689 + "jdm_humoristes"
9690 + ],
9691 + "total_followers": null,
9692 + "platform_count": 0,
9693 + "tier": "nano/inconnu"
9694 + },
9695 + {
9696 + "name": "Mathieu Cyr",
9697 + "handles": {
9698 + "instagram": null,
9699 + "tiktok": null,
9700 + "youtube": null,
9701 + "facebook": null,
9702 + "x": null,
9703 + "twitch": null
9704 + },
9705 + "metrics": {
9706 + "instagram_followers": null,
9707 + "tiktok_followers": null,
9708 + "youtube_subscribers": null,
9709 + "facebook_followers": null,
9710 + "x_followers": null,
9711 + "twitch_followers": null
9712 + },
9713 + "category": "humour",
9714 + "location": "Québec",
9715 + "notes": "#20 humoriste QC: 446 800 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9716 + "sources": [
9717 + "jdm_humoristes"
9718 + ],
9719 + "total_followers": null,
9720 + "platform_count": 0,
9721 + "tier": "nano/inconnu"
9722 + },
9723 + {
9724 + "name": "Pierre Hébert",
9725 + "handles": {
9726 + "instagram": null,
9727 + "tiktok": null,
9728 + "youtube": null,
9729 + "facebook": null,
9730 + "x": null,
9731 + "twitch": null
9732 + },
9733 + "metrics": {
9734 + "instagram_followers": null,
9735 + "tiktok_followers": null,
9736 + "youtube_subscribers": null,
9737 + "facebook_followers": null,
9738 + "x_followers": null,
9739 + "twitch_followers": null
9740 + },
9741 + "category": "humour",
9742 + "location": "Québec",
9743 + "notes": "#21 humoriste QC: 439 200 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9744 + "sources": [
9745 + "jdm_humoristes"
9746 + ],
9747 + "total_followers": null,
9748 + "platform_count": 0,
9749 + "tier": "nano/inconnu"
9750 + },
9751 + {
9752 + "name": "Marie-Lyne Joncas",
9753 + "handles": {
9754 + "instagram": null,
9755 + "tiktok": null,
9756 + "youtube": null,
9757 + "facebook": null,
9758 + "x": null,
9759 + "twitch": null
9760 + },
9761 + "metrics": {
9762 + "instagram_followers": null,
9763 + "tiktok_followers": null,
9764 + "youtube_subscribers": null,
9765 + "facebook_followers": null,
9766 + "x_followers": null,
9767 + "twitch_followers": null
9768 + },
9769 + "category": "humour",
9770 + "location": "Québec",
9771 + "notes": "#22 humoriste QC: 414 185 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9772 + "sources": [
9773 + "jdm_humoristes"
9774 + ],
9775 + "total_followers": null,
9776 + "platform_count": 0,
9777 + "tier": "nano/inconnu"
9778 + },
9779 + {
9780 + "name": "Alex Perron",
9781 + "handles": {
9782 + "instagram": null,
9783 + "tiktok": null,
9784 + "youtube": null,
9785 + "facebook": null,
9786 + "x": "alexxxperron",
9787 + "twitch": null
9788 + },
9789 + "metrics": {
9790 + "instagram_followers": null,
9791 + "tiktok_followers": null,
9792 + "youtube_subscribers": null,
9793 + "facebook_followers": null,
9794 + "x_followers": null,
9795 + "twitch_followers": null
9796 + },
9797 + "category": "humour",
9798 + "location": "Québec",
9799 + "notes": "#23 humoriste QC: 382 700 abonnés cumulés (FB+IG+TikTok+X, 2024); aussi au top 25 Twitter 2019 (>120K)",
9800 + "sources": [
9801 + "jdm_humoristes",
9802 + "rythme_twitter"
9803 + ],
9804 + "total_followers": null,
9805 + "platform_count": 1,
9806 + "tier": "nano/inconnu"
9807 + },
9808 + {
9809 + "name": "Jean-Marc Parent",
9810 + "handles": {
9811 + "instagram": null,
9812 + "tiktok": null,
9813 + "youtube": null,
9814 + "facebook": null,
9815 + "x": null,
9816 + "twitch": null
9817 + },
9818 + "metrics": {
9819 + "instagram_followers": null,
9820 + "tiktok_followers": null,
9821 + "youtube_subscribers": null,
9822 + "facebook_followers": null,
9823 + "x_followers": null,
9824 + "twitch_followers": null
9825 + },
9826 + "category": "humour",
9827 + "location": "Québec",
9828 + "notes": "#24 humoriste QC: 376 300 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9829 + "sources": [
9830 + "jdm_humoristes"
9831 + ],
9832 + "total_followers": null,
9833 + "platform_count": 0,
9834 + "tier": "nano/inconnu"
9835 + },
9836 + {
9837 + "name": "Jean-François Mercier",
9838 + "handles": {
9839 + "instagram": null,
9840 + "tiktok": null,
9841 + "youtube": null,
9842 + "facebook": null,
9843 + "x": null,
9844 + "twitch": null
9845 + },
9846 + "metrics": {
9847 + "instagram_followers": null,
9848 + "tiktok_followers": null,
9849 + "youtube_subscribers": null,
9850 + "facebook_followers": null,
9851 + "x_followers": null,
9852 + "twitch_followers": null
9853 + },
9854 + "category": "humour",
9855 + "location": "Québec",
9856 + "notes": "#25 humoriste QC: 373 900 abonnés cumulés (FB+IG+TikTok+X, 2024)",
9857 + "sources": [
9858 + "jdm_humoristes"
9859 + ],
9860 + "total_followers": null,
9861 + "platform_count": 0,
9862 + "tier": "nano/inconnu"
9863 + },
9864 + {
9865 + "name": "Tommy Néron",
9866 + "handles": {
9867 + "instagram": null,
9868 + "tiktok": null,
9869 + "youtube": null,
9870 + "facebook": null,
9871 + "x": null,
9872 + "twitch": null
9873 + },
9874 + "metrics": {
9875 + "instagram_followers": null,
9876 + "tiktok_followers": null,
9877 + "youtube_subscribers": null,
9878 + "facebook_followers": null,
9879 + "x_followers": null,
9880 + "twitch_followers": null
9881 + },
9882 + "category": "humour",
9883 + "location": "Québec",
9884 + "notes": "Humoriste de la relève cité dans l'article JdM; auditions obtenues via TikTok/Instagram",
9885 + "sources": [
9886 + "jdm_humoristes"
9887 + ],
9888 + "total_followers": null,
9889 + "platform_count": 0,
9890 + "tier": "nano/inconnu"
9891 + },
9892 + {
9893 + "name": "Charles Pellerin",
9894 + "handles": {
9895 + "instagram": null,
9896 + "tiktok": null,
9897 + "youtube": null,
9898 + "facebook": null,
9899 + "x": null,
9900 + "twitch": null
9901 + },
9902 + "metrics": {
9903 + "instagram_followers": null,
9904 + "tiktok_followers": null,
9905 + "youtube_subscribers": null,
9906 + "facebook_followers": null,
9907 + "x_followers": null,
9908 + "twitch_followers": null
9909 + },
9910 + "category": "humour",
9911 + "location": "Québec",
9912 + "notes": "Humoriste cité dans l'article JdM sur l'importance des réseaux sociaux",
9913 + "sources": [
9914 + "jdm_humoristes"
9915 + ],
9916 + "total_followers": null,
9917 + "platform_count": 0,
9918 + "tier": "nano/inconnu"
9919 + },
9920 + {
9921 + "name": "Jean-Michel Martel",
9922 + "handles": {
9923 + "instagram": null,
9924 + "tiktok": null,
9925 + "youtube": null,
9926 + "facebook": null,
9927 + "x": null,
9928 + "twitch": null
9929 + },
9930 + "metrics": {
9931 + "instagram_followers": null,
9932 + "tiktok_followers": null,
9933 + "youtube_subscribers": null,
9934 + "facebook_followers": null,
9935 + "x_followers": null,
9936 + "twitch_followers": null
9937 + },
9938 + "category": "humour",
9939 + "location": "Québec",
9940 + "notes": "Humoriste cité dans l'article JdM sur l'importance des réseaux sociaux",
9941 + "sources": [
9942 + "jdm_humoristes"
9943 + ],
9944 + "total_followers": null,
9945 + "platform_count": 0,
9946 + "tier": "nano/inconnu"
9947 + },
9948 + {
9949 + "name": "Guy A. Lepage",
9950 + "handles": {
9951 + "instagram": null,
9952 + "tiktok": null,
9953 + "youtube": null,
9954 + "facebook": null,
9955 + "x": "guyalepage",
9956 + "twitch": null
9957 + },
9958 + "metrics": {
9959 + "instagram_followers": null,
9960 + "tiktok_followers": null,
9961 + "youtube_subscribers": null,
9962 + "facebook_followers": null,
9963 + "x_followers": null,
9964 + "twitch_followers": null
9965 + },
9966 + "category": "médias",
9967 + "location": "Québec",
9968 + "notes": "#1 du top 25 personnalités QC les plus suivies sur Twitter (2019, >120K abonnés); animateur de Tout le monde en parle",
9969 + "sources": [
9970 + "rythme_twitter"
9971 + ],
9972 + "total_followers": null,
9973 + "platform_count": 1,
9974 + "tier": "nano/inconnu"
9975 + },
9976 + {
9977 + "name": "Dominic Arpin",
9978 + "handles": {
9979 + "instagram": null,
9980 + "tiktok": null,
9981 + "youtube": null,
9982 + "facebook": null,
9983 + "x": null,
9984 + "twitch": null
9985 + },
9986 + "metrics": {
9987 + "instagram_followers": null,
9988 + "tiktok_followers": null,
9989 + "youtube_subscribers": null,
9990 + "facebook_followers": null,
9991 + "x_followers": null,
9992 + "twitch_followers": null
9993 + },
9994 + "category": "médias",
9995 + "location": "Québec",
9996 + "notes": "#2 du top 25 Twitter QC 2019 (>120K abonnés); animateur/chroniqueur",
9997 + "sources": [
9998 + "rythme_twitter"
9999 + ],
10000 + "total_followers": null,
10001 + "platform_count": 0,
10002 + "tier": "nano/inconnu"
10003 + },
10004 + {
10005 + "name": "Véronique Cloutier",
10006 + "handles": {
10007 + "instagram": null,
10008 + "tiktok": null,
10009 + "youtube": null,
10010 + "facebook": null,
10011 + "x": null,
10012 + "twitch": null
10013 + },
10014 + "metrics": {
10015 + "instagram_followers": null,
10016 + "tiktok_followers": null,
10017 + "youtube_subscribers": null,
10018 + "facebook_followers": null,
10019 + "x_followers": null,
10020 + "twitch_followers": null
10021 + },
10022 + "category": "médias",
10023 + "location": "Québec",
10024 + "notes": "#3 du top 25 Twitter QC 2019 (>120K abonnés); animatrice",
10025 + "sources": [
10026 + "rythme_twitter"
10027 + ],
10028 + "total_followers": null,
10029 + "platform_count": 0,
10030 + "tier": "nano/inconnu"
10031 + },
10032 + {
10033 + "name": "Cœur de Pirate (Béatrice Martin)",
10034 + "handles": {
10035 + "instagram": null,
10036 + "tiktok": null,
10037 + "youtube": null,
10038 + "facebook": null,
10039 + "x": null,
10040 + "twitch": null
10041 + },
10042 + "metrics": {
10043 + "instagram_followers": null,
10044 + "tiktok_followers": null,
10045 + "youtube_subscribers": null,
10046 + "facebook_followers": null,
10047 + "x_followers": null,
10048 + "twitch_followers": null
10049 + },
10050 + "category": "musique",
10051 + "location": "Québec",
10052 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); +20,8% d'abonnés cette année-là",
10053 + "sources": [
10054 + "rythme_twitter"
10055 + ],
10056 + "total_followers": null,
10057 + "platform_count": 0,
10058 + "tier": "nano/inconnu"
10059 + },
10060 + {
10061 + "name": "Richard Martineau",
10062 + "handles": {
10063 + "instagram": null,
10064 + "tiktok": null,
10065 + "youtube": null,
10066 + "facebook": null,
10067 + "x": null,
10068 + "twitch": null
10069 + },
10070 + "metrics": {
10071 + "instagram_followers": null,
10072 + "tiktok_followers": null,
10073 + "youtube_subscribers": null,
10074 + "facebook_followers": null,
10075 + "x_followers": null,
10076 + "twitch_followers": null
10077 + },
10078 + "category": "médias",
10079 + "location": "Québec",
10080 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); +2,5% d'abonnés; chroniqueur",
10081 + "sources": [
10082 + "rythme_twitter"
10083 + ],
10084 + "total_followers": null,
10085 + "platform_count": 0,
10086 + "tier": "nano/inconnu"
10087 + },
10088 + {
10089 + "name": "Denis Coderre",
10090 + "handles": {
10091 + "instagram": null,
10092 + "tiktok": null,
10093 + "youtube": null,
10094 + "facebook": null,
10095 + "x": null,
10096 + "twitch": null
10097 + },
10098 + "metrics": {
10099 + "instagram_followers": null,
10100 + "tiktok_followers": null,
10101 + "youtube_subscribers": null,
10102 + "facebook_followers": null,
10103 + "x_followers": null,
10104 + "twitch_followers": null
10105 + },
10106 + "category": "autre",
10107 + "location": "Montréal",
10108 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); politicien, ex-maire de Montréal",
10109 + "sources": [
10110 + "rythme_twitter"
10111 + ],
10112 + "total_followers": null,
10113 + "platform_count": 0,
10114 + "tier": "nano/inconnu"
10115 + },
10116 + {
10117 + "name": "Patrick Lagacé",
10118 + "handles": {
10119 + "instagram": null,
10120 + "tiktok": null,
10121 + "youtube": null,
10122 + "facebook": null,
10123 + "x": null,
10124 + "twitch": null
10125 + },
10126 + "metrics": {
10127 + "instagram_followers": null,
10128 + "tiktok_followers": null,
10129 + "youtube_subscribers": null,
10130 + "facebook_followers": null,
10131 + "x_followers": null,
10132 + "twitch_followers": null
10133 + },
10134 + "category": "médias",
10135 + "location": "Québec",
10136 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); journaliste-chroniqueur",
10137 + "sources": [
10138 + "rythme_twitter"
10139 + ],
10140 + "total_followers": null,
10141 + "platform_count": 0,
10142 + "tier": "nano/inconnu"
10143 + },
10144 + {
10145 + "name": "Georges Laraque",
10146 + "handles": {
10147 + "instagram": null,
10148 + "tiktok": null,
10149 + "youtube": null,
10150 + "facebook": null,
10151 + "x": null,
10152 + "twitch": null
10153 + },
10154 + "metrics": {
10155 + "instagram_followers": null,
10156 + "tiktok_followers": null,
10157 + "youtube_subscribers": null,
10158 + "facebook_followers": null,
10159 + "x_followers": null,
10160 + "twitch_followers": null
10161 + },
10162 + "category": "autre",
10163 + "location": "Québec",
10164 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); ex-joueur de la LNH",
10165 + "sources": [
10166 + "rythme_twitter"
10167 + ],
10168 + "total_followers": null,
10169 + "platform_count": 0,
10170 + "tier": "nano/inconnu"
10171 + },
10172 + {
10173 + "name": "Dany Turcotte",
10174 + "handles": {
10175 + "instagram": null,
10176 + "tiktok": null,
10177 + "youtube": null,
10178 + "facebook": null,
10179 + "x": "danyturcotte",
10180 + "twitch": null
10181 + },
10182 + "metrics": {
10183 + "instagram_followers": null,
10184 + "tiktok_followers": null,
10185 + "youtube_subscribers": null,
10186 + "facebook_followers": null,
10187 + "x_followers": null,
10188 + "twitch_followers": null
10189 + },
10190 + "category": "humour",
10191 + "location": "Québec",
10192 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); humoriste, ex-fou du roi de TLMEP",
10193 + "sources": [
10194 + "rythme_twitter"
10195 + ],
10196 + "total_followers": null,
10197 + "platform_count": 1,
10198 + "tier": "nano/inconnu"
10199 + },
10200 + {
10201 + "name": "Julie Snyder",
10202 + "handles": {
10203 + "instagram": null,
10204 + "tiktok": null,
10205 + "youtube": null,
10206 + "facebook": null,
10207 + "x": "snyderjulie",
10208 + "twitch": null
10209 + },
10210 + "metrics": {
10211 + "instagram_followers": null,
10212 + "tiktok_followers": null,
10213 + "youtube_subscribers": null,
10214 + "facebook_followers": null,
10215 + "x_followers": null,
10216 + "twitch_followers": null
10217 + },
10218 + "category": "médias",
10219 + "location": "Québec",
10220 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); animatrice et productrice",
10221 + "sources": [
10222 + "rythme_twitter"
10223 + ],
10224 + "total_followers": null,
10225 + "platform_count": 1,
10226 + "tier": "nano/inconnu"
10227 + },
10228 + {
10229 + "name": "Stéphane Laporte",
10230 + "handles": {
10231 + "instagram": null,
10232 + "tiktok": null,
10233 + "youtube": null,
10234 + "facebook": null,
10235 + "x": "laportestephane",
10236 + "twitch": null
10237 + },
10238 + "metrics": {
10239 + "instagram_followers": null,
10240 + "tiktok_followers": null,
10241 + "youtube_subscribers": null,
10242 + "facebook_followers": null,
10243 + "x_followers": null,
10244 + "twitch_followers": null
10245 + },
10246 + "category": "médias",
10247 + "location": "Québec",
10248 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); auteur et chroniqueur",
10249 + "sources": [
10250 + "rythme_twitter"
10251 + ],
10252 + "total_followers": null,
10253 + "platform_count": 1,
10254 + "tier": "nano/inconnu"
10255 + },
10256 + {
10257 + "name": "Paul Arcand",
10258 + "handles": {
10259 + "instagram": null,
10260 + "tiktok": null,
10261 + "youtube": null,
10262 + "facebook": null,
10263 + "x": null,
10264 + "twitch": null
10265 + },
10266 + "metrics": {
10267 + "instagram_followers": null,
10268 + "tiktok_followers": null,
10269 + "youtube_subscribers": null,
10270 + "facebook_followers": null,
10271 + "x_followers": null,
10272 + "twitch_followers": null
10273 + },
10274 + "category": "médias",
10275 + "location": "Québec",
10276 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); animateur radio",
10277 + "sources": [
10278 + "rythme_twitter"
10279 + ],
10280 + "total_followers": null,
10281 + "platform_count": 0,
10282 + "tier": "nano/inconnu"
10283 + },
10284 + {
10285 + "name": "Marie-Mai",
10286 + "handles": {
10287 + "instagram": null,
10288 + "tiktok": null,
10289 + "youtube": null,
10290 + "facebook": null,
10291 + "x": "mariemaireal",
10292 + "twitch": null
10293 + },
10294 + "metrics": {
10295 + "instagram_followers": null,
10296 + "tiktok_followers": null,
10297 + "youtube_subscribers": null,
10298 + "facebook_followers": null,
10299 + "x_followers": null,
10300 + "twitch_followers": null
10301 + },
10302 + "category": "musique",
10303 + "location": "Québec",
10304 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); chanteuse",
10305 + "sources": [
10306 + "rythme_twitter"
10307 + ],
10308 + "total_followers": null,
10309 + "platform_count": 1,
10310 + "tier": "nano/inconnu"
10311 + },
10312 + {
10313 + "name": "Marie-France Bazzo",
10314 + "handles": {
10315 + "instagram": null,
10316 + "tiktok": null,
10317 + "youtube": null,
10318 + "facebook": null,
10319 + "x": null,
10320 + "twitch": null
10321 + },
10322 + "metrics": {
10323 + "instagram_followers": null,
10324 + "tiktok_followers": null,
10325 + "youtube_subscribers": null,
10326 + "facebook_followers": null,
10327 + "x_followers": null,
10328 + "twitch_followers": null
10329 + },
10330 + "category": "médias",
10331 + "location": "Québec",
10332 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); animatrice et productrice",
10333 + "sources": [
10334 + "rythme_twitter"
10335 + ],
10336 + "total_followers": null,
10337 + "platform_count": 0,
10338 + "tier": "nano/inconnu"
10339 + },
10340 + {
10341 + "name": "Lara Fabian",
10342 + "handles": {
10343 + "instagram": null,
10344 + "tiktok": null,
10345 + "youtube": null,
10346 + "facebook": null,
10347 + "x": null,
10348 + "twitch": null
10349 + },
10350 + "metrics": {
10351 + "instagram_followers": null,
10352 + "tiktok_followers": null,
10353 + "youtube_subscribers": null,
10354 + "facebook_followers": null,
10355 + "x_followers": null,
10356 + "twitch_followers": null
10357 + },
10358 + "category": "musique",
10359 + "location": "Québec",
10360 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); chanteuse",
10361 + "sources": [
10362 + "rythme_twitter"
10363 + ],
10364 + "total_followers": null,
10365 + "platform_count": 0,
10366 + "tier": "nano/inconnu"
10367 + },
10368 + {
10369 + "name": "Charles Lafortune",
10370 + "handles": {
10371 + "instagram": null,
10372 + "tiktok": null,
10373 + "youtube": null,
10374 + "facebook": null,
10375 + "x": "charleslaf",
10376 + "twitch": null
10377 + },
10378 + "metrics": {
10379 + "instagram_followers": null,
10380 + "tiktok_followers": null,
10381 + "youtube_subscribers": null,
10382 + "facebook_followers": null,
10383 + "x_followers": null,
10384 + "twitch_followers": null
10385 + },
10386 + "category": "médias",
10387 + "location": "Québec",
10388 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); animateur",
10389 + "sources": [
10390 + "rythme_twitter"
10391 + ],
10392 + "total_followers": null,
10393 + "platform_count": 1,
10394 + "tier": "nano/inconnu"
10395 + },
10396 + {
10397 + "name": "Jean-Thomas Jobin",
10398 + "handles": {
10399 + "instagram": null,
10400 + "tiktok": null,
10401 + "youtube": null,
10402 + "facebook": null,
10403 + "x": "jeanthomasjobin",
10404 + "twitch": null
10405 + },
10406 + "metrics": {
10407 + "instagram_followers": null,
10408 + "tiktok_followers": null,
10409 + "youtube_subscribers": null,
10410 + "facebook_followers": null,
10411 + "x_followers": null,
10412 + "twitch_followers": null
10413 + },
10414 + "category": "humour",
10415 + "location": "Québec",
10416 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); humoriste",
10417 + "sources": [
10418 + "rythme_twitter"
10419 + ],
10420 + "total_followers": null,
10421 + "platform_count": 1,
10422 + "tier": "nano/inconnu"
10423 + },
10424 + {
10425 + "name": "Benoît Dutrizac",
10426 + "handles": {
10427 + "instagram": null,
10428 + "tiktok": null,
10429 + "youtube": null,
10430 + "facebook": null,
10431 + "x": null,
10432 + "twitch": null
10433 + },
10434 + "metrics": {
10435 + "instagram_followers": null,
10436 + "tiktok_followers": null,
10437 + "youtube_subscribers": null,
10438 + "facebook_followers": null,
10439 + "x_followers": null,
10440 + "twitch_followers": null
10441 + },
10442 + "category": "médias",
10443 + "location": "Québec",
10444 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); animateur et chroniqueur",
10445 + "sources": [
10446 + "rythme_twitter"
10447 + ],
10448 + "total_followers": null,
10449 + "platform_count": 0,
10450 + "tier": "nano/inconnu"
10451 + },
10452 + {
10453 + "name": "Mario Dumont",
10454 + "handles": {
10455 + "instagram": null,
10456 + "tiktok": null,
10457 + "youtube": null,
10458 + "facebook": null,
10459 + "x": null,
10460 + "twitch": null
10461 + },
10462 + "metrics": {
10463 + "instagram_followers": null,
10464 + "tiktok_followers": null,
10465 + "youtube_subscribers": null,
10466 + "facebook_followers": null,
10467 + "x_followers": null,
10468 + "twitch_followers": null
10469 + },
10470 + "category": "médias",
10471 + "location": "Québec",
10472 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); animateur, ex-politicien",
10473 + "sources": [
10474 + "rythme_twitter"
10475 + ],
10476 + "total_followers": null,
10477 + "platform_count": 0,
10478 + "tier": "nano/inconnu"
10479 + },
10480 + {
10481 + "name": "Patrick Groulx",
10482 + "handles": {
10483 + "instagram": null,
10484 + "tiktok": null,
10485 + "youtube": null,
10486 + "facebook": null,
10487 + "x": null,
10488 + "twitch": null
10489 + },
10490 + "metrics": {
10491 + "instagram_followers": null,
10492 + "tiktok_followers": null,
10493 + "youtube_subscribers": null,
10494 + "facebook_followers": null,
10495 + "x_followers": null,
10496 + "twitch_followers": null
10497 + },
10498 + "category": "humour",
10499 + "location": "Québec",
10500 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); humoriste",
10501 + "sources": [
10502 + "rythme_twitter"
10503 + ],
10504 + "total_followers": null,
10505 + "platform_count": 0,
10506 + "tier": "nano/inconnu"
10507 + },
10508 + {
10509 + "name": "MC Gilles",
10510 + "handles": {
10511 + "instagram": null,
10512 + "tiktok": null,
10513 + "youtube": null,
10514 + "facebook": null,
10515 + "x": "mcgilles",
10516 + "twitch": null
10517 + },
10518 + "metrics": {
10519 + "instagram_followers": null,
10520 + "tiktok_followers": null,
10521 + "youtube_subscribers": null,
10522 + "facebook_followers": null,
10523 + "x_followers": null,
10524 + "twitch_followers": null
10525 + },
10526 + "category": "médias",
10527 + "location": "Québec",
10528 + "notes": "Top 25 Twitter QC 2019 (>120K abonnés); chroniqueur et DJ",
10529 + "sources": [
10530 + "rythme_twitter"
10531 + ],
10532 + "total_followers": null,
10533 + "platform_count": 1,
10534 + "tier": "nano/inconnu"
10535 + },
10536 + {
10537 + "name": "Garou",
10538 + "handles": {
10539 + "instagram": null,
10540 + "tiktok": null,
10541 + "youtube": null,
10542 + "facebook": null,
10543 + "x": null,
10544 + "twitch": null
10545 + },
10546 + "metrics": {
10547 + "instagram_followers": null,
10548 + "tiktok_followers": null,
10549 + "youtube_subscribers": null,
10550 + "facebook_followers": null,
10551 + "x_followers": null,
10552 + "twitch_followers": null
10553 + },
10554 + "category": "musique",
10555 + "location": "Québec",
10556 + "notes": "3e du top Twitter QC 2018, retiré du palmarès 2019 (ne vivait plus principalement au Québec)",
10557 + "sources": [
10558 + "rythme_twitter"
10559 + ],
10560 + "total_followers": null,
10561 + "platform_count": 0,
10562 + "tier": "nano/inconnu"
10563 + },
10564 + {
10565 + "name": "Martin Rego",
10566 + "handles": {
10567 + "instagram": null,
10568 + "tiktok": null,
10569 + "youtube": null,
10570 + "facebook": null,
10571 + "x": "martin_rego",
10572 + "twitch": null
10573 + },
10574 + "metrics": {
10575 + "instagram_followers": null,
10576 + "tiktok_followers": null,
10577 + "youtube_subscribers": null,
10578 + "facebook_followers": null,
10579 + "x_followers": null,
10580 + "twitch_followers": null
10581 + },
10582 + "category": "médias",
10583 + "location": "Québec",
10584 + "notes": "Animateur et youtubeur, auteur du palmarès annuel des personnalités QC sur Twitter",
10585 + "sources": [
10586 + "rythme_twitter"
10587 + ],
10588 + "total_followers": null,
10589 + "platform_count": 1,
10590 + "tier": "nano/inconnu"
10591 + },
10592 + {
10593 + "name": "Le Rêve d'un Vagabond",
10594 + "handles": {
10595 + "instagram": null,
10596 + "tiktok": null,
10597 + "youtube": null,
10598 + "facebook": "revedunvagabond",
10599 + "x": null,
10600 + "twitch": null
10601 + },
10602 + "metrics": {
10603 + "instagram_followers": null,
10604 + "tiktok_followers": null,
10605 + "youtube_subscribers": null,
10606 + "facebook_followers": null,
10607 + "x_followers": null,
10608 + "twitch_followers": null
10609 + },
10610 + "category": "voyage",
10611 + "location": "Québec",
10612 + "notes": "Québécois digital nomade, vidéos du quotidien d'un voyageur sur Facebook; #4 du top VoyageurQc",
10613 + "sources": [
10614 + "voyageurqc"
10615 + ],
10616 + "total_followers": null,
10617 + "platform_count": 1,
10618 + "tier": "nano/inconnu"
10619 + },
10620 + {
10621 + "name": "Rim Hajri",
10622 + "handles": {
10623 + "instagram": "rimhajri",
10624 + "tiktok": null,
10625 + "youtube": null,
10626 + "facebook": null,
10627 + "x": null,
10628 + "twitch": null
10629 + },
10630 + "metrics": {
10631 + "instagram_followers": null,
10632 + "tiktok_followers": null,
10633 + "youtube_subscribers": null,
10634 + "facebook_followers": null,
10635 + "x_followers": null,
10636 + "twitch_followers": null
10637 + },
10638 + "category": "voyage",
10639 + "location": "Montréal",
10640 + "notes": "Blogueuse Where It's Blue (plongée, îles); #2 du top VoyageurQc",
10641 + "sources": [
10642 + "voyageurqc"
10643 + ],
10644 + "total_followers": null,
10645 + "platform_count": 1,
10646 + "tier": "nano/inconnu"
10647 + },
10648 + {
10649 + "name": "Steeven (Voyageur.Qc / Minion)",
10650 + "handles": {
10651 + "instagram": "voyageur.qc",
10652 + "tiktok": null,
10653 + "youtube": null,
10654 + "facebook": null,
10655 + "x": null,
10656 + "twitch": null
10657 + },
10658 + "metrics": {
10659 + "instagram_followers": null,
10660 + "tiktok_followers": null,
10661 + "youtube_subscribers": null,
10662 + "facebook_followers": null,
10663 + "x_followers": null,
10664 + "twitch_followers": null
10665 + },
10666 + "category": "voyage",
10667 + "location": "Québec",
10668 + "notes": "Auteur du blogue VoyageurQc; met en scène un Minion dans ses photos de voyage; #1 de son top",
10669 + "sources": [
10670 + "voyageurqc"
10671 + ],
10672 + "total_followers": null,
10673 + "platform_count": 1,
10674 + "tier": "nano/inconnu"
10675 + },
10676 + {
10677 + "name": "Alex & MJ on the go",
10678 + "handles": {
10679 + "instagram": null,
10680 + "tiktok": null,
10681 + "youtube": null,
10682 + "facebook": null,
10683 + "x": null,
10684 + "twitch": null
10685 + },
10686 + "metrics": {
10687 + "instagram_followers": null,
10688 + "tiktok_followers": null,
10689 + "youtube_subscribers": null,
10690 + "facebook_followers": null,
10691 + "x_followers": null,
10692 + "twitch_followers": null
10693 + },
10694 + "category": "voyage",
10695 + "location": "Québec",
10696 + "notes": "Couple de voyageurs québécois mentionné dans l'article VoyageurQc",
10697 + "sources": [
10698 + "voyageurqc"
10699 + ],
10700 + "total_followers": null,
10701 + "platform_count": 0,
10702 + "tier": "nano/inconnu"
10703 + },
10704 + {
10705 + "name": "Mathieu Dupuis",
10706 + "handles": {
10707 + "instagram": "mathieu_dupuis_photo",
10708 + "tiktok": null,
10709 + "youtube": null,
10710 + "facebook": null,
10711 + "x": null,
10712 + "twitch": null
10713 + },
10714 + "metrics": {
10715 + "instagram_followers": null,
10716 + "tiktok_followers": null,
10717 + "youtube_subscribers": null,
10718 + "facebook_followers": null,
10719 + "x_followers": null,
10720 + "twitch_followers": null
10721 + },
10722 + "category": "voyage",
10723 + "location": "Québec",
10724 + "notes": "Photographe de voyage sillonnant les routes en Sprinter 4x4 (aussi @matdupuisphoto)",
10725 + "sources": [
10726 + "noovomoi_voyage"
10727 + ],
10728 + "total_followers": null,
10729 + "platform_count": 1,
10730 + "tier": "nano/inconnu"
10731 + }
10732 +]
\ No newline at end of file
added data/sources.json +98 −0
@@ -0,0 +1,98 @@
1 +{
2 + "_author": "Simon-Pierre Boucher <contact@spboucher.ai>",
3 + "_file": "data/sources.json",
4 + "_desc": "Registre des sources connectées — famille, mode d'accès, cadence (CLAUDE.md §9, §14, §19)",
5 + "updated": "2026-08-17",
6 + "sources": [
7 + {
8 + "id": "listes-medias",
9 + "famille": "decouverte",
10 + "palier": 3,
11 + "acces": "dataset local (listes & palmarès médias publics compilés : billie, frank, metricool, Favikon QC, canadult…)",
12 + "signal_identite": "listes_medias (0.85)",
13 + "cadence": "mensuelle (recompilation du dataset)",
14 + "notes": "376 créateurs québécois, handles par plateforme + audiences approximatives"
15 + },
16 + {
17 + "id": "wikidata-qc",
18 + "famille": "decouverte",
19 + "palier": 3,
20 + "acces": "API SPARQL publique Wikidata (nés/résidant au Québec, occupation créateur, handles sociaux curés)",
21 + "signal_identite": "base_publique (0.85)",
22 + "cadence": "hebdomadaire",
23 + "notes": "humoristes, youtubeurs, vidéastes web, streamers, podcasteurs notables ; is_minor levé via l'année de naissance publique"
24 + },
25 + {
26 + "id": "onlyqueb",
27 + "famille": "decouverte",
28 + "palier": 3,
29 + "acces": "requêtes directes (sitemap.xml + JSON-LD ProfilePage des pages profil publiques SSR)",
30 + "signal_identite": "cross_link (0.90) — liens sameAs auto-déclarés par la personne",
31 + "cadence": "hebdomadaire",
32 + "notes": "annuaire public onlyqueb.com — créateurs québécois sur abonnement (18+) ; nom public, photo, liens OnlyFans/Instagram/TikTok/X"
33 + },
34 + {
35 + "id": "youtube-recherche",
36 + "famille": "decouverte",
37 + "palier": 3,
38 + "acces": "requêtes directes (recherche publique YouTube filtre « chaînes », ytInitialData) — ~120 requêtes thématiques × marqueurs QC",
39 + "signal_identite": "profil_source (0.98) — la chaîne est le compte d'origine de la fiche",
40 + "cadence": "quotidienne (watch) — la couverture s'accumule à chaque passage",
41 + "notes": "filtre conservateur : marqueur québécois requis dans titre/description de la chaîne"
42 + },
43 + {
44 + "id": "balados-itunes",
45 + "famille": "decouverte",
46 + "palier": 2,
47 + "acces": "API de recherche iTunes/Apple Podcasts (officielle, gratuite, sans clé)",
48 + "signal_identite": "profil_source (0.98)",
49 + "cadence": "quotidienne (watch)",
50 + "notes": "podcasteurs québécois : nom, artiste, pochette, genre, nb d'épisodes, flux RSS ; marqueur QC requis"
51 + },
52 + {
53 + "id": "instagram-profil",
54 + "famille": "enrichissement",
55 + "palier": 4,
56 + "acces": "Scrapfly (profil public web_profile_info — pas d'API publique de lecture)",
57 + "signal_identite": "cross_link (0.90) pour les liens de bio découverts",
58 + "cadence": "hebdomadaire (gros créateurs) / mensuelle",
59 + "notes": "abonnés, badge, bio, liens de bio → link_in_bio_url + comptes croisés"
60 + },
61 + {
62 + "id": "tiktok-profil",
63 + "famille": "enrichissement",
64 + "palier": 4,
65 + "acces": "Scrapfly (JSON d'hydratation de la page publique @handle)",
66 + "signal_identite": "cross_link (0.90) pour le lien de bio",
67 + "cadence": "hebdomadaire (gros créateurs) / mensuelle",
68 + "notes": "abonnés, badge, bio, lien de bio"
69 + },
70 + {
71 + "id": "youtube",
72 + "famille": "enrichissement",
73 + "palier": 2,
74 + "acces": "YouTube Data API v3 (officielle) si YOUTUBE_API_KEY, sinon page publique /@handle",
75 + "signal_identite": "api_officielle (0.95) quand confirmé par l'API",
76 + "cadence": "hebdomadaire",
77 + "notes": "abonnés par chaîne"
78 + },
79 + {
80 + "id": "twitch",
81 + "famille": "enrichissement",
82 + "palier": 2,
83 + "acces": "API officielle Helix (client_credentials) — sauté sans clés",
84 + "signal_identite": "api_officielle (0.95)",
85 + "cadence": "hebdomadaire",
86 + "notes": "existence, type de diffuseur, description"
87 + },
88 + {
89 + "id": "link-in-bio",
90 + "famille": "enrichissement",
91 + "palier": 1,
92 + "acces": "requêtes directes (pages publiques SSR : linktr.ee, beacons.ai, bio.link, msha.ke, campsite.bio, snipfeed.co), repli Firecrawl",
93 + "signal_identite": "link_in_bio (0.95) — le signal le plus fort (§12.1)",
94 + "cadence": "mensuelle",
95 + "notes": "une page → TOUS les comptes du créateur ; consomme les link_in_bio_url découvertes par instagram-profil/tiktok-profil"
96 + }
97 + ]
98 +}
added docs/screenshots/accueil.png +0 −0

Binary file not shown.

added docs/screenshots/createur.png +0 −0

Binary file not shown.

added docs/screenshots/mobile.png +0 −0

Binary file not shown.

added docs/screenshots/stats.png +0 −0

Binary file not shown.

added frontend/dist/index.html +695 −0
@@ -0,0 +1,695 @@
1 +<!--
2 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 + File: frontend/src/index.template.html
4 + Desc: Gabarit du frontend Créa-Ka — {"_author":"Simon-Pierre Boucher <contact@spboucher.ai>","_file":"frontend/src/icons.json","_desc":"Trac\u00e9s SVG des marques de plateformes (simple-icons v13, CC0) + glyphes maison (linkedin/site-web/autre)","instagram":"M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077","tiktok":"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z","youtube":"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z","twitch":"M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z","kick":"M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z","x":"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z","facebook":"M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z","snapchat":"M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z","substack":"M22.539 8.242H1.46V5.406h21.08v2.836zM1.46 10.812V24L12 18.11 22.54 24V10.812H1.46zM22.54 0H1.46v2.836h21.08V0z","patreon":"M22.957 7.21c-.004-3.064-2.391-5.576-5.191-6.482-3.478-1.125-8.064-.962-11.384.604C2.357 3.231 1.093 7.391 1.046 11.54c-.039 3.411.302 12.396 5.369 12.46 3.765.047 4.326-4.804 6.068-7.141 1.24-1.662 2.836-2.132 4.801-2.618 3.376-.836 5.678-3.501 5.673-7.031Z","onlyfans":"M24 4.003h-4.015c-3.45 0-5.3.197-6.748 1.957a7.996 7.996 0 1 0 2.103 9.211c3.182-.231 5.39-2.134 6.085-5.173 0 0-2.399.585-4.43 0 4.018-.777 6.333-3.037 7.005-5.995zM5.61 11.999A2.391 2.391 0 0 1 9.28 9.97a2.966 2.966 0 0 1 2.998-2.528h.008c-.92 1.778-1.407 3.352-1.998 5.263A2.392 2.392 0 0 1 5.61 12Zm2.386-7.996a7.996 7.996 0 1 0 7.996 7.996 7.996 7.996 0 0 0-7.996-7.996Zm0 10.394A2.399 2.399 0 1 1 10.395 12a2.396 2.396 0 0 1-2.399 2.398Z","threads":"M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z","podcast":"M5.34 0A5.328 5.328 0 000 5.34v13.32A5.328 5.328 0 005.34 24h13.32A5.328 5.328 0 0024 18.66V5.34A5.328 5.328 0 0018.66 0zm6.525 2.568c2.336 0 4.448.902 6.056 2.587 1.224 1.272 1.912 2.619 2.264 4.392.12.59.12 2.2.007 2.864a8.506 8.506 0 01-3.24 5.296c-.608.46-2.096 1.261-2.336 1.261-.088 0-.096-.091-.056-.46.072-.592.144-.715.48-.856.536-.224 1.448-.874 2.008-1.435a7.644 7.644 0 002.008-3.536c.208-.824.184-2.656-.048-3.504-.728-2.696-2.928-4.792-5.624-5.352-.784-.16-2.208-.16-3 0-2.728.56-4.984 2.76-5.672 5.528-.184.752-.184 2.584 0 3.336.456 1.832 1.64 3.512 3.192 4.512.304.2.672.408.824.472.336.144.408.264.472.856.04.36.03.464-.056.464-.056 0-.464-.176-.896-.384l-.04-.03c-2.472-1.216-4.056-3.274-4.632-6.012-.144-.706-.168-2.392-.03-3.04.36-1.74 1.048-3.1 2.192-4.304 1.648-1.737 3.768-2.656 6.128-2.656zm.134 2.81c.409.004.803.04 1.106.106 2.784.62 4.76 3.408 4.376 6.174-.152 1.114-.536 2.03-1.216 2.88-.336.43-1.152 1.15-1.296 1.15-.023 0-.048-.272-.048-.603v-.605l.416-.496c1.568-1.878 1.456-4.502-.256-6.224-.664-.67-1.432-1.064-2.424-1.246-.64-.118-.776-.118-1.448-.008-1.02.167-1.81.562-2.512 1.256-1.72 1.704-1.832 4.342-.264 6.222l.413.496v.608c0 .336-.027.608-.06.608-.03 0-.264-.16-.512-.36l-.034-.011c-.832-.664-1.568-1.842-1.872-2.997-.184-.698-.184-2.024.008-2.72.504-1.878 1.888-3.335 3.808-4.019.41-.145 1.133-.22 1.814-.211zm-.13 2.99c.31 0 .62.06.844.178.488.253.888.745 1.04 1.259.464 1.578-1.208 2.96-2.72 2.254h-.015c-.712-.331-1.096-.956-1.104-1.77 0-.733.408-1.371 1.112-1.745.224-.117.534-.176.844-.176zm-.011 4.728c.988-.004 1.706.349 1.97.97.198.464.124 1.932-.218 4.302-.232 1.656-.36 2.074-.68 2.356-.44.39-1.064.498-1.656.288h-.003c-.716-.257-.87-.605-1.164-2.644-.341-2.37-.416-3.838-.218-4.302.262-.616.974-.966 1.97-.97z","linktree":"m13.73635 5.85251 4.00467-4.11665 2.3248 2.3808-4.20064 4.00466h5.9085v3.30473h-5.9365l4.22865 4.10766-2.3248 2.3338L12.0005 12.099l-5.74052 5.76852-2.3248-2.3248 4.22864-4.10766h-5.9375V8.12132h5.9085L3.93417 4.11666l2.3248-2.3808 4.00468 4.11665V0h3.4727zm-3.4727 10.30614h3.4727V24h-3.4727z","linkedin":"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z","site-web":"M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm7.938 9h-3.243a15.6 15.6 0 00-1.4-4.653A8.03 8.03 0 0119.938 9zM12 2.05c.9 1.2 1.9 3.2 2.5 6.95h-5C10.1 5.25 11.1 3.25 12 2.05zM4.062 15A8.06 8.06 0 013.75 12c0-1.05.15-2.05.312-3h3.55A25 25 0 007.5 12c0 1.05.05 2.05.113 3zm.643 2h3.243c.35 1.8.85 3.35 1.4 4.653A8.03 8.03 0 014.705 17zm3.243-10H4.705a8.03 8.03 0 014.643-4.653A15.6 15.6 0 007.948 7zM12 21.95c-.9-1.2-1.9-3.2-2.5-6.95h5c-.6 3.75-1.6 5.75-2.5 6.95zM14.787 13H9.213A23 23 0 019.1 12c0-1.05.037-2.05.113-3h5.574c.076.95.113 1.95.113 3s-.037 2.05-.113 3zm.508 8.653c.55-1.303 1.05-2.853 1.4-4.653h3.243a8.03 8.03 0 01-4.643 4.653zM16.388 15c.062-.95.112-1.95.112-3s-.05-2.05-.112-3h3.55c.162.95.312 1.95.312 3s-.15 2.05-.312 3z","autre":"M10.59 13.41a1 1 0 010-1.41l2.83-2.83a3 3 0 114.24 4.24l-1.41 1.42a1 1 0 11-1.42-1.42l1.42-1.41a1 1 0 10-1.42-1.42L12 13.41a1 1 0 01-1.41 0zm2.82-2.82a1 1 0 010 1.41l-2.82 2.83a3 3 0 11-4.25-4.24l1.42-1.42a1 1 0 111.41 1.42L7.76 12a1 1 0 101.41 1.41L12 10.59a1 1 0 011.41 0z"} est remplacé par les tracés
5 + SVG des marques (simple-icons, CC0) via scripts/build_frontend.py.
6 + Système de design « éditorial sharp » du Groupe KA (identité partagée
7 + avec groupe-ka.com, Vrai-Prix, ValoPlex : papier grainé #f5f3ee, encre
8 + #141814, bordures encre + ombres décalées dures, Space Grotesk / Inter
9 + / JetBrains Mono) — accent propre Créa-Ka : VIOLET ÉLECTRIQUE.
10 + Patrons mobiles calqués sur Lou-Ka (tab bar 4 onglets, filtres
11 + défilants, anti-zoom iOS). Routes : / · /createur/{id} · /stats · /retrait
12 +-->
13 +<!DOCTYPE html>
14 +<html lang="fr">
15 +<head>
16 +<meta charset="utf-8">
17 +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
18 +<title>Créa-Ka — Tous les créateurs québécois. Tous leurs liens.</title>
19 +<meta name="description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.">
20 +<meta name="theme-color" content="#141814">
21 +<link rel="preconnect" href="https://fonts.googleapis.com">
22 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
23 +<link rel="preconnect" href="https://unavatar.io">
24 +<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
25 +<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='10' fill='%23141814'/%3E%3Ccircle cx='32' cy='23' r='9' fill='%23c9a3ff'/%3E%3Cpath d='M14 52c3-10 9-15 18-15s15 5 18 15' stroke='%23c9a3ff' stroke-width='6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
26 +<style>
27 +/* système « éditorial sharp » Groupe KA — accent Créa-Ka : violet électrique.
28 + Convention du groupe : les NOMS de variables (--green/--lime) sont partagés
29 + entre les apps, seules les VALEURS changent. */
30 +:root{
31 + --paper:#f5f3ee; --surface:#fff; --surface-2:#faf9f5;
32 + --ink:#141814; --ink-2:#4d5551; --ink-3:#8b928c;
33 + --line:rgba(20,24,20,.14); --line-strong:rgba(20,24,20,.85);
34 + --green:#5b34a8; /* violet profond (primaire) */
35 + --green-deep:#3d2178;
36 + --lime:#c9a3ff; /* violet électrique (accent) */
37 + --lime-soft:#efe6ff;
38 + --amber:#e8a33d; --amber-soft:#fdf3e2;
39 + --danger:#b3423a;
40 + --r-card:10px; --r-ctl:6px; --r-pill:999px;
41 + --shadow-flat:0 1px 2px rgba(20,24,20,.05);
42 + --shadow-off:6px 6px 0 var(--ink);
43 + --shadow-off-soft:8px 8px 0 rgba(20,24,20,.08);
44 + --font-display:"Space Grotesk",system-ui,sans-serif;
45 + --font-body:"Inter",system-ui,sans-serif;
46 + --font-mono:"JetBrains Mono",ui-monospace,monospace;
47 +}
48 +*{box-sizing:border-box}
49 +html{scroll-behavior:smooth}
50 +body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--font-body);
51 + font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased;
52 + padding-bottom:env(safe-area-inset-bottom)}
53 +/* grain de film pleine page (signature du groupe) — aucun z-index sur body>* */
54 +body::before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.35;z-index:9999;
55 + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E")}
56 +body>*{position:relative}
57 +h1,h2,h3,h4{font-family:var(--font-display);letter-spacing:-.03em;margin:0}
58 +a{color:inherit;text-decoration:none} button{font-family:inherit;cursor:pointer} img{display:block}
59 +::selection{background:var(--lime);color:var(--ink)}
60 +:focus-visible{outline:2px solid var(--green);outline-offset:2px}
61 +.icn{width:1em;height:1em;display:block;fill:currentColor}
62 +.container{max-width:1240px;margin:0 auto;padding:0 24px}
63 +@media(max-width:640px){.container{padding:0 16px}}
64 +.kicker{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-mono);
65 + font-size:11.5px;font-weight:500;text-transform:uppercase;letter-spacing:.12em;color:var(--green)}
66 +.kicker::before{content:"";width:22px;height:2px;background:var(--green)}
67 +.klabel{font-family:var(--font-mono);font-size:10px;font-weight:700;
68 + text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3)}
69 +.hl{display:inline-block;background:var(--lime);border-radius:8px;padding:0 10px 2px;transform:rotate(-1deg)}
70 +.outline-txt{color:transparent;-webkit-text-stroke:2px var(--ink)}
71 +
72 +/* ---------- cartes & boutons (langage du groupe) ---------- */
73 +.gk-card{background:var(--surface);border:1.5px solid var(--ink);
74 + border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}
75 +.gk-hover{transition:transform .18s ease,box-shadow .18s ease}
76 +.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}
77 +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;
78 + padding:10px 20px;border:1.5px solid var(--ink);border-radius:var(--r-ctl);
79 + font-family:var(--font-display);font-size:14px;font-weight:700;cursor:pointer;
80 + transition:background .15s ease,transform .1s ease,box-shadow .1s ease}
81 +.btn:active{transform:translate(2px,2px);box-shadow:none!important}
82 +.btn-primary{background:var(--ink);color:var(--lime);box-shadow:4px 4px 0 rgba(20,24,20,.25)}
83 +.btn-primary:hover{background:var(--green-deep)}
84 +.btn-ghost{background:transparent;color:var(--ink)}
85 +.btn-ghost:hover{background:var(--lime)}
86 +
87 +/* ===== header ===== */
88 +.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);
89 + backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}
90 +.header-inner{display:flex;align-items:center;gap:16px;height:64px}
91 +.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;
92 + display:flex;align-items:center;line-height:1;color:var(--ink)}
93 +.brand svg{margin-right:11px;flex:none}
94 +.brand .ka{background:var(--lime);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}
95 +.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);
96 + line-height:1.5;text-transform:uppercase;letter-spacing:.08em}
97 +@media(max-width:900px){.brand-tag{display:none}}
98 +.header-spacer{flex:1}
99 +.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);
100 + border-radius:var(--r-ctl);padding:8px 16px;font-family:var(--font-display);
101 + font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;
102 + gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}
103 +.hbtn:hover{background:var(--lime)}
104 +.hbtn:active{transform:translate(2px,2px);box-shadow:none}
105 +.hbtn.primary{background:var(--ink);color:var(--lime)}
106 +.hbtn.primary:hover{background:var(--green-deep)}
107 +@media(max-width:760px){.header .hbtn{display:none}}
108 +
109 +/* ===== héros ===== */
110 +.hero{padding:54px 0 26px}
111 +@media(max-width:640px){.hero{padding:30px 0 12px}}
112 +.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;
113 + text-transform:uppercase}
114 +.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}
115 +.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}
116 +.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
117 + padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;
118 + text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);
119 + display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}
120 +.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}
121 +.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}
122 +@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}
123 +.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
124 + padding:16px;box-shadow:var(--shadow-off-soft)}
125 +.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);
126 + background:var(--lime);color:var(--ink);display:flex;align-items:center;
127 + justify-content:center;margin-bottom:10px;font-size:15px}
128 +.adv:nth-child(2n) .ico{background:var(--amber-soft)}
129 +.adv h3{font-size:14px;margin-bottom:3px}
130 +.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}
131 +
132 +/* ===== recherche + filtres ===== */
133 +.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);
134 + backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
135 + padding:12px 0;border-bottom:1.5px solid var(--ink)}
136 +.search-wrap{display:flex;gap:10px;align-items:center}
137 +.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);
138 + border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;
139 + transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}
140 +.search-input:focus-within{box-shadow:3px 3px 0 rgba(91,52,168,.3)}
141 +.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}
142 +.search-input svg{flex:none;color:var(--ink-3)}
143 +.filters{display:flex;gap:8px;flex-wrap:wrap}
144 +select.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);
145 + background:var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 12px center;
146 + border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);
147 + font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;
148 + box-shadow:3px 3px 0 rgba(20,24,20,.08)}
149 +select.f:focus{outline:2px solid var(--green);outline-offset:1px}
150 +select.f.active{background-color:var(--lime);color:var(--ink)}
151 +@media(max-width:760px){
152 + .search-wrap{flex-wrap:wrap}
153 + .search-input{flex-basis:100%}
154 + .search-input input{font-size:16px}
155 + .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;
156 + margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}
157 + .filters::-webkit-scrollbar{display:none}
158 + select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}
159 +}
160 +
161 +/* ===== grille créateurs ===== */
162 +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}
163 +@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}
164 +@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}
165 +.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
166 + overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;
167 + display:flex;flex-direction:column}
168 +.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}
169 +.card-photo{position:relative;aspect-ratio:1/1;background:
170 + repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);
171 + display:flex;align-items:center;justify-content:center;overflow:hidden;
172 + border-bottom:1.5px solid var(--ink)}
173 +.card-photo .init{color:var(--lime);font-family:var(--font-display);font-weight:700;
174 + font-size:46px;letter-spacing:.02em}
175 +.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
176 +.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}
177 +.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);
178 + color:var(--lime);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
179 + padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;
180 + text-transform:uppercase;letter-spacing:.05em}
181 +.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);
182 + border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;
183 + font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}
184 +.tier.mega{background:var(--lime)}
185 +.tier.macro{background:var(--amber-soft)}
186 +.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}
187 +.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;
188 + letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}
189 +.vbadge{color:var(--green);flex:none;width:15px;height:15px}
190 +.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);
191 + text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}
192 +.chips{display:flex;gap:6px;flex-wrap:wrap}
193 +.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);
194 + padding:2px 10px;font-size:11px;font-weight:600;color:var(--ink-2);white-space:nowrap}
195 +.reach{font-size:12px;color:var(--ink-2)}
196 +.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}
197 +.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}
198 +.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);
199 + display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;
200 + box-shadow:2px 2px 0 rgba(20,24,20,.25)}
201 +.plat svg{width:15px;height:15px;fill:currentColor}
202 +.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}
203 +.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}
204 +.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}
205 +.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;
206 + letter-spacing:.08em;color:var(--ink-3);padding-top:18px}
207 +.count-line b{color:var(--ink)}
208 +
209 +/* ===== page créateur ===== */
210 +.cpage{padding:30px 0 60px}
211 +@media(max-width:640px){.cpage{padding:20px 0 40px}}
212 +.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);
213 + font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;
214 + color:var(--green);margin-bottom:20px}
215 +.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);
216 + border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;
217 + color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}
218 +@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}
219 +.cavatar{width:104px;height:104px;border-radius:var(--r-card);
220 + background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);
221 + color:var(--lime);flex:none;display:flex;align-items:center;justify-content:center;
222 + font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;
223 + position:relative;border:2px solid var(--lime)}
224 +@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}
225 +.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
226 +.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);
227 + line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}
228 +.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--lime)}
229 +.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}
230 +.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);
231 + border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);
232 + font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}
233 +.mchip.o{background:var(--lime);border-color:var(--lime);color:var(--ink)}
234 +.cpage-reach{margin-left:auto;text-align:right}
235 +.cpage-reach b{display:block;font-family:var(--font-display);
236 + font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--lime);letter-spacing:-.02em}
237 +.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);
238 + text-transform:uppercase;letter-spacing:.08em;font-weight:700}
239 +@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}
240 +.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}
241 +.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;
242 + background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
243 + padding:13px 17px;box-shadow:var(--shadow-off-soft)}
244 +.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}
245 +.acc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}
246 +@media(max-width:640px){.acc-grid{grid-template-columns:1fr}}
247 +.acc{display:flex;align-items:center;gap:13px;background:var(--surface);
248 + border:1.5px solid var(--ink);border-radius:var(--r-card);padding:14px 16px;
249 + box-shadow:var(--shadow-off-soft);transition:transform .16s ease,box-shadow .16s ease;min-width:0}
250 +.acc:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}
251 +.acc .plat{width:44px;height:44px;border-radius:9px;flex:none}
252 +.acc .plat svg{width:20px;height:20px}
253 +.acc-mid{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px;overflow:hidden}
254 +.acc-name{font-family:var(--font-display);font-weight:700;font-size:14px;display:flex;align-items:center;gap:6px}
255 +.acc-handle{font-size:12px;color:var(--ink-3);overflow:hidden;text-overflow:ellipsis;
256 + white-space:nowrap;display:block;max-width:100%}
257 +.acc-right{margin-left:auto;text-align:right;flex:none;padding-left:8px}
258 +.acc-right b{display:block;font-family:var(--font-display);font-size:16.5px;color:var(--ink);font-weight:700}
259 +.acc-right span{font-family:var(--font-mono);font-size:9px;color:var(--ink-3);
260 + text-transform:uppercase;letter-spacing:.06em;font-weight:700}
261 +.conf{display:inline-flex;padding:1px 7px;border-radius:var(--r-ctl);
262 + font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.03em}
263 +.conf.hi{background:var(--lime-soft);color:var(--green-deep)}
264 +.conf.mid{background:var(--amber-soft);color:#8a5a12}
265 +.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;
266 + background:var(--lime);color:var(--ink);border:1.5px solid var(--ink);
267 + border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);
268 + font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}
269 +.linkinbio:hover{background:var(--lime-soft)}
270 +.linkinbio:active{transform:translate(2px,2px);box-shadow:none}
271 +.linkinbio svg{width:16px;height:16px;fill:currentColor}
272 +.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);
273 + border-top:1.5px solid var(--ink);padding-top:16px}
274 +.cpage-foot a{color:var(--green);font-weight:600}
275 +
276 +/* ===== page stats ===== */
277 +.stats-page{padding:40px 0 70px}
278 +.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}
279 +.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
280 + padding:18px 20px;box-shadow:var(--shadow-off-soft)}
281 +.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);
282 + letter-spacing:-.03em;line-height:1.05;color:var(--ink)}
283 +.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;
284 + letter-spacing:.1em;color:var(--ink-3);margin-top:7px}
285 +.tile.hero-tile{background:var(--ink)}
286 +.tile.hero-tile .tile-v{color:var(--lime)}
287 +.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}
288 +.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
289 + box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}
290 +.viz-card h2{font-size:18px;text-transform:uppercase}
291 +.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;
292 + text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}
293 +.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}
294 +.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}
295 +.hrow .plat svg{width:13px;height:13px}
296 +.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);
297 + overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
298 +@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}
299 +.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;
300 + background:var(--surface-2);overflow:hidden}
301 +.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}
302 +.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;
303 + color:var(--ink);min-width:44px;text-align:right}
304 +
305 +/* ===== retrait ===== */
306 +.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}
307 +.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}
308 +.optout-page p{color:var(--ink-2)}
309 +.field{margin-top:16px}
310 +.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;
311 + text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}
312 +.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);
313 + padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);
314 + box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}
315 +.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;
316 + box-shadow:3px 3px 0 rgba(91,52,168,.25)}
317 +@media(max-width:900px){.field input,.field textarea{font-size:16px}}
318 +.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);
319 + border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;
320 + box-shadow:var(--shadow-off-soft)}
321 +.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}
322 +
323 +/* ===== footer + tab bar mobile (patron Lou-Ka) ===== */
324 +.footer{border-top:1.5px solid var(--ink);margin-top:40px;padding:32px 0 44px;background:var(--ink);color:var(--paper)}
325 +.footer-inner{display:flex;gap:26px;flex-wrap:wrap;align-items:flex-start;justify-content:space-between}
326 +.footer .brand{font-size:19px;color:var(--paper)}
327 +.footer .brand .ka{color:var(--ink)}
328 +.footer p{font-size:12px;color:rgba(245,243,238,.55);max-width:460px;margin:10px 0 0}
329 +.footer-links{display:flex;flex-direction:column;gap:9px;font-family:var(--font-mono);
330 + font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
331 + color:rgba(245,243,238,.8)}
332 +.footer-links a:hover{color:var(--lime)}
333 +.fam-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
334 +.fam{border:1px solid rgba(245,243,238,.3);border-radius:var(--r-ctl);padding:3px 10px;
335 + font-family:var(--font-mono);font-size:10px;font-weight:700;color:rgba(245,243,238,.7)}
336 +.fam:hover{background:var(--lime);color:var(--ink);border-color:var(--lime)}
337 +.tabbar{display:none}
338 +@media(max-width:760px){
339 + .tabbar{display:grid;grid-template-columns:repeat(4,1fr);
340 + position:fixed;left:0;right:0;bottom:0;z-index:85;
341 + background:rgba(245,243,238,.94);
342 + backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
343 + border-top:1.5px solid var(--ink);
344 + padding:6px 4px calc(6px + env(safe-area-inset-bottom))}
345 + .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;
346 + padding:5px 2px;min-height:48px;justify-content:center;
347 + font-family:var(--font-mono);font-size:9.5px;font-weight:700;
348 + text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);
349 + border-radius:var(--r-ctl);transition:color .12s ease}
350 + .tabbar a.active{color:var(--green)}
351 + .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;
352 + stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
353 + body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}
354 + .footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}
355 +}
356 +.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);
357 + border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}
358 +@keyframes sp{to{transform:rotate(360deg)}}
359 +</style>
360 +</head>
361 +<body>
362 +<div id="app"></div>
363 +<script>
364 +"use strict";
365 +/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait */
366 +const ICONS={"_author":"Simon-Pierre Boucher <contact@spboucher.ai>","_file":"frontend/src/icons.json","_desc":"Trac\u00e9s SVG des marques de plateformes (simple-icons v13, CC0) + glyphes maison (linkedin/site-web/autre)","instagram":"M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077","tiktok":"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z","youtube":"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z","twitch":"M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z","kick":"M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z","x":"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z","facebook":"M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z","snapchat":"M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z","substack":"M22.539 8.242H1.46V5.406h21.08v2.836zM1.46 10.812V24L12 18.11 22.54 24V10.812H1.46zM22.54 0H1.46v2.836h21.08V0z","patreon":"M22.957 7.21c-.004-3.064-2.391-5.576-5.191-6.482-3.478-1.125-8.064-.962-11.384.604C2.357 3.231 1.093 7.391 1.046 11.54c-.039 3.411.302 12.396 5.369 12.46 3.765.047 4.326-4.804 6.068-7.141 1.24-1.662 2.836-2.132 4.801-2.618 3.376-.836 5.678-3.501 5.673-7.031Z","onlyfans":"M24 4.003h-4.015c-3.45 0-5.3.197-6.748 1.957a7.996 7.996 0 1 0 2.103 9.211c3.182-.231 5.39-2.134 6.085-5.173 0 0-2.399.585-4.43 0 4.018-.777 6.333-3.037 7.005-5.995zM5.61 11.999A2.391 2.391 0 0 1 9.28 9.97a2.966 2.966 0 0 1 2.998-2.528h.008c-.92 1.778-1.407 3.352-1.998 5.263A2.392 2.392 0 0 1 5.61 12Zm2.386-7.996a7.996 7.996 0 1 0 7.996 7.996 7.996 7.996 0 0 0-7.996-7.996Zm0 10.394A2.399 2.399 0 1 1 10.395 12a2.396 2.396 0 0 1-2.399 2.398Z","threads":"M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z","podcast":"M5.34 0A5.328 5.328 0 000 5.34v13.32A5.328 5.328 0 005.34 24h13.32A5.328 5.328 0 0024 18.66V5.34A5.328 5.328 0 0018.66 0zm6.525 2.568c2.336 0 4.448.902 6.056 2.587 1.224 1.272 1.912 2.619 2.264 4.392.12.59.12 2.2.007 2.864a8.506 8.506 0 01-3.24 5.296c-.608.46-2.096 1.261-2.336 1.261-.088 0-.096-.091-.056-.46.072-.592.144-.715.48-.856.536-.224 1.448-.874 2.008-1.435a7.644 7.644 0 002.008-3.536c.208-.824.184-2.656-.048-3.504-.728-2.696-2.928-4.792-5.624-5.352-.784-.16-2.208-.16-3 0-2.728.56-4.984 2.76-5.672 5.528-.184.752-.184 2.584 0 3.336.456 1.832 1.64 3.512 3.192 4.512.304.2.672.408.824.472.336.144.408.264.472.856.04.36.03.464-.056.464-.056 0-.464-.176-.896-.384l-.04-.03c-2.472-1.216-4.056-3.274-4.632-6.012-.144-.706-.168-2.392-.03-3.04.36-1.74 1.048-3.1 2.192-4.304 1.648-1.737 3.768-2.656 6.128-2.656zm.134 2.81c.409.004.803.04 1.106.106 2.784.62 4.76 3.408 4.376 6.174-.152 1.114-.536 2.03-1.216 2.88-.336.43-1.152 1.15-1.296 1.15-.023 0-.048-.272-.048-.603v-.605l.416-.496c1.568-1.878 1.456-4.502-.256-6.224-.664-.67-1.432-1.064-2.424-1.246-.64-.118-.776-.118-1.448-.008-1.02.167-1.81.562-2.512 1.256-1.72 1.704-1.832 4.342-.264 6.222l.413.496v.608c0 .336-.027.608-.06.608-.03 0-.264-.16-.512-.36l-.034-.011c-.832-.664-1.568-1.842-1.872-2.997-.184-.698-.184-2.024.008-2.72.504-1.878 1.888-3.335 3.808-4.019.41-.145 1.133-.22 1.814-.211zm-.13 2.99c.31 0 .62.06.844.178.488.253.888.745 1.04 1.259.464 1.578-1.208 2.96-2.72 2.254h-.015c-.712-.331-1.096-.956-1.104-1.77 0-.733.408-1.371 1.112-1.745.224-.117.534-.176.844-.176zm-.011 4.728c.988-.004 1.706.349 1.97.97.198.464.124 1.932-.218 4.302-.232 1.656-.36 2.074-.68 2.356-.44.39-1.064.498-1.656.288h-.003c-.716-.257-.87-.605-1.164-2.644-.341-2.37-.416-3.838-.218-4.302.262-.616.974-.966 1.97-.97z","linktree":"m13.73635 5.85251 4.00467-4.11665 2.3248 2.3808-4.20064 4.00466h5.9085v3.30473h-5.9365l4.22865 4.10766-2.3248 2.3338L12.0005 12.099l-5.74052 5.76852-2.3248-2.3248 4.22864-4.10766h-5.9375V8.12132h5.9085L3.93417 4.11666l2.3248-2.3808 4.00468 4.11665V0h3.4727zm-3.4727 10.30614h3.4727V24h-3.4727z","linkedin":"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z","site-web":"M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm7.938 9h-3.243a15.6 15.6 0 00-1.4-4.653A8.03 8.03 0 0119.938 9zM12 2.05c.9 1.2 1.9 3.2 2.5 6.95h-5C10.1 5.25 11.1 3.25 12 2.05zM4.062 15A8.06 8.06 0 013.75 12c0-1.05.15-2.05.312-3h3.55A25 25 0 007.5 12c0 1.05.05 2.05.113 3zm.643 2h3.243c.35 1.8.85 3.35 1.4 4.653A8.03 8.03 0 014.705 17zm3.243-10H4.705a8.03 8.03 0 014.643-4.653A15.6 15.6 0 007.948 7zM12 21.95c-.9-1.2-1.9-3.2-2.5-6.95h5c-.6 3.75-1.6 5.75-2.5 6.95zM14.787 13H9.213A23 23 0 019.1 12c0-1.05.037-2.05.113-3h5.574c.076.95.113 1.95.113 3s-.037 2.05-.113 3zm.508 8.653c.55-1.303 1.05-2.853 1.4-4.653h3.243a8.03 8.03 0 01-4.643 4.653zM16.388 15c.062-.95.112-1.95.112-3s-.05-2.05-.112-3h3.55c.162.95.312 1.95.312 3s-.15 2.05-.312 3z","autre":"M10.59 13.41a1 1 0 010-1.41l2.83-2.83a3 3 0 114.24 4.24l-1.41 1.42a1 1 0 11-1.42-1.42l1.42-1.41a1 1 0 10-1.42-1.42L12 13.41a1 1 0 01-1.41 0zm2.82-2.82a1 1 0 010 1.41l-2.82 2.83a3 3 0 11-4.25-4.24l1.42-1.42a1 1 0 111.41 1.42L7.76 12a1 1 0 101.41 1.41L12 10.59a1 1 0 011.41 0z"};
367 +const $=s=>document.querySelector(s);
368 +const app=$("#app");
369 +const fmt=new Intl.NumberFormat("fr-CA",{notation:"compact",maximumFractionDigits:1});
370 +const fmtFull=new Intl.NumberFormat("fr-CA");
371 +const PLAT={
372 + instagram:{label:"Instagram",color:"#c13584",av:"instagram"},
373 + tiktok:{label:"TikTok",color:"#141814",av:"tiktok"},
374 + youtube:{label:"YouTube",color:"#e02424",av:"youtube"},
375 + twitch:{label:"Twitch",color:"#9146ff",av:"twitch"},
376 + kick:{label:"Kick",color:"#141814"},
377 + x:{label:"X (Twitter)",color:"#141814",av:"twitter"},
378 + facebook:{label:"Facebook",color:"#1877f2"},
379 + snapchat:{label:"Snapchat",color:"#d4a900"},
380 + substack:{label:"Substack",color:"#ff6719"},
381 + patreon:{label:"Patreon",color:"#f1465a"},
382 + onlyfans:{label:"OnlyFans",color:"#00aff0"},
383 + linkedin:{label:"LinkedIn",color:"#0a66c2"},
384 + threads:{label:"Threads",color:"#141814"},
385 + podcast:{label:"Balado",color:"#8940fa"},
386 + "site-web":{label:"Site web",color:"#5b34a8"},
387 + autre:{label:"Autre",color:"#8b928c"}};
388 +const NICHE_LBL={"humour":"Humour","mode":"Mode","beaute":"Beauté","lifestyle":"Lifestyle",
389 + "famille-parentalite":"Famille","cuisine":"Cuisine","gaming":"Gaming","tech":"Tech",
390 + "sport-fitness":"Sport & fitness","plein-air":"Plein air","voyage":"Voyage","musique":"Musique",
391 + "arts":"Arts","danse":"Danse","education":"Éducation","finance-affaires":"Finance & affaires",
392 + "sante-mieux-etre":"Santé & mieux-être","bouffe-resto":"Bouffe & resto",
393 + "actualite-opinion":"Actualité & opinion","autre":"Autre"};
394 +const TIER_LBL={nano:"Nano",micro:"Micro",macro:"Macro",mega:"Méga"};
395 +const state={q:"",niche:"",region:"",langue:"",plateforme:"",tier:"",sort:"reach",
396 + offset:0,items:[],total:0,stats:null,tax:null};
397 +
398 +const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
399 +const initials=n=>n.split(/\s+/).slice(0,2).map(w=>w[0]||"").join("").toUpperCase();
400 +async function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}
401 +function icon(p,extra){return `<svg class="icn" viewBox="0 0 24 24" aria-hidden="true"${extra||""}><path d="${ICONS[p]||ICONS.autre}"/></svg>`}
402 +const checkSvg=`<svg class="vbadge" viewBox="0 0 24 24" aria-label="vérifié"><path fill="currentColor" d="M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z"/><path fill="#f5f3ee" d="M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z"/></svg>`;
403 +
404 +/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,
405 + repli final = initiales sur motif encre/violet (déjà en dessous) */
406 +function avatarSources(c){
407 + const order=["instagram","tiktok","youtube","twitch","x"];
408 + const srcs=[];
409 + if(c.avatar_url)srcs.push(c.avatar_url);
410 + for(const p of order){
411 + const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);
412 + if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);
413 + }
414 + return srcs;
415 +}
416 +window.nextSrc=el=>{
417 + const srcs=JSON.parse(el.dataset.srcs||"[]");const i=+(el.dataset.i||0)+1;
418 + if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();
419 +};
420 +function photoImg(c){
421 + const srcs=avatarSources(c);
422 + if(!srcs.length)return"";
423 + return `<img loading="lazy" alt="${esc(c.display_name)}" src="${srcs[0]}"
424 + data-srcs='${esc(JSON.stringify(srcs))}' data-i="0" onerror="nextSrc(this)">`;
425 +}
426 +const brandSvg=`<svg width="30" height="30" viewBox="0 0 64 64"><rect width="64" height="64" rx="10" fill="#141814"/><circle cx="32" cy="23" r="9" fill="#c9a3ff"/><path d="M14 52c3-10 9-15 18-15s15 5 18 15" stroke="#c9a3ff" stroke-width="6" fill="none" stroke-linecap="round"/></svg>`;
427 +
428 +function header(){
429 + return `<header class="header"><div class="container header-inner">
430 + <a href="/" data-nav class="brand">${brandSvg}Créa<span class="ka">Ka</span></a>
431 + <span class="brand-tag">Tous les créateurs québécois.<br>Tous leurs liens.</span>
432 + <span class="header-spacer"></span>
433 + <a class="hbtn" href="/stats" data-nav>Statistiques</a>
434 + <a class="hbtn" href="/retrait" data-nav>Retrait</a>
435 + <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a>
436 + </div></header>`;
437 +}
438 +function tabbar(active){
439 + const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`;
440 + return `<nav class="tabbar">
441 + ${t("/","Annuaire",`<svg viewBox="0 0 24 24"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/></svg>`,"home")}
442 + ${t("/#annuaire","Recherche",`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>`,"search")}
443 + ${t("/stats","Stats",`<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>`,"stats")}
444 + ${t("/retrait","Retrait",`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>`,"optout")}
445 + </nav>`;
446 +}
447 +function footer(){
448 + return `<footer class="footer"><div class="container footer-inner">
449 + <div><span class="brand">${brandSvg}Créa<span class="ka" style="background:var(--lime)">Ka</span></span>
450 + <p>Annuaire public des créateurs de contenu québécois. Uniquement des profils publics
451 + et professionnels ; aucune donnée privée. Retrait simple et respecté (Loi 25).
452 + — Simon-Pierre Boucher</p>
453 + <div class="fam-row"><span class="klabel" style="color:rgba(245,243,238,.5);align-self:center">Groupe KA :</span>
454 + <a class="fam" href="https://www.groupe-ka.com" target="_blank" rel="noopener">KA ID</a>
455 + <a class="fam" href="https://www.lou-ka.com" target="_blank" rel="noopener">Lou-Ka</a>
456 + <a class="fam" href="https://www.immo-ka.com" target="_blank" rel="noopener">Immo-Ka</a>
457 + <a class="fam" href="https://www.auto-ka.com" target="_blank" rel="noopener">Auto-Ka</a>
458 + <a class="fam" href="https://www.food-ka.com" target="_blank" rel="noopener">Food-Ka</a>
459 + <a class="fam" href="https://www.fabri-ka.com" target="_blank" rel="noopener">Fabri-Ka</a></div></div>
460 + <div class="footer-links">
461 + <a href="/stats" data-nav>Statistiques</a>
462 + <a href="/retrait" data-nav>Demander un retrait</a>
463 + <a href="/api/stats" target="_blank" rel="noopener">API publique</a>
464 + </div>
465 + </div></footer>`;
466 +}
467 +
468 +/* ---------- annuaire ---------- */
469 +function platIcon(a){
470 + const p=PLAT[a.platform]||PLAT.autre;
471 + return `<a class="plat" href="${esc(a.url)}" target="_blank" rel="noopener"
472 + title="${p.label} — @${esc(a.handle)}" style="background:${p.color}"
473 + onclick="event.stopPropagation()">${icon(a.platform)}</a>`;
474 +}
475 +function cardHtml(c){
476 + const plats=(c.platforms||[]).slice(0,7).map(platIcon).join("");
477 + const niches=(c.niches||[]).slice(0,2).map(n=>`<span class="chip">${NICHE_LBL[n]||n}</span>`).join("");
478 + const verified=(c.platforms||[]).some(a=>a.verified);
479 + const np=(c.platforms||[]).length;
480 + return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer">
481 + <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)}
482 + <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
483 + <span class="nplat">${np} plat.</span></div>
484 + <div class="card-body">
485 + <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div>
486 + <div class="card-sub">${c.region?`${esc(c.region)} · `:""}${(c.languages||[]).map(l=>({fr:"FR",en:"EN",bilingue:"FR/EN"}[l]||l)).join(", ")}</div>
487 + <div class="chips">${niches}</div>
488 + ${c.total_reach?`<div class="reach"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:""}
489 + <div class="platrow">${plats}</div></div></div>`;
490 +}
491 +function selectHtml(id,label,opts,val,lbls){
492 + const o=opts.map(v=>`<option value="${esc(v)}"${v===val?" selected":""}>${esc(lbls?(lbls[v]||v):v)}</option>`).join("");
493 + return `<select class="f${val?" active":""}" id="${id}"><option value="">${label}</option>${o}</select>`;
494 +}
495 +async function renderHome(){
496 + document.title="Créa-Ka — Tous les créateurs québécois. Tous leurs liens.";
497 + if(!state.tax)state.tax=await api("/api/taxonomies");
498 + if(!state.stats)state.stats=await api("/api/stats").catch(()=>null);
499 + const s=state.stats||{creators:0,accounts:0,by_platform:{}};
500 + app.innerHTML=header()+`
501 + <section class="hero"><div class="container">
502 + <span class="kicker">Annuaire des créateurs du Québec</span>
503 + <h1 style="margin-top:16px">Chaque créateur<br>québécois. <span class="outline-txt">Tous</span> ses <span class="hl">liens.</span></h1>
504 + <p class="lead">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,
505 + podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>
506 + <div class="stats-row">
507 + <span class="stat-pill"><b>${fmtFull.format(s.creators)}</b> créateurs</span>
508 + <span class="stat-pill"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>
509 + <span class="stat-pill"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>
510 + </div>
511 + <div class="advantages">
512 + <div class="adv"><div class="ico">${icon("autre")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>
513 + <div class="adv"><div class="ico"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>
514 + <div class="adv"><div class="ico">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>
515 + <div class="adv"><div class="ico">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>
516 + </div>
517 + </div></section>
518 + <div class="searchbar" id="annuaire"><div class="container search-wrap">
519 + <label class="search-input"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="M20 20l-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
520 + <input id="q" placeholder="Chercher un créateur…" value="${esc(state.q)}"></label>
521 + <div class="filters">
522 + ${selectHtml("f-niche","Niche",state.tax.niches,state.niche,NICHE_LBL)}
523 + ${selectHtml("f-region","Région",state.tax.regions,state.region)}
524 + ${selectHtml("f-plat","Plateforme",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}
525 + ${selectHtml("f-tier","Taille",state.tax.tiers,state.tier,TIER_LBL)}
526 + ${selectHtml("f-langue","Langue",state.tax.langues,state.langue,{fr:"Français",en:"Anglais",bilingue:"Bilingue"})}
527 + ${selectHtml("f-sort","Tri",["reach","nom","recent"],state.sort==="reach"?"":state.sort,{reach:"Audience",nom:"Nom A-Z",recent:"Récents"})}
528 + </div>
529 + </div></div>
530 + <main class="container"><div class="count-line" id="countline"></div>
531 + <div class="grid" id="grid"><div class="spin"></div></div>
532 + <div class="more-wrap" id="more"></div></main>`+footer()+tabbar(location.hash==="#annuaire"?"search":"home");
533 + bindNav();
534 + $("#q").addEventListener("input",deb(()=>{state.q=$("#q").value;reload()},300));
535 + [["f-niche","niche"],["f-region","region"],["f-plat","plateforme"],
536 + ["f-tier","tier"],["f-langue","langue"],["f-sort","sort"]].forEach(([id,key])=>{
537 + $("#"+id).addEventListener("change",e=>{state[key]=e.target.value||(key==="sort"?"reach":"");
538 + e.target.classList.toggle("active",!!e.target.value);reload()})});
539 + if(location.hash==="#annuaire"){const el=$("#annuaire");if(el)el.scrollIntoView()}
540 + await reload();
541 +}
542 +function deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}
543 +async function reload(more){
544 + if(!more){state.offset=0;state.items=[]}
545 + const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,
546 + langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,
547 + limit:"60",offset:String(state.offset)});
548 + const data=await api("/api/creators?"+p).catch(()=>({items:[],total:0}));
549 + state.items=state.items.concat(data.items);state.total=data.total;
550 + const grid=$("#grid");if(!grid)return;
551 + $("#countline").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?"s":""} trouvé${state.total>1?"s":""}`;
552 + grid.innerHTML=state.items.length?state.items.map(cardHtml).join(""):
553 + `<div class="empty">Aucun créateur ne correspond à ces filtres.</div>`;
554 + const moreDiv=$("#more");
555 + moreDiv.innerHTML=state.items.length<state.total?
556 + `<button class="btn btn-primary" id="loadmore">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:"";
557 + const lm=$("#loadmore");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};
558 + bindNav(grid);
559 +}
560 +
561 +/* ---------- page créateur ---------- */
562 +async function renderCreator(id){
563 + app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("");bindNav();
564 + let c;try{c=await api("/api/creators/"+encodeURIComponent(id))}
565 + catch(e){app.innerHTML=header()+`<div class="container"><div class="empty"><h2>Créateur introuvable</h2>
566 + <p style="margin-top:10px"><a href="/" data-nav style="color:var(--green);font-weight:600">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar("");bindNav();return}
567 + document.title=`${c.display_name} — Créa-Ka`;
568 + const verified=(c.platforms||[]).some(a=>a.verified);
569 + const niches=(c.niches||[]).map(n=>`<span class="mchip">${NICHE_LBL[n]||n}</span>`).join("");
570 + const M_LBL={following:"abonnements",posts:"publications",likes:"j'aime",
571 + views:"vues",videos:"vidéos",episodes:"épisodes"};
572 + const accs=(c.platforms||[]).map(a=>{const p=PLAT[a.platform]||PLAT.autre;
573 + const conf=(a.confidence||0)>=0.75?["hi","confiance élevée"]:["mid","à confirmer"];
574 + const mx=Object.entries(a.metrics||{}).filter(([k,v])=>M_LBL[k]&&v!=null)
575 + .map(([k,v])=>`${fmt.format(v)} ${M_LBL[k]}`).join(" · ");
576 + return `<a class="acc" href="${esc(a.url)}" target="_blank" rel="noopener">
577 + <span class="plat" style="background:${p.color}">${icon(a.platform)}</span>
578 + <span class="acc-mid"><span class="acc-name">${p.label}${a.verified?checkSvg:""}</span>
579 + <span class="acc-handle">@${esc(a.handle)}</span>
580 + <span class="acc-handle" style="overflow:visible"><span class="conf ${conf[0]}" title="Score de rattachement ${Math.round((a.confidence||0)*100)} %">${conf[1]}</span>${mx?` <span style="font-size:11px">${mx}</span>`:""}</span></span>
581 + <span class="acc-right">${a.followers?`<b>${fmt.format(a.followers)}</b><span>abonnés</span>`:`<span style="font-size:11px">voir le profil ↗</span>`}</span></a>`}).join("");
582 + const checked=(c.platforms||[]).map(a=>a.last_checked).filter(Boolean).sort().pop();
583 + app.innerHTML=header()+`
584 + <div class="container cpage">
585 + <a class="backlink" href="/" data-nav>← Tous les créateurs</a>
586 + <div class="cpage-head">
587 + <div class="cavatar">${esc(initials(c.display_name))}${photoImg(c)}</div>
588 + <div style="flex:1;min-width:200px"><h1>${esc(c.display_name)}${verified?checkSvg:""}</h1>
589 + <div class="meta"><span class="mchip o">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
590 + ${niches}${c.region?`<span class="mchip">${esc(c.region)}</span>`:""}
591 + ${(c.languages||[]).map(l=>`<span class="mchip">${{fr:"Français",en:"Anglais",bilingue:"Bilingue"}[l]||l}</span>`).join("")}</div></div>
592 + ${c.total_reach?`<div class="cpage-reach"><b>${fmt.format(c.total_reach)}</b><span>abonnés cumulés</span></div>`:""}
593 + </div>
594 + ${c.bio?`<p class="cpage-bio">${esc(c.bio)}</p>`:""}
595 + ${c.notes?`<div class="cpage-notes"><span class="klabel">Contexte public</span><br>${esc(c.notes)}</div>`:""}
596 + <h2 class="sect-title">Ses plateformes <span class="chip">${(c.platforms||[]).length}</span></h2>
597 + <div class="acc-grid">${accs}</div>
598 + ${c.link_in_bio_url?`<a class="linkinbio" href="${esc(c.link_in_bio_url)}" target="_blank" rel="noopener">${icon("linktree")} Sa page de liens officielle ↗</a>`:""}
599 + <div class="cpage-foot">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes.
600 + Audiences approximatives${checked?`, dernière vérification ${esc(checked.slice(0,10))}`:""}.
601 + Une erreur dans cette fiche ? <a href="/retrait" data-nav>Signaler / demander un retrait</a>.</div>
602 + </div>`+footer()+tabbar("");
603 + bindNav();window.scrollTo(0,0);
604 +}
605 +
606 +/* ---------- page stats ---------- */
607 +async function renderStats(){
608 + document.title="Statistiques — Créa-Ka";
609 + app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("stats");bindNav();
610 + const s=await api("/api/stats").catch(()=>null);
611 + if(!s){app.innerHTML=header()+`<div class="container empty">Statistiques indisponibles.</div>`+footer()+tabbar("stats");bindNav();return}
612 + const bars=(obj,lbls,useIcon)=>{const entries=Object.entries(obj).slice(0,12);
613 + const max=Math.max(...entries.map(e=>e[1]),1);
614 + return entries.map(([k,v])=>`<div class="hrow">
615 + ${useIcon?`<span class="plat" style="background:${(PLAT[k]||PLAT.autre).color}">${icon(k)}</span>`:""}
616 + <span class="hrow-label">${esc(lbls?(lbls[k]||k):k)}</span>
617 + <span class="hbar"><i style="width:${Math.max(2,Math.round(v/max*100))}%"></i></span>
618 + <span class="n">${fmtFull.format(v)}</span></div>`).join("")};
619 + app.innerHTML=header()+`
620 + <div class="container stats-page">
621 + <span class="kicker">Portrait de l'annuaire</span>
622 + <h1 style="font-size:clamp(28px,4.6vw,46px);margin:12px 0 4px;text-transform:uppercase">Les créateurs québécois <span class="hl">en chiffres.</span></h1>
623 + <div class="tiles">
624 + <div class="tile hero-tile"><div class="tile-v">${fmtFull.format(s.creators)}</div><div class="tile-k">Créateurs</div></div>
625 + <div class="tile"><div class="tile-v">${fmtFull.format(s.accounts)}</div><div class="tile-k">Comptes reliés</div></div>
626 + <div class="tile"><div class="tile-v">${Object.keys(s.by_platform||{}).length}</div><div class="tile-k">Plateformes</div></div>
627 + <div class="tile"><div class="tile-v">${(s.accounts/Math.max(1,s.creators)).toFixed(1)}</div><div class="tile-k">Comptes / créateur</div></div>
628 + </div>
629 + <div class="viz-card"><h2>Par plateforme</h2><div class="viz-sub">comptes publics reliés</div>
630 + ${bars(s.by_platform,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])),true)}</div>
631 + <div class="viz-card"><h2>Par niche</h2><div class="viz-sub">créateurs actifs</div>
632 + ${bars(s.by_niche,NICHE_LBL)}</div>
633 + <div class="viz-card"><h2>Par taille d'audience</h2><div class="viz-sub">basée sur la plateforme principale</div>
634 + ${bars(s.by_tier,TIER_LBL)}</div>
635 + ${Object.keys(s.by_region||{}).length?`<div class="viz-card"><h2>Par région déclarée</h2><div class="viz-sub">quand le créateur la rend publique</div>${bars(s.by_region)}</div>`:""}
636 + </div>`+footer()+tabbar("stats");
637 + bindNav();window.scrollTo(0,0);
638 +}
639 +
640 +/* ---------- retrait / opt-out ---------- */
641 +function renderOptout(){
642 + document.title="Retrait (opt-out) — Créa-Ka";
643 + app.innerHTML=header()+`
644 + <div class="container optout-page">
645 + <span class="kicker">Vie privée d'abord</span>
646 + <h1>Demander un <span class="hl">retrait</span></h1>
647 + <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?
648 + La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.
649 + Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>
650 + <form id="of">
651 + <div class="field"><label>Nom public / nom de scène</label><input id="o-name" maxlength="200" placeholder="ex. Prénom Nom"></div>
652 + <div class="field"><label>Un de vos comptes (plateforme:handle)</label><input id="o-acc" maxlength="200" placeholder="ex. instagram:monhandle"></div>
653 + <div class="field"><label>Raison (facultatif)</label><textarea id="o-reason" rows="3" maxlength="500"></textarea></div>
654 + <div class="field"><label>Courriel de contact (facultatif, pour confirmation)</label><input id="o-contact" maxlength="200" type="email"></div>
655 + <div class="field"><button class="btn btn-primary" type="submit">Masquer ma fiche</button></div>
656 + </form><div id="o-result"></div>
657 + </div>`+footer()+tabbar("optout");
658 + bindNav();
659 + $("#of").addEventListener("submit",async e=>{e.preventDefault();
660 + const body={name:$("#o-name").value.trim(),account:$("#o-acc").value.trim(),
661 + reason:$("#o-reason").value.trim(),contact:$("#o-contact").value.trim()};
662 + if(!body.name&&!body.account){$("#o-result").innerHTML=`<div class="optout-ok warn">Indiquez au moins un nom ou un compte.</div>`;return}
663 + try{const r=await fetch("/api/optout",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
664 + const j=await r.json();
665 + $("#o-result").innerHTML=`<div class="optout-ok">✓ ${esc(j.message||"Demande enregistrée.")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:""}</div>`;
666 + e.target.reset();}
667 + catch(err){$("#o-result").innerHTML=`<div class="optout-ok warn">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});
668 +}
669 +
670 +/* ---------- routeur ---------- */
671 +function bindNav(root){
672 + (root||document).querySelectorAll("[data-nav]").forEach(a=>{
673 + if(a._nav)return;a._nav=1;
674 + a.addEventListener("click",e=>{const href=a.getAttribute("href");
675 + e.preventDefault();history.pushState(null,"",href);route();
676 + if(href.endsWith("#annuaire")){const el=$("#annuaire");if(el){el.scrollIntoView();const q=$("#q");if(q)q.focus()}}})});
677 + (root||document).querySelectorAll("[data-href]").forEach(card=>{
678 + if(card._nav)return;card._nav=1;
679 + const go=()=>{history.pushState(null,"",card.dataset.href);route()};
680 + card.addEventListener("click",e=>{if(e.target.closest("a"))return;go()});
681 + card.addEventListener("keydown",e=>{if(e.key==="Enter")go()});
682 + });}
683 +function route(){
684 + const p=location.pathname;
685 + const m=p.match(/^\/createur\/([^/]+)$/);
686 + if(m)return renderCreator(decodeURIComponent(m[1]));
687 + if(p==="/retrait")return renderOptout();
688 + if(p==="/stats")return renderStats();
689 + return renderHome();
690 +}
691 +window.addEventListener("popstate",route);
692 +route();
693 +</script>
694 +</body>
695 +</html>
added frontend/src/icons.json +22 −0
@@ -0,0 +1,22 @@
1 +{
2 +"_author": "Simon-Pierre Boucher <contact@spboucher.ai>",
3 +"_file": "frontend/src/icons.json",
4 +"_desc": "Tracés SVG des marques de plateformes (simple-icons v13, CC0) + glyphes maison (linkedin/site-web/autre)",
5 +"instagram": "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077",
6 +"tiktok": "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
7 +"youtube": "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z",
8 +"twitch": "M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z",
9 +"kick": "M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z",
10 +"x": "M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
11 +"facebook": "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
12 +"snapchat": "M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z",
13 +"substack": "M22.539 8.242H1.46V5.406h21.08v2.836zM1.46 10.812V24L12 18.11 22.54 24V10.812H1.46zM22.54 0H1.46v2.836h21.08V0z",
14 +"patreon": "M22.957 7.21c-.004-3.064-2.391-5.576-5.191-6.482-3.478-1.125-8.064-.962-11.384.604C2.357 3.231 1.093 7.391 1.046 11.54c-.039 3.411.302 12.396 5.369 12.46 3.765.047 4.326-4.804 6.068-7.141 1.24-1.662 2.836-2.132 4.801-2.618 3.376-.836 5.678-3.501 5.673-7.031Z",
15 +"onlyfans": "M24 4.003h-4.015c-3.45 0-5.3.197-6.748 1.957a7.996 7.996 0 1 0 2.103 9.211c3.182-.231 5.39-2.134 6.085-5.173 0 0-2.399.585-4.43 0 4.018-.777 6.333-3.037 7.005-5.995zM5.61 11.999A2.391 2.391 0 0 1 9.28 9.97a2.966 2.966 0 0 1 2.998-2.528h.008c-.92 1.778-1.407 3.352-1.998 5.263A2.392 2.392 0 0 1 5.61 12Zm2.386-7.996a7.996 7.996 0 1 0 7.996 7.996 7.996 7.996 0 0 0-7.996-7.996Zm0 10.394A2.399 2.399 0 1 1 10.395 12a2.396 2.396 0 0 1-2.399 2.398Z",
16 +"threads": "M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z",
17 +"podcast": "M5.34 0A5.328 5.328 0 000 5.34v13.32A5.328 5.328 0 005.34 24h13.32A5.328 5.328 0 0024 18.66V5.34A5.328 5.328 0 0018.66 0zm6.525 2.568c2.336 0 4.448.902 6.056 2.587 1.224 1.272 1.912 2.619 2.264 4.392.12.59.12 2.2.007 2.864a8.506 8.506 0 01-3.24 5.296c-.608.46-2.096 1.261-2.336 1.261-.088 0-.096-.091-.056-.46.072-.592.144-.715.48-.856.536-.224 1.448-.874 2.008-1.435a7.644 7.644 0 002.008-3.536c.208-.824.184-2.656-.048-3.504-.728-2.696-2.928-4.792-5.624-5.352-.784-.16-2.208-.16-3 0-2.728.56-4.984 2.76-5.672 5.528-.184.752-.184 2.584 0 3.336.456 1.832 1.64 3.512 3.192 4.512.304.2.672.408.824.472.336.144.408.264.472.856.04.36.03.464-.056.464-.056 0-.464-.176-.896-.384l-.04-.03c-2.472-1.216-4.056-3.274-4.632-6.012-.144-.706-.168-2.392-.03-3.04.36-1.74 1.048-3.1 2.192-4.304 1.648-1.737 3.768-2.656 6.128-2.656zm.134 2.81c.409.004.803.04 1.106.106 2.784.62 4.76 3.408 4.376 6.174-.152 1.114-.536 2.03-1.216 2.88-.336.43-1.152 1.15-1.296 1.15-.023 0-.048-.272-.048-.603v-.605l.416-.496c1.568-1.878 1.456-4.502-.256-6.224-.664-.67-1.432-1.064-2.424-1.246-.64-.118-.776-.118-1.448-.008-1.02.167-1.81.562-2.512 1.256-1.72 1.704-1.832 4.342-.264 6.222l.413.496v.608c0 .336-.027.608-.06.608-.03 0-.264-.16-.512-.36l-.034-.011c-.832-.664-1.568-1.842-1.872-2.997-.184-.698-.184-2.024.008-2.72.504-1.878 1.888-3.335 3.808-4.019.41-.145 1.133-.22 1.814-.211zm-.13 2.99c.31 0 .62.06.844.178.488.253.888.745 1.04 1.259.464 1.578-1.208 2.96-2.72 2.254h-.015c-.712-.331-1.096-.956-1.104-1.77 0-.733.408-1.371 1.112-1.745.224-.117.534-.176.844-.176zm-.011 4.728c.988-.004 1.706.349 1.97.97.198.464.124 1.932-.218 4.302-.232 1.656-.36 2.074-.68 2.356-.44.39-1.064.498-1.656.288h-.003c-.716-.257-.87-.605-1.164-2.644-.341-2.37-.416-3.838-.218-4.302.262-.616.974-.966 1.97-.97z",
18 +"linktree": "m13.73635 5.85251 4.00467-4.11665 2.3248 2.3808-4.20064 4.00466h5.9085v3.30473h-5.9365l4.22865 4.10766-2.3248 2.3338L12.0005 12.099l-5.74052 5.76852-2.3248-2.3248 4.22864-4.10766h-5.9375V8.12132h5.9085L3.93417 4.11666l2.3248-2.3808 4.00468 4.11665V0h3.4727zm-3.4727 10.30614h3.4727V24h-3.4727z",
19 +"linkedin": "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z",
20 +"site-web": "M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm7.938 9h-3.243a15.6 15.6 0 00-1.4-4.653A8.03 8.03 0 0119.938 9zM12 2.05c.9 1.2 1.9 3.2 2.5 6.95h-5C10.1 5.25 11.1 3.25 12 2.05zM4.062 15A8.06 8.06 0 013.75 12c0-1.05.15-2.05.312-3h3.55A25 25 0 007.5 12c0 1.05.05 2.05.113 3zm.643 2h3.243c.35 1.8.85 3.35 1.4 4.653A8.03 8.03 0 014.705 17zm3.243-10H4.705a8.03 8.03 0 014.643-4.653A15.6 15.6 0 007.948 7zM12 21.95c-.9-1.2-1.9-3.2-2.5-6.95h5c-.6 3.75-1.6 5.75-2.5 6.95zM14.787 13H9.213A23 23 0 019.1 12c0-1.05.037-2.05.113-3h5.574c.076.95.113 1.95.113 3s-.037 2.05-.113 3zm.508 8.653c.55-1.303 1.05-2.853 1.4-4.653h3.243a8.03 8.03 0 01-4.643 4.653zM16.388 15c.062-.95.112-1.95.112-3s-.05-2.05-.112-3h3.55c.162.95.312 1.95.312 3s-.15 2.05-.312 3z",
21 +"autre": "M10.59 13.41a1 1 0 010-1.41l2.83-2.83a3 3 0 114.24 4.24l-1.41 1.42a1 1 0 11-1.42-1.42l1.42-1.41a1 1 0 10-1.42-1.42L12 13.41a1 1 0 01-1.41 0zm2.82-2.82a1 1 0 010 1.41l-2.82 2.83a3 3 0 11-4.25-4.24l1.42-1.42a1 1 0 111.41 1.42L7.76 12a1 1 0 101.41 1.41L12 10.59a1 1 0 011.41 0z"
22 +}
\ No newline at end of file
added frontend/src/index.template.html +695 −0
@@ -0,0 +1,695 @@
1 +<!--
2 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 + File: frontend/src/index.template.html
4 + Desc: Gabarit du frontend Créa-Ka — __ICONS__ est remplacé par les tracés
5 + SVG des marques (simple-icons, CC0) via scripts/build_frontend.py.
6 + Système de design « éditorial sharp » du Groupe KA (identité partagée
7 + avec groupe-ka.com, Vrai-Prix, ValoPlex : papier grainé #f5f3ee, encre
8 + #141814, bordures encre + ombres décalées dures, Space Grotesk / Inter
9 + / JetBrains Mono) — accent propre Créa-Ka : VIOLET ÉLECTRIQUE.
10 + Patrons mobiles calqués sur Lou-Ka (tab bar 4 onglets, filtres
11 + défilants, anti-zoom iOS). Routes : / · /createur/{id} · /stats · /retrait
12 +-->
13 +<!DOCTYPE html>
14 +<html lang="fr">
15 +<head>
16 +<meta charset="utf-8">
17 +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
18 +<title>Créa-Ka — Tous les créateurs québécois. Tous leurs liens.</title>
19 +<meta name="description" content="Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.">
20 +<meta name="theme-color" content="#141814">
21 +<link rel="preconnect" href="https://fonts.googleapis.com">
22 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
23 +<link rel="preconnect" href="https://unavatar.io">
24 +<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
25 +<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='10' fill='%23141814'/%3E%3Ccircle cx='32' cy='23' r='9' fill='%23c9a3ff'/%3E%3Cpath d='M14 52c3-10 9-15 18-15s15 5 18 15' stroke='%23c9a3ff' stroke-width='6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
26 +<style>
27 +/* système « éditorial sharp » Groupe KA — accent Créa-Ka : violet électrique.
28 + Convention du groupe : les NOMS de variables (--green/--lime) sont partagés
29 + entre les apps, seules les VALEURS changent. */
30 +:root{
31 + --paper:#f5f3ee; --surface:#fff; --surface-2:#faf9f5;
32 + --ink:#141814; --ink-2:#4d5551; --ink-3:#8b928c;
33 + --line:rgba(20,24,20,.14); --line-strong:rgba(20,24,20,.85);
34 + --green:#5b34a8; /* violet profond (primaire) */
35 + --green-deep:#3d2178;
36 + --lime:#c9a3ff; /* violet électrique (accent) */
37 + --lime-soft:#efe6ff;
38 + --amber:#e8a33d; --amber-soft:#fdf3e2;
39 + --danger:#b3423a;
40 + --r-card:10px; --r-ctl:6px; --r-pill:999px;
41 + --shadow-flat:0 1px 2px rgba(20,24,20,.05);
42 + --shadow-off:6px 6px 0 var(--ink);
43 + --shadow-off-soft:8px 8px 0 rgba(20,24,20,.08);
44 + --font-display:"Space Grotesk",system-ui,sans-serif;
45 + --font-body:"Inter",system-ui,sans-serif;
46 + --font-mono:"JetBrains Mono",ui-monospace,monospace;
47 +}
48 +*{box-sizing:border-box}
49 +html{scroll-behavior:smooth}
50 +body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--font-body);
51 + font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased;
52 + padding-bottom:env(safe-area-inset-bottom)}
53 +/* grain de film pleine page (signature du groupe) — aucun z-index sur body>* */
54 +body::before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.35;z-index:9999;
55 + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E")}
56 +body>*{position:relative}
57 +h1,h2,h3,h4{font-family:var(--font-display);letter-spacing:-.03em;margin:0}
58 +a{color:inherit;text-decoration:none} button{font-family:inherit;cursor:pointer} img{display:block}
59 +::selection{background:var(--lime);color:var(--ink)}
60 +:focus-visible{outline:2px solid var(--green);outline-offset:2px}
61 +.icn{width:1em;height:1em;display:block;fill:currentColor}
62 +.container{max-width:1240px;margin:0 auto;padding:0 24px}
63 +@media(max-width:640px){.container{padding:0 16px}}
64 +.kicker{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-mono);
65 + font-size:11.5px;font-weight:500;text-transform:uppercase;letter-spacing:.12em;color:var(--green)}
66 +.kicker::before{content:"";width:22px;height:2px;background:var(--green)}
67 +.klabel{font-family:var(--font-mono);font-size:10px;font-weight:700;
68 + text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3)}
69 +.hl{display:inline-block;background:var(--lime);border-radius:8px;padding:0 10px 2px;transform:rotate(-1deg)}
70 +.outline-txt{color:transparent;-webkit-text-stroke:2px var(--ink)}
71 +
72 +/* ---------- cartes & boutons (langage du groupe) ---------- */
73 +.gk-card{background:var(--surface);border:1.5px solid var(--ink);
74 + border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}
75 +.gk-hover{transition:transform .18s ease,box-shadow .18s ease}
76 +.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}
77 +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;
78 + padding:10px 20px;border:1.5px solid var(--ink);border-radius:var(--r-ctl);
79 + font-family:var(--font-display);font-size:14px;font-weight:700;cursor:pointer;
80 + transition:background .15s ease,transform .1s ease,box-shadow .1s ease}
81 +.btn:active{transform:translate(2px,2px);box-shadow:none!important}
82 +.btn-primary{background:var(--ink);color:var(--lime);box-shadow:4px 4px 0 rgba(20,24,20,.25)}
83 +.btn-primary:hover{background:var(--green-deep)}
84 +.btn-ghost{background:transparent;color:var(--ink)}
85 +.btn-ghost:hover{background:var(--lime)}
86 +
87 +/* ===== header ===== */
88 +.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);
89 + backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}
90 +.header-inner{display:flex;align-items:center;gap:16px;height:64px}
91 +.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;
92 + display:flex;align-items:center;line-height:1;color:var(--ink)}
93 +.brand svg{margin-right:11px;flex:none}
94 +.brand .ka{background:var(--lime);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}
95 +.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);
96 + line-height:1.5;text-transform:uppercase;letter-spacing:.08em}
97 +@media(max-width:900px){.brand-tag{display:none}}
98 +.header-spacer{flex:1}
99 +.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);
100 + border-radius:var(--r-ctl);padding:8px 16px;font-family:var(--font-display);
101 + font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;
102 + gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}
103 +.hbtn:hover{background:var(--lime)}
104 +.hbtn:active{transform:translate(2px,2px);box-shadow:none}
105 +.hbtn.primary{background:var(--ink);color:var(--lime)}
106 +.hbtn.primary:hover{background:var(--green-deep)}
107 +@media(max-width:760px){.header .hbtn{display:none}}
108 +
109 +/* ===== héros ===== */
110 +.hero{padding:54px 0 26px}
111 +@media(max-width:640px){.hero{padding:30px 0 12px}}
112 +.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;
113 + text-transform:uppercase}
114 +.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}
115 +.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}
116 +.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
117 + padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;
118 + text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);
119 + display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}
120 +.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}
121 +.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}
122 +@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}
123 +.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
124 + padding:16px;box-shadow:var(--shadow-off-soft)}
125 +.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);
126 + background:var(--lime);color:var(--ink);display:flex;align-items:center;
127 + justify-content:center;margin-bottom:10px;font-size:15px}
128 +.adv:nth-child(2n) .ico{background:var(--amber-soft)}
129 +.adv h3{font-size:14px;margin-bottom:3px}
130 +.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}
131 +
132 +/* ===== recherche + filtres ===== */
133 +.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);
134 + backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
135 + padding:12px 0;border-bottom:1.5px solid var(--ink)}
136 +.search-wrap{display:flex;gap:10px;align-items:center}
137 +.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);
138 + border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;
139 + transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}
140 +.search-input:focus-within{box-shadow:3px 3px 0 rgba(91,52,168,.3)}
141 +.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}
142 +.search-input svg{flex:none;color:var(--ink-3)}
143 +.filters{display:flex;gap:8px;flex-wrap:wrap}
144 +select.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);
145 + background:var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 12px center;
146 + border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);
147 + font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;
148 + box-shadow:3px 3px 0 rgba(20,24,20,.08)}
149 +select.f:focus{outline:2px solid var(--green);outline-offset:1px}
150 +select.f.active{background-color:var(--lime);color:var(--ink)}
151 +@media(max-width:760px){
152 + .search-wrap{flex-wrap:wrap}
153 + .search-input{flex-basis:100%}
154 + .search-input input{font-size:16px}
155 + .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;
156 + margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}
157 + .filters::-webkit-scrollbar{display:none}
158 + select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}
159 +}
160 +
161 +/* ===== grille créateurs ===== */
162 +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}
163 +@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}
164 +@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}
165 +.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
166 + overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;
167 + display:flex;flex-direction:column}
168 +.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}
169 +.card-photo{position:relative;aspect-ratio:1/1;background:
170 + repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);
171 + display:flex;align-items:center;justify-content:center;overflow:hidden;
172 + border-bottom:1.5px solid var(--ink)}
173 +.card-photo .init{color:var(--lime);font-family:var(--font-display);font-weight:700;
174 + font-size:46px;letter-spacing:.02em}
175 +.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
176 +.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}
177 +.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);
178 + color:var(--lime);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
179 + padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;
180 + text-transform:uppercase;letter-spacing:.05em}
181 +.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);
182 + border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;
183 + font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}
184 +.tier.mega{background:var(--lime)}
185 +.tier.macro{background:var(--amber-soft)}
186 +.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}
187 +.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;
188 + letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}
189 +.vbadge{color:var(--green);flex:none;width:15px;height:15px}
190 +.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);
191 + text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}
192 +.chips{display:flex;gap:6px;flex-wrap:wrap}
193 +.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);
194 + padding:2px 10px;font-size:11px;font-weight:600;color:var(--ink-2);white-space:nowrap}
195 +.reach{font-size:12px;color:var(--ink-2)}
196 +.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}
197 +.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}
198 +.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);
199 + display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;
200 + box-shadow:2px 2px 0 rgba(20,24,20,.25)}
201 +.plat svg{width:15px;height:15px;fill:currentColor}
202 +.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}
203 +.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}
204 +.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}
205 +.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;
206 + letter-spacing:.08em;color:var(--ink-3);padding-top:18px}
207 +.count-line b{color:var(--ink)}
208 +
209 +/* ===== page créateur ===== */
210 +.cpage{padding:30px 0 60px}
211 +@media(max-width:640px){.cpage{padding:20px 0 40px}}
212 +.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);
213 + font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;
214 + color:var(--green);margin-bottom:20px}
215 +.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);
216 + border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;
217 + color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}
218 +@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}
219 +.cavatar{width:104px;height:104px;border-radius:var(--r-card);
220 + background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);
221 + color:var(--lime);flex:none;display:flex;align-items:center;justify-content:center;
222 + font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;
223 + position:relative;border:2px solid var(--lime)}
224 +@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}
225 +.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
226 +.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);
227 + line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}
228 +.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--lime)}
229 +.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}
230 +.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);
231 + border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);
232 + font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}
233 +.mchip.o{background:var(--lime);border-color:var(--lime);color:var(--ink)}
234 +.cpage-reach{margin-left:auto;text-align:right}
235 +.cpage-reach b{display:block;font-family:var(--font-display);
236 + font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--lime);letter-spacing:-.02em}
237 +.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);
238 + text-transform:uppercase;letter-spacing:.08em;font-weight:700}
239 +@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}
240 +.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}
241 +.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;
242 + background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
243 + padding:13px 17px;box-shadow:var(--shadow-off-soft)}
244 +.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}
245 +.acc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}
246 +@media(max-width:640px){.acc-grid{grid-template-columns:1fr}}
247 +.acc{display:flex;align-items:center;gap:13px;background:var(--surface);
248 + border:1.5px solid var(--ink);border-radius:var(--r-card);padding:14px 16px;
249 + box-shadow:var(--shadow-off-soft);transition:transform .16s ease,box-shadow .16s ease;min-width:0}
250 +.acc:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}
251 +.acc .plat{width:44px;height:44px;border-radius:9px;flex:none}
252 +.acc .plat svg{width:20px;height:20px}
253 +.acc-mid{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px;overflow:hidden}
254 +.acc-name{font-family:var(--font-display);font-weight:700;font-size:14px;display:flex;align-items:center;gap:6px}
255 +.acc-handle{font-size:12px;color:var(--ink-3);overflow:hidden;text-overflow:ellipsis;
256 + white-space:nowrap;display:block;max-width:100%}
257 +.acc-right{margin-left:auto;text-align:right;flex:none;padding-left:8px}
258 +.acc-right b{display:block;font-family:var(--font-display);font-size:16.5px;color:var(--ink);font-weight:700}
259 +.acc-right span{font-family:var(--font-mono);font-size:9px;color:var(--ink-3);
260 + text-transform:uppercase;letter-spacing:.06em;font-weight:700}
261 +.conf{display:inline-flex;padding:1px 7px;border-radius:var(--r-ctl);
262 + font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.03em}
263 +.conf.hi{background:var(--lime-soft);color:var(--green-deep)}
264 +.conf.mid{background:var(--amber-soft);color:#8a5a12}
265 +.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;
266 + background:var(--lime);color:var(--ink);border:1.5px solid var(--ink);
267 + border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);
268 + font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}
269 +.linkinbio:hover{background:var(--lime-soft)}
270 +.linkinbio:active{transform:translate(2px,2px);box-shadow:none}
271 +.linkinbio svg{width:16px;height:16px;fill:currentColor}
272 +.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);
273 + border-top:1.5px solid var(--ink);padding-top:16px}
274 +.cpage-foot a{color:var(--green);font-weight:600}
275 +
276 +/* ===== page stats ===== */
277 +.stats-page{padding:40px 0 70px}
278 +.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}
279 +.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
280 + padding:18px 20px;box-shadow:var(--shadow-off-soft)}
281 +.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);
282 + letter-spacing:-.03em;line-height:1.05;color:var(--ink)}
283 +.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;
284 + letter-spacing:.1em;color:var(--ink-3);margin-top:7px}
285 +.tile.hero-tile{background:var(--ink)}
286 +.tile.hero-tile .tile-v{color:var(--lime)}
287 +.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}
288 +.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
289 + box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}
290 +.viz-card h2{font-size:18px;text-transform:uppercase}
291 +.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;
292 + text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}
293 +.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}
294 +.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}
295 +.hrow .plat svg{width:13px;height:13px}
296 +.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);
297 + overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
298 +@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}
299 +.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;
300 + background:var(--surface-2);overflow:hidden}
301 +.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}
302 +.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;
303 + color:var(--ink);min-width:44px;text-align:right}
304 +
305 +/* ===== retrait ===== */
306 +.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}
307 +.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}
308 +.optout-page p{color:var(--ink-2)}
309 +.field{margin-top:16px}
310 +.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;
311 + text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}
312 +.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);
313 + padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);
314 + box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}
315 +.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;
316 + box-shadow:3px 3px 0 rgba(91,52,168,.25)}
317 +@media(max-width:900px){.field input,.field textarea{font-size:16px}}
318 +.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);
319 + border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;
320 + box-shadow:var(--shadow-off-soft)}
321 +.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}
322 +
323 +/* ===== footer + tab bar mobile (patron Lou-Ka) ===== */
324 +.footer{border-top:1.5px solid var(--ink);margin-top:40px;padding:32px 0 44px;background:var(--ink);color:var(--paper)}
325 +.footer-inner{display:flex;gap:26px;flex-wrap:wrap;align-items:flex-start;justify-content:space-between}
326 +.footer .brand{font-size:19px;color:var(--paper)}
327 +.footer .brand .ka{color:var(--ink)}
328 +.footer p{font-size:12px;color:rgba(245,243,238,.55);max-width:460px;margin:10px 0 0}
329 +.footer-links{display:flex;flex-direction:column;gap:9px;font-family:var(--font-mono);
330 + font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
331 + color:rgba(245,243,238,.8)}
332 +.footer-links a:hover{color:var(--lime)}
333 +.fam-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
334 +.fam{border:1px solid rgba(245,243,238,.3);border-radius:var(--r-ctl);padding:3px 10px;
335 + font-family:var(--font-mono);font-size:10px;font-weight:700;color:rgba(245,243,238,.7)}
336 +.fam:hover{background:var(--lime);color:var(--ink);border-color:var(--lime)}
337 +.tabbar{display:none}
338 +@media(max-width:760px){
339 + .tabbar{display:grid;grid-template-columns:repeat(4,1fr);
340 + position:fixed;left:0;right:0;bottom:0;z-index:85;
341 + background:rgba(245,243,238,.94);
342 + backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
343 + border-top:1.5px solid var(--ink);
344 + padding:6px 4px calc(6px + env(safe-area-inset-bottom))}
345 + .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;
346 + padding:5px 2px;min-height:48px;justify-content:center;
347 + font-family:var(--font-mono);font-size:9.5px;font-weight:700;
348 + text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);
349 + border-radius:var(--r-ctl);transition:color .12s ease}
350 + .tabbar a.active{color:var(--green)}
351 + .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;
352 + stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
353 + body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}
354 + .footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}
355 +}
356 +.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);
357 + border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}
358 +@keyframes sp{to{transform:rotate(360deg)}}
359 +</style>
360 +</head>
361 +<body>
362 +<div id="app"></div>
363 +<script>
364 +"use strict";
365 +/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait */
366 +const ICONS=__ICONS__;
367 +const $=s=>document.querySelector(s);
368 +const app=$("#app");
369 +const fmt=new Intl.NumberFormat("fr-CA",{notation:"compact",maximumFractionDigits:1});
370 +const fmtFull=new Intl.NumberFormat("fr-CA");
371 +const PLAT={
372 + instagram:{label:"Instagram",color:"#c13584",av:"instagram"},
373 + tiktok:{label:"TikTok",color:"#141814",av:"tiktok"},
374 + youtube:{label:"YouTube",color:"#e02424",av:"youtube"},
375 + twitch:{label:"Twitch",color:"#9146ff",av:"twitch"},
376 + kick:{label:"Kick",color:"#141814"},
377 + x:{label:"X (Twitter)",color:"#141814",av:"twitter"},
378 + facebook:{label:"Facebook",color:"#1877f2"},
379 + snapchat:{label:"Snapchat",color:"#d4a900"},
380 + substack:{label:"Substack",color:"#ff6719"},
381 + patreon:{label:"Patreon",color:"#f1465a"},
382 + onlyfans:{label:"OnlyFans",color:"#00aff0"},
383 + linkedin:{label:"LinkedIn",color:"#0a66c2"},
384 + threads:{label:"Threads",color:"#141814"},
385 + podcast:{label:"Balado",color:"#8940fa"},
386 + "site-web":{label:"Site web",color:"#5b34a8"},
387 + autre:{label:"Autre",color:"#8b928c"}};
388 +const NICHE_LBL={"humour":"Humour","mode":"Mode","beaute":"Beauté","lifestyle":"Lifestyle",
389 + "famille-parentalite":"Famille","cuisine":"Cuisine","gaming":"Gaming","tech":"Tech",
390 + "sport-fitness":"Sport & fitness","plein-air":"Plein air","voyage":"Voyage","musique":"Musique",
391 + "arts":"Arts","danse":"Danse","education":"Éducation","finance-affaires":"Finance & affaires",
392 + "sante-mieux-etre":"Santé & mieux-être","bouffe-resto":"Bouffe & resto",
393 + "actualite-opinion":"Actualité & opinion","autre":"Autre"};
394 +const TIER_LBL={nano:"Nano",micro:"Micro",macro:"Macro",mega:"Méga"};
395 +const state={q:"",niche:"",region:"",langue:"",plateforme:"",tier:"",sort:"reach",
396 + offset:0,items:[],total:0,stats:null,tax:null};
397 +
398 +const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
399 +const initials=n=>n.split(/\s+/).slice(0,2).map(w=>w[0]||"").join("").toUpperCase();
400 +async function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}
401 +function icon(p,extra){return `<svg class="icn" viewBox="0 0 24 24" aria-hidden="true"${extra||""}><path d="${ICONS[p]||ICONS.autre}"/></svg>`}
402 +const checkSvg=`<svg class="vbadge" viewBox="0 0 24 24" aria-label="vérifié"><path fill="currentColor" d="M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z"/><path fill="#f5f3ee" d="M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z"/></svg>`;
403 +
404 +/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,
405 + repli final = initiales sur motif encre/violet (déjà en dessous) */
406 +function avatarSources(c){
407 + const order=["instagram","tiktok","youtube","twitch","x"];
408 + const srcs=[];
409 + if(c.avatar_url)srcs.push(c.avatar_url);
410 + for(const p of order){
411 + const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);
412 + if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);
413 + }
414 + return srcs;
415 +}
416 +window.nextSrc=el=>{
417 + const srcs=JSON.parse(el.dataset.srcs||"[]");const i=+(el.dataset.i||0)+1;
418 + if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();
419 +};
420 +function photoImg(c){
421 + const srcs=avatarSources(c);
422 + if(!srcs.length)return"";
423 + return `<img loading="lazy" alt="${esc(c.display_name)}" src="${srcs[0]}"
424 + data-srcs='${esc(JSON.stringify(srcs))}' data-i="0" onerror="nextSrc(this)">`;
425 +}
426 +const brandSvg=`<svg width="30" height="30" viewBox="0 0 64 64"><rect width="64" height="64" rx="10" fill="#141814"/><circle cx="32" cy="23" r="9" fill="#c9a3ff"/><path d="M14 52c3-10 9-15 18-15s15 5 18 15" stroke="#c9a3ff" stroke-width="6" fill="none" stroke-linecap="round"/></svg>`;
427 +
428 +function header(){
429 + return `<header class="header"><div class="container header-inner">
430 + <a href="/" data-nav class="brand">${brandSvg}Créa<span class="ka">Ka</span></a>
431 + <span class="brand-tag">Tous les créateurs québécois.<br>Tous leurs liens.</span>
432 + <span class="header-spacer"></span>
433 + <a class="hbtn" href="/stats" data-nav>Statistiques</a>
434 + <a class="hbtn" href="/retrait" data-nav>Retrait</a>
435 + <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a>
436 + </div></header>`;
437 +}
438 +function tabbar(active){
439 + const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`;
440 + return `<nav class="tabbar">
441 + ${t("/","Annuaire",`<svg viewBox="0 0 24 24"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/></svg>`,"home")}
442 + ${t("/#annuaire","Recherche",`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>`,"search")}
443 + ${t("/stats","Stats",`<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>`,"stats")}
444 + ${t("/retrait","Retrait",`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>`,"optout")}
445 + </nav>`;
446 +}
447 +function footer(){
448 + return `<footer class="footer"><div class="container footer-inner">
449 + <div><span class="brand">${brandSvg}Créa<span class="ka" style="background:var(--lime)">Ka</span></span>
450 + <p>Annuaire public des créateurs de contenu québécois. Uniquement des profils publics
451 + et professionnels ; aucune donnée privée. Retrait simple et respecté (Loi 25).
452 + — Simon-Pierre Boucher</p>
453 + <div class="fam-row"><span class="klabel" style="color:rgba(245,243,238,.5);align-self:center">Groupe KA :</span>
454 + <a class="fam" href="https://www.groupe-ka.com" target="_blank" rel="noopener">KA ID</a>
455 + <a class="fam" href="https://www.lou-ka.com" target="_blank" rel="noopener">Lou-Ka</a>
456 + <a class="fam" href="https://www.immo-ka.com" target="_blank" rel="noopener">Immo-Ka</a>
457 + <a class="fam" href="https://www.auto-ka.com" target="_blank" rel="noopener">Auto-Ka</a>
458 + <a class="fam" href="https://www.food-ka.com" target="_blank" rel="noopener">Food-Ka</a>
459 + <a class="fam" href="https://www.fabri-ka.com" target="_blank" rel="noopener">Fabri-Ka</a></div></div>
460 + <div class="footer-links">
461 + <a href="/stats" data-nav>Statistiques</a>
462 + <a href="/retrait" data-nav>Demander un retrait</a>
463 + <a href="/api/stats" target="_blank" rel="noopener">API publique</a>
464 + </div>
465 + </div></footer>`;
466 +}
467 +
468 +/* ---------- annuaire ---------- */
469 +function platIcon(a){
470 + const p=PLAT[a.platform]||PLAT.autre;
471 + return `<a class="plat" href="${esc(a.url)}" target="_blank" rel="noopener"
472 + title="${p.label} — @${esc(a.handle)}" style="background:${p.color}"
473 + onclick="event.stopPropagation()">${icon(a.platform)}</a>`;
474 +}
475 +function cardHtml(c){
476 + const plats=(c.platforms||[]).slice(0,7).map(platIcon).join("");
477 + const niches=(c.niches||[]).slice(0,2).map(n=>`<span class="chip">${NICHE_LBL[n]||n}</span>`).join("");
478 + const verified=(c.platforms||[]).some(a=>a.verified);
479 + const np=(c.platforms||[]).length;
480 + return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer">
481 + <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)}
482 + <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
483 + <span class="nplat">${np} plat.</span></div>
484 + <div class="card-body">
485 + <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div>
486 + <div class="card-sub">${c.region?`${esc(c.region)} · `:""}${(c.languages||[]).map(l=>({fr:"FR",en:"EN",bilingue:"FR/EN"}[l]||l)).join(", ")}</div>
487 + <div class="chips">${niches}</div>
488 + ${c.total_reach?`<div class="reach"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:""}
489 + <div class="platrow">${plats}</div></div></div>`;
490 +}
491 +function selectHtml(id,label,opts,val,lbls){
492 + const o=opts.map(v=>`<option value="${esc(v)}"${v===val?" selected":""}>${esc(lbls?(lbls[v]||v):v)}</option>`).join("");
493 + return `<select class="f${val?" active":""}" id="${id}"><option value="">${label}</option>${o}</select>`;
494 +}
495 +async function renderHome(){
496 + document.title="Créa-Ka — Tous les créateurs québécois. Tous leurs liens.";
497 + if(!state.tax)state.tax=await api("/api/taxonomies");
498 + if(!state.stats)state.stats=await api("/api/stats").catch(()=>null);
499 + const s=state.stats||{creators:0,accounts:0,by_platform:{}};
500 + app.innerHTML=header()+`
501 + <section class="hero"><div class="container">
502 + <span class="kicker">Annuaire des créateurs du Québec</span>
503 + <h1 style="margin-top:16px">Chaque créateur<br>québécois. <span class="outline-txt">Tous</span> ses <span class="hl">liens.</span></h1>
504 + <p class="lead">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,
505 + podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>
506 + <div class="stats-row">
507 + <span class="stat-pill"><b>${fmtFull.format(s.creators)}</b> créateurs</span>
508 + <span class="stat-pill"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>
509 + <span class="stat-pill"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>
510 + </div>
511 + <div class="advantages">
512 + <div class="adv"><div class="ico">${icon("autre")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>
513 + <div class="adv"><div class="ico"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>
514 + <div class="adv"><div class="ico">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>
515 + <div class="adv"><div class="ico">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>
516 + </div>
517 + </div></section>
518 + <div class="searchbar" id="annuaire"><div class="container search-wrap">
519 + <label class="search-input"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="M20 20l-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
520 + <input id="q" placeholder="Chercher un créateur…" value="${esc(state.q)}"></label>
521 + <div class="filters">
522 + ${selectHtml("f-niche","Niche",state.tax.niches,state.niche,NICHE_LBL)}
523 + ${selectHtml("f-region","Région",state.tax.regions,state.region)}
524 + ${selectHtml("f-plat","Plateforme",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}
525 + ${selectHtml("f-tier","Taille",state.tax.tiers,state.tier,TIER_LBL)}
526 + ${selectHtml("f-langue","Langue",state.tax.langues,state.langue,{fr:"Français",en:"Anglais",bilingue:"Bilingue"})}
527 + ${selectHtml("f-sort","Tri",["reach","nom","recent"],state.sort==="reach"?"":state.sort,{reach:"Audience",nom:"Nom A-Z",recent:"Récents"})}
528 + </div>
529 + </div></div>
530 + <main class="container"><div class="count-line" id="countline"></div>
531 + <div class="grid" id="grid"><div class="spin"></div></div>
532 + <div class="more-wrap" id="more"></div></main>`+footer()+tabbar(location.hash==="#annuaire"?"search":"home");
533 + bindNav();
534 + $("#q").addEventListener("input",deb(()=>{state.q=$("#q").value;reload()},300));
535 + [["f-niche","niche"],["f-region","region"],["f-plat","plateforme"],
536 + ["f-tier","tier"],["f-langue","langue"],["f-sort","sort"]].forEach(([id,key])=>{
537 + $("#"+id).addEventListener("change",e=>{state[key]=e.target.value||(key==="sort"?"reach":"");
538 + e.target.classList.toggle("active",!!e.target.value);reload()})});
539 + if(location.hash==="#annuaire"){const el=$("#annuaire");if(el)el.scrollIntoView()}
540 + await reload();
541 +}
542 +function deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}
543 +async function reload(more){
544 + if(!more){state.offset=0;state.items=[]}
545 + const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,
546 + langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,
547 + limit:"60",offset:String(state.offset)});
548 + const data=await api("/api/creators?"+p).catch(()=>({items:[],total:0}));
549 + state.items=state.items.concat(data.items);state.total=data.total;
550 + const grid=$("#grid");if(!grid)return;
551 + $("#countline").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?"s":""} trouvé${state.total>1?"s":""}`;
552 + grid.innerHTML=state.items.length?state.items.map(cardHtml).join(""):
553 + `<div class="empty">Aucun créateur ne correspond à ces filtres.</div>`;
554 + const moreDiv=$("#more");
555 + moreDiv.innerHTML=state.items.length<state.total?
556 + `<button class="btn btn-primary" id="loadmore">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:"";
557 + const lm=$("#loadmore");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};
558 + bindNav(grid);
559 +}
560 +
561 +/* ---------- page créateur ---------- */
562 +async function renderCreator(id){
563 + app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("");bindNav();
564 + let c;try{c=await api("/api/creators/"+encodeURIComponent(id))}
565 + catch(e){app.innerHTML=header()+`<div class="container"><div class="empty"><h2>Créateur introuvable</h2>
566 + <p style="margin-top:10px"><a href="/" data-nav style="color:var(--green);font-weight:600">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar("");bindNav();return}
567 + document.title=`${c.display_name} — Créa-Ka`;
568 + const verified=(c.platforms||[]).some(a=>a.verified);
569 + const niches=(c.niches||[]).map(n=>`<span class="mchip">${NICHE_LBL[n]||n}</span>`).join("");
570 + const M_LBL={following:"abonnements",posts:"publications",likes:"j'aime",
571 + views:"vues",videos:"vidéos",episodes:"épisodes"};
572 + const accs=(c.platforms||[]).map(a=>{const p=PLAT[a.platform]||PLAT.autre;
573 + const conf=(a.confidence||0)>=0.75?["hi","confiance élevée"]:["mid","à confirmer"];
574 + const mx=Object.entries(a.metrics||{}).filter(([k,v])=>M_LBL[k]&&v!=null)
575 + .map(([k,v])=>`${fmt.format(v)} ${M_LBL[k]}`).join(" · ");
576 + return `<a class="acc" href="${esc(a.url)}" target="_blank" rel="noopener">
577 + <span class="plat" style="background:${p.color}">${icon(a.platform)}</span>
578 + <span class="acc-mid"><span class="acc-name">${p.label}${a.verified?checkSvg:""}</span>
579 + <span class="acc-handle">@${esc(a.handle)}</span>
580 + <span class="acc-handle" style="overflow:visible"><span class="conf ${conf[0]}" title="Score de rattachement ${Math.round((a.confidence||0)*100)} %">${conf[1]}</span>${mx?` <span style="font-size:11px">${mx}</span>`:""}</span></span>
581 + <span class="acc-right">${a.followers?`<b>${fmt.format(a.followers)}</b><span>abonnés</span>`:`<span style="font-size:11px">voir le profil ↗</span>`}</span></a>`}).join("");
582 + const checked=(c.platforms||[]).map(a=>a.last_checked).filter(Boolean).sort().pop();
583 + app.innerHTML=header()+`
584 + <div class="container cpage">
585 + <a class="backlink" href="/" data-nav>← Tous les créateurs</a>
586 + <div class="cpage-head">
587 + <div class="cavatar">${esc(initials(c.display_name))}${photoImg(c)}</div>
588 + <div style="flex:1;min-width:200px"><h1>${esc(c.display_name)}${verified?checkSvg:""}</h1>
589 + <div class="meta"><span class="mchip o">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
590 + ${niches}${c.region?`<span class="mchip">${esc(c.region)}</span>`:""}
591 + ${(c.languages||[]).map(l=>`<span class="mchip">${{fr:"Français",en:"Anglais",bilingue:"Bilingue"}[l]||l}</span>`).join("")}</div></div>
592 + ${c.total_reach?`<div class="cpage-reach"><b>${fmt.format(c.total_reach)}</b><span>abonnés cumulés</span></div>`:""}
593 + </div>
594 + ${c.bio?`<p class="cpage-bio">${esc(c.bio)}</p>`:""}
595 + ${c.notes?`<div class="cpage-notes"><span class="klabel">Contexte public</span><br>${esc(c.notes)}</div>`:""}
596 + <h2 class="sect-title">Ses plateformes <span class="chip">${(c.platforms||[]).length}</span></h2>
597 + <div class="acc-grid">${accs}</div>
598 + ${c.link_in_bio_url?`<a class="linkinbio" href="${esc(c.link_in_bio_url)}" target="_blank" rel="noopener">${icon("linktree")} Sa page de liens officielle ↗</a>`:""}
599 + <div class="cpage-foot">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes.
600 + Audiences approximatives${checked?`, dernière vérification ${esc(checked.slice(0,10))}`:""}.
601 + Une erreur dans cette fiche ? <a href="/retrait" data-nav>Signaler / demander un retrait</a>.</div>
602 + </div>`+footer()+tabbar("");
603 + bindNav();window.scrollTo(0,0);
604 +}
605 +
606 +/* ---------- page stats ---------- */
607 +async function renderStats(){
608 + document.title="Statistiques — Créa-Ka";
609 + app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("stats");bindNav();
610 + const s=await api("/api/stats").catch(()=>null);
611 + if(!s){app.innerHTML=header()+`<div class="container empty">Statistiques indisponibles.</div>`+footer()+tabbar("stats");bindNav();return}
612 + const bars=(obj,lbls,useIcon)=>{const entries=Object.entries(obj).slice(0,12);
613 + const max=Math.max(...entries.map(e=>e[1]),1);
614 + return entries.map(([k,v])=>`<div class="hrow">
615 + ${useIcon?`<span class="plat" style="background:${(PLAT[k]||PLAT.autre).color}">${icon(k)}</span>`:""}
616 + <span class="hrow-label">${esc(lbls?(lbls[k]||k):k)}</span>
617 + <span class="hbar"><i style="width:${Math.max(2,Math.round(v/max*100))}%"></i></span>
618 + <span class="n">${fmtFull.format(v)}</span></div>`).join("")};
619 + app.innerHTML=header()+`
620 + <div class="container stats-page">
621 + <span class="kicker">Portrait de l'annuaire</span>
622 + <h1 style="font-size:clamp(28px,4.6vw,46px);margin:12px 0 4px;text-transform:uppercase">Les créateurs québécois <span class="hl">en chiffres.</span></h1>
623 + <div class="tiles">
624 + <div class="tile hero-tile"><div class="tile-v">${fmtFull.format(s.creators)}</div><div class="tile-k">Créateurs</div></div>
625 + <div class="tile"><div class="tile-v">${fmtFull.format(s.accounts)}</div><div class="tile-k">Comptes reliés</div></div>
626 + <div class="tile"><div class="tile-v">${Object.keys(s.by_platform||{}).length}</div><div class="tile-k">Plateformes</div></div>
627 + <div class="tile"><div class="tile-v">${(s.accounts/Math.max(1,s.creators)).toFixed(1)}</div><div class="tile-k">Comptes / créateur</div></div>
628 + </div>
629 + <div class="viz-card"><h2>Par plateforme</h2><div class="viz-sub">comptes publics reliés</div>
630 + ${bars(s.by_platform,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])),true)}</div>
631 + <div class="viz-card"><h2>Par niche</h2><div class="viz-sub">créateurs actifs</div>
632 + ${bars(s.by_niche,NICHE_LBL)}</div>
633 + <div class="viz-card"><h2>Par taille d'audience</h2><div class="viz-sub">basée sur la plateforme principale</div>
634 + ${bars(s.by_tier,TIER_LBL)}</div>
635 + ${Object.keys(s.by_region||{}).length?`<div class="viz-card"><h2>Par région déclarée</h2><div class="viz-sub">quand le créateur la rend publique</div>${bars(s.by_region)}</div>`:""}
636 + </div>`+footer()+tabbar("stats");
637 + bindNav();window.scrollTo(0,0);
638 +}
639 +
640 +/* ---------- retrait / opt-out ---------- */
641 +function renderOptout(){
642 + document.title="Retrait (opt-out) — Créa-Ka";
643 + app.innerHTML=header()+`
644 + <div class="container optout-page">
645 + <span class="kicker">Vie privée d'abord</span>
646 + <h1>Demander un <span class="hl">retrait</span></h1>
647 + <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?
648 + La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.
649 + Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>
650 + <form id="of">
651 + <div class="field"><label>Nom public / nom de scène</label><input id="o-name" maxlength="200" placeholder="ex. Prénom Nom"></div>
652 + <div class="field"><label>Un de vos comptes (plateforme:handle)</label><input id="o-acc" maxlength="200" placeholder="ex. instagram:monhandle"></div>
653 + <div class="field"><label>Raison (facultatif)</label><textarea id="o-reason" rows="3" maxlength="500"></textarea></div>
654 + <div class="field"><label>Courriel de contact (facultatif, pour confirmation)</label><input id="o-contact" maxlength="200" type="email"></div>
655 + <div class="field"><button class="btn btn-primary" type="submit">Masquer ma fiche</button></div>
656 + </form><div id="o-result"></div>
657 + </div>`+footer()+tabbar("optout");
658 + bindNav();
659 + $("#of").addEventListener("submit",async e=>{e.preventDefault();
660 + const body={name:$("#o-name").value.trim(),account:$("#o-acc").value.trim(),
661 + reason:$("#o-reason").value.trim(),contact:$("#o-contact").value.trim()};
662 + if(!body.name&&!body.account){$("#o-result").innerHTML=`<div class="optout-ok warn">Indiquez au moins un nom ou un compte.</div>`;return}
663 + try{const r=await fetch("/api/optout",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
664 + const j=await r.json();
665 + $("#o-result").innerHTML=`<div class="optout-ok">✓ ${esc(j.message||"Demande enregistrée.")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:""}</div>`;
666 + e.target.reset();}
667 + catch(err){$("#o-result").innerHTML=`<div class="optout-ok warn">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});
668 +}
669 +
670 +/* ---------- routeur ---------- */
671 +function bindNav(root){
672 + (root||document).querySelectorAll("[data-nav]").forEach(a=>{
673 + if(a._nav)return;a._nav=1;
674 + a.addEventListener("click",e=>{const href=a.getAttribute("href");
675 + e.preventDefault();history.pushState(null,"",href);route();
676 + if(href.endsWith("#annuaire")){const el=$("#annuaire");if(el){el.scrollIntoView();const q=$("#q");if(q)q.focus()}}})});
677 + (root||document).querySelectorAll("[data-href]").forEach(card=>{
678 + if(card._nav)return;card._nav=1;
679 + const go=()=>{history.pushState(null,"",card.dataset.href);route()};
680 + card.addEventListener("click",e=>{if(e.target.closest("a"))return;go()});
681 + card.addEventListener("keydown",e=>{if(e.key==="Enter")go()});
682 + });}
683 +function route(){
684 + const p=location.pathname;
685 + const m=p.match(/^\/createur\/([^/]+)$/);
686 + if(m)return renderCreator(decodeURIComponent(m[1]));
687 + if(p==="/retrait")return renderOptout();
688 + if(p==="/stats")return renderStats();
689 + return renderHome();
690 +}
691 +window.addEventListener("popstate",route);
692 +route();
693 +</script>
694 +</body>
695 +</html>
added requirements.txt +10 −0
@@ -0,0 +1,10 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: requirements.txt
4 +# Desc: Dépendances backend Créa-Ka
5 +# ==============================================================================
6 +fastapi>=0.110
7 +uvicorn>=0.29
8 +requests>=2.31
9 +pydantic>=2.5
10 +pytest>=8.0
added run.py +62 −0
@@ -0,0 +1,62 @@
1 +#!/usr/bin/env python3
2 +# ==============================================================================
3 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 +# File: run.py
5 +# Desc: Point d'entrée Créa-Ka — `sync`, `watch`, `serve` (calqué sur Lou-Ka)
6 +# ==============================================================================
7 +"""Utilisation :
8 + python run.py sync [source ...] # synchronise (découverte + enrichissement)
9 + python run.py watch [heures] # synchronise en boucle (défaut 24 h)
10 + python run.py serve [port] # démarre l'API + le frontend (défaut 8160)
11 + python run.py optout <nom|plateforme:handle> # retrait manuel immédiat
12 +"""
13 +from __future__ import annotations
14 +
15 +import os
16 +import sys
17 +from pathlib import Path
18 +
19 +# Charger .env (SCRAPFLY_KEY, FIRECRAWL_API_KEY, YOUTUBE_API_KEY…) sans dépendance
20 +_env = Path(__file__).parent / ".env"
21 +if _env.exists():
22 + for line in _env.read_text().splitlines():
23 + line = line.strip()
24 + if line and not line.startswith("#") and "=" in line:
25 + k, _, v = line.partition("=")
26 + os.environ.setdefault(k.strip(), v.strip())
27 +
28 +
29 +def main() -> None:
30 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
31 + if cmd == "sync":
32 + from creaka import ingest
33 + ingest.run(sys.argv[2:] or None)
34 + elif cmd == "watch":
35 + from creaka import ingest
36 + hours = float(sys.argv[2]) if len(sys.argv) > 2 else 24.0
37 + ingest.watch(int(hours * 3600))
38 + elif cmd == "optout":
39 + if len(sys.argv) < 3:
40 + print(__doc__)
41 + sys.exit(1)
42 + from creaka import db, ethics
43 + target = sys.argv[2]
44 + is_account = ":" in target
45 + ethics.add_optout(name=None if is_account else target,
46 + account=target if is_account else None,
47 + reason="retrait manuel (CLI)")
48 + n = db.apply_optout(db.connect(),
49 + name=None if is_account else target,
50 + account=target if is_account else None)
51 + print(f"[crea-ka] opt-out enregistré, {n} fiche(s) masquée(s)")
52 + elif cmd == "serve":
53 + import uvicorn
54 + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8160
55 + uvicorn.run("creaka.web:app", host="0.0.0.0", port=port)
56 + else:
57 + print(__doc__)
58 + sys.exit(1)
59 +
60 +
61 +if __name__ == "__main__":
62 + main()
added scripts/build_frontend.py +32 −0
@@ -0,0 +1,32 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: scripts/build_frontend.py
4 +# Desc: Construit frontend/dist/index.html à partir du gabarit en injectant
5 +# les tracés SVG des marques (simple-icons, licence CC0)
6 +# ==============================================================================
7 +"""Usage : python3 scripts/build_frontend.py
8 +
9 +Lit frontend/src/index.template.html + frontend/src/icons.json et produit
10 +frontend/dist/index.html (remplacement du marqueur __ICONS__)."""
11 +from __future__ import annotations
12 +
13 +import json
14 +from pathlib import Path
15 +
16 +ROOT = Path(__file__).resolve().parent.parent
17 +SRC = ROOT / "frontend" / "src"
18 +DIST = ROOT / "frontend" / "dist"
19 +
20 +
21 +def main() -> None:
22 + icons = json.loads((SRC / "icons.json").read_text(encoding="utf-8"))
23 + html = (SRC / "index.template.html").read_text(encoding="utf-8")
24 + html = html.replace("__ICONS__", json.dumps(icons, separators=(",", ":")))
25 + DIST.mkdir(parents=True, exist_ok=True)
26 + (DIST / "index.html").write_text(html, encoding="utf-8")
27 + print(f"[crea-ka] frontend construit → {DIST/'index.html'} "
28 + f"({len(html)//1024} Ko, {len(icons)} icônes)")
29 +
30 +
31 +if __name__ == "__main__":
32 + main()
added scripts/deploy-m3u96b.sh +43 −0
@@ -0,0 +1,43 @@
1 +#!/bin/zsh
2 +# ==============================================================================
3 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 +# File: scripts/deploy-m3u96b.sh
5 +# Desc: Déploiement/redéploiement de Créa-Ka sur le node M3U96b —
6 +# rsync + venv + PM2 (web :8160, sync watch 24 h, ngrok www.crea-ka.com)
7 +# ==============================================================================
8 +set -euo pipefail
9 +
10 +NODE=M3U96b
11 +# ⚠️ openrsync (macOS) : TOUJOURS un chemin distant ABSOLU — la forme
12 +# « hôte:chemin/relatif » est interprétée comme un chemin LOCAL (bug silencieux)
13 +APP_DIR=/Users/simon-pierreboucher/apps/crea-ka
14 +SRC="$(cd "$(dirname "$0")/.." && pwd)"
15 +PORT=8160
16 +DOMAIN=www.crea-ka.com
17 +
18 +echo "[deploy] rsync → $NODE:$APP_DIR"
19 +ssh "$NODE" "mkdir -p ~/apps/crea-ka"
20 +# .env et base de données du node préservés (--update pour la DB seulement au 1er envoi)
21 +rsync -az --delete \
22 + --exclude '.venv' --exclude '__pycache__' --exclude '.pytest_cache' \
23 + --exclude '.env' --exclude 'data/creaka.db*' \
24 + --exclude 'data/optout.json' --exclude 'data/optout_requests.jsonl' \
25 + "$SRC/" "$NODE:$APP_DIR/"
26 +
27 +echo "[deploy] venv + dépendances"
28 +ssh "$NODE" "cd ~/apps/crea-ka && \
29 + ([ -d .venv ] || python3 -m venv .venv) && \
30 + .venv/bin/pip -q install -r requirements.txt"
31 +
32 +echo "[deploy] PM2 (web :$PORT + sync + ngrok $DOMAIN)"
33 +ssh "$NODE" "cd ~/apps/crea-ka && \
34 + pm2 delete crea-ka-web crea-ka-sync crea-ka-ngrok 2>/dev/null; \
35 + pm2 start .venv/bin/python --name crea-ka-web --interpreter none --cwd ~/apps/crea-ka -- run.py serve $PORT && \
36 + PYTHONUNBUFFERED=1 pm2 start .venv/bin/python --name crea-ka-sync --interpreter none --cwd ~/apps/crea-ka -- run.py watch 24 && \
37 + pm2 start /opt/homebrew/bin/ngrok --name crea-ka-ngrok --interpreter none -- http --url=$DOMAIN $PORT && \
38 + pm2 save"
39 +
40 +echo "[deploy] healthcheck"
41 +sleep 4
42 +ssh "$NODE" "curl -sf localhost:$PORT/healthz" && echo " ← local OK"
43 +curl -sf "https://$DOMAIN/healthz" && echo " ← https://$DOMAIN OK"
added scripts/screenshots.sh +20 −0
@@ -0,0 +1,20 @@
1 +#!/bin/zsh
2 +# ==============================================================================
3 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 +# File: scripts/screenshots.sh
5 +# Desc: Captures d'écran du site en production pour le README
6 +# (Playwright CLI, Chromium headless) → docs/screenshots/
7 +# ==============================================================================
8 +set -euo pipefail
9 +BASE=${1:-https://www.crea-ka.com}
10 +OUT="$(cd "$(dirname "$0")/.." && pwd)/docs/screenshots"
11 +mkdir -p "$OUT"
12 +PW="npx -y playwright@1.55 screenshot --wait-for-timeout 6000"
13 +
14 +eval $PW --viewport-size=1440,900 "$BASE/" "$OUT/accueil.png"
15 +eval $PW --viewport-size=1440,900 --full-page "$BASE/stats" "$OUT/stats.png"
16 +eval $PW --viewport-size=390,844 "$BASE/" "$OUT/mobile.png"
17 +# page créateur : premier résultat de l'annuaire
18 +CID=$(curl -s "$BASE/api/creators?limit=1" | python3 -c "import json,sys;print(json.load(sys.stdin)['items'][0]['id'])")
19 +eval $PW --viewport-size=1440,900 "$BASE/createur/$CID" "$OUT/createur.png"
20 +echo "[crea-ka] captures → $OUT"
added tests/fixtures/linktree_sample.html +25 −0
@@ -0,0 +1,25 @@
1 +<!--
2 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 + File: tests/fixtures/linktree_sample.html
4 + Desc: Fixture figée — page Linktree publique simplifiée (structure Next.js
5 + réelle : liens dans __NEXT_DATA__ + ancres de repli)
6 +-->
7 +<!DOCTYPE html>
8 +<html><head><title>@creatrice-exemple | Linktree</title></head>
9 +<body>
10 +<div id="__next">
11 + <a href="https://www.instagram.com/creatrice.exemple/">Instagram</a>
12 + <a href="https://linktr.ee/discover">Découvrir Linktree</a>
13 +</div>
14 +<script id="__NEXT_DATA__" type="application/json">
15 +{"props":{"pageProps":{"account":{"username":"creatrice-exemple",
16 +"links":[
17 + {"id":1,"title":"Ma chaîne YouTube","url":"https://www.youtube.com/@CreatriceExemple"},
18 + {"id":2,"title":"TikTok","url":"https://www.tiktok.com/@creatrice.exemple"},
19 + {"id":3,"title":"Twitch","url":"https://twitch.tv/creatriceexemple"},
20 + {"id":4,"title":"Mon Patreon","url":"https://www.patreon.com/creatriceexemple"},
21 + {"id":5,"title":"Boutique","url":"https://boutique-exemple.com/produits"},
22 + {"id":6,"title":"Un reel","url":"https://www.instagram.com/reel/xyz123/"}
23 +],"socialLinks":[{"type":"x","url":"https://x.com/creatrice_ex"}]}}}}
24 +</script>
25 +</body></html>
added tests/test_ethics.py +76 −0
@@ -0,0 +1,76 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_ethics.py
4 +# Desc: Contrôle éthique automatisé (§18) — aucune donnée privée en base,
5 +# régime des mineurs, opt-out persistant et respecté
6 +# ==============================================================================
7 +import json
8 +
9 +from creaka import db, ethics
10 +from creaka.identity import account
11 +from creaka.schema import Creator
12 +
13 +
14 +def _creator(name, accounts, **kw):
15 + return Creator(source="test", external_id="", display_name=name,
16 + platforms=accounts, **kw).finalize()
17 +
18 +
19 +def test_scrub_donnees_privees():
20 + cr = _creator("Nom Public", [account("instagram", "nompublic", "listes_medias")],
21 + bio="Rejoins-moi au 514 555-1234 ou perso@gmail.com, "
22 + "123, rue Exemple app. 4")
23 + ethics.scrub(cr)
24 + assert "514" not in cr.bio
25 + assert "@gmail" not in cr.bio
26 + assert "rue Exemple" not in cr.bio
27 +
28 +
29 +def test_courriel_perso_jamais_conserve():
30 + cr = _creator("Nom Public", [account("instagram", "nompublic", "listes_medias")],
31 + business_contact="perso@hotmail.com")
32 + ethics.scrub(cr)
33 + assert cr.business_contact is None
34 + cr2 = _creator("Nom Public", [account("instagram", "nompublic", "listes_medias")],
35 + business_contact="booking@agence-exemple.ca")
36 + ethics.scrub(cr2)
37 + assert cr2.business_contact == "booking@agence-exemple.ca" # pro affiché → OK
38 +
39 +
40 +def test_regime_mineur():
41 + cr = _creator("Jeune Créateur", [account("tiktok", "jeune", "listes_medias")],
42 + is_minor=True, region="Montréal", city="Montréal",
43 + business_contact="pro@agence.ca")
44 + ethics.scrub(cr)
45 + assert cr.business_contact is None
46 + assert cr.region is None and cr.city is None
47 +
48 +
49 +def test_optout_persistant_et_exclu(tmp_path, monkeypatch):
50 + monkeypatch.setattr(ethics, "OPTOUT_PATH", tmp_path / "optout.json")
51 + monkeypatch.setattr(ethics, "OPTOUT_REQUESTS_PATH", tmp_path / "req.jsonl")
52 + con = db.connect(tmp_path / "test.db")
53 + cr = _creator("Créatrice Retrait",
54 + [account("instagram", "retrait.exemple", "listes_medias")])
55 + db.sync_source(con, "test", [cr])
56 + assert db.search(con, q="retrait")["total"] == 1
57 + # demande de retrait → masquée immédiatement
58 + ethics.add_optout(account="instagram:retrait.exemple", reason="test")
59 + assert db.apply_optout(con, account="instagram:retrait.exemple") == 1
60 + assert db.search(con, q="retrait")["total"] == 0
61 + # ré-agrégation future → JAMAIS réintégrée (liste de suppression, §15)
62 + cr2 = _creator("Créatrice Retrait",
63 + [account("instagram", "retrait.exemple", "link_in_bio")])
64 + stats = db.sync_source(con, "test", [cr2])
65 + assert stats["optout_skipped"] == 1
66 + assert db.search(con, q="retrait")["total"] == 0
67 + persisted = json.loads((tmp_path / "optout.json").read_text())
68 + assert "instagram:retrait.exemple" in persisted["accounts"]
69 +
70 +
71 +def test_mineurs_exclus_de_lannuaire_public(tmp_path):
72 + con = db.connect(tmp_path / "test2.db")
73 + cr = _creator("Jeune Vedette", [account("tiktok", "jeunevedette", "listes_medias")],
74 + is_minor=True)
75 + db.sync_source(con, "test", [cr])
76 + assert db.search(con)["total"] == 0 # prudence extrême §15
added tests/test_identity.py +63 −0
@@ -0,0 +1,63 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_identity.py
4 +# Desc: Tests de résolution d'identité & déduplication — cas multi-plateformes
5 +# + cas-pièges d'HOMONYMES à ne jamais fusionner (§12, §18)
6 +# ==============================================================================
7 +from creaka.dedup import dedupe, merge_creators, should_merge
8 +from creaka.identity import account, merge_accounts
9 +from creaka.schema import Creator
10 +
11 +
12 +def _creator(name, accounts, **kw):
13 + return Creator(source="test", external_id="", display_name=name,
14 + platforms=accounts, **kw).finalize()
15 +
16 +
17 +def test_homonymes_jamais_fusionnes():
18 + """Deux personnes au même nom SANS compte partagé → deux fiches (§12.2)."""
19 + a = _creator("Alex Tremblay", [account("instagram", "alex.gaming", "listes_medias")])
20 + b = _creator("Alex Tremblay", [account("instagram", "alexcuisine", "listes_medias")])
21 + assert not should_merge(a, b)
22 + assert len(dedupe([a, b])) == 2
23 +
24 +
25 +def test_fusion_sur_compte_fort_partage():
26 + a = _creator("Nom Scène", [account("instagram", "nomscene", "listes_medias"),
27 + account("youtube", "nomscene", "listes_medias")])
28 + b = _creator("Nom Scene", [account("instagram", "nomscene", "link_in_bio"),
29 + account("twitch", "nomscene_tv", "link_in_bio")])
30 + assert should_merge(a, b)
31 + merged = dedupe([a, b])
32 + assert len(merged) == 1
33 + keys = {acc.key for acc in merged[0].platforms}
34 + # union : on ne perd AUCUN lien vérifié
35 + assert keys == {"instagram:nomscene", "youtube:nomscene", "twitch:nomscene_tv"}
36 +
37 +
38 +def test_pas_de_fusion_sur_compte_faible():
39 + """Un compte partagé à confiance basse ne suffit pas (sous STRONG_THRESHOLD)."""
40 + a = _creator("Personne Un", [account("instagram", "commun", "mention"),
41 + account("youtube", "personneun", "listes_medias")])
42 + b = _creator("Personne Deux", [account("instagram", "commun", "mention"),
43 + account("tiktok", "personnedeux", "listes_medias")])
44 + assert not should_merge(a, b)
45 +
46 +
47 +def test_merge_accounts_meilleure_confiance():
48 + old = [account("instagram", "nom", "handle_match", followers=1000).finalize()]
49 + new = [account("instagram", "nom", "link_in_bio").finalize()]
50 + merged = merge_accounts(old, new)
51 + assert len(merged) == 1
52 + assert merged[0].confidence == 0.95 # link_in_bio gagne
53 + assert merged[0].followers == 1000 # métrique conservée
54 +
55 +
56 +def test_merge_creators_regime_mineur_et_optout():
57 + a = _creator("Jeune Talent", [account("tiktok", "jeune", "listes_medias")],
58 + region="Montréal", city="Montréal")
59 + b = _creator("Jeune Talent", [account("tiktok", "jeune", "link_in_bio")],
60 + is_minor=True)
61 + merged = merge_creators(a, b)
62 + assert merged.is_minor is True
63 + assert merged.region is None and merged.city is None # régime restreint §15
added tests/test_linkinbio.py +39 −0
@@ -0,0 +1,39 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_linkinbio.py
4 +# Desc: Tests du connecteur générique link-in-bio sur fixture réelle figée —
5 +# extraction des comptes avec signal fort (0.95)
6 +# ==============================================================================
7 +from pathlib import Path
8 +
9 +from creaka.connectors.linkinbio import extract_accounts, is_supported, parse_links
10 +
11 +FIXTURE = (Path(__file__).parent / "fixtures" / "linktree_sample.html").read_text()
12 +
13 +
14 +def test_is_supported():
15 + assert is_supported("https://linktr.ee/nom")
16 + assert is_supported("https://beacons.ai/nom")
17 + assert not is_supported("https://exemple.com/nom")
18 + assert not is_supported(None)
19 +
20 +
21 +def test_parse_links_next_data_et_ancres():
22 + links = parse_links(FIXTURE)
23 + assert "https://www.youtube.com/@CreatriceExemple" in links
24 + assert "https://www.instagram.com/creatrice.exemple" in links # ancre de repli
25 +
26 +
27 +def test_extract_accounts_signal_fort():
28 + accounts = extract_accounts(FIXTURE)
29 + by_platform = {a.platform: a for a in accounts}
30 + assert by_platform["youtube"].handle == "creatriceexemple"
31 + assert by_platform["tiktok"].handle == "creatrice.exemple"
32 + assert by_platform["twitch"].handle == "creatriceexemple"
33 + assert by_platform["patreon"].handle == "creatriceexemple"
34 + assert by_platform["x"].handle == "creatrice_ex"
35 + assert all(a.confidence == 0.95 for a in accounts) # signal link_in_bio
36 + assert all(a.signal == "link_in_bio" for a in accounts)
37 + # la boutique (site marchand) et le reel (page générique) ne sont PAS des comptes
38 + assert "site-web" not in by_platform or "boutique" not in \
39 + by_platform.get("site-web").handle
added tests/test_normalize.py +82 −0
@@ -0,0 +1,82 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_normalize.py
4 +# Desc: Tests de normalisation — handles, URLs canoniques, niches, régions,
5 +# compteurs d'abonnés, taille d'audience
6 +# ==============================================================================
7 +from creaka.normalize import (
8 + audience_tier, canonical_url, clean_handle, map_niche, map_region,
9 + parse_count, platform_from_url,
10 +)
11 +
12 +
13 +def test_clean_handle():
14 + assert clean_handle("@Enola.Bedard") == "enola.bedard"
15 + assert clean_handle("https://www.instagram.com/enola.bedard/") == "enola.bedard"
16 + assert clean_handle(" @Nom_Scene ") == "nom_scene"
17 + assert clean_handle(None) == ""
18 +
19 +
20 +def test_youtube_channel_id():
21 + # ID de chaîne : casse préservée + forme /channel/ (jamais @ucxxx minuscule)
22 + cid = "UCtH1VriJJmhYkGSbq_7Jhra"[:24]
23 + assert clean_handle(cid) == cid
24 + assert canonical_url("youtube", cid) == f"https://www.youtube.com/channel/{cid}"
25 +
26 +
27 +def test_canonical_url():
28 + assert canonical_url("instagram", "nom") == "https://www.instagram.com/nom/"
29 + assert canonical_url("tiktok", "@Nom") == "https://www.tiktok.com/@nom"
30 + assert canonical_url("substack", "infolettre") == "https://infolettre.substack.com"
31 + assert canonical_url("onlyfans", "nom") == "https://onlyfans.com/nom"
32 + # pas de forme canonique → repli sur l'URL source
33 + assert canonical_url("site-web", "x", fallback="https://exemple.com") == \
34 + "https://exemple.com"
35 +
36 +
37 +def test_platform_from_url():
38 + assert platform_from_url("https://www.tiktok.com/@nom") == ("tiktok", "nom")
39 + assert platform_from_url("https://twitter.com/nom") == ("x", "nom")
40 + assert platform_from_url("https://mono.substack.com") == ("substack", "mono")
41 + # pages génériques ≠ handles
42 + assert platform_from_url("https://www.instagram.com/p/abc123/") is None
43 + assert platform_from_url("https://www.youtube.com/watch?v=x") is None
44 + assert platform_from_url("https://exemple.com/page") is None
45 +
46 +
47 +def test_map_niche():
48 + assert map_niche("food") == "bouffe-resto"
49 + assert map_niche("Beauté") == "beaute"
50 + assert map_niche("fitness") == "sport-fitness"
51 + assert map_niche("entrepreneur") == "finance-affaires"
52 + assert map_niche("catégorie-inconnue") == "autre"
53 +
54 +
55 +def test_map_region_prudence():
56 + # « Québec » seul = la PROVINCE → jamais de région inférée (§7)
57 + assert map_region("Québec") == (None, None)
58 + assert map_region("Québec (ville)") == ("Capitale-Nationale", "Québec")
59 + assert map_region("Montréal, QC")[0] == "Montréal"
60 + assert map_region("Blainville, Québec")[0] == "Laurentides"
61 + assert map_region("Los Angeles (originaire du Québec)") == (None, None)
62 + assert map_region(None) == (None, None)
63 +
64 +
65 +def test_parse_count():
66 + assert parse_count("1,2 M") == 1_200_000
67 + assert parse_count("12.3k") == 12_300
68 + assert parse_count("4 567 abonnés") == 4_567
69 + assert parse_count("1,234,567") == 1_234_567
70 + assert parse_count(2600000) == 2_600_000
71 + assert parse_count(None) is None
72 + # un handle n'est JAMAIS un compteur (bug réel : « @chandrayoga-tw5bu » → 5 G)
73 + assert parse_count("@chandrayoga-tw5bu") is None
74 + assert parse_count("@nom123") is None
75 +
76 +
77 +def test_audience_tier():
78 + assert audience_tier(5_000) == "nano"
79 + assert audience_tier(50_000) == "micro"
80 + assert audience_tier(500_000) == "macro"
81 + assert audience_tier(2_000_000) == "mega"
82 + assert audience_tier(None) == "nano"
added tests/test_schema.py +50 −0
@@ -0,0 +1,50 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_schema.py
4 +# Desc: Validation de schéma (§18) — rejet des comptes sans url/confidence,
5 +# agrégats (tier, portée, plateforme principale)
6 +# ==============================================================================
7 +import pytest
8 +
9 +from creaka.identity import account
10 +from creaka.schema import Creator, PlatformAccount
11 +
12 +
13 +def test_compte_sans_confidence_rejete():
14 + with pytest.raises(ValueError):
15 + PlatformAccount(platform="instagram", handle="nom", url="x",
16 + confidence=None).finalize()
17 +
18 +
19 +def test_compte_sans_url_canonisable_rejete():
20 + with pytest.raises(ValueError):
21 + PlatformAccount(platform="site-web", handle="nom", url="",
22 + confidence=0.9).finalize()
23 +
24 +
25 +def test_creator_sans_compte_valide_rejete():
26 + with pytest.raises(ValueError):
27 + Creator(source="t", external_id="", display_name="Nom",
28 + platforms=[]).finalize()
29 +
30 +
31 +def test_agregats():
32 + cr = Creator(source="t", external_id="", display_name="Nom Scène", platforms=[
33 + account("instagram", "nom", "listes_medias", followers=250_000),
34 + account("tiktok", "nom", "listes_medias", followers=900_000),
35 + ]).finalize()
36 + assert cr.primary_platform == "tiktok" # plus grosse audience
37 + assert cr.total_reach == 1_150_000
38 + assert cr.audience_tier == "macro" # basée sur la principale (900k)
39 + assert cr.external_id == "nom-scene"
40 + assert cr.languages == ["fr"]
41 +
42 +
43 +def test_dedup_intra_fiche_meilleure_confiance():
44 + cr = Creator(source="t", external_id="", display_name="Nom", platforms=[
45 + account("instagram", "nom", "handle_match", followers=100),
46 + account("instagram", "@NOM", "link_in_bio"),
47 + ]).finalize()
48 + assert len(cr.platforms) == 1
49 + assert cr.platforms[0].confidence == 0.95
50 + assert cr.platforms[0].followers == 100
51