/* InfluenceursQC — classement des influenceurs du Québec, multi-plateformes */ const express = require("express"); const fs = require("fs"); const path = require("path"); const PORT = process.env.PORT || 3010; const DATA = JSON.parse(fs.readFileSync(path.join(__dirname, "qc_influenceurs.json"), "utf8")); let GALLERY = {}; try { GALLERY = JSON.parse(fs.readFileSync(path.join(__dirname, "gallery.json"), "utf8")); } catch (e) { console.warn("gallery.json absent — galeries désactivées"); } let DOSSIERS = {}; try { DOSSIERS = JSON.parse(fs.readFileSync(path.join(__dirname, "dossiers.json"), "utf8")); } catch (e) { console.warn("dossiers.json absent — sections éditoriales désactivées"); } const domain = u => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; } }; const MOIS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."]; const fmtDate = d => { if (!d) return ""; const m = String(d).match(/^(\d{4})(?:-(\d{2}))?/); if (!m) return String(d); return m[2] ? `${MOIS[parseInt(m[2], 10) - 1]} ${m[1]}` : m[1]; }; const PLATFORMS = { instagram: { label: "Instagram", metric: "instagram_followers", unit: "abonnés", url: h => `https://instagram.com/${h}` }, tiktok: { label: "TikTok", metric: "tiktok_followers", unit: "abonnés", url: h => `https://tiktok.com/@${h}` }, youtube: { label: "YouTube", metric: "youtube_subscribers", unit: "abonnés", url: h => `https://youtube.com/@${h}` }, facebook: { label: "Facebook", metric: "facebook_followers", unit: "abonnés", url: h => `https://facebook.com/${h}` }, x: { label: "X", metric: "x_followers", unit: "abonnés", url: h => `https://x.com/${h}` }, twitch: { label: "Twitch", metric: "twitch_followers", unit: "followers", url: h => `https://twitch.tv/${h}` }, }; const ICONS = { instagram: '', tiktok: '', youtube: '', facebook: '', x: '', twitch: '', }; /* ---------- préparation des données ---------- */ function slugify(s) { return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase() .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x"; } const seen = new Map(); DATA.forEach((r, i) => { let s = slugify(r.name); if (seen.has(s)) { s = `${s}-${seen.get(s) + 1}`; seen.set(slugify(r.name), seen.get(slugify(r.name)) + 1); } else seen.set(s, 1); r.slug = s; r.rank_global = null; r._i = i; }); const RANKED = DATA.filter(r => r.total_followers).sort((a, b) => b.total_followers - a.total_followers); RANKED.forEach((r, i) => { r.rank_global = i + 1; }); const CATEGORIES = [...new Set(DATA.map(r => r.category))].sort((a, b) => a.localeCompare(b, "fr")); const bySlug = new Map(DATA.map(r => [r.slug, r])); /* ---------- valeur nette estimée (voir /methodologie) ---------- */ const NW_RATES = { instagram_followers: 0.20, tiktok_followers: 0.08, youtube_subscribers: 0.35, facebook_followers: 0.05, x_followers: 0.03, twitch_followers: 0.50 }; const NW_TIER = { "méga (1M+)": 1.25, "macro (100K+)": 1.0, "micro (10K+)": 0.75, "nano/inconnu": 0.5 }; const NW_CAT = { mode: 1.2, "beauté": 1.2, entrepreneur: 1.15, food: 1.1, sport: 1.1, lifestyle: 1.05, fitness: 1.05, "télé-réalité": 0.95, gaming: 0.9, "médias": 0.9 }; function computeNetWorth(r, dossiers) { const parts = []; let base = 0; for (const [mk, rate] of Object.entries(NW_RATES)) { const v = r.metrics[mk]; if (v) { const a = v * rate; base += a; parts.push({ metric: mk, followers: v, amount: a }); } } if (!base) return null; const mult = (NW_TIER[r.tier] || 1) * (NW_CAT[r.category] || 1); const annual = base * mult; // années actives : premier fait daté de la chronologie, sinon 5 ans par défaut let years = 5; const facts = (dossiers[r.name] || {}).facts || []; const yearsDated = facts.map(f => parseInt(String(f.date || "").slice(0, 4), 10)).filter(y => y >= 2006 && y <= 2026); if (yearsDated.length) years = Math.min(15, Math.max(2, 2026 - Math.min(...yearsDated))); return { annual, years, mult, parts, low: Math.round(annual * 0.7 * years * 0.20), high: Math.round(annual * 1.3 * years * 0.45), }; } DATA.forEach(r => { r.networth = computeNetWorth(r, DOSSIERS); }); const normTxt = s => String(s).normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); const fmtFull = new Intl.NumberFormat("fr-CA"); const fmtCompact = new Intl.NumberFormat("fr-CA", { notation: "compact", maximumFractionDigits: 1 }); const nf = n => (n == null ? "—" : fmtCompact.format(n)); const fmtMoney = new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", notation: "compact", maximumFractionDigits: 1 }); const nm = n => (n == null ? "—" : fmtMoney.format(n)); const nfull = n => (n == null ? "—" : fmtFull.format(n)); const esc = s => String(s == null ? "" : s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const initials = name => name.split(/\s+/).slice(0, 2).map(w => (w.replace(/[^\p{L}\p{N}]/gu, "")[0] || "")).join("").toUpperCase() || "•"; function avatar(r) { const prov = r.handles.instagram ? ["instagram", r.handles.instagram] : r.handles.tiktok ? ["tiktok", r.handles.tiktok] : r.handles.x ? ["x", r.handles.x] : r.handles.youtube ? ["youtube", r.handles.youtube] : null; const img = prov ? `` : ""; return `${esc(initials(r.name))}${img}`; } /* ---------- gabarit ---------- */ function layout({ title, active, content, description }) { return ` ${esc(title)} · InfluenceursQC
${content}
`; } /* ---------- composants ---------- */ function rankTable(rows, { metric, networth } = {}) { if (!rows.length) return `
Aucun influenceur trouvé.
`; const max = Math.max(...rows.map(r => metric ? (r.metrics[metric] || 0) : (r.total_followers || 0))); const body = rows.map((r, i) => { const val = metric ? r.metrics[metric] : r.total_followers; const w = max ? Math.max(1.5, (val || 0) / max * 100) : 0; const minis = Object.entries(PLATFORMS) .filter(([k, p]) => r.metrics[p.metric]) .map(([k, p]) => `${ICONS[k]}${nf(r.metrics[p.metric])}`).join(""); return ` ${i + 1} ${avatar(r)} ${esc(r.name)}
${esc(r.location)}
${esc(r.category)} ${metric ? `${nf(r.metrics[metric])} · ${esc(r.handles[Object.keys(PLATFORMS).find(k => PLATFORMS[k].metric === metric)] ? "@" + r.handles[Object.keys(PLATFORMS).find(k => PLATFORMS[k].metric === metric)] : "")}` : `${minis || ''}`} ${nf(val)}${metric ? PLATFORMS[Object.keys(PLATFORMS).find(k => PLATFORMS[k].metric === metric)].unit : "cumulés"} ${networth ? `${r.networth ? `${nm(r.networth.low)}–${nm(r.networth.high)}` : "—"}valeur nette est.` : ""}
`; }).join(""); return `
${networth ? '' : ""}${body}
#InfluenceurCatégorie${metric ? "Compte" : "Plateformes"}AudienceValeur nette est.
`; } function statTile(label, value, note) { return `
${esc(label)}
${value}
${note ? `
${esc(note)}
` : ""}
`; } /* ---------- routes ---------- */ const app = express(); app.disable("x-powered-by"); app.use("/public", express.static(path.join(__dirname, "public"))); app.get("/", (req, res) => { const q = (req.query.q || "").trim(); const cat = (req.query.categorie || "").trim(); let rows = RANKED; if (q) { const nq = slugify(q).replace(/-/g, " "); rows = DATA.filter(r => slugify(r.name).replace(/-/g, " ").includes(nq) || Object.values(r.handles).some(h => h && h.includes(q.toLowerCase())) ).sort((a, b) => (b.total_followers || 0) - (a.total_followers || 0)); } if (cat) rows = rows.filter(r => r.category === cat); const cumul = RANKED.reduce((s, r) => s + r.total_followers, 0); const content = `
Province de Québec · 6 plateformes

${q ? `Résultats pour « ${esc(q)} »` : cat ? `Catégorie ${esc(cat)}` : "Le classement des influenceurs du Québec"}

${q || cat ? `← Retour au classement complet` : "Audience cumulée sur Instagram, TikTok, YouTube, Facebook, X et Twitch — compilée à partir des classements publics."}

${!q && !cat ? `
${statTile("Influenceurs répertoriés", nfull(DATA.length))} ${statTile("Audience cumulée", nf(cumul), "toutes plateformes confondues")} ${statTile("Créateurs méga (1M+)", nfull(RANKED.filter(r => r.tier.startsWith("méga")).length))} ${statTile("Catégories", nfull(CATEGORIES.length))}
` : ""}
${nfull(rows.length)} influenceur${rows.length > 1 ? "s" : ""} · triés par audience totale
${rankTable(rows)}`; res.send(layout({ title: q ? `Recherche : ${q}` : "Classement global", active: "global", content })); }); app.get("/plateforme/:p", (req, res) => { const p = PLATFORMS[req.params.p]; if (!p) return res.status(404).send(notFound()); const rows = DATA.filter(r => r.metrics[p.metric]).sort((a, b) => b.metrics[p.metric] - a.metrics[p.metric]); const cumul = rows.reduce((s, r) => s + r.metrics[p.metric], 0); const content = `
Classement par plateforme

Top ${p.label} au Québec

Les créateurs québécois classés par nombre d'${p.unit} sur ${p.label}.

${statTile(`Créateurs sur ${p.label}`, nfull(rows.length))} ${statTile("Audience cumulée", nf(cumul), p.label)} ${statTile("N° 1", rows.length ? esc(rows[0].name) : "—", rows.length ? nf(rows[0].metrics[p.metric]) + " " + p.unit : "")}
${rankTable(rows, { metric: p.metric })}`; res.send(layout({ title: `Top ${p.label}`, active: "p:" + req.params.p, content })); }); app.get("/categories", (req, res) => { const cards = CATEGORIES.map(c => { const rows = RANKED.filter(r => r.category === c); const total = rows.reduce((s, r) => s + r.total_followers, 0); const top = rows[0]; return `
${esc(c)} ${rows.length + (DATA.filter(r => r.category === c).length - rows.length)} créateurs
Audience cumulée ${nf(total)}
${top ? `
N° 1 : ${esc(top.name)} (${nf(top.total_followers)})
` : ""}
`; }).join(""); const content = `
Explorer

Catégories

Les influenceurs québécois par domaine de création.

${cards}
`; res.send(layout({ title: "Catégories", active: "cats", content })); }); app.get("/categorie/:c", (req, res) => { const c = req.params.c; if (!CATEGORIES.includes(c)) return res.status(404).send(notFound()); const rows = DATA.filter(r => r.category === c).sort((a, b) => (b.total_followers || 0) - (a.total_followers || 0)); const content = `
Catégories

${esc(c)}

${nfull(rows.length)} créateurs québécois dans cette catégorie, triés par audience totale.

${rankTable(rows)}`; res.send(layout({ title: `Catégorie ${c}`, active: "cats", content })); }); app.get("/influenceur/:slug", (req, res) => { const r = bySlug.get(req.params.slug); if (!r) return res.status(404).send(notFound()); const plats = Object.entries(PLATFORMS).filter(([k, p]) => r.metrics[p.metric] || r.handles[k]); const withMetric = plats.filter(([k, p]) => r.metrics[p.metric]); const max = Math.max(1, ...withMetric.map(([k, p]) => r.metrics[p.metric])); const catRows = RANKED.filter(x => x.category === r.category); const rankCat = catRows.findIndex(x => x.slug === r.slug) + 1; const bars = withMetric.sort((a, b) => r.metrics[b[1].metric] - r.metrics[a[1].metric]).map(([k, p]) => `
${ICONS[k]}${p.label} ${nf(r.metrics[p.metric])}
`).join(""); const socials = plats.map(([k, p]) => r.handles[k] ? ` ${ICONS[k]}@${esc(r.handles[k])} ${r.metrics[p.metric] ? nf(r.metrics[p.metric]) + " " + p.unit : p.label} ` : ` ${ICONS[k]}${p.label} ${nf(r.metrics[p.metric])} ${p.unit}`).join(""); const notes = (r.notes || "").split(" | ").filter(Boolean); const content = `
Classement · ${esc(r.category)} · ${esc(r.name)}
${avatar(r)}

${esc(r.name)}

${r.rank_global ? `N° ${r.rank_global} au Québec` : ""} ${esc(r.category)} ${esc(r.location)} ${esc(r.tier)}
${statTile("Audience totale", nf(r.total_followers), "toutes plateformes")} ${statTile("Plateformes actives", nfull(r.platform_count))} ${r.rank_global ? statTile("Rang global", "N° " + r.rank_global, `sur ${nfull(RANKED.length)} classés`) : ""} ${rankCat ? statTile(`Rang · ${r.category}`, "N° " + rankCat, `sur ${nfull(catRows.length)}`) : ""}

Audience par plateforme

${withMetric.length ? `
${bars}
0${nf(max)}
` : `

Aucune métrique chiffrée disponible pour ce profil.

`} ${notes.length ? `

À propos

${notes.map(esc).join("
")}

` : ""}
${(r.sources || []).map(s => `${esc(s)}`).join("")}
${r.networth ? `

Valeur nette estimée

${nm(r.networth.low)} – ${nm(r.networth.high)}
revenu annuel estimé ${nm(r.networth.annual)} · ~${r.networth.years} ans d'activité
${r.networth.parts.map(p => { const key = Object.keys(PLATFORMS).find(k => PLATFORMS[k].metric === p.metric); return `
${PLATFORMS[key].label}${nm(p.amount)}/an
`; }).join("")}
Estimation indicative fondée sur l'audience publique — voir la méthodologie.
` : ""}

Réseaux sociaux

${socials || '

Aucun compte répertorié.

'}

Fiche

Nom${esc(r.name)}
Catégorie${esc(r.category)}
Localisation${esc(r.location)}
Tier${esc(r.tier)}
Audience totale${nfull(r.total_followers)}
${(() => { const d = DOSSIERS[r.name]; if (!d || (!(d.facts || []).length && !(d.news || []).length)) return ""; const dated = (d.facts || []).filter(f => f.date).sort((a, b) => String(b.date).localeCompare(String(a.date))); const undated = (d.facts || []).filter(f => !f.date); const srcChip = f => f.source_url ? `${esc(domain(f.source_url) || "source")}` : ""; const tl = dated.map(f => `
${esc(fmtDate(f.date))}
${esc(f.text)}${srcChip(f)}
`).join(""); const facts = undated.map(f => `
${esc(f.text)}${srcChip(f)}
`).join(""); const news = (d.news || []).slice(0, 8).map(n => `
${esc(n.title)}
${esc(domain(n.url))}${n.date ? " · " + esc(n.date) : ""}
${n.snippet ? `
${esc(n.snippet)}
` : ""}
`).join(""); return `${tl ? `

Parcours — chronologie

${tl}
Chronologie compilée par recherche web (août 2026) — chaque fait est lié à sa source.
` : ""}
${facts ? `

Faits saillants

${facts}
Faits compilés par recherche web (août 2026) — chaque fait est lié à sa source.
` : "
"} ${news ? `

Dans l'actualité

${news}
` : ""}
`; })()} ${(GALLERY[r.name] || []).length ? `

Galerie

${GALLERY[r.name].slice(0, 9).map(im => ` ${esc(im.title || r.name)} `).join("")}
Photos issues de la recherche web — cliquer pour ouvrir la source.
` : ""}`; res.send(layout({ title: r.name, active: "", content, description: `${r.name} — ${r.category}, ${r.location}. Audience totale ${nf(r.total_followers)} sur ${r.platform_count} plateformes.` })); }); app.get("/recherche", (req, res) => { const q = (req.query.q || "").trim(); const plat = PLATFORMS[req.query.plateforme] ? req.query.plateforme : ""; const cat = CATEGORIES.includes(req.query.categorie) ? req.query.categorie : ""; const tier = (req.query.tier || "").trim(); const min = parseInt(req.query.min || "0", 10) || 0; const tri = req.query.tri || "audience"; const deep = req.query.web !== "0"; let rows = DATA.slice(); if (q) { const nq = normTxt(q); rows = rows.filter(r => { const hay = [r.name, r.location, r.category, r.notes || "", ...Object.values(r.handles).filter(Boolean)]; if (deep) { const d = DOSSIERS[r.name]; if (d) { hay.push(...(d.facts || []).map(f => f.text || "")); hay.push(...(d.news || []).map(n => `${n.title || ""} ${n.snippet || ""}`)); } } return hay.some(h => normTxt(h).includes(nq)); }); } if (plat) rows = rows.filter(r => r.handles[plat] || r.metrics[PLATFORMS[plat].metric]); if (cat) rows = rows.filter(r => r.category === cat); if (tier) rows = rows.filter(r => r.tier.startsWith(tier)); if (min) rows = rows.filter(r => (r.total_followers || 0) >= min); if (tri === "networth") rows.sort((a, b) => ((b.networth || {}).high || 0) - ((a.networth || {}).high || 0)); else if (tri === "nom") rows.sort((a, b) => a.name.localeCompare(b.name, "fr")); else if (tri === "plateforme" && plat) rows.sort((a, b) => (b.metrics[PLATFORMS[plat].metric] || 0) - (a.metrics[PLATFORMS[plat].metric] || 0)); else rows.sort((a, b) => (b.total_followers || 0) - (a.total_followers || 0)); const opt = (v, lab, cur) => ``; const content = `
Moteur de recherche

Recherche avancée

Filtre par plateforme, catégorie et audience — la recherche fouille aussi les faits et actualités compilés du web.

Réinitialiser
${nfull(rows.length)} résultat${rows.length > 1 ? "s" : ""}${q ? ` pour « ${esc(q)} »` : ""} · valeur nette : méthodologie
${rankTable(rows, { metric: plat ? PLATFORMS[plat].metric : undefined, networth: true })}`; res.send(layout({ title: q ? `Recherche : ${q}` : "Recherche avancée", active: "search", content })); }); app.get("/methodologie", (req, res) => { const rates = [ ["Instagram", "0,20 $ CAD/abonné/an", "≈ 10 $ par 1 000 abonnés par publication commanditée × ~20 publications/an"], ["TikTok", "0,08 $ CAD/abonné/an", "fonds des créateurs + commandites (tarifs par abonné plus faibles)"], ["YouTube", "0,35 $ CAD/abonné/an", "revenus publicitaires (RPM ~2–4 $/1 000 vues × ~15 vues/abonné/an) + intégrations"], ["Facebook", "0,05 $ CAD/abonné/an", "monétisation faible des pages"], ["X", "0,03 $ CAD/abonné/an", "partage de revenus limité"], ["Twitch", "0,50 $ CAD/follower/an", "abonnements payants + bits + commandites"], ]; const content = `
Transparence

Méthodologie

Comment les données sont compilées et comment la valeur nette estimée est calculée.

Valeur nette estimée — formule

1. Revenu annuel estimé = Σ (abonnés par plateforme × taux annuel) × multiplicateur de tier × multiplicateur de catégorie.

2. Années d'activité = 2026 − année du premier fait daté de la chronologie du profil (bornées entre 2 et 15 ans; 5 ans par défaut si aucune date connue).

3. Fourchette de valeur nette (en $ CAD) : basse = revenu × 0,7 × années × 0,20 · haute = revenu × 1,3 × années × 0,45 — la fraction (20–45 %) représente la part des revenus bruts convertie en patrimoine après impôts et dépenses de vie.

Taux annuels par plateforme (CAD)

${rates.map(([p, t, j]) => `
${p} — ${t}${j}
`).join("")}

Multiplicateurs

Tier : méga (1M+) ×1,25 · macro ×1,0 · micro ×0,75 · nano ×0,5 — les grands comptes commandent des tarifs unitaires supérieurs.
Catégorie : mode/beauté ×1,2 · entrepreneur ×1,15 · food/sport ×1,1 · lifestyle/fitness ×1,05 · télé-réalité ×0,95 · gaming/médias ×0,9 · autres ×1,0 — reflète les écarts de CPM publicitaires.

⚠️ Ces montants sont des estimations indicatives fondées uniquement sur les métriques publiques d'audience. Ils excluent les revenus hors plateformes (tournées, produits, immobilier, contrats télé) et ne constituent pas une information financière vérifiée.

Sources des données

Audiences compilées d'une trentaine de classements publics (Feedspot, Collabstr, Favikon, Modash, Billie, agences québécoises, Gala InfluenceCréation…) datés 2022–2026, dédupliquées et enrichies par recherche web (Firecrawl, août 2026). Les faits, chronologies et actualités proviennent de 376 recherches web + nouvelles, chaque élément étant lié à sa source.

Limites

Les chiffres d'abonnés sont des ordres de grandeur à dates variables, pas du temps réel. Facebook, X et Twitch sont moins documentés publiquement. La valeur nette réelle d'une personne dépend de facteurs privés (contrats, dépenses, investissements) que ce modèle ne peut pas observer.

`; res.send(layout({ title: "Méthodologie", active: "", content })); }); app.get("/api/influenceurs", (req, res) => res.json(DATA)); app.get("/health", (req, res) => res.json({ ok: true, count: DATA.length })); function notFound() { return layout({ title: "Introuvable", active: "", content: `

Page introuvable

← Retour au classement

` }); } app.use((req, res) => res.status(404).send(notFound())); app.listen(PORT, () => console.log(`InfluenceursQC en ligne — port ${PORT} · ${DATA.length} influenceurs`));