MAJ le 15 juin 2026
Analyse de marché – Le cycliste sur Instagram (2/3)
Ce qui m’intéresse pour notre analyse de marché de Folk, ce n’est pas l’influenceur vélo mais le cycliste de masse. Celui qui consomme plus qu’il ne crée, et dont l’influence est confidentielle (moins de 1 000 followers).
Cette semaine, j’ai affiné la collecte. 560 profils pour 4 800 publications répartis en 13 clusters. En attendant l’analyse complète (Folk #7.3), je vous dévoile ma méthode.
Ma méthode pour trouver les cyclistes français sur Instagram
Théorie
Collecter de la donnée sur Internet, c’est trois opérations :
- Extraire. Scraper* la matière première — la donnée brute — à la source (ici Instagram) — Apify.
*système de bots qui chargent le code HTML d’une URL pour en extraire les données ciblées. - Connecter. Transformer et transporter la donnée d’une opération à une autre — n8n. Outre connecter, n8n pilote l’ensemble de la chaîne : il décide quand appeler les outils et leur donne les instructions. À l’instar de toute activité logistique, collecter des données c’est gérer des stocks et des flux — à la différence que le transport numérique est quasi instantané et au coût quasi nul… pour l’utilisateur ^^.
- Stocker. Stocker la matière transformée dans une base de données — Google Drive et Google Sheet.
Un bon pipeline est un pipeline fiable et efficient.
Fiable. Une erreur en amont a des répercussions en aval. n8n est fragile : un champ vide peut faire planter toute la chaîne. L’outil obéit à une logique andon avec des dispositifs de gestion d’erreurs semblables à des poka-yoke — n8n permet d’ajuster ces dispositifs en ignorant les erreurs (visible dans le parcours) ou en ajoutant des opérations de correction manuelles ou automatisées.
Efficient. Toute opération a un coût — en temps, en argent, en énergie. Il faut harmoniser ces trois variables sans tomber dans la suroptimisation. Un système performant est un système au triptyque qualité, productivité et efficacité bien équilibré.
Pour créer de bons pipelines, j’adopte quatre principes lean :
1. Miniaturiser. Je travaille par cellule — un petit paquet d’opérations bouclées une à une. Plutôt que de collecter 10 000 données d’un coup, je collecte par lots de 10 à 25. Si ça plante à la ligne 239 de la Google Sheet par exemple, je relance à partir de cette ligne — pas de zéro !
2. Éviter les goulots d’étranglement. L’API Google Sheets est limitée à 300 requêtes par minute. Si vous dépassez ce seuil, l’API vous bloque. Au-delà de 20 000 à 30 000 lignes dans un seul onglet, n8n met beaucoup de temps à récupérer les données et consomme énormément de mémoire RAM…ce qui peut faire planter votre serveur (selon votre config) Une opération trop volumineuse crée une file d’attente qui ralentit tout ce qui est en aval. De manière générale, un scénario usine à gaz fait perdre un temps considérable à la moindre erreur.
3. Éviter le gaspillage. Collecter 10 000 données ne sert à rien si vous ne pouvez pas les analyser correctement. La question clé est : quelle donnée a de la valeur ? Par « valeur », comprenez « utile » — exploitable pour l’analyse. Certaines données se révèlent inutiles… Mieux vaut le découvrir sur 25 données que sur 1 000 — d’où la miniaturisation.
4. Assurer l’adaptabilité. Je travaille d’abord en semi-automatique. Une fois le système fiabilisé, je désactive certaines interventions manuelles. Quand un système ne fonctionne pas, j’accepte de repartir de zéro – « kaikaku ». Le reste du temps je réalise des améliorations progressives par itération — « kaizen ». L’idée est de pouvoir facilement et rapidement changer un outil, répliquer un scénario, etc. La clé de l’adaptabilité, c’est la simplicité !
Mes bases de données jouent un double rôle : stockage et pilotage de flux. J’intègre directement dans chaque Google Sheet une logique de suivi en deux colonnes. La première contient pour chaque ligne : « 0 » (ne pas collecter) ou « 1 » (à collecter). La seconde contient : « True » (collecté) ou « False » (non collecté). Trois combinaisons possibles : 0+False, 1+False (début de scénario), 1+True (fin de scénario). Chaque élément est identifié par un ID unique — retrouvable dans n’importe quelle base. Cette logique facilite le filtrage par condition dans les scénarios n8n et évite de multiplier les bases de données. Ce sont des outils simples de contrôle qualité et de suivi — « karakuri kaizen ». L’huile moteur de tout le système.
Feuille 1 (collecte de profil à partir d’un hashtag)
| Row | Profil_ID | Nationalité | Private | data_scraping | data_scraped |
| 2 | X1 | FR | True | 0 | False |
| 3 | X2 | FR | False | 1 | True |
| 4 | X3 | FR | False | 1 | True |
| 5 | X4 | US | False | 0 | False |
| 6 | X5 | FR | False | 1 | True |
| 7 | X6 | FR | False | 1 | True |
Je cherche dans un premiers temps à récupérer les données de profils français dont l’accès est public.
J’applique donc le filtre suivant :
Si « Nationalité = FR + Private= False » Alors data_scraping = 1, sinon data_scraping = 0
Feuille 2 (collecte de 3 posts à partir d’un profil)
| Row | Profil_ID | Post_ID | Caption | data_scraping | data_scraped |
| 2 | X2 | Y2A | N/A | 0 | False |
| 3 | X2 | Y2B | « Aujourd’hui, je … » | 1 | True |
| 4 | X2 | Y2C | N/A | 0 | False |
| 5 | X3 | Y3A | « Test de la… » | 1 | True |
| 6 | X3 | Y3B | N/A | 0 | False |
| 7 | X3 | Y3C | N/A | 0 | False |
| 8 | X5 | Y5A | N/A | 0 | False |
| 9 | X5 | Y5B | « Je vous parle de… » | 1 | True |
| 10 | X5 | Y5C | N/A | 0 | False |
| 11 | X6 | Y6A | N/A | 0 | False |
| 12 | X6 | Y6B | N/A | 0 | False |
| 13 | X6 | Y6C | N/A | 0 | False |
Je cherche dans un second temps à récupérer les « données « Captions » (légende) des 3 derniers posts des profils sélectionnés précédemment. Je veux uniquement récupérer les cellules qui ne sont pas vides car inutiles pour l’analyse IA. J’applique donc le filtre suivant : Si « Caption ≠ N/A » Alors « data_scraping = 1 », sinon « data_scraping = 0 ».
Feuille 3 (Analyse IA à partir de données des posts)
| Row | Post_ID | Analyse_IA |
| 2 | Y2B | « Journal de bord de @X2… » |
| 3 | Y3A | « @X3 test une montre… » |
| 4 | Y5B | « @x5 donne ses conseils… » |
La feuille 3 contient les analyses IA qui serviront à l’analyse transversale des profils par une autre IA.
Choisissez la méthode qui vous convient, pour ma part j’applique la méthode PDCA — Plan, Do, Check, Act. C’est une méthode « vivante » dont le mouvement est celui d’une valse… chaotique.
Plan. Je simplifie cette phase au maximum — nous sommes avant tout dans l’opérationnel. J’identifie les données dont j’ai besoin et les contraintes qui s’appliquent.
Do. Je lance la collecte sur un petit échantillon — 5 à 10 lignes pour valider le pipeline avant de travailler sur de plus gros volumes.
Check. Je vérifie la qualité des données collectées. C’est à cette étape que je découvre les données inutiles, les champs vides, les biais de collecte, etc.
Act. J’ajuste le pipeline – collecte et analyse. Puis je recommence…Jusqu’à un certain seuil (point d’équilibre = efficience optimale). Ce que je collecte détermine ce que j’analyse. Et vice-versa.
En pratique
Phase 1 – Hashtags
Sur 102 tags collectés, j’en ai retenu 26 — deux par cluster*.
Ma sélection repose sur quatre critères :
- Langue. Afin de rester au plus près du cycliste français, j’ai privilégié les tags francophones (FR): #veloderoute, #cyclisme, #velotaf, #cyclotourisme, #velovintage, etc. Néanmoins Instagram communique majoritairement en anglais. J’ai donc gardé certains tags anglophones (EN) pertinents, et souvent sans équivalent français : #bmxlife, #gravel, #stravacycling.
- Volume. J’ai mixé des tags anglophones de masse et des tags français confidentiels – le cycliste français est très facilement identifiable sur ces tags. L’écart est parfois considérable (exemple : #cyclinglife cumule 16, 3M de publications, contre 6,7K pour #gravelfrance).
- Pertinence. Certains tags sont parasités par des communautés sans rapport avec le vélo. #bikelife est dominé par les motards. #vttdescente remonte du golf japonais. VAE fait référence aux Émirats Arabes Unis et au validation d’acquis en France. J’ai donc écarté ces tags. Cas intéressant : #steelbike. Ce tag, marqué par une forte communauté asiatique sur Instagram qui aurait pu m’amener à l’écarter, mais le « vélo en acier » est tendance selon Google – Google Trends indique un pic d’intérêt de 100 pour la recherche « steel bike » en avril 2026 par rapport aux cinq dernières années ! J’ai donc conservé ce tag.
- Technique. La phase suivante consiste à collecter les posts et profils associés à chacun des tags sélectionnés. J’ai donc limité la sélection afin de rendre la collecte gérable et à faible coût ^^.
- VTT
- Route
- Urbain
- Cyclotourisme
- Vintage
- Gravel
- BMX
- Médias
- Général
- Mécanique
- Electrique
- Lifestyle
- Compétition
| Outil | Usage | Coût unitaire | Coût total |
| Instagram Hashtag Analytics Scraper (Apify API) | 102 tags | 0$/tag | 0$ |
*Tout outil via API est facturé à l’usage (crédit payé en avance dans la plupart des cas). Les coûts indiqués sont en cas de succès pour les valeurs max paramétrées. Si l’outil retourne moins de résultats que demandé, le coût est proportionnellement plus faible. Les tarifs pour Apify sont liés à un abonnement « Starter » de 29$ de crédit /mois (Avril 2026).
Phase 2 – Profils d’influenceurs vélo français
À partir des 26 tags sélectionnés en Phase 1, j’ai collecté pour chacun les données de 100 posts, 30 top posts et jusqu’à 50 profils.
Je pensais y trouver directement des cyclistes français. Que nenni !
Le cycliste de masse français est quasi invisible sur les hashtags. Certains comptes dominent les tags sans pour autant remonter dans les profils collectés. D’autres gros comptes, pourtant actifs sur ces tags, n’apparaissent pas. Les « actors » d’Apify dédiés aux publications et profils se sont révélés peu fiables pour cet usage. Une solution serait d’ augmenter le volume de données à collecter mais cela augmente mécaniquement le coût opérationnel.
J’ai donc procédé autrement.
→ Mon idée : trouver les influenceurs vélo français et écouter leur audience pour trouver le cycliste français.
J’ai constitué cette liste à partir de cinq sources croisées :
- 100posts/ hashtag
↳ 25 reels + 75 posts (images et carroussels). - 30 top posts/hashtag
- 50profils/hashtag
- Surf direct sur Instagram.
↳ J’ai visité les feed d’influenceurs liés à des hashtags vélo qui n’apparaissent pas dans les données collectées. j’ai sélectionné parmi eux : Clara Pichon, Maxime Prieur, Guidon & Saucisson, et Marie Maheux. - Données Instagram des chaînes YouTube vélo analysées dans Folk #6.2.
↳ Pour ces dernières, j’ai d’abord identifié leur compte Instagram, collecté leurs métriques, puis comparé leur influence respective sur les deux plateformes — en ne retenant que les profils davantage « instagrameurs » que « youtubeurs ». J’ai gardé Vintage Queen Bike Repair, et DECATHLON CMA CGM TEAM.
Pour obtenir un échantillon varié de des profils d’influenceurs, j’ai respecté la logique des 13 clusters et intégré différentes catégories d’influence — du nano au macro. J’ai abouti à une trentaine de profils, que j’ai ensuite réduit à 18 après filtrage par « type » de profil – je souhaitais un mix de solopreneur, communauté, média, fabricant, etc.
L’échantillon est déséquilibré : le vélo de route (cluster B) représente 7 profils sur 18. Le VTT n’est présent qu’à travers Vélo Vert Magazine. Le vélo électrique, lui, est absent (en tant que thème principal du moins). C’est une erreur de ma part — le cyclisme est culturellement associé à la route, ce qui biaise mécaniquement les résultats.
J’ai tenté des corrections, notamment en relançant une collecte avec #VTTAE qui est à cheval entre le cluster K (électrique) et A (VTT). Le hic : seul @vttae.fr est intéressant dans les profils remontés, j’ai donc décidé de ne pas le sélectionner pour l’échantillon final. je n’ai pas réitéré avec un autre hashtag. Nous verrons que cela ne pose pas de problème majeur pour la suite : les clusters sont plus poreux qu’ils n’y paraissent, et le volume de données collectées est suffisant pour fonder l’analyse.
La liste d’influenceurs vélo sur Instagram :
| CLUSTER | USERNAME | FULLNAME | VOL. FOLLOWERS | CAT. INFLUENCEUR | VOL. POSTS | TYPE |
|---|---|---|---|---|---|---|
| E, J | @vintagequeenbikerepair | Vintage Queen Bike Repair | 61044 | micro | 1261 | Solopreneur (conseil, atelier, vente) |
| B, M | @astuce_cyclisme | Astuce cyclisme 🚴♂️ | 18204 | micro | 201 | Solopreneur (dev perso) |
| C | @velotafons | Vélotafons ! | 7275 | nano | 460 | Communauté |
| D | @bivouak.cc | Bivouak – la colo bikepacking ! | 19692 | micro | 416 | Communauté |
| A, H | @velovert | Vélo Vert Magazine | 12072 | micro | 606 | Media de niche |
| E, J | l@ovevelovintage | LOVÉ. | 30208 | micro | 1686 | Boutique (atelier & vente) |
| B, D, M | @victor_bouscavet | Victor Bouscavet | 27129 | micro | 100 | solopreneur (dev perso & influence) |
| D, H | @unmondeavelo | Un Monde À Vélo | 17203 | micro | 788 | Media de niche |
| L | @cycliste_tricolore | Sébastien Jarrot | 27161 | micro | 989 | Solo (non lucratif) |
| G, L | @valentincotot | Valentin COTOT | 7052 | nano | 568 | Solo (non lucratif) |
| B, M | @decathloncmacgmteam | DECATHLON CMA CGM TEAM | 308464 | macro | 7623 | Team professionnelle |
| I, H | @cafemanivelles | Café Manivelles | 10019 | micro | 247 | Media |
| B, L | @maxime_prieur_ | Maxime Prieur | 33376 | micro | 1030 | Solopreneur (dev perso, influence & média) |
| B | @guidon_et_saucisson | Guidon & Saucisson | 11528 | micro | 120 | Solopreneur (conseil & vente) |
| D, H | @mariemahx | Marie Maheux | 27537 | micro | 326 | Solopreneur (media) |
| E, F | @distance.bike | D I S T A N C E | 14582 | micro | 554 | Fabricant |
| L, G | @joanleh67 | Joan Lehmann | 41457 | micro | 858 | Solopreneur (influence & vente) |
| B, M, L | @clara.pichn | Clara Pichon | 360078 | macro | 527 | Solopreneur (influence & coaching) |
Note : les clusters utilisés ici sont plus précis que les 13 clusters originaux. La raison : certains influenceurs sont à cheval sur deux thématiques (ex : B,M pour Route & Compétition, E,J pour Vintage & Mécanique). Cet affinement m’a permis de mieux représenter la diversité des profils retenus.
| Outil | Usage | Coût unitaire | Coût total |
| Instagram Hashtag Scraper (Apify API) | 100 posts/tag × 26 tags | 0,23$/tag | 6$ |
| Instagram Hashtag Analytics Scraper (Apify API) | 30 top posts/tag × 26 tags | 0,01$/tag | 0,26$ |
| Instagram Search Scraper (Apify API) | 50 profils/tag × 26 tags | 0,11$/tag | 2,86$ |
| Instagram Profile Scraper (Apify API) | 62 chaînes YouTube + 30 influenceurs | 0,01$/profil | 1$ |
| Total | 10, 12$* |
*Tout outil via API est facturé à l’usage (crédit payé en avance dans la plupart des cas). Les coûts indiqués sont en cas de succès pour les valeurs max paramétrées. Si l’outil retourne moins de résultats que demandé, le coût est proportionnellement plus faible. Les tarifs pour Apify sont liés à un abonnement « Starter » de 29$ de crédit /mois (Avril 2026).
Phase 3 – Profils de cyclistes français
Il s’agit dans cette phase d’écouter l’audience des 18 influenceurs pour trouver les cyclistes français. mon intuition est de collecter des commentaires d’utilisateurs en réaction à des publications de ces influenceurs.
→ Collecte de 5 top posts par influenceur
Pour commencer, j’ai collecté 20 posts par influenceurs. Ensuite, j’ai sélectionné les 5 posts les plus pertinents pour la collecte de commentaires. J’ai réalisé cela via un petit code dans n8n, dont voici la logique de filtrage :
✕ reposts et mentions. Lors de la collecte par le scraper d’Apify, si la valeur « OwnerUsername » diffère du « Username » alors cela signifie que l’influenceur à posté ou reposté une publication d’un autre utilisateur.
✕ posts avec moins de 5 commentaires
✓ 5 posts avec le plus de commentaires. Score de « 2 » si le post dépasse les 50 commentaires, sinon « 1 » – par défaut « 0 ».
↳ j’avais prévu de différencier les scores 1 et 2 pour prioriser la collecte — les posts scorés 2 en premier. Je ne l’ai finalement pas appliqué.
Note : J’aurais pu affiner ce tri avec un ratio d’engagement (commentaires/likes × 100), mais le volume de commentaires est un critère suffisant pour notre collecte.
const items = $input.all();
const grouped = {};for (const item of items) {
const username = item.json.USERNAME;
const comments = parseInt(item.json.COMMENTS) || 0;
const postOwner = item.json.POST_OWNER;if (postOwner !== username) continue;
if (comments < 5) continue;if (!grouped[username]) grouped[username] = [];
grouped[username].push(item.json);
}const rows = [];
for (const username in grouped) {
const top5 = grouped[username]
.sort((a, b) => b.COMMENTS – a.COMMENTS)
.slice(0, 5);for (const post of top5) {
const score = parseInt(post.COMMENTS) >= 50 ? « 2 » : « 1 »;
rows.push({ …post, COMMENTS_SCRAPPING: score });
}
}return rows.map(row => ({ json: row }));
→ Collecte des profils de cyclistes
J’ai collecté jusqu’à 30 commentaires par post, soit environ 1 800 commentaires (5posts x18 influenceurs x30 commentaires). A ce stade, je n’ai pas de données précise sur l’utilisateur hormis son @username.
J’ai donc lancé une collecte de données des 1 615 profils sélectionnés après suppression des doublons. Le scraper que j’ai utilisé retourne pour chaque profil ses métriques – nom complet, bio, volume de followers, de posts, de follows, type de compte (personal, business), statut public/privé – ainsi que ses 12 derniers posts – soit 8 785 publications collectées !
Note : selon une logique lean, l’idéal aurait été de collecter les métriques d’abord, filtrer, puis collecter les posts uniquement pour les profils retenus. En pratique, le coût étant identique que je collecte ou non ces données directement , j’ai accepté un gaspillage de données.
→ Enrichissement des profils – Nationalité & genre
Sur les 1 614 profils collectés, j’ai d’abord identifié la nationalité via GPT-4o-mini (API). J’ai rédigé un prompt qui demande au modèle de déterminer le pays probable à partir des indices disponibles dans le profil : ville ou région dans la bio, langue de la bio, prénom et nom de famille, username. Le modèle produit un code pays ISO à deux lettres (N/A si aucun indice exploitable)
Résultat : 52% des profils ont été identifiés comme français (FR), soit 839 profils.
Tu es un expert en identification de nationalité à partir de profils Instagram.
Pour chaque profil, détermine le pays probable.
Réponds UNIQUEMENT en JSON valide, sans markdown, sans explication.
Format : [{« username »: « … », « nationalite »: « FR »}, …]
Codes pays ISO 2 lettres. « N/A » UNIQUEMENT si aucun indice exploitable.Règles par ordre de priorité :
- Ville/région dans la bio → pays correspondant
- Bio en français → FR, BE, CH ou CA (défaut FR)
- Prénom français typique (Pascal, Sylvain, Gauthier, Christophe, Laurent, Fabien…) → FR par défaut
- Nom de famille français typique (Marcos, Graglia, Aldunate, Mongelli…) → FR si prénom aussi français
- Username contenant des mots français (labicycletteperigourdine, bruno_dessinspeinturesetplus…) → FR
- Bio en anglais + indices francophones → CA possible
- N/A uniquement si prénom/nom/username sont tous ambigus ET bio vide ou incompréhensible
Sois assertif : un « Pascal Marcos » avec username français → FR, pas N/A.
{{ $json.profiles }}
Sur ces 839 profils français, j’ai identifié le genre via l’API de Genderize.io — un service qui estime la probabilité de genre à partir d’un prénom. Le prénom de l’utilisateur est extrait du FULLNAME nettoyé avec un petit code dans n8n avant envoi de la donnée à Genderize.io :
✕ underscores
✕ mots vides (le, la, de, du, etc.)
✓ premier mot significatif
const items = $input.all();
const STOP_WORDS = [« le », « la », « les », « de », « du », « des », « un », « une », « the », « a », « an », « l »];
const data = items.map(item => {
const fullName = (item.json.FULL_NAME || « »).trim();const cleaned = fullName.replace(/_/g, » « ).replace(/\s+/g, » « ).trim();
const parts = cleaned.split( » « );
const firstName = parts.find(p => !STOP_WORDS.includes(p.toLowerCase())) || parts[0];return {
username: item.json.USERNAME,
firstName: firstName
};
});const params = data.map(d =>
name[]=${encodeURIComponent(d.firstName)}).join(« & »);return [{ json: { query: params, data: data } }];
En sortie, un petit code interprète le résultat : « H » ou « F » si la probabilité dépasse 50%, sinon « N/A ».
Résultat : 73% d’hommes, 27% de femmes.
const items = $input.all();
const data = $(‘Code in JavaScript2’).first().json.data;const rows = [];
for (let i = 0; i < items.length; i++) {
const result = items[i].json;
const profile = data[i];let genre = « N/A »;
if (result.probability >= 0.50) {
if (result.gender === « male ») genre = « H »;
else if (result.gender === « female ») genre = « F »;
}rows.push({ json: {
USERNAME: profile?.username || « N/A »,
GENRE: genre,
PROBA_GENRE: result.probability ? Math.round(result.probability * 100) : « N/A »,
}});
}return rows;
Note : j’avais envisagé d’enrichir également avec l’âge via Agify.io. Les résultats se sont révélés trop peu fiables pour être exploitables. je l’ai donc abandonné, et vous déconseille cet outil.
→ Filtrage final
Sur les 839 profils français identifiés, j’ai appliqués deux filtres supplémentaires pour la suite de la collecte :
- Compte public uniquement. Seuls les publications des comptes publics peuvent être collectées apr des scrappers.
↳ 578profils via filtre : « is private = false » - Volume de publication supérieur à 0 — un profil sans post est unitile pour notre collecte^^.
↳ 559 profils via filtre : « posts > 0 »
Résultat : 559 profils de cyclistes français exploitables
| Outil | Usage | Coût unitaire | Coût total |
| Instagram Post Scraper (Apify API) | 20 posts × 18 influenceurs | 0,03$/influenceur | 0,54$ |
| Fast Instagram Comments Scraper (Apify API) | (30 commentaires × 5posts/influenceur) × 18 influenceurs | 0,02$/post | 1,80$ |
| Instagram Profile Scraper (Apify API) | 1 614 profils cyclistes | 0,0011$/profil | 1,80$ |
| GPT 4o-mini (Open AI API) | >1600 profils | 0, 0001$/profil | 0, 16$ |
| Total | 4, 3$* |
*Tout outil via API est facturé à l’usage (crédit payé en avance dans la plupart des cas). Les coûts indiqués sont en cas de succès pour les valeurs max paramétrées. Si l’outil retourne moins de résultats que demandé, le coût est proportionnellement plus faible. Les tarifs pour Apify sont liés à un abonnement « Starter » de 29$ de crédit /mois (Avril 2026).
Phase 4 (bonus) – Fiches de cyclistes français
A ce stade, nous avons collectés de la donnée brute : 559 profils cyclistes français pour 4 811 publications.
Analyser ces données manuellement est (très) fastidieux, je vais donc m’appuyer sur des outils IA. Pour obtenir des résultats fiables et de qualité tout en assurant une certaine efficience de l’IA (coût d’utilisation exprimé en tokens), il faut « mâcher » le travail à celle-ci : structurer et miniaturiser la donnée.
Mon idée : créer des sortes de fiches analytiques pour chaque cycliste afin de réaliser des analyses comparatives intra et inter cluster. Ces fiches sont issues de métriques et de premières analyses IA.
→ Mosaïques de posts
Instagram étant une plateforme visuelle, j’ai décidé de réaliser des mosaïques des 4811 posts des cyclistes français. A partir des display URLs collectées lors de la Phase 2, un script Python télécharge les miniatures et les assemble en planches de 50 images (1 post = 1 image, 1 utilisateur = 9images max). Résultat : 96 mosaïques au total — plusieurs planches par cluster selon le volume de posts.
Avant de lancer le script
Adaptez les variables en haut du fichier à votre cas :
INPUT_FILE— le nom de votre CSV doit contenir au minimum les colonnesCLUSTER,USERNAME,DATE,DISPLAY_URL.MAX_POSTS_PER_PROFILE— nombre de posts conservés par profil (9 par défaut).MAX_PER_MOSAIC— nombre d’images par planche (50 par défaut). Si un cluster dépasse ce nombre, le script génère automatiquement plusieurs planches – numérotées « _01 », « _02 », etc.THUMB_SIZEetCOLS— taille des miniatures et nombre de colonnes par planche. Avec 220px et 5 colonnes, 50 images donnent 10 lignes.
Nommage des fichiers : le nom de chaque mosaïque reprend directement la valeur de la colonne CLUSTER (ex : « E, F » devient mosaic_E_F_01.jpg). Si votre fichier utilise une autre nomenclature de clusters, le script s’adapte automatiquement.
Polices : le script tente de charger Arial sur Windows (arialbd.ttf, arial.ttf). Sur MacOS ou Linux, le script bascule sur une police par défaut.
ATTENTION : les URLs DISPLAY_URL proviennent du CDN Instagram et expirent rapidement (24-48h). Exportez et lancez le script peu de temps après la collecte de données !
Code Python :
import csv
import requests
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
from collections import defaultdict
import os
import math#Configuration
THUMB_SIZE = 220
COLS = 5
MAX_POSTS_PER_PROFILE = 9
MAX_PER_MOSAIC = 50
INPUT_FILE = ‘cyclistes_posts.csv’
OUTPUT_DIR = ‘mosaiques_cyclistes’os.makedirs(OUTPUT_DIR, exist_ok=True)
clusters = defaultdict(list)
seen_per_user = defaultdict(int)with open(INPUT_FILE, encoding=’utf-8′) as f:
reader = csv.DictReader(f)
for row in reader:
c = row.get(‘CLUSTER’, ‘?’).strip()
username = row.get(‘USERNAME’, »)
display_url = row.get(‘DISPLAY_URL’, »)
date = row.get(‘DATE’, »)if not display_url or display_url == ‘N/A’:
continue
if seen_per_user[username] >= MAX_POSTS_PER_PROFILE:
continue
seen_per_user[username] += 1
clusters[c].append((username, display_url, date))for cluster_id in sorted(clusters.keys()):
posts = clusters[cluster_id]
cluster_label = cluster_id.replace(‘, ‘, ‘‘).replace(‘,’, ‘‘).replace(‘ ‘, ‘_’)nb_planches = math.ceil(len(posts) / MAX_PER_MOSAIC)
print(f’Cluster {cluster_id} : {len(posts)} posts -> {nb_planches} planche(s)’)
for planche_idx in range(nb_planches):
chunk = posts[planche_idx * MAX_PER_MOSAIC : (planche_idx + 1) * MAX_PER_MOSAIC]
images = []
for username, url, date in chunk:
try:
r = requests.get(url, timeout=8, headers={‘User-Agent’: ‘Mozilla/5.0’})
if r.status_code == 200:
img = Image.open(BytesIO(r.content)).convert(‘RGB’)
img = img.resize((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
images.append((img, username, date))
else:
print(f’ ERREUR HTTP {r.status_code} — @{username}’)
except Exception as e:
print(f’ ERREUR — @{username} : {e}’)
if not images:
continue
rows = math.ceil(len(images) / COLS)
header_height = 60
mosaic = Image.new(‘RGB’, (COLS * THUMB_SIZE, rows * THUMB_SIZE + header_height), (20, 20, 20))
draw = ImageDraw.Draw(mosaic)
draw.rectangle([0, 0, COLS * THUMB_SIZE, header_height], fill=(30, 30, 30))
try:
font = ImageFont.truetype(‘arialbd.ttf’, 22)
font_small = ImageFont.truetype(‘arial.ttf’, 13)
except:
font = ImageFont.load_default()
font_small = font
draw.text((20, 18), f’Cluster {cluster_id} | Planche {planche_idx + 1}/{nb_planches}’,
fill=(255, 255, 255), font=font)
for i, (img, username, date) in enumerate(images):
x = (i % COLS) * THUMB_SIZE
y = (i // COLS) * THUMB_SIZE + header_height
mosaic.paste(img, (x, y))
mosaic_rgba = mosaic.convert(‘RGBA’)
overlay = Image.new(‘RGBA’, (THUMB_SIZE, 36), (0, 0, 0, 140))
mosaic_rgba.paste(overlay, (x, y + THUMB_SIZE – 36), overlay)
mosaic = mosaic_rgba.convert(‘RGB’)
draw = ImageDraw.Draw(mosaic)
draw.text((x + 4, y + THUMB_SIZE – 32), f’@{username[:18]}’, fill=(255, 255, 255), font=font_small)
draw.text((x + 4, y + THUMB_SIZE – 16), f'{date}’, fill=(200, 200, 200), font=font_small)
output_path = os.path.join(OUTPUT_DIR, f’mosaic_{cluster_label}_{planche_idx+1:02d}.jpg’)
mosaic.save(output_path, quality=85)
print(f’ Sauvegarde : {output_path}’)print(‘Termine !’)
Note : les URLs CDN Instagram ont une durée de vie limitée — 24 à 48h après la collecte. Au-delà, les images expirent et le script retourne des erreurs HTTP : « URL signature expired ». La procédure doit donc être lancée rapidement après chaque collecte !
J’ai ensuite soumises ces mosaïques accompagnées d’instructions à GPT-4o-mini (OpenAI API). Pour chaque utilisateur, Ie modèle analyse les thèmes dominants, la présence ou non de vélo, d’humain, où se situe l’action, la palette de couleur dominante, la qualité de prise de vue, et les éléments récurrents entre les posts.
Tu analyses une mosaïque de publications Instagram.
Chaque vignette affiche en bas : le symbole ▶ (vidéo) ou □ (image), le @username et la date.Pour chaque username identifiable, rédige une description factuelle et précise :
@username (N posts observés) :
- Thèmes dominants
- Vélo : absent / anecdotique / central. Si présent, précise le type.
- Présence humaine : absente / occasionnelle / fréquente. Si présente, précise.
- Prises de vue : angle, cadrage, intérieur/extérieur, action/statique
- Palette de couleurs : dominantes, tons, saturation, contraste
- Qualité visuelle : soignée / spontanée / brute / hétérogène
- Éléments récurrents
Sois factuel.
Note : GPT analysant plusieurs @usernames par mosaïque, j’ai réalisé un petit code pour les séparer afin d’alimenter une feuille Google sheet – 1 @username = 1 ligne. GPT ayant parfois déformé le nom de l’utilisateur, j’ai donc réalisé un code de correction supplémentaire afin de faire matcher avec l’@username de la base de donnée originale.
En sortie du node GPT dans n8n, l’analyse est sous forme de texte brut avec plusieurs blocs – 1 bloc = 1 @username identifié depuis la mosaïque, avec les champs « Thèmes dominants », « Vélo », « Présence humaine », etc. Ce code restructure ce texte en valeurs exploitables pour Google Sheets.
Fonctionnement du code :
- Récupération du texte — le code extrait la réponse GPT depuis
content[0].text. Si la réponse est vide ou mal formée, il inscrit « NO DATA ». - Identification du cluster — le nom de la mosaïque (ex :
mosaic_E_F_01.jpg) est lu depuis le nœudDownload file. Le cluster est extrait via une regex (mosaic_(.+)_\d+) et reformaté :E_FredevientE, F. - Découpage par username. Le texte est découpé à chaque occurrence d’un «
@username« . - Extraction des champs. Pour chaque bloc, le code identifie d’abord le nombre de posts observés (deux formats GPT possibles sont gérés), puis extrait chaque champ structuré via regex : Thèmes dominants, Vélo, Présence humaine, Prises de vue, Palette de couleurs, Qualité visuelle, Éléments récurrents. Si un champ est absent, la valeur est « N/A ».
- Sortie. une ligne par username, avec tous les champs extraits.
const items = $input.all();
const rows = [];for (const item of items) {
const data = Array.isArray(item.json) ? item.json[0] : item.json;
const text = data?.content?.[0]?.text || « NO DATA »;const filename = $(‘Download file’).first().json.MOSAIC_NAME || « mosaic_unknown »;
const clusterMatch = filename.match(/mosaic_(.+)\d+/); const clusterBase = clusterMatch ? clusterMatch[1].replace(//g, ‘, ‘)
: « unknown »;// Regex flexible pour capturer les différents formats de GPT
const blocks = text.split(/(?=*{0,2}@[\w.-]+)/);for (const block of blocks) {
if (!block.trim()) continue;// Matcher les deux formats possibles
const usernameMatch = block.match(/@([\w.\-]+)\s*[:\(]\s*(?:N\s*posts\s*observés\s*[:\s]*)?(\d+)/i) ||
block.match(/@([\w.\-]+).*?\((\d+)\s*posts?\s*observés?\)/i);
if (!usernameMatch) continue;
const username = usernameMatch[1];
const postCount = parseInt(usernameMatch[2]);
const extract = (label) => {
const regex = new RegExp(`\\*{0,2}${label}\\*{0,2}\\s*:\\s*(.+?)(?=\\n-|\\n\\*{0,2}@|—|\n\n|$)`, ‘si’);
const match = block.match(regex);
return match ? match[1].replace(/\*\*/g, »).trim() : « N/A »;
};
rows.push({ json: {
CLUSTER: clusterBase,
MOSAIC_NAME: filename,
USERNAME: username,
POSTS_OBSERVES: postCount,
THEMES: extract(« Thèmes dominants »),
VELO: extract(« Vélo »),
PRESENCE_HUMAINE: extract(« Présence humaine »),
PRISES_DE_VUE: extract(« Prises de vue »),
PALETTE: extract(« Palette de couleurs »),
QUALITE: extract(« Qualité visuelle »),
ELEMENTS_RECURRENTS: extract(« Éléments récurrents »),
}});}
}return rows;
Lorsque GPT « lit » un username affiché sur une mosaïque, le modèle peut commettre des erreurs – « l » lu « 1 », caractère oublié, majuscule mal interprétée, etc.
Il est donc nécessaire de faire matcher les @usernames interprétés par GPT avec ceux originaux.
Fonctionnement du code
Ce code se base sur la « distance de Levenshtein » qui mesure le nombre minimal de modifications (ajout, suppression, substitution d’un caractère) nécessaires pour transformer une chaîne en une autre.
4 principes :
- Le code sépare les données en deux groupes selon la colonne
SOURCE: les résultats d’analyse GPT ("analyse") et la liste de référence des cyclistes ("cycliste"). - Pour chaque username retourné par l’analyse, le code calcule sa distance avec tous les usernames de référence et retient le plus proche.
- Si la distance est inférieure ou égale à 3, l’username est corrigé. Au-delà, il est conservé tel quel.
- Chaque ligne de sortie contient l’username original (
USERNAME_GPT), l’username corrigé (USERNAME_CORRIGE), et le score de correspondance (MATCH_SCORE) — utile pour vérifier manuellement les cas limites.
function levenshtein(a, b) {
const dp = Array.from({length: a.length + 1}, (, i) => Array.from({length: b.length + 1}, (, j) => i === 0 ? j : j === 0 ? i : 0)
);
for (let i = 1; i <= a.length; i++)
for (let j = 1; j <= b.length; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] :
1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
return dp[a.length][b.length];
}const all = $input.all();
const analyses = all.filter(i => i.json.SOURCE === « analyse »);
const cyclistes = all.filter(i => i.json.SOURCE === « cycliste »).map(i => i.json.USERNAME.toLowerCase());const rows = [];
for (const item of analyses) {
const gptUsername = (item.json.USERNAME || « »).toLowerCase();let best = null;
let bestDist = Infinity;for (const ref of cyclistes) {
const dist = levenshtein(gptUsername, ref);
if (dist < bestDist) {
bestDist = dist;
best = ref;
}
}rows.push({ json: {
…item.json,
USERNAME_GPT: item.json.USERNAME,
USERNAME_CORRIGE: bestDist <= 3 ? best : item.json.USERNAME,
MATCH_SCORE: bestDist,
}});
}return rows;
→ Fiches de cyclistes optimisées pour analyse IA
Objectif = produire des fichiers optimisés pour l’analyse IA.
2 opérations :
- Structuration des fichiers pour faciliter la lecture
- Minimisations des tokens consommés lors de l’analyse
3 codes JavaScript:
Code 1. Création de fiche par profil.
Pour chaque cycliste, ce code assemble et structure les données issues de 5 sources dans Google Sheets : les posts (captions, likes, commentaires, dates), l’analyse visuelle GPT de la mosaïque, les commentaires laissés sous les posts des influenceurs, les tags, et les infos de profil (bio, followers, type de compte, genre).
Pour optimiser l’analyse IA, les hashtags sont remplacés par le symbole ∇, les dates sont compactées (AAAAMMJJ), les sauts de ligne sont supprimés des captions.
Structure par fiche :
#N @USERNAME (Nom complet)
A1 Genre | A2 Followers (catégorie) | A3 Vol. posts | A4 Type de compte
A5 « Bio »
#N.B — Publications
□/▶ date ♥likes ✦comments « caption » ∇tags
#N.C — Analyse GPT mosaïque
C1 Thèmes | C2 Vélo | C3 Présence humaine | C4 Prises de vue | C5 Palette | C6 Qualité | C7 Éléments récurrents
N.D — Commentaires
D1 date ♥likes « commentaire »
↳ @influenceur
function cleanCaption(text) {
if (!text) return »;
return text
.replace(/\n+/g, ‘ ‘)
.replace(/#(\w+)/g, ‘∇$1’)
.trim();
}function extractTags(text) {
if (!text) return »;
const matches = text.match(/#(\w+)/g);
if (!matches) return »;
return matches.map(t => t.replace(‘#’, »)).join(‘, ‘);
}const all = $input.all();
const index = $runIndex + 1;const posts = all.filter(i => i.json.CAPTION !== undefined && i.json.COMMENT === undefined && i.json.TAGS === undefined && i.json.THEMES === undefined && i.json.USERNAME === undefined);
const analyses = all.filter(i => i.json.THEMES !== undefined);
const commentaires = all.filter(i => i.json.COMMENT !== undefined);
const tags = all.filter(i => i.json.TAGS !== undefined);
const profil = all.find(i => i.json.USERNAME !== undefined && i.json.FULL_NAME !== undefined)?.json || {};// Posts valides (caption non vide et non N/A)
const validPosts = posts.filter(p => {
const cap = (p.json.CAPTION || »).trim();
return cap && cap !== ‘N/A’;
});// Commentaires valides
const validComments = commentaires.filter(c => {
const com = (c.json.COMMENT || »).trim();
return com && com !== ‘N/A’;
});// Si aucune donnée utile → fiche NO DATA
if (validPosts.length === 0 && validComments.length === 0 && analyses.length === 0) {
const fiche =# ${index} @${username} (${fullName})\nNO DATA\n---\n;
return [{ json: { USERNAME: profil.USERNAME, FICHE: fiche, CLUSTER_LABEL: profil.CLUSTER_LABEL || » } }];
}const username = (profil.USERNAME || ‘N/A’).toUpperCase();
const fullName = profil.FULL_NAME || »;
const followers = profil[‘Vol’]?.[ ‘ FOLLOWERS’] ?? profil[‘Vol. FOLLOWERS’] ?? ‘N/A’;
const volPosts = profil[‘Vol’]?.[ ‘ POSTS’] ?? profil[‘Vol. POSTS’] ?? ‘N/A’;
const cat = profil[‘CAT’]?.[‘ INFLUENCEUR’] || ‘N/A’;;
const bio = (profil.BIO || »).replace(/\n+/g, ‘ ‘).trim();// En-tête profil
let fiche =# ${index} @${username} (${fullName})\n;
fiche +=A1 ${profil.GENRE || 'N/A'} | A2 ${followers} (${cat}) | A3 ${volPosts} | A4 ${profil.ACCOUNT_TYPE || 'N/A'}\n;
if (bio && bio !== ‘N/A’) fiche +=A5 "${bio}"\n;// Bloc publications
if (validPosts.length > 0) {
fiche +=\n## ${index}.B\n;
validPosts.forEach(p => {
const type = p.json.IS_VIDEO === true || p.json.IS_VIDEO === ‘true’ ? ‘▶’ : ‘□’;
const caption = cleanCaption(p.json.CAPTION || »);
const likes = parseInt(p.json.LIKES) || 0;
const comments = parseInt(p.json.COMMENTS) || 0;
const date = (p.json.DATE || ‘N/A’).replace(/-/g, »);
const rawTags = extractTags(p.json.CAPTION || »);
const captionClean = caption.replace(/∇\w+/g, »).trim();
const tagStr = rawTags ?∇ ${rawTags}: »;
fiche +=${type} ${date} ♥${likes} ✦${comments} "${captionClean}"${tagStr}\n;
});
}// Bloc analyse GPT
if (analyses.length > 0) {
fiche +=\n## ${index}.C\n;
const a = analyses[0].json;
fiche +=C1 ${a.THEMES || 'N/A'} | C2 ${a.VELO || 'N/A'} | C3 ${a.PRESENCE_HUMAINE || 'N/A'} | C4 ${a.PRISES_DE_VUE || 'N/A'} | C5 ${a.PALETTE || 'N/A'} | C6 ${a.QUALITE || 'N/A'} | C7 ${a.ELEMENTS_RECURRENTS || 'N/A'}\n
.replace(/.\s*|/g, ‘ |’)
.replace(/.$/, »)
.replace(/.\n$/, ‘\n’);;
}// Bloc commentaires
if (validComments.length > 0) {
fiche +=\n## ${index}.D\n;
validComments.forEach((c, i) => {
const comment = (c.json.COMMENT || »).trim();
const date = (c.json.DATE || ‘N/A’).replace(/-/g, »);
const likes = parseInt(c.json.LIKES) || 0;
const influenceur = tags.find(t => t.json.POST_URL === (c.json.POST_URL || »))?.json?.INFLUENCEUR ?? c.json.INFLUENCEUR ?? ‘N/A’;
fiche +=D${i+1} ${date} ♥${likes} "${comment}"\n;
fiche +=↳ @${influenceur}\n;
});
}fiche +=
---\n;return [{ json: { USERNAME: profil.USERNAME, FICHE: fiche, CLUSTER_LABEL: profil.CLUSTER_LABEL || » } }];
Note. Si un profil ne contient aucune donnée utile (ni post valide, ni commentaire, ni analyse GPT), la fiche est marquée NO DATA qui sera ignorée lors de l’analyse.
Code 2. Assemblage des profils par cluster.
Une fois toutes les fiches d’un cluster produites, ce code les assemble en un seul fichier .md nommé automatiquement CLUSTER_[NOM]_FICHES_CYCLISTES_[DATE].md.
const items = $input.all();
const clusterLabel = items[0]?.json?.CLUSTER_LABEL || ‘unknown’;
const today = new Date();
const dateStr =${today.getDate().toString().padStart(2,'0')}${(today.getMonth()+1).toString().padStart(2,'0')}${today.getFullYear()};
const clusterSlug = clusterLabel.replace(/[^a-zA-Z0-9]/g, ‘‘).replace(/+/g, ‘_’).toUpperCase();
const filename =CLUSTER_${clusterSlug}_FICHES_CYCLISTES_${dateStr}.md;
const noData = items.filter(i => i.json.FICHE.includes(‘NO DATA’)).length;const header = `# ${filename}
## ${items.length} profils (${noData} NO DATA)
`;
const body = items.map(i => i.json.FICHE).join(‘\n’);
return [{ json: {
filename: filename,
content: header + body
}}];
Note. Un header de synthèse indique le nombre total de profils et le nombre de fiches NO DATA
Code 3. Optimisation des fichiers pour analyse IA.
Afin de limiter le coût d’utilisation et d’hallucination par requête IA, ce code produit des fichiers calibrés à 25-30 profils.
Si le fichier contient plus de 30 profils, il est découpé en deux parties de 25 profils maximum. En dessous de 30, le fichier est laissé intact (OPERATION 0). Les profils au-delà de 50 sont tronqués.
Un token correspond à environ 3 – 4 caractères en français. Dans notre cas, une fiche complète atteint facilement les 2 000 caractères (env. 800 tokens).
Le code a produit 19 fichiers pour 13 clusters :
- Le plus lourd: 148 000 caractères (52 000 tokens)
- Le plus léger : 18 000 caractères (6 000 tokens)
Note. Ces volumes sont très en deçà du seuil de dégradation de Claude Sonnet 4.6 , dit « context rot »(env. 400 000 tokens selon Anthropic), à partir duquel le modèle perd en qualité (hallucinations). De manière générale, plus le contexte est « chargé », plus la qualité du modèle se dégrade. C’est pourquoi je vous conseille de limiter le contexte de discussion à une tâche précise.
// 1. Récupération du texte Markdown brut depuis le JSON d’entrée
const inputItem = items[0]?.json;
const rawMarkdown = inputItem?.data || « »;if (!rawMarkdown) {
throw new Error(« Aucune donnée Markdown trouvée dans le champ ‘data’. »);
}// 2. Séparation de l’en-tête et du reste des fiches
const headerSeparator = « \n# 1 @ »;
const parts = rawMarkdown.split(headerSeparator);
const originalHeader = parts[0];// Extraction et nettoyage de la première ligne pour le nom de fichier
const firstLine = originalHeader.split(‘\n’)[0];
let baseFileName = firstLine.replace(‘#’, »).replace(‘.md’, »).trim();// On isole et reconstruit la partie contenant toutes les fiches
const allFichesContent = « # 1 @ » + parts[1];// 3. Découpage de chaque fiche individuelle
const fiches = allFichesContent.split(/\n(?=# \d+ @)/);
const totalFiches = fiches.length;// Définition des constantes strictes pour votre règle
const TRIGGER_LIMIT = 30;
const MAX_PER_FILE = 25;let outputItems = [];
// 4. Logique de découpage avec plafonnement à 50
if (totalFiches > TRIGGER_LIMIT) {// — FICHIER 1 : Profils 1 à 25 —
const chunk1 = fiches.slice(0, MAX_PER_FILE);
const header1 = originalHeader.replace(/## \d+ profils/, `## ${chunk1.length} profils (Partie 1/2)`);
const content1 = header1 + « \n—\n\n » + chunk1.join(« \n »).trim() + « \n »;
outputItems.push({
json: {
status: « OPERATION_SPLIT »,
part: 1,
profileCount: chunk1.length,
filename: `${baseFileName}_01.md`,
data: content1
}
});
// — FICHIER 2 : Profils 26 à 50 (Le reste après 50 est jeté) —
// .slice(25, 50) prendra un maximum de 25 profils, s’arrêtant pile au 50e
const chunk2 = fiches.slice(MAX_PER_FILE, 50);
const header2 = originalHeader.replace(/## \d+ profils/, `## ${chunk2.length} profils (Partie 2/2 – Tronqué à 50)`);
const content2 = header2 + « \n—\n\n » + chunk2.join(« \n »).trim() + « \n »;
outputItems.push({
json: {
status: « OPERATION_SPLIT »,
part: 2,
profileCount: chunk2.length,
filename: `${baseFileName}_02.md`,
data: content2
}
});} else {
// Si inférieur ou égal à 30 profils : « OPERATION 0 » (Inchangé)
outputItems.push({
json: {
status: « OPERATION 0 »,
profileCount: totalFiches,
message: « Le fichier contient 30 profils ou moins. Aucune division nécessaire. »
}
});
}// Renvoie les 2 éléments maximum à n8n
return outputItems;
Note. Ce code sert également à ré-optimiser un fichier existant sans relancer l’ensemble du pipeline.
→ Analyse par Claude des fiches de cyclistes
Pour analyser les fiches de cyclistes avec Claude Sonnet 4.6, j’ai créé un projet dans l’interface de l’éditeur auquel j’ai joint directement des instructions, un fichier de prompt (.txt), et les 19 fiches de cyclistes et les (.md).
1 discussion = 1 analyse
#INSTRUCTION PROJET — Analyse Instagram Cyclistes FR
Tu es un analyste spécialisé dans l’étude des communautés cyclistes françaises sur Instagram.
Ce projet contient les fiches de cyclistes identifiés sous les posts d’influenceurs vélo français.
Les fichiers sont nommés : CLUSTER_[NOM]FICHES_CYCLISTES[DATE].mdChaque fiche suit cette structure :
#[N] @USERNAME (Nom complet)
A1 Genre | A2 Followers (cat. influenceur) | A3 Posts | A4 Type de compte
A5 Bio[N].B — Publications du cycliste sur son propre compte
□ image | ▶ vidéo | ♥ likes | ✦ commentaires | ∇ hashtags[N].C — Analyse visuelle GPT des publications
C1 Thèmes | C2 Présence vélo | C3 Présence humaine
C4 Prises de vue | C5 Palette | C6 Qualité | C7 Éléments récurrents[N].D — Commentaires postés sous les publications d’influenceurs vélo
♥ likes | ↳ influenceur concernéDÉFINITION DES CLUSTERS :
A — VTT
B — Route
C — Urbain
D — Cyclotourisme & Loisir
E — Vintage
F — Gravel
G — BMX & Freestyle
H — Médias & Événementiel
I — Général
J — Mécanique
K — Électrique
L — Lifestyle
M — Sport & CompétitionLes clusters peuvent être combinés. Ceux à analyser sont :
L, G — Lifestyle & BMX
E, F — Vintage & Gravel
B — Route
B, L — Route & Lifestyle
I, H — Général & Médias
L — Lifestyle
D, H — Cyclotourisme & Médias
B, D, M — Route, Cyclotourisme & Compétition
A, H — VTT & Médias
D — Cyclotourisme
C — Urbain
B, M — Route & Compétition
E, J — Vintage & MécaniqueSOURCES AUTORISÉES :
Tu travailles exclusivement sur les fichiers présents dans ce projet.
Aucune donnée externe. Aucune inférence sur des profils que tu connaîtrais par ailleurs.
Si une donnée est absente ou marquée NO DATA, indique « N/A ».NOTES MÉTHODOLOGIQUES :
Sur la représentativité :
Les cyclistes analysés sont ceux qui ont commenté sous les posts d’influenceurs vélo français identifiés.
Ce ne sont pas nécessairement des cyclistes actifs — certains peuvent être des spectateurs ou des proches.
Le cluster d’appartenance est celui de l’influenceur sous lequel le commentaire a été posté,
pas nécessairement celui de la pratique du cycliste.Sur les données C (analyse visuelle GPT) :
Ces données ont été générées automatiquement par analyse de mosaïques de posts.
Elles sont approximatives — certains profils peuvent présenter des erreurs d’analyse.
Traite-les comme des signaux indicatifs, pas comme des données certaines.Sur les commentaires D :
Le volume de commentaires est faible (moyenne 1 à 2 par cycliste).
Les commentaires sont souvent courts (emojis, 2-3 mots).
Ce signal est indicatif du type de réaction, pas de la profondeur d’engagement.
Tous les profils d’un même cluster ont été collectés à partir des commentaires postés sous 1 à 3 influenceurs spécifiques. Il est donc normal et attendu que tous les commentaires (D) pointent vers ces mêmes influenceurs. Ce n’est pas un signal analytique en soi — ne le mentionne pas comme un effet tribu ou une observation notable.Sur le genre :
Le genre a été déterminé automatiquement via le prénom.
Il peut être incorrect pour certains profils.RÈGLES GÉNÉRALES :
- Ne spécule pas au-delà des données disponibles
- Chaque affirmation doit s’appuyer sur des éléments présents dans les fiches
- Si un pattern te semble minoritaire ou anecdotique, signale-le explicitement comme tel sans donner de pourcentage
- Ne donne jamais de pourcentage ou de chiffre que tu n’as pas calculé explicitement depuis les données
- Appuie chaque observation sur des exemples explicites en citant le @USERNAME concerné
- Ne formule jamais d’observation générale sans au moins 2-3 @USERNAME à l’appui
DONNÉES INDISPONIBLES — ne jamais inférer :
- L’âge des profils
- Le niveau de revenus
- La fréquence de pratique réelle
- Les relations hors Instagram
ANALYSE PAR CLUSTER :
Le prompt d’analyse est disponible en fichier .txt dans ce projet : P_ANALYSE_CLUSTER.txtLes fichiers de fiches sont nommés : CLUSTER_[NOM]FICHES_CYCLISTES[DATE].md
Certains clusters sont divisés en plusieurs parties (_01, _02).Quand je dis « Analyse le cluster [NOM] » :
- Identifie le ou les fichiers correspondants dans le projet
- Si plusieurs parties, analyse-les séparément et indique la partie concernée
- Applique le prompt P_ANALYSE_CLUSTER.txt
# P — ANALYSE DU CLUSTER
CONTEXTE
Les profils analysés ont été collectés à partir des commentaires postés sous des publications d’influenceurs vélo français sur Instagram. Il s’agit d’un échantillon infime de la plateforme, constitué uniquement de données publiques. L’analyse porte donc sur la partie émergée de l’iceberg — ce que ces cyclistes choisissent de montrer publiquement. Le cluster d’appartenance est celui de l’influenceur sous lequel le commentaire a été posté, pas nécessairement celui de la pratique du cycliste. Certains profils peuvent être des spectateurs, des proches ou des pratiquants d’une discipline différente.
---À partir des fiches du cluster étudié, réponds aux quatre questions suivantes.
Rédige en français, en paragraphes denses, sans listes à puces.
Maximum 10 lignes par question.
Appuie chaque observation sur au moins 5 @USERNAME avec leurs données exactes.
Ne donne aucun pourcentage que tu n’as pas calculé explicitement depuis les données.
Ne répète pas la même observation d’une question à l’autre.
N’infère jamais l’âge, le niveau de revenus ou la fréquence de pratique réelle.
Si une donnée est absente ou NO DATA, indique N/A.
Si une observation ne concerne que quelques profils isolés, signale-le comme cas particulier et non comme tendance.
---Q1 — INFLUENCE & ACTIVITÉ
Quelle est la distribution des comptes par taille (confidentiel <1k, nano 1k-10k, micro 10k-100k, macro >100k) ?
Quels sont les centres d’intérêt dominants déclarés (bios A5) et publiés (captions B, hashtags ∇) ?
Y a-t-il des profils qui se démarquent par leur engagement reçu (♥ et ✦) ? Cite les métriques exactes.
Q2 — VÉLO & CLUSTER
La présence du vélo est-elle centrale, anecdotique ou absente dans leur communication (B et C2) ?
Y a-t-il une cohérence entre ce qu’ils publient et le cluster sous lequel ils ont été identifiés ?
Quel registre dominent leurs commentaires sous les posts d’influenceurs (D) — admiration, humour, échange technique, familiarité ? Cite des commentaires exacts entre guillemets.
Q3 — PROFIL & TRIBU
Se dégage-t-il un profil type dominant ou l’échantillon est-il trop hétérogène pour en dégager un portrait cohérent ?
Y a-t-il des sous-groupes identifiables — par pratique, territoire, réseau, taille de compte ?
Y a-t-il des mentions croisées entre profils, des hashtags communautaires partagés ou des influenceurs récurrents dans les commentaires D qui révèlent un effet tribu ?
Q4 — SIGNAUX FAIBLES
Y a-t-il des profils atypiques ou des cas particuliers qui nuancent le portrait général du cluster ?
Y a-t-il des incohérences visibles entre l’analyse visuelle C et les publications B réelles ?
Y a-t-il des profils dormants ou quasi-vides qui signalent une présence périphérique ?
Maximum 5 lignes. Cite les @USERNAME concernés avec leurs données exactes.
#Analyse — Cluster I, H (Général & Médias)
27 profils · fichier unique CLUSTER_I_H_FICHES_CYCLISTES_31052026.md
##1. Influence & Activité
La distribution du cluster est écrasée dans les tiers inférieurs : on dénombre au moins 14 profils confidentiels (<1k abonnés — @X1 332, @X2 239, @X3 43, @X4 105, @X5 95, @X6 475, @X7 753, @X8 203, @X9 93, @X10 312, @X11 181, @X12, @X13 420), 8 nano (1k–10k : @X14 1 257, @X15 1 253, @X16 1 216, @X17 1 873, @X18 1 899, @X19 1 244, @X20 2 394, @X21 1 150, @X22 1 835, @X23 1 878), et seulement 3 micro (10k–100k : @X24 45 556, @X25 33 367, @cafemanivelles 10 099). Aucun macro.
Les centres d’intérêt déclarés en bio sont remarquablement hétérogènes : VTT/enduro (@X14, @X6, @X21), médias/podcast vélo (@cafemanivelles), mécanique-guidage (@X16, @X13), et des univers entièrement non-cyclistes — barbier (@X18), illustration manga (@X25), menuiserie van (@X5), automobile sportive (@X24).
En matière d’engagement reçu, @cafemanivelles se distingue nettement avec des posts atteignant 4 332 ♥ et 61 ✦, et régulièrement 1 000–1 400 ♥. @X20 présente des métriques solides (929 ♥ sur une vidéo, 417 ♥ sur un post statique) tout comme @X25 dont les illustrations récoltent jusqu’à 5 710 ♥ et 247 ✦, bien que dans un registre sans rapport avec le vélo.
##2.Vélo & Cluster
Le cluster I/H (« Général & Médias ») révèle une présence du vélo extrêmement inégale. Un noyau dur de profils fait du VTT son contenu central : @X16 (shop VTT, présentation de modèles Santa Cruz, Mondraker, Forbidden), @cafemanivelles (média VTT, tests terrain, débats e-bike), @X20 (essais presse Santa Cruz, Bronson, V10), @X19 (guide VTT/gravel, événements Haut-Languedoc), @X14 (compétitions enduro Côte d’Azur), @X21 (freestyles pumptrack, Mondraker électrique).
À l’opposé, several profils affichent une présence vélo anecdotique ou nulle dans leurs publications propres : @X25 (100 % illustration manga, DBZ), @X4 (automobile, carrosserie), @X5 (menuiserie, aménagement van), @X18 (barbier, lifestyle Réunion), @X24 (drift automobile, contenus streetstyle). L’analyse C confirme cette fracture : C2 est marqué « central » pour les profils VTT actifs, « absent » pour les profils périphériques.
La cohérence avec le cluster « Général & Médias » est vérifiée pour @cafemanivelles, @X17, @X20 et @X16, mais de nombreux profils ont manifestement été capturés par leur présence sous les posts de @cafemanivelles, sans appartenir à la communauté cycliste. Les commentaires en D s’inscrivent dans trois registres :
- L’échange technique et la prise de position argumentée (« le millieu du vtt aberrant, à 10 000 tu as une 125cc. Je trouve les prix artificiellement haut » — @X22 ; « le problème, ce n’est pas seulement la puissance : c’est aussi que certains accèdent à des sommets sans avoir le niveau technique » — @X19)
- L’opinion tranchée voire polémique (« N’importe quoi les conneries qu’il faut pas entendre » / « je déteste les ebike » — @X3)
- La réaction courte de connivence (« C est bon ça ! » — @X2 ; « 👏👏👏 » — @X20).
##3. Profil & Tribu
Il ne se dégage pas de profil type dominant : le cluster est trop hétérogène pour constituer un portrait cohérent. On peut cependant distinguer deux sous-groupes réels et un ensemble de profils périphériques.
Le premier sous-groupe est celui des acteurs médias et pros du VTT : @cafemanivelles (média digital, podcast, tests), @X20 (testeur presse, vidéaste), @X16 (commerce spécialisé VTT), @X17 (photographe vélo, Belgique), @X19 (moniteur, organisateur gravel). Ces profils partagent une pratique structurée, des partenariats marque, et une production de contenu régulière orientée communauté.
Le second sous-groupe regroupe des pratiquants VTT actifs sans dimension média : @X14 (compétition enduro Côte d’Azur, Lapierre), @X15 (custom builds, étudiant design), @X21 (freeride, pumptrack), @X23 (pumptrack/BMX, chaîne YouTube).
L’ensemble restant — soit une partie substantielle du cluster — constitue des présences périphériques : profils non-cyclistes identifiés uniquement par leur commentaire sous @cafemanivelles (@X25, @X4, @X5, @X18, @X24). Aucune mention croisée entre profils du cluster n’est observable dans les fiches. @cafemanivelles est l’unique hub gravitationnel de ce cluster : tous les commentaires D du fichier pointent vers ce seul compte, ce qui confirme que la collecte a été réalisée sous ses publications et que la « tribu » est en réalité son audience, non une communauté organique entre ses followers.
##4. Signaux Faibles
Plusieurs cas atypiques méritent d’être signalés.
- @X25 (33 367 abonnés, micro) est le profil le plus suivi du cluster hors @cafemanivelles, mais son contenu est exclusivement constitué d’illustrations manga DBZ (♥ jusqu’à 5 710) — aucun rapport avec le vélo ; sa présence ici est une capture périphérique pure.
- @X17 est un profil belge (#Brompton, circuit de Zolder), seul profil géographiquement hors de France métropolitaine, signalant l’étendue de l’audience de @cafemanivelles au-delà des frontières françaises.
- @cafemanivelles lui-même figure dans les fiches (profil 21) — il est à la fois l’influenceur-source de la collecte et un membre du cluster analysé, ce qui constitue une anomalie structurelle : ses propres métriques (10 099 abonnés, posts à 4 332 ♥) écrasent l’ensemble du groupe.
- @X5 (95 abonnés, 9 posts) est le profil le plus dormant du cluster avec un contenu 100 % menuiserie/van-life (#hautesavoie) et zéro lien cycliste, présence périphérique maximale.
- @X8 (203 abonnés) affiche quant à lui une bio déclarée VTT DH et un projet « deaflinpique », mais ses publications récentes montrent peu de vélo — cas particulier d’un profil qui se revendique de la pratique sans la documenter encore.
J’espère que cet article vous as plu !
Vous pouvez utiliser ma méthode pour vos projets. Celle-ci est facilement réplicable pour trouver et analyser n’importe quel autre échantillon d’utilisateurs sur Instagram ;).
En ce qui concerne Folk, nous analyserons les résultats obtenus avec cette méthode dans le prochain article.
En attendant, je vous souhaite une bonne semaine.
Matthieu
Ressources (Github)
→ Toutes les ressources du projet sont disponibles ici (Github).
Pour ne rien rater de l’aventure Folk
→ Abonnez-vous à la newsletter de Folk :
Laisser un commentaire