SPB Git

spb/qc-influenceurs Public

InfluenceursQC — classement des influenceurs du Québec.

JavaScript 83.3% Python 16.7%
42.4 KB · 618 lines javascript
Raw Blame History
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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