SPB Git

spb/crea-ka

Public

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

feat: initial commit

Simon-Pierre Boucher committed (Aug 17, 2026) Browse files

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
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added creaka/connectors/youtube.py +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added creaka/connectors/youtube_recherche.py +281 −0
Large diff (281 lines) is not loaded by default to keep the page fast.Load diff
added creaka/db.py +362 −0
Large diff (362 lines) is not loaded by default to keep the page fast.Load diff
added creaka/dedup.py +93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added creaka/ethics.py +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added creaka/identity.py +81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added creaka/ingest.py +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added creaka/normalize.py +362 −0
Large diff (362 lines) is not loaded by default to keep the page fast.Load diff
added creaka/schema.py +191 −0
Large diff (191 lines) is not loaded by default to keep the page fast.Load diff
added creaka/web.py +130 −0
Large diff (130 lines) is not loaded by default to keep the page fast.Load diff
added data/optout.json +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added data/seed/qc_influenceurs.json +10732 −0
Large diff (10,733 lines) is not loaded by default to keep the page fast.Load diff
added data/sources.json +98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
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
Large diff (695 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/icons.json +22 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/index.template.html +695 −0
Large diff (695 lines) is not loaded by default to keep the page fast.Load diff
added requirements.txt +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added run.py +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added scripts/build_frontend.py +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added scripts/deploy-m3u96b.sh +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added scripts/screenshots.sh +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/linktree_sample.html +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_ethics.py +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_identity.py +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_linkinbio.py +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_normalize.py +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_schema.py +50 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme