spb/qc-influenceurs Public
InfluenceursQC — classement des influenceurs du Québec.
JavaScript 83.3%
Python 16.7%
1/* InfluenceursQC — classement des influenceurs du Québec, multi-plateformes */2const express = require("express");3const fs = require("fs");4const path = require("path");56const PORT = process.env.PORT || 3010;7const DATA = JSON.parse(fs.readFileSync(path.join(__dirname, "qc_influenceurs.json"), "utf8"));8let GALLERY = {};9try { GALLERY = JSON.parse(fs.readFileSync(path.join(__dirname, "gallery.json"), "utf8")); } catch (e) { console.warn("gallery.json absent — galeries désactivées"); }10let DOSSIERS = {};11try { DOSSIERS = JSON.parse(fs.readFileSync(path.join(__dirname, "dossiers.json"), "utf8")); } catch (e) { console.warn("dossiers.json absent — sections éditoriales désactivées"); }12const domain = u => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; } };13const MOIS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."];14const fmtDate = d => {15 if (!d) return "";16 const m = String(d).match(/^(\d{4})(?:-(\d{2}))?/);17 if (!m) return String(d);18 return m[2] ? `${MOIS[parseInt(m[2], 10) - 1]} ${m[1]}` : m[1];19};2021const PLATFORMS = {22 instagram: { label: "Instagram", metric: "instagram_followers", unit: "abonnés", url: h => `https://instagram.com/${h}` },23 tiktok: { label: "TikTok", metric: "tiktok_followers", unit: "abonnés", url: h => `https://tiktok.com/@${h}` },24 youtube: { label: "YouTube", metric: "youtube_subscribers", unit: "abonnés", url: h => `https://youtube.com/@${h}` },25 facebook: { label: "Facebook", metric: "facebook_followers", unit: "abonnés", url: h => `https://facebook.com/${h}` },26 x: { label: "X", metric: "x_followers", unit: "abonnés", url: h => `https://x.com/${h}` },27 twitch: { label: "Twitch", metric: "twitch_followers", unit: "followers", url: h => `https://twitch.tv/${h}` },28};2930const ICONS = {31 instagram: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.2" cy="6.8" r="1.1" fill="currentColor" stroke="none"/></svg>',32 tiktok: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.6 3c.4 2.3 1.9 3.8 4.4 4v3.2c-1.6.1-3-.4-4.4-1.3v6.6c0 4.2-3.4 6.6-6.8 5.9-2.8-.6-4.8-3.1-4.8-5.9 0-3.3 2.7-6 6-6 .3 0 .7 0 1 .1v3.3c-.3-.1-.7-.2-1-.2-1.5 0-2.7 1.2-2.7 2.7s1.2 2.7 2.7 2.7 2.7-1.2 2.7-2.7V3h2.9z"/></svg>',33 youtube: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 12c0-2.2-.2-3.8-.5-4.8-.3-1-1-1.7-2-2C17.9 4.8 15 4.7 12 4.7s-5.9.1-7.5.5c-1 .3-1.7 1-2 2C2.2 8.2 2 9.8 2 12s.2 3.8.5 4.8c.3 1 1 1.7 2 2 1.6.4 4.5.5 7.5.5s5.9-.1 7.5-.5c1-.3 1.7-1 2-2 .3-1 .5-2.6.5-4.8zM10 15.5v-7l6 3.5-6 3.5z"/></svg>',34 facebook: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 21v-7h2.4l.4-3h-2.8V9.1c0-.9.2-1.5 1.5-1.5h1.4V4.9c-.2 0-1.1-.1-2.1-.1-2.1 0-3.5 1.3-3.5 3.6V11H8.4v3h2.4v7h2.7z"/></svg>',35 x: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.8 3h3l-6.6 7.6L22 21h-6.1l-4.8-6.3L5.6 21h-3l7.1-8.1L2 3h6.3l4.3 5.7L17.8 3zm-1.1 16.2h1.7L7.4 4.7H5.6l11.1 14.5z"/></svg>',36 twitch: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.3 3 3 6.4v13.9h4.7V23h2.6l2.7-2.7h4L21.5 16V3H4.3zm15.4 12.1-3 3h-4.8l-2.7 2.7v-2.7H5.1V4.7h14.6v10.4zM16.5 8v5h-1.7V8h1.7zm-4.6 0v5h-1.7V8h1.7z"/></svg>',37};3839/* ---------- préparation des données ---------- */40function slugify(s) {41 return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase()42 .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x";43}44const seen = new Map();45DATA.forEach((r, i) => {46 let s = slugify(r.name);47 if (seen.has(s)) { s = `${s}-${seen.get(s) + 1}`; seen.set(slugify(r.name), seen.get(slugify(r.name)) + 1); }48 else seen.set(s, 1);49 r.slug = s;50 r.rank_global = null;51 r._i = i;52});53const RANKED = DATA.filter(r => r.total_followers).sort((a, b) => b.total_followers - a.total_followers);54RANKED.forEach((r, i) => { r.rank_global = i + 1; });55const CATEGORIES = [...new Set(DATA.map(r => r.category))].sort((a, b) => a.localeCompare(b, "fr"));56const bySlug = new Map(DATA.map(r => [r.slug, r]));5758/* ---------- valeur nette estimée (voir /methodologie) ---------- */59const 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 };60const NW_TIER = { "méga (1M+)": 1.25, "macro (100K+)": 1.0, "micro (10K+)": 0.75, "nano/inconnu": 0.5 };61const 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 };62function computeNetWorth(r, dossiers) {63 const parts = [];64 let base = 0;65 for (const [mk, rate] of Object.entries(NW_RATES)) {66 const v = r.metrics[mk];67 if (v) { const a = v * rate; base += a; parts.push({ metric: mk, followers: v, amount: a }); }68 }69 if (!base) return null;70 const mult = (NW_TIER[r.tier] || 1) * (NW_CAT[r.category] || 1);71 const annual = base * mult;72 // années actives : premier fait daté de la chronologie, sinon 5 ans par défaut73 let years = 5;74 const facts = (dossiers[r.name] || {}).facts || [];75 const yearsDated = facts.map(f => parseInt(String(f.date || "").slice(0, 4), 10)).filter(y => y >= 2006 && y <= 2026);76 if (yearsDated.length) years = Math.min(15, Math.max(2, 2026 - Math.min(...yearsDated)));77 return {78 annual, years, mult, parts,79 low: Math.round(annual * 0.7 * years * 0.20),80 high: Math.round(annual * 1.3 * years * 0.45),81 };82}83DATA.forEach(r => { r.networth = computeNetWorth(r, DOSSIERS); });84const normTxt = s => String(s).normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase();8586const fmtFull = new Intl.NumberFormat("fr-CA");87const fmtCompact = new Intl.NumberFormat("fr-CA", { notation: "compact", maximumFractionDigits: 1 });88const nf = n => (n == null ? "—" : fmtCompact.format(n));89const fmtMoney = new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", notation: "compact", maximumFractionDigits: 1 });90const nm = n => (n == null ? "—" : fmtMoney.format(n));91const nfull = n => (n == null ? "—" : fmtFull.format(n));92const esc = s => String(s == null ? "" : s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));93const initials = name => name.split(/\s+/).slice(0, 2).map(w => (w.replace(/[^\p{L}\p{N}]/gu, "")[0] || "")).join("").toUpperCase() || "•";94function avatar(r) {95 const prov = r.handles.instagram ? ["instagram", r.handles.instagram]96 : r.handles.tiktok ? ["tiktok", r.handles.tiktok]97 : r.handles.x ? ["x", r.handles.x]98 : r.handles.youtube ? ["youtube", r.handles.youtube] : null;99 const img = prov ? `<img src="https://unavatar.io/${prov[0]}/${encodeURIComponent(prov[1])}?fallback=false" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.remove()">` : "";100 return `<span class="pfp">${esc(initials(r.name))}${img}</span>`;101}102103/* ---------- gabarit ---------- */104function layout({ title, active, content, description }) {105 return `<!doctype html>106<html lang="fr" style="color-scheme: light">107<head>108<meta charset="utf-8">109<meta name="viewport" content="width=device-width, initial-scale=1">110<title>${esc(title)} · InfluenceursQC</title>111<meta name="description" content="${esc(description || "Classement des influenceurs du Québec — Instagram, TikTok, YouTube, Facebook, X et Twitch.")}">112<style>113:root{114 --page:#f9f9f7; --surface:#fcfcfb; --ink:#0b0b0b; --ink-2:#52514e; --muted:#898781;115 --hairline:#e1e0d9; --border:rgba(11,11,11,.10); --accent:#2a78d6; --accent-deep:#1c5cab;116 --accent-100:#cde2fb; --accent-250:#86b6ef; --wash:rgba(42,120,214,.08);117}118*{box-sizing:border-box;margin:0}119html{scroll-behavior:smooth}120body{background:var(--page);color:var(--ink);font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}121a{color:inherit;text-decoration:none}122.wrap{max-width:1080px;margin:0 auto;padding:0 24px}123header.site{position:sticky;top:0;z-index:50;background:rgba(252,252,251,.86);backdrop-filter:blur(12px);border-bottom:1px solid var(--hairline)}124.bar{display:flex;align-items:center;gap:28px;height:60px}125.logo{font-weight:700;font-size:17px;letter-spacing:-.02em;display:flex;align-items:center;gap:9px;white-space:nowrap}126.logo .fleur{width:26px;height:26px;border-radius:7px;background:var(--accent);color:#fff;display:grid;place-items:center;font-size:13px;font-weight:700}127nav.main{display:flex;gap:2px;flex:1;min-width:0;overflow-x:auto;scrollbar-width:none}128nav.main::-webkit-scrollbar{display:none}129nav.main a{padding:6px 10px;border-radius:8px;color:var(--ink-2);font-weight:500;font-size:13.5px;white-space:nowrap}130nav.main a:hover{background:var(--wash);color:var(--ink)}131nav.main a.on{background:var(--ink);color:#fff}132.search{position:relative}133.search input{width:170px;padding:8px 12px 8px 34px;border:1px solid var(--border);border-radius:9px;background:var(--surface);font:inherit;font-size:13.5px;color:var(--ink);outline:none;transition:border-color .15s, box-shadow .15s}134.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--wash)}135.search svg{position:absolute;left:11px;top:50%;transform:translateY(-50%);width:15px;height:15px;color:var(--muted)}136main{padding:36px 0 72px}137h1{font-size:27px;letter-spacing:-.03em;line-height:1.2}138.sub{color:var(--ink-2);margin-top:6px;font-size:14.5px}139.kicker{font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--accent-deep)}140/* tuiles stat */141.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:26px 0}142.tile{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px 18px}143.tile .lab{font-size:12.5px;color:var(--ink-2);font-weight:500}144.tile .val{font-size:26px;font-weight:600;letter-spacing:-.02em;margin-top:3px}145.tile .note{font-size:12px;color:var(--muted);margin-top:2px}146/* onglets plateformes */147.tabs{display:flex;gap:6px;flex-wrap:wrap;margin:22px 0 14px}148.tabs a{display:flex;align-items:center;gap:7px;padding:7px 13px;border:1px solid var(--border);border-radius:99px;background:var(--surface);font-size:13px;font-weight:500;color:var(--ink-2)}149.tabs a svg{width:14px;height:14px}150.tabs a:hover{border-color:var(--accent);color:var(--ink)}151.tabs a.on{background:var(--ink);border-color:var(--ink);color:#fff}152/* tableau classement */153.board{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden}154table{width:100%;border-collapse:collapse}155th{font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:12px 14px;border-bottom:1px solid var(--hairline);background:var(--surface)}156td{padding:11px 14px;border-bottom:1px solid var(--hairline);vertical-align:middle}157tr:last-child td{border-bottom:none}158tbody tr{transition:background .1s}159tbody tr:hover{background:var(--wash)}160td.rank{width:52px;color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums;font-size:13.5px}161.who{display:flex;align-items:center;gap:12px;min-width:0}162.pfp{width:36px;height:36px;border-radius:10px;background:var(--accent-100);color:var(--accent-deep);display:grid;place-items:center;font-size:12.5px;font-weight:700;flex:none;position:relative;overflow:hidden}163.pfp img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}164.who .n{font-weight:600;font-size:14.5px;letter-spacing:-.01em}165.who .l{font-size:12px;color:var(--muted)}166.chip{display:inline-block;padding:3px 10px;border-radius:99px;background:var(--page);border:1px solid var(--hairline);font-size:12px;font-weight:500;color:var(--ink-2);white-space:nowrap}167td.num{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;font-size:14px;white-space:nowrap}168td.num .u{display:block;font-size:11px;color:var(--muted);font-weight:500}169.meter{width:150px}170.meter .track{height:8px;border-radius:0 4px 4px 0;background:var(--accent);min-width:2px}171.minis{display:flex;gap:10px;flex-wrap:wrap}172.mini{display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--ink-2);font-variant-numeric:tabular-nums;white-space:nowrap}173.mini svg{width:13px;height:13px;color:var(--muted)}174/* page profil */175.crumbs{font-size:13px;color:var(--muted);margin-bottom:22px}176.crumbs a:hover{color:var(--accent-deep)}177.hero{display:flex;gap:22px;align-items:center;flex-wrap:wrap}178.hero .pfp{width:84px;height:84px;border-radius:22px;font-size:28px}179.hero h1{font-size:34px}180.badges{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}181.badge{padding:4px 12px;border-radius:99px;font-size:12.5px;font-weight:600;background:var(--wash);color:var(--accent-deep);border:1px solid transparent}182.badge.ghost{background:var(--page);border-color:var(--hairline);color:var(--ink-2);font-weight:500}183.cols{display:grid;grid-template-columns:1.6fr 1fr;gap:16px;margin-top:26px}184@media(max-width:840px){.cols{grid-template-columns:1fr}.search input{width:150px}.bar{gap:14px}}185.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px}186.card h2{font-size:15px;font-weight:600;margin-bottom:16px;letter-spacing:-.01em}187/* graphique barres */188.viz{display:grid;gap:13px}189.brow{display:grid;grid-template-columns:96px 1fr;gap:12px;align-items:center}190.brow .pl{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:500;color:var(--ink-2)}191.brow .pl svg{width:15px;height:15px;color:var(--muted)}192.brow .bwrap{display:flex;align-items:center;gap:10px;min-width:0}193.brow .b{height:18px;border-radius:0 4px 4px 0;background:var(--accent);min-width:3px;transition:opacity .12s}194.brow:hover .b{opacity:.82}195.brow .v{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}196.axis{grid-column:2;border-top:1px solid var(--hairline);margin-top:2px;padding-top:5px;display:flex;justify-content:space-between;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}197/* liste de liens sociaux */198.soc{display:grid;gap:9px}199.soc a{display:flex;align-items:center;gap:11px;padding:11px 13px;border:1px solid var(--hairline);border-radius:11px;background:var(--page);transition:border-color .12s}200.soc a:hover{border-color:var(--accent)}201.soc svg{width:17px;height:17px;color:var(--ink-2);flex:none}202.soc .h{font-size:13.5px;font-weight:600}203.soc .c{margin-left:auto;font-size:13px;color:var(--ink-2);font-variant-numeric:tabular-nums}204.kv{display:grid;gap:0;font-size:13.5px}205.kv .row{display:flex;justify-content:space-between;gap:14px;padding:9px 0;border-bottom:1px solid var(--hairline)}206.kv .row:last-child{border-bottom:none}207.kv .k{color:var(--muted)}208.kv .v{font-weight:500;text-align:right}209.notes{font-size:13.5px;color:var(--ink-2);line-height:1.65}210.srcs{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}211.srcs span{font-size:11.5px;padding:3px 9px;border-radius:6px;background:var(--page);border:1px solid var(--hairline);color:var(--muted)}212/* filtres recherche avancée */213.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:end;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:14px;margin:22px 0}214.filters .f{display:grid;gap:4px}215.filters label{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}216.filters select,.filters input[type=text],.filters input[type=search]{padding:8px 11px;border:1px solid var(--border);border-radius:9px;background:var(--page);font:inherit;font-size:13.5px;color:var(--ink);outline:none;min-width:130px}217.filters select:focus,.filters input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--wash)}218.filters button{padding:9px 18px;border:none;border-radius:9px;background:var(--accent);color:#fff;font:inherit;font-size:13.5px;font-weight:600;cursor:pointer}219.filters button:hover{background:var(--accent-deep)}220.filters .reset{font-size:12.5px;color:var(--muted);align-self:center;padding:9px 4px}221.filters .reset:hover{color:var(--accent-deep)}222/* chronologie */223.tl{position:relative;padding-left:26px}224.tl::before{content:"";position:absolute;left:7px;top:8px;bottom:8px;width:2px;background:var(--hairline)}225.tl-item{position:relative;padding-bottom:20px}226.tl-item:last-child{padding-bottom:0}227.tl-item::before{content:"";position:absolute;left:-24px;top:5px;width:10px;height:10px;border-radius:99px;background:var(--accent);box-shadow:0 0 0 2px var(--surface)}228.tl-date{font-size:12px;font-weight:700;color:var(--accent-deep);letter-spacing:.05em;text-transform:uppercase}229.tl-text{font-size:14px;color:var(--ink-2);margin-top:3px;line-height:1.6}230.tl-text .src a{font-size:11.5px;padding:2px 8px;border-radius:6px;background:var(--wash);color:var(--accent-deep);font-weight:600;margin-left:6px;white-space:nowrap}231.tl-text .src a:hover{background:var(--accent-100)}232/* dossier éditorial */233.facts{display:grid;gap:12px}234.fact{display:flex;gap:10px;font-size:14px;line-height:1.6;color:var(--ink-2)}235.fact::before{content:"";width:6px;height:6px;border-radius:99px;background:var(--accent);margin-top:8px;flex:none}236.fact .src{white-space:nowrap}237.fact .src a{font-size:11.5px;padding:2px 8px;border-radius:6px;background:var(--wash);color:var(--accent-deep);font-weight:600;margin-left:6px}238.fact .src a:hover{background:var(--accent-100)}239.news{display:grid;gap:0}240.news a{display:block;padding:12px 2px;border-bottom:1px solid var(--hairline)}241.news a:last-child{border-bottom:none}242.news a:hover .nt{color:var(--accent-deep)}243.news .nt{font-size:14px;font-weight:600;letter-spacing:-.01em;line-height:1.4}244.news .nm{font-size:12px;color:var(--muted);margin-top:2px}245.news .ns{font-size:13px;color:var(--ink-2);margin-top:4px;line-height:1.55}246/* galerie */247.gal{columns:3 210px;column-gap:10px}248.gal a{display:block;break-inside:avoid;margin-bottom:10px;border-radius:12px;overflow:hidden;border:1px solid var(--hairline);background:var(--page)}249.gal img{display:block;width:100%;height:auto;transition:transform .25s ease,opacity .2s}250.gal a:hover img{transform:scale(1.03)}251.gal-cap{font-size:11px;color:var(--muted);margin-top:10px}252footer.site{border-top:1px solid var(--hairline);padding:26px 0 40px;color:var(--muted);font-size:12.5px}253footer.site .wrap{display:flex;justify-content:space-between;gap:14px;flex-wrap:wrap}254.empty{padding:60px 20px;text-align:center;color:var(--muted)}255.count{color:var(--muted);font-size:13px;margin:14px 2px 8px}256</style>257</head>258<body>259<header class="site"><div class="wrap bar">260 <a class="logo" href="/"><span class="fleur">⚜</span>InfluenceursQC</a>261 <nav class="main">262 <a href="/" class="${active === "global" ? "on" : ""}">Classement</a>263 ${Object.entries(PLATFORMS).map(([k, p]) => `<a href="/plateforme/${k}" class="${active === "p:" + k ? "on" : ""}">${p.label}</a>`).join("")}264 <a href="/categories" class="${active === "cats" ? "on" : ""}">Catégories</a>265 <a href="/recherche" class="${active === "search" ? "on" : ""}">Recherche</a>266 </nav>267 <form class="search" action="/recherche" method="get">268 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>269 <input type="search" name="q" placeholder="Rechercher un influenceur…" aria-label="Rechercher">270 </form>271</div></header>272<main><div class="wrap">${content}</div></main>273<footer class="site"><div class="wrap">274 <div>InfluenceursQC — classement des créateurs de contenu du Québec.</div>275 <div>Données compilées de classements publics (2022–2026) · mises à jour août 2026 · chiffres indicatifs · <a href="/methodologie" style="color:var(--accent-deep)">méthodologie</a></div>276</div></footer>277</body></html>`;278}279280/* ---------- composants ---------- */281function rankTable(rows, { metric, networth } = {}) {282 if (!rows.length) return `<div class="board"><div class="empty">Aucun influenceur trouvé.</div></div>`;283 const max = Math.max(...rows.map(r => metric ? (r.metrics[metric] || 0) : (r.total_followers || 0)));284 const body = rows.map((r, i) => {285 const val = metric ? r.metrics[metric] : r.total_followers;286 const w = max ? Math.max(1.5, (val || 0) / max * 100) : 0;287 const minis = Object.entries(PLATFORMS)288 .filter(([k, p]) => r.metrics[p.metric])289 .map(([k, p]) => `<span class="mini" title="${p.label} : ${nfull(r.metrics[p.metric])} ${p.unit}">${ICONS[k]}${nf(r.metrics[p.metric])}</span>`).join("");290 return `<tr onclick="location.href='/influenceur/${r.slug}'" style="cursor:pointer">291 <td class="rank">${i + 1}</td>292 <td><a class="who" href="/influenceur/${r.slug}">293 ${avatar(r)}294 <span><span class="n">${esc(r.name)}</span><br><span class="l">${esc(r.location)}</span></span>295 </a></td>296 <td><span class="chip">${esc(r.category)}</span></td>297 <td>${metric ? `<span class="mini" style="font-size:13px">${nf(r.metrics[metric])} <span style="color:var(--muted)">· ${esc(r.handles[Object.keys(PLATFORMS).find(k => PLATFORMS[k].metric === metric)] ? "@" + r.handles[Object.keys(PLATFORMS).find(k => PLATFORMS[k].metric === metric)] : "")}</span></span>` : `<span class="minis">${minis || '<span class="mini" style="color:var(--muted)">—</span>'}</span>`}</td>298 <td class="num">${nf(val)}<span class="u">${metric ? PLATFORMS[Object.keys(PLATFORMS).find(k => PLATFORMS[k].metric === metric)].unit : "cumulés"}</span></td>299 ${networth ? `<td class="num">${r.networth ? `${nm(r.networth.low)}–${nm(r.networth.high)}` : "—"}<span class="u">valeur nette est.</span></td>` : ""}300 <td class="meter"><div class="track" style="width:${w}%" title="${nfull(val)}"></div></td>301 </tr>`;302 }).join("");303 return `<div class="board"><table>304 <thead><tr><th>#</th><th>Influenceur</th><th>Catégorie</th><th>${metric ? "Compte" : "Plateformes"}</th><th style="text-align:right">Audience</th>${networth ? '<th style="text-align:right">Valeur nette est.</th>' : ""}<th></th></tr></thead>305 <tbody>${body}</tbody></table></div>`;306}307308function statTile(label, value, note) {309 return `<div class="tile"><div class="lab">${esc(label)}</div><div class="val">${value}</div>${note ? `<div class="note">${esc(note)}</div>` : ""}</div>`;310}311312/* ---------- routes ---------- */313const app = express();314app.disable("x-powered-by");315app.use("/public", express.static(path.join(__dirname, "public")));316317app.get("/", (req, res) => {318 const q = (req.query.q || "").trim();319 const cat = (req.query.categorie || "").trim();320 let rows = RANKED;321 if (q) {322 const nq = slugify(q).replace(/-/g, " ");323 rows = DATA.filter(r =>324 slugify(r.name).replace(/-/g, " ").includes(nq) ||325 Object.values(r.handles).some(h => h && h.includes(q.toLowerCase()))326 ).sort((a, b) => (b.total_followers || 0) - (a.total_followers || 0));327 }328 if (cat) rows = rows.filter(r => r.category === cat);329 const cumul = RANKED.reduce((s, r) => s + r.total_followers, 0);330 const content = `331 <div class="kicker">Province de Québec · 6 plateformes</div>332 <h1 style="margin-top:6px">${q ? `Résultats pour « ${esc(q)} »` : cat ? `Catégorie ${esc(cat)}` : "Le classement des influenceurs du Québec"}</h1>333 <p class="sub">${q || cat ? `<a href="/" style="color:var(--accent-deep);font-weight:500">← Retour au classement complet</a>` : "Audience cumulée sur Instagram, TikTok, YouTube, Facebook, X et Twitch — compilée à partir des classements publics."}</p>334 ${!q && !cat ? `<div class="tiles">335 ${statTile("Influenceurs répertoriés", nfull(DATA.length))}336 ${statTile("Audience cumulée", nf(cumul), "toutes plateformes confondues")}337 ${statTile("Créateurs méga (1M+)", nfull(RANKED.filter(r => r.tier.startsWith("méga")).length))}338 ${statTile("Catégories", nfull(CATEGORIES.length))}339 </div>` : ""}340 <div class="count">${nfull(rows.length)} influenceur${rows.length > 1 ? "s" : ""} · triés par audience totale</div>341 ${rankTable(rows)}`;342 res.send(layout({ title: q ? `Recherche : ${q}` : "Classement global", active: "global", content }));343});344345app.get("/plateforme/:p", (req, res) => {346 const p = PLATFORMS[req.params.p];347 if (!p) return res.status(404).send(notFound());348 const rows = DATA.filter(r => r.metrics[p.metric]).sort((a, b) => b.metrics[p.metric] - a.metrics[p.metric]);349 const cumul = rows.reduce((s, r) => s + r.metrics[p.metric], 0);350 const content = `351 <div class="kicker">Classement par plateforme</div>352 <h1 style="margin-top:6px">Top ${p.label} au Québec</h1>353 <p class="sub">Les créateurs québécois classés par nombre d'${p.unit} sur ${p.label}.</p>354 <div class="tiles">355 ${statTile(`Créateurs sur ${p.label}`, nfull(rows.length))}356 ${statTile("Audience cumulée", nf(cumul), p.label)}357 ${statTile("N° 1", rows.length ? esc(rows[0].name) : "—", rows.length ? nf(rows[0].metrics[p.metric]) + " " + p.unit : "")}358 </div>359 ${rankTable(rows, { metric: p.metric })}`;360 res.send(layout({ title: `Top ${p.label}`, active: "p:" + req.params.p, content }));361});362363app.get("/categories", (req, res) => {364 const cards = CATEGORIES.map(c => {365 const rows = RANKED.filter(r => r.category === c);366 const total = rows.reduce((s, r) => s + r.total_followers, 0);367 const top = rows[0];368 return `<a class="card" style="display:block" href="/categorie/${encodeURIComponent(c)}">369 <div style="display:flex;justify-content:space-between;align-items:baseline">370 <strong style="font-size:16px;letter-spacing:-.01em;text-transform:capitalize">${esc(c)}</strong>371 <span class="chip">${rows.length + (DATA.filter(r => r.category === c).length - rows.length)} créateurs</span>372 </div>373 <div style="margin-top:10px;font-size:13px;color:var(--ink-2)">Audience cumulée <strong style="color:var(--ink)">${nf(total)}</strong></div>374 ${top ? `<div style="margin-top:4px;font-size:13px;color:var(--muted)">N° 1 : ${esc(top.name)} (${nf(top.total_followers)})</div>` : ""}375 </a>`;376 }).join("");377 const content = `378 <div class="kicker">Explorer</div>379 <h1 style="margin-top:6px">Catégories</h1>380 <p class="sub">Les influenceurs québécois par domaine de création.</p>381 <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px;margin-top:24px">${cards}</div>`;382 res.send(layout({ title: "Catégories", active: "cats", content }));383});384385app.get("/categorie/:c", (req, res) => {386 const c = req.params.c;387 if (!CATEGORIES.includes(c)) return res.status(404).send(notFound());388 const rows = DATA.filter(r => r.category === c).sort((a, b) => (b.total_followers || 0) - (a.total_followers || 0));389 const content = `390 <div class="kicker"><a href="/categories" style="color:inherit">Catégories</a></div>391 <h1 style="margin-top:6px;text-transform:capitalize">${esc(c)}</h1>392 <p class="sub">${nfull(rows.length)} créateurs québécois dans cette catégorie, triés par audience totale.</p>393 <div style="height:18px"></div>394 ${rankTable(rows)}`;395 res.send(layout({ title: `Catégorie ${c}`, active: "cats", content }));396});397398app.get("/influenceur/:slug", (req, res) => {399 const r = bySlug.get(req.params.slug);400 if (!r) return res.status(404).send(notFound());401 const plats = Object.entries(PLATFORMS).filter(([k, p]) => r.metrics[p.metric] || r.handles[k]);402 const withMetric = plats.filter(([k, p]) => r.metrics[p.metric]);403 const max = Math.max(1, ...withMetric.map(([k, p]) => r.metrics[p.metric]));404 const catRows = RANKED.filter(x => x.category === r.category);405 const rankCat = catRows.findIndex(x => x.slug === r.slug) + 1;406407 const bars = withMetric.sort((a, b) => r.metrics[b[1].metric] - r.metrics[a[1].metric]).map(([k, p]) => `408 <div class="brow" title="${p.label} : ${nfull(r.metrics[p.metric])} ${p.unit}">409 <span class="pl">${ICONS[k]}${p.label}</span>410 <span class="bwrap"><span class="b" style="width:${Math.max(1.5, r.metrics[p.metric] / max * 78)}%"></span><span class="v">${nf(r.metrics[p.metric])}</span></span>411 </div>`).join("");412413 const socials = plats.map(([k, p]) => r.handles[k] ? `414 <a href="${p.url(r.handles[k])}" target="_blank" rel="noopener">415 ${ICONS[k]}<span class="h">@${esc(r.handles[k])}</span>416 <span class="c">${r.metrics[p.metric] ? nf(r.metrics[p.metric]) + " " + p.unit : p.label}</span>417 </a>` : `418 <a style="pointer-events:none;opacity:.75">${ICONS[k]}<span class="h" style="font-weight:500;color:var(--ink-2)">${p.label}</span>419 <span class="c">${nf(r.metrics[p.metric])} ${p.unit}</span></a>`).join("");420421 const notes = (r.notes || "").split(" | ").filter(Boolean);422 const content = `423 <div class="crumbs"><a href="/">Classement</a> · <a href="/categorie/${encodeURIComponent(r.category)}">${esc(r.category)}</a> · ${esc(r.name)}</div>424 <div class="hero">425 ${avatar(r)}426 <div>427 <h1>${esc(r.name)}</h1>428 <div class="badges">429 ${r.rank_global ? `<span class="badge">N° ${r.rank_global} au Québec</span>` : ""}430 <span class="badge ghost" style="text-transform:capitalize">${esc(r.category)}</span>431 <span class="badge ghost">${esc(r.location)}</span>432 <span class="badge ghost">${esc(r.tier)}</span>433 </div>434 </div>435 </div>436 <div class="tiles">437 ${statTile("Audience totale", nf(r.total_followers), "toutes plateformes")}438 ${statTile("Plateformes actives", nfull(r.platform_count))}439 ${r.rank_global ? statTile("Rang global", "N° " + r.rank_global, `sur ${nfull(RANKED.length)} classés`) : ""}440 ${rankCat ? statTile(`Rang · ${r.category}`, "N° " + rankCat, `sur ${nfull(catRows.length)}`) : ""}441 </div>442 <div class="cols">443 <div class="card">444 <h2>Audience par plateforme</h2>445 ${withMetric.length ? `<div class="viz">${bars}<div class="brow" style="margin-top:-6px"><span></span><span class="axis"><span>0</span><span>${nf(max)}</span></span></div></div>` : `<p class="notes">Aucune métrique chiffrée disponible pour ce profil.</p>`}446 ${notes.length ? `<h2 style="margin-top:26px">À propos</h2><p class="notes">${notes.map(esc).join("<br>")}</p>` : ""}447 <div class="srcs">${(r.sources || []).map(s => `<span>${esc(s)}</span>`).join("")}</div>448 </div>449 <div style="display:grid;gap:16px;align-content:start">450 ${r.networth ? `<div class="card"><h2>Valeur nette estimée</h2>451 <div style="font-size:25px;font-weight:700;letter-spacing:-.02em">${nm(r.networth.low)} – ${nm(r.networth.high)}</div>452 <div style="font-size:12.5px;color:var(--muted);margin-top:3px">revenu annuel estimé ${nm(r.networth.annual)} · ~${r.networth.years} ans d'activité</div>453 <div class="kv" style="margin-top:12px">454 ${r.networth.parts.map(p => { const key = Object.keys(PLATFORMS).find(k => PLATFORMS[k].metric === p.metric); return `<div class="row"><span class="k">${PLATFORMS[key].label}</span><span class="v">${nm(p.amount)}/an</span></div>`; }).join("")}455 </div>456 <div class="gal-cap">Estimation indicative fondée sur l'audience publique — <a href="/methodologie" style="color:var(--accent-deep)">voir la méthodologie</a>.</div>457 </div>` : ""}458 <div class="card"><h2>Réseaux sociaux</h2><div class="soc">${socials || '<p class="notes">Aucun compte répertorié.</p>'}</div></div>459 <div class="card"><h2>Fiche</h2><div class="kv">460 <div class="row"><span class="k">Nom</span><span class="v">${esc(r.name)}</span></div>461 <div class="row"><span class="k">Catégorie</span><span class="v" style="text-transform:capitalize">${esc(r.category)}</span></div>462 <div class="row"><span class="k">Localisation</span><span class="v">${esc(r.location)}</span></div>463 <div class="row"><span class="k">Tier</span><span class="v">${esc(r.tier)}</span></div>464 <div class="row"><span class="k">Audience totale</span><span class="v">${nfull(r.total_followers)}</span></div>465 </div></div>466 </div>467 </div>468 ${(() => {469 const d = DOSSIERS[r.name];470 if (!d || (!(d.facts || []).length && !(d.news || []).length)) return "";471 const dated = (d.facts || []).filter(f => f.date).sort((a, b) => String(b.date).localeCompare(String(a.date)));472 const undated = (d.facts || []).filter(f => !f.date);473 const srcChip = f => f.source_url ? `<span class="src"><a href="${esc(f.source_url)}" target="_blank" rel="noopener" title="${esc(f.source_title || "")}">${esc(domain(f.source_url) || "source")}</a></span>` : "";474 const tl = dated.map(f => `475 <div class="tl-item">476 <div class="tl-date">${esc(fmtDate(f.date))}</div>477 <div class="tl-text">${esc(f.text)}${srcChip(f)}</div>478 </div>`).join("");479 const facts = undated.map(f => `480 <div class="fact"><span>${esc(f.text)}${srcChip(f)}</span></div>`).join("");481 const news = (d.news || []).slice(0, 8).map(n => `482 <a href="${esc(n.url)}" target="_blank" rel="noopener">483 <div class="nt">${esc(n.title)}</div>484 <div class="nm">${esc(domain(n.url))}${n.date ? " · " + esc(n.date) : ""}</div>485 ${n.snippet ? `<div class="ns">${esc(n.snippet)}</div>` : ""}486 </a>`).join("");487 return `${tl ? `<div class="card" style="margin-top:16px">488 <h2>Parcours — chronologie</h2><div class="tl">${tl}</div>489 <div class="gal-cap">Chronologie compilée par recherche web (août 2026) — chaque fait est lié à sa source.</div>490 </div>` : ""}491 <div class="cols" style="margin-top:16px">492 ${facts ? `<div class="card"><h2>Faits saillants</h2><div class="facts">${facts}</div>493 <div class="gal-cap">Faits compilés par recherche web (août 2026) — chaque fait est lié à sa source.</div></div>` : "<div></div>"}494 ${news ? `<div class="card"><h2>Dans l'actualité</h2><div class="news">${news}</div></div>` : ""}495 </div>`;496 })()}497 ${(GALLERY[r.name] || []).length ? `498 <div class="card" style="margin-top:16px">499 <h2>Galerie</h2>500 <div class="gal">501 ${GALLERY[r.name].slice(0, 9).map(im => `502 <a href="${esc(im.page || im.src)}" target="_blank" rel="noopener" title="${esc(im.title)}">503 <img src="${esc(im.src)}" alt="${esc(im.title || r.name)}" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentElement.remove()">504 </a>`).join("")}505 </div>506 <div class="gal-cap">Photos issues de la recherche web — cliquer pour ouvrir la source.</div>507 </div>` : ""}`;508 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.` }));509});510511app.get("/recherche", (req, res) => {512 const q = (req.query.q || "").trim();513 const plat = PLATFORMS[req.query.plateforme] ? req.query.plateforme : "";514 const cat = CATEGORIES.includes(req.query.categorie) ? req.query.categorie : "";515 const tier = (req.query.tier || "").trim();516 const min = parseInt(req.query.min || "0", 10) || 0;517 const tri = req.query.tri || "audience";518 const deep = req.query.web !== "0";519 let rows = DATA.slice();520 if (q) {521 const nq = normTxt(q);522 rows = rows.filter(r => {523 const hay = [r.name, r.location, r.category, r.notes || "", ...Object.values(r.handles).filter(Boolean)];524 if (deep) {525 const d = DOSSIERS[r.name];526 if (d) {527 hay.push(...(d.facts || []).map(f => f.text || ""));528 hay.push(...(d.news || []).map(n => `${n.title || ""} ${n.snippet || ""}`));529 }530 }531 return hay.some(h => normTxt(h).includes(nq));532 });533 }534 if (plat) rows = rows.filter(r => r.handles[plat] || r.metrics[PLATFORMS[plat].metric]);535 if (cat) rows = rows.filter(r => r.category === cat);536 if (tier) rows = rows.filter(r => r.tier.startsWith(tier));537 if (min) rows = rows.filter(r => (r.total_followers || 0) >= min);538 if (tri === "networth") rows.sort((a, b) => ((b.networth || {}).high || 0) - ((a.networth || {}).high || 0));539 else if (tri === "nom") rows.sort((a, b) => a.name.localeCompare(b.name, "fr"));540 else if (tri === "plateforme" && plat) rows.sort((a, b) => (b.metrics[PLATFORMS[plat].metric] || 0) - (a.metrics[PLATFORMS[plat].metric] || 0));541 else rows.sort((a, b) => (b.total_followers || 0) - (a.total_followers || 0));542 const opt = (v, lab, cur) => `<option value="${esc(v)}"${v === cur ? " selected" : ""}>${esc(lab)}</option>`;543 const content = `544 <div class="kicker">Moteur de recherche</div>545 <h1 style="margin-top:6px">Recherche avancée</h1>546 <p class="sub">Filtre par plateforme, catégorie et audience — la recherche fouille aussi les faits et actualités compilés du web.</p>547 <form class="filters" action="/recherche" method="get">548 <div class="f" style="flex:1;min-width:200px"><label>Mots-clés</label><input type="search" name="q" value="${esc(q)}" placeholder="Nom, handle, fait, actualité…"></div>549 <div class="f"><label>Plateforme</label><select name="plateforme">${opt("", "Toutes", plat)}${Object.entries(PLATFORMS).map(([k, p]) => opt(k, p.label, plat)).join("")}</select></div>550 <div class="f"><label>Catégorie</label><select name="categorie">${opt("", "Toutes", cat)}${CATEGORIES.map(c => opt(c, c, cat)).join("")}</select></div>551 <div class="f"><label>Tier</label><select name="tier">${opt("", "Tous", tier)}${opt("méga", "Méga (1M+)", tier)}${opt("macro", "Macro (100K+)", tier)}${opt("micro", "Micro (10K+)", tier)}</select></div>552 <div class="f"><label>Audience min.</label><select name="min">${[["0", "—"], ["10000", "10 k"], ["100000", "100 k"], ["500000", "500 k"], ["1000000", "1 M"], ["5000000", "5 M"]].map(([v, l]) => opt(v, l, String(min))).join("")}</select></div>553 <div class="f"><label>Tri</label><select name="tri">${opt("audience", "Audience totale", tri)}${opt("networth", "Valeur nette est.", tri)}${plat ? opt("plateforme", "Abonnés " + PLATFORMS[plat].label, tri) : ""}${opt("nom", "Nom (A–Z)", tri)}</select></div>554 <div class="f"><label>Recherche web</label><select name="web">${opt("1", "Incluse", deep ? "1" : "0")}${opt("0", "Profils seulement", deep ? "1" : "0")}</select></div>555 <button type="submit">Rechercher</button>556 <a class="reset" href="/recherche">Réinitialiser</a>557 </form>558 <div class="count">${nfull(rows.length)} résultat${rows.length > 1 ? "s" : ""}${q ? ` pour « ${esc(q)} »` : ""} · valeur nette : <a href="/methodologie" style="color:var(--accent-deep)">méthodologie</a></div>559 ${rankTable(rows, { metric: plat ? PLATFORMS[plat].metric : undefined, networth: true })}`;560 res.send(layout({ title: q ? `Recherche : ${q}` : "Recherche avancée", active: "search", content }));561});562563app.get("/methodologie", (req, res) => {564 const rates = [565 ["Instagram", "0,20 $ CAD/abonné/an", "≈ 10 $ par 1 000 abonnés par publication commanditée × ~20 publications/an"],566 ["TikTok", "0,08 $ CAD/abonné/an", "fonds des créateurs + commandites (tarifs par abonné plus faibles)"],567 ["YouTube", "0,35 $ CAD/abonné/an", "revenus publicitaires (RPM ~2–4 $/1 000 vues × ~15 vues/abonné/an) + intégrations"],568 ["Facebook", "0,05 $ CAD/abonné/an", "monétisation faible des pages"],569 ["X", "0,03 $ CAD/abonné/an", "partage de revenus limité"],570 ["Twitch", "0,50 $ CAD/follower/an", "abonnements payants + bits + commandites"],571 ];572 const content = `573 <div class="kicker">Transparence</div>574 <h1 style="margin-top:6px">Méthodologie</h1>575 <p class="sub">Comment les données sont compilées et comment la valeur nette estimée est calculée.</p>576 <div class="cols" style="margin-top:26px">577 <div class="card">578 <h2>Valeur nette estimée — formule</h2>579 <p class="notes" style="margin-bottom:14px">580 <strong>1. Revenu annuel estimé</strong> = Σ (abonnés par plateforme × taux annuel) × multiplicateur de tier × multiplicateur de catégorie.<br><br>581 <strong>2. Années d'activité</strong> = 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).<br><br>582 <strong>3. Fourchette de valeur nette (en $ CAD)</strong> : 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.583 </p>584 <h2>Taux annuels par plateforme (CAD)</h2>585 <div class="kv">586 ${rates.map(([p, t, j]) => `<div class="row"><span class="k">${p} — ${t}</span><span class="v" style="font-weight:400;color:var(--ink-2);text-align:right;max-width:55%">${j}</span></div>`).join("")}587 </div>588 <h2 style="margin-top:22px">Multiplicateurs</h2>589 <p class="notes">590 <strong>Tier</strong> : méga (1M+) ×1,25 · macro ×1,0 · micro ×0,75 · nano ×0,5 — les grands comptes commandent des tarifs unitaires supérieurs.<br>591 <strong>Catégorie</strong> : 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.592 </p>593 <div class="gal-cap" style="margin-top:16px">⚠️ Ces montants sont des <strong>estimations indicatives</strong> 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.</div>594 </div>595 <div style="display:grid;gap:16px;align-content:start">596 <div class="card">597 <h2>Sources des données</h2>598 <p class="notes">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.</p>599 </div>600 <div class="card">601 <h2>Limites</h2>602 <p class="notes">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.</p>603 </div>604 </div>605 </div>`;606 res.send(layout({ title: "Méthodologie", active: "", content }));607});608609app.get("/api/influenceurs", (req, res) => res.json(DATA));610app.get("/health", (req, res) => res.json({ ok: true, count: DATA.length }));611612function notFound() {613 return layout({ title: "Introuvable", active: "", content: `<div class="empty" style="padding:100px 20px"><h1 style="font-size:22px">Page introuvable</h1><p class="sub"><a href="/" style="color:var(--accent-deep)">← Retour au classement</a></p></div>` });614}615app.use((req, res) => res.status(404).send(notFound()));616617app.listen(PORT, () => console.log(`InfluenceursQC en ligne — port ${PORT} · ${DATA.length} influenceurs`));618