feat: initial commit
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 | +[](https://www.crea-ka.com) | |
| 13 | +[](https://www.crea-ka.com/stats) | |
| 14 | +[](https://www.crea-ka.com) | |
| 15 | + | |
| 16 | +[](https://www.python.org) | |
| 17 | +[](creaka/web.py) | |
| 18 | +[](frontend/src/index.template.html) | |
| 19 | +[](tests/) | |
| 20 | +[](creaka/connectors/) | |
| 21 | +[](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 | +|  |  | | |
| 38 | + | |
| 39 | +| Statistiques | Mobile (tab bar Lou·Ka) | | |
| 40 | +|---|---| | |
| 41 | +|  |  | | |
| 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®ion&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