SPB Git forge

spb/ka-stats

Public
8commits 1branches 0releases
2.2 MBsize
maindefault branch
26 days agolast push
JavaScript 73.4% CSS 25.3% HTML 1.3%
21.8 KB · 395 lines javascript
Raw Blame History
1/* -----------------------------------------------------------------------------2  Auteur : Simon-Pierre Boucher — contact@spboucher.ai3  Fichier : ka-stats/server.mjs4  Desc.  : Ka·Stats — l'explorateur de statistiques du Québec (Groupe KA).5           Serveur Node ZÉRO dépendance : sert le front statique et agrège les6           dashboards /api/stats/dashboard (contrat ka-stats v2) des 127           plateformes du Groupe KA, avec cache mémoire + disque et8           stale-while-error (un satellite injoignable ⇒ dernière bonne copie).9  Usage  : node server.mjs [port]   (défaut 8140) — healthcheck: /healthz10----------------------------------------------------------------------------- */11import http from "node:http";12import fs from "node:fs";13import path from "node:path";14import { fileURLToPath } from "node:url";1516const PORT = Number(process.argv[2] || process.env.PORT || 8140);17const ROOT = path.dirname(fileURLToPath(import.meta.url));18const PUB = path.join(ROOT, "public");19const DATA = path.join(ROOT, "data");20const CACHE_FILE = path.join(DATA, "cache.json");21const TTL_MS = 10 * 60 * 1000; // 10 min par (site, période)22fs.mkdirSync(DATA, { recursive: true });2324/* ---- Les 13 plateformes publiques du Groupe KA (contrat ka-stats v2) ----25   Accents = ecosystem.json de ka-ui (job-ka relevé sur le site). */26const SITES = [27  { id: "lou-ka",    wordmark: "Lou·Ka",    domain: "www.lou-ka.com",    accent: "#ff6a00", accentSoft: "#fff1e6", accentDeep: "#cc5500", onAccent: "#ffffff", tagline: "Tous les logements à louer",                theme: "habitation" },28  { id: "immo-ka",   wordmark: "Immo·Ka",   domain: "www.immo-ka.com",   accent: "#e23744", accentSoft: "#fbe0e2", accentDeep: "#a8232e", onAccent: "#ffffff", tagline: "Toutes les propriétés à vendre",            theme: "habitation" },29  { id: "house-ka",  wordmark: "House·Ka",  domain: "www.house-ka.com",  accent: "#0f6b4f", accentSoft: "#dcefe6", accentDeep: "#0a4a37", onAccent: "#ffffff", tagline: "Maisons à vendre — Canada hors Québec (EN)",  theme: "habitation" },30  { id: "vrai-prix", wordmark: "Vrai-Prix", domain: "www.vrai-prix.com", accent: "#ff5148", accentSoft: "#ffe3e0", accentDeep: "#9e2a25", onAccent: "#ffffff", tagline: "La valeur réelle de chaque propriété",      theme: "habitation" },31  { id: "auto-ka",   wordmark: "Auto·Ka",   domain: "www.auto-ka.com",   accent: "#ff5a2a", accentSoft: "#ffe8de", accentDeep: "#cc3f16", onAccent: "#ffffff", tagline: "Les voitures usagées du Québec",            theme: "mobilite" },32  { id: "job-ka",    wordmark: "Job·Ka",    domain: "www.job-ka.com",    accent: "#0c8599", accentSoft: "#def0f4", accentDeep: "#095c6b", onAccent: "#ffffff", tagline: "Tous les emplois des employeurs québécois", theme: "emploi" },33  { id: "food-ka",   wordmark: "Food·Ka",   domain: "www.food-ka.com",   accent: "#1f9d55", accentSoft: "#e2f5ea", accentDeep: "#157a40", onAccent: "#ffffff", tagline: "Les prix d'épicerie, suivis à la source",   theme: "consommation" },34  { id: "resto-ka",  wordmark: "Resto·Ka",  domain: "www.resto-ka.com",  accent: "#f08c00", accentSoft: "#fdeed7", accentDeep: "#b96a00", onAccent: "#141814", tagline: "Chaque resto, chaque plat, chaque prix",    theme: "consommation" },35  { id: "fabri-ka",  wordmark: "Fabri·Ka",  domain: "www.fabri-ka.com",  accent: "#c4532e", accentSoft: "#f7e3da", accentDeep: "#a94525", onAccent: "#ffffff", tagline: "Les produits fabriqués au Québec",          theme: "consommation" },36  { id: "sorti-ka",  wordmark: "Sorti·Ka",  domain: "www.sorti-ka.com",  accent: "#d6336c", accentSoft: "#fbe0eb", accentDeep: "#a12551", onAccent: "#ffffff", tagline: "Toutes les sorties, dans les 17 régions",   theme: "culture" },37  { id: "crea-ka",   wordmark: "Créa·Ka",   domain: "www.crea-ka.com",   accent: "#7048e8", accentSoft: "#ece5fc", accentDeep: "#5433b8", onAccent: "#ffffff", tagline: "Les créateurs d'ici, tous leurs liens",     theme: "culture" },38  { id: "trouve-ka", wordmark: "Trouve·Ka", domain: "www.trouve-ka.com", accent: "#1c7ed6", accentSoft: "#e7f2fd", accentDeep: "#14508f", onAccent: "#ffffff", tagline: "Le moteur de recherche du web québécois",   theme: "culture" },39  { id: "rent-ka",  wordmark: "Rent·Ka",  domain: "www.rent-ka.com",  accent: "#2456e6", accentSoft: "#e9efff", accentDeep: "#1738a8", onAccent: "#ffffff", tagline: "Locations résidentielles — Canada hors Québec", theme: "habitation" },40  { id: "api-ka",    wordmark: "API·Ka",    domain: "www.api-ka.com",    accent: "#3b5bdb", accentSoft: "#e4eafb", accentDeep: "#2b44a8", onAccent: "#ffffff", tagline: "La donnée de l'écosystème, par API",        theme: "infrastructure" },41];42const SITE_BY_ID = Object.fromEntries(SITES.map((s) => [s.id, s]));4344const PERIODS = new Set(["auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"]);45const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;4647/* ---- Cache (mémoire + disque) ---- */48let cache = {};49try { cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); } catch { cache = {}; }50let persistTimer = null;51function persistCache() {52  clearTimeout(persistTimer);53  persistTimer = setTimeout(() => {54    // On ne persiste que les périodes standard (pas les plages custom, illimitées)55    const keep = {};56    for (const [k, v] of Object.entries(cache)) if (!k.includes("from=")) keep[k] = v;57    fs.writeFile(CACHE_FILE, JSON.stringify(keep), () => {});58  }, 2000);59}6061const inflight = new Map();6263function buildQuery(q) {64  const period = PERIODS.has(q.period) ? q.period : "30j";65  const parts = [`period=${period}`];66  if (DATE_RE.test(q.from || "") && DATE_RE.test(q.to || "")) {67    parts.push(`from=${q.from}`, `to=${q.to}`);68  }69  return parts.join("&");70}7172async function fetchRemote(site, query) {73  const url = `https://${site.domain}/api/stats/dashboard?${query}`;74  const ac = new AbortController();75  const timer = setTimeout(() => ac.abort(), 45000);76  try {77    const r = await fetch(url, {78      signal: ac.signal,79      headers: { "User-Agent": "ka-stats/1.0 (Groupe KA; +https://www.ka-stats.com)", Accept: "application/json" },80    });81    if (!r.ok) throw new Error(`HTTP ${r.status}`);82    let j = await r.json();83    if (j && j.success === true && j.data) j = j.data; // enveloppe api-ka84    if (!j || typeof j !== "object" || (!j.kpis && !j.series)) throw new Error("payload inattendu");85    return j;86  } finally {87    clearTimeout(timer);88  }89}9091async function getDashboard(siteId, q) {92  const site = SITE_BY_ID[siteId];93  if (!site) return null;94  const query = buildQuery(q);95  const key = `${siteId}|${query}`;96  const hit = cache[key];97  const now = Date.now();98  if (hit && now - hit.at < TTL_MS) return { site: siteId, ok: true, stale: false, fetched: hit.at, data: hit.data };99  if (inflight.has(key)) return inflight.get(key);100  const p = (async () => {101    try {102      const data = await fetchRemote(site, query);103      cache[key] = { at: Date.now(), data };104      persistCache();105      return { site: siteId, ok: true, stale: false, fetched: cache[key].at, data };106    } catch (e) {107      if (hit) return { site: siteId, ok: true, stale: true, fetched: hit.at, error: String(e.message || e), data: hit.data };108      return { site: siteId, ok: false, stale: false, error: String(e.message || e), data: null };109    } finally {110      inflight.delete(key);111    }112  })();113  inflight.set(key, p);114  return p;115}116117/* ---- Vue d'ensemble consolidée (calculée serveur, légère) ---- */118const MONEY_RE = /\$/;119function summarize(res) {120  const site = SITE_BY_ID[res.site];121  if (!res.ok || !res.data) return { id: res.site, ok: false, error: res.error || "injoignable" };122  const d = res.data;123  const kpis = Array.isArray(d.kpis) ? d.kpis : [];124  const primary = kpis[0] || null;125  const spark = (primary && primary.spark) || (d.series && d.series[0] && d.series[0].points) || [];126  const money = kpis.filter((k) => MONEY_RE.test(k.unit || "")).slice(0, 4)127    .map((k) => ({ label: k.label, value: k.value, unit: k.unit, delta_pct: k.delta_pct ?? null, spark: k.spark || null }));128  return {129    id: res.site, ok: true, stale: !!res.stale, updated: d.updated || null, fetched: res.fetched,130    period: d.period || null,131    primary: primary ? { id: primary.id, label: primary.label, value: primary.value, unit: primary.unit || "", delta_pct: primary.delta_pct ?? null } : null,132    spark: spark.slice(-90),133    kpis: kpis.slice(0, 8).map((k) => ({ id: k.id, label: k.label, value: k.value, unit: k.unit || "", delta_pct: k.delta_pct ?? null })),134    money,135    gauges: (d.gauges || []).slice(0, 3),136    records: (d.records || []).slice(0, 4),137    sections: {138      series: (d.series || []).length, multiseries: (d.multiseries || []).length,139      stacked: (d.stacked || []).length, breakdowns: (d.breakdowns || []).length,140      distributions: (d.distributions || []).length, tables: (d.tables || []).length,141      geo: d.geo ? 1 : 0, heatmap: d.heatmap ? 1 : 0, hourly: d.hourly ? 1 : 0,142      records: (d.records || []).length, kpis: kpis.length, gauges: (d.gauges || []).length,143    },144    theme: site.theme,145  };146}147148async function getOverview(q) {149  const results = await Promise.all(SITES.map((s) => getDashboard(s.id, q)));150  const sites = results.map(summarize);151  // Volume agrégé par jour — même règle que le hub : seuls les points datés au152  // JOUR comptent (vrai-prix publie une série annuelle → exclue), un site doit153  // avoir ≥ 8 points quotidiens, on reporte la dernière valeur connue (stocks)154  // et on ne garde que les jours couverts par ≥ 70 % des sites inclus.155  const daily = sites156    .filter((s) => s.ok && Array.isArray(s.spark))157    .map((s) => ({ id: s.id, pts: s.spark.filter((p) => p && DATE_RE.test(String(p.t)) && typeof p.v === "number") }))158    .filter((s) => s.pts.length >= 8);159  const dates = [...new Set(daily.flatMap((s) => s.pts.map((p) => p.t)))].sort();160  const totalSpark = [];161  const lastVal = new Map();162  const ptMaps = daily.map((s) => new Map(s.pts.map((p) => [p.t, p.v])));163  for (const t of dates) {164    let sum = 0, covered = 0;165    daily.forEach((s, i) => {166      const v = ptMaps[i].get(t);167      if (typeof v === "number") lastVal.set(s.id, v);168      if (lastVal.has(s.id)) { sum += lastVal.get(s.id); covered++; }169    });170    if (covered >= Math.ceil(daily.length * 0.7)) totalSpark.push({ t, v: Math.round(sum) });171  }172  const totalItems = sites.reduce((acc, s) => acc + (s.ok && s.primary && typeof s.primary.value === "number" ? s.primary.value : 0), 0);173  const indicators = [];174  for (const s of sites) {175    if (!s.ok) continue;176    for (const m of s.money) indicators.push({ site: s.id, ...m });177  }178  const records = [];179  for (const [i, s] of sites.entries()) {180    if (!s.ok) continue;181    for (const r of s.records) records.push({ site: s.id, ...r });182  }183  const chartsTotal = sites.reduce((acc, s) => {184    if (!s.ok || !s.sections) return acc;185    const c = s.sections;186    return acc + c.series + c.multiseries + c.stacked + c.breakdowns + c.distributions + c.geo + c.heatmap + c.hourly + c.kpis + c.gauges;187  }, 0);188  return {189    updated: new Date().toISOString(),190    period: sites.find((s) => s.ok && s.period)?.period || null,191    totals: { items: totalItems, sitesLive: sites.filter((s) => s.ok).length, sites: SITES.length, charts: chartsTotal, spark: totalSpark },192    sites, indicators, records,193  };194}195196/* ---- Catalogue de métriques (pour le Studio d'indicateurs sur mesure) ----197   Aplati tout ce qui est traçable dans les 13 dashboards en références198   adressables : site|kpi|<id> · site|series|<id> · site|multi|<id>|<i> ·199   site|stacked|<id>|<i>. /api/catalog = métadonnées ; /api/metric = points. */200function catalogOf(siteId, d) {201  const site = SITE_BY_ID[siteId];202  const out = [];203  const push = (ref, label, unit, n) => out.push({ ref, site: siteId, wordmark: site.wordmark, label, unit: unit || "", n });204  for (const k of d.kpis || []) {205    if (Array.isArray(k.spark) && k.spark.length > 2) push(`${siteId}|kpi|${k.id}`, k.label, k.unit, k.spark.length);206  }207  for (const s of d.series || []) {208    if (Array.isArray(s.points) && s.points.length > 1) push(`${siteId}|series|${s.id}`, s.title, s.unit, s.points.length);209  }210  for (const m of d.multiseries || []) {211    (m.series || []).forEach((sub, i) => {212      if (Array.isArray(sub.points) && sub.points.length > 1) push(`${siteId}|multi|${m.id}|${i}`, `${m.title} — ${sub.label}`, m.unit, sub.points.length);213    });214  }215  for (const st of d.stacked || []) {216    (st.keys || []).forEach((key, i) => {217      const pts = (st.points || []).filter((p) => Array.isArray(p.values) && typeof p.values[i] === "number");218      if (pts.length > 1) push(`${siteId}|stacked|${st.id}|${i}`, `${st.title} — ${key}`, st.unit, pts.length);219    });220  }221  return out;222}223224function resolveMetric(d, kind, id, sub) {225  if (kind === "kpi") {226    const k = (d.kpis || []).find((x) => x.id === id);227    return k && k.spark ? { points: k.spark, unit: k.unit || "", label: k.label } : null;228  }229  if (kind === "series") {230    const s = (d.series || []).find((x) => x.id === id);231    return s ? { points: s.points || [], unit: s.unit || "", label: s.title } : null;232  }233  if (kind === "multi") {234    const m = (d.multiseries || []).find((x) => x.id === id);235    const s = m && (m.series || [])[Number(sub)];236    return s ? { points: s.points || [], unit: m.unit || "", label: `${m.title} — ${s.label}` } : null;237  }238  if (kind === "stacked") {239    const st = (d.stacked || []).find((x) => x.id === id);240    if (!st) return null;241    const i = Number(sub);242    const key = (st.keys || [])[i];243    if (key === undefined) return null;244    const points = (st.points || []).filter((p) => Array.isArray(p.values)).map((p) => ({ t: p.t, v: p.values[i] ?? 0 }));245    return { points, unit: st.unit || "", label: `${st.title} — ${key}` };246  }247  return null;248}249250async function getCatalog(q) {251  const results = await Promise.all(SITES.map((s) => getDashboard(s.id, q)));252  const metrics = [];253  for (const r of results) if (r.ok && r.data) metrics.push(...catalogOf(r.site, r.data));254  return { updated: new Date().toISOString(), count: metrics.length, metrics };255}256257async function getMetric(ref, q) {258  const [siteId, kind, id, sub] = String(ref).split("|");259  if (!SITE_BY_ID[siteId]) return null;260  const r = await getDashboard(siteId, q);261  if (!r.ok || !r.data) return null;262  const m = resolveMetric(r.data, kind, id, sub);263  if (!m) return null;264  return { ref, site: siteId, ...m, stale: !!r.stale };265}266267/* ---- HTTP ---- */268const MIME = {269  ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",270  ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",271  ".ttf": "font/ttf", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".xml": "application/xml; charset=utf-8",272  ".webmanifest": "application/manifest+json; charset=utf-8",273};274275function sendJSON(res, code, obj) {276  const body = JSON.stringify(obj);277  res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=120", "Access-Control-Allow-Origin": "*" });278  res.end(body);279}280281/* ---- SEO : SSR léger des metas — title/description/canonical/og + JSON-LD282   par route, injectés dans index.html (SPA rendue client, metas rendues283   serveur — même approche que les autres sites Ka). ---- */284const BASE_URL = "https://www.ka-stats.com";285const SEO_ROUTES = {286  "/": {287    title: "Ka·Stats — L'explorateur de statistiques du Québec",288    desc: "Loyers, propriétés, autos, emplois, épicerie, restos, sorties, créateurs : les statistiques vivantes du Québec, agrégées en direct depuis les 13 plateformes du Groupe KA. Graphiques interactifs, tendances, palmarès et rapports PDF.",289  },290  "/indicateurs": {291    title: "Les chiffres du Québec en direct — loyers, prix, salaires | Ka·Stats",292    desc: "Les indicateurs du Québec par thème : loyer moyen, prix des propriétés, valeur foncière, prix des voitures usagées, salaires affichés, prix d'épicerie — extraits en direct des plateformes du Groupe KA.",293  },294  "/comparer": {295    title: "Comparer les plateformes du Groupe KA — indice base 100 | Ka·Stats",296    desc: "Comparez la croissance des plateformes du Groupe KA sur un seul axe (indice base 100) : logements, propriétés, autos, emplois, produits, événements, créateurs, pages indexées.",297  },298  "/studio": {299    title: "Studio d'indicateurs — construisez vos graphiques sur mesure | Ka·Stats",300    desc: "Construisez votre propre indicateur du Québec : choisissez jusqu'à 4 métriques parmi plus de 200, appliquez une transformation (indice 100, variation, moyenne mobile, cumul) ou un ratio, choisissez le type de graphique, enregistrez et partagez.",301  },302  "/palmares": {303    title: "Palmarès & records des données du Québec | Ka·Stats",304    desc: "Jours records, plus fortes croissances et faits marquants détectés dans les données des 13 plateformes du Groupe KA : logements, propriétés, autos, emplois, épicerie, restos, sorties et plus.",305  },306};307function seoFor(pathname) {308  const clean = pathname.replace(/\/+$/, "") || "/";309  if (SEO_ROUTES[clean]) return { ...SEO_ROUTES[clean], path: clean === "/" ? "/" : clean };310  const m = /^\/site\/([a-z0-9-]+)$/.exec(clean);311  if (m && SITE_BY_ID[m[1]]) {312    const s = SITE_BY_ID[m[1]];313    return {314      title: `Statistiques ${s.wordmark} — ${s.tagline} | Ka·Stats`,315      desc: `Le tableau de bord statistique complet de ${s.wordmark} (${s.tagline.charAt(0).toLowerCase()}${s.tagline.slice(1)}) : indicateurs clés, évolution quotidienne, répartitions, distributions, records et rapports PDF — mis à jour en continu par Ka·Stats, l'observatoire du Groupe KA.`,316      path: clean,317    };318  }319  return { ...SEO_ROUTES["/"], path: "/" };320}321const escHtml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");322let indexCache = { mtime: 0, html: "" };323function renderIndex(pathname) {324  const file = path.join(PUB, "index.html");325  const mtime = fs.statSync(file).mtimeMs;326  if (mtime !== indexCache.mtime) indexCache = { mtime, html: fs.readFileSync(file, "utf8") };327  const m = seoFor(pathname);328  const url = BASE_URL + (m.path === "/" ? "/" : m.path);329  const jsonld = JSON.stringify({330    "@context": "https://schema.org",331    "@graph": [332      { "@type": "Organization", "@id": "https://www.groupe-ka.com/#org", name: "Groupe KA", url: "https://www.groupe-ka.com", logo: BASE_URL + "/apple-touch-icon.png", email: "contact@groupe-ka.com", areaServed: "CA-QC" },333      { "@type": "WebSite", "@id": BASE_URL + "/#website", name: "Ka·Stats", alternateName: "Ka-Stats — L'explorateur de statistiques du Québec", url: BASE_URL + "/", inLanguage: "fr-CA", publisher: { "@id": "https://www.groupe-ka.com/#org" } },334      { "@type": "WebPage", "@id": url + "#webpage", url, name: m.title, description: m.desc, inLanguage: "fr-CA", isPartOf: { "@id": BASE_URL + "/#website" }, primaryImageOfPage: BASE_URL + "/og.png" },335    ],336  });337  return indexCache.html338    .replace(/<title>[^<]*<\/title>/, `<title>${escHtml(m.title)}</title>`)339    .replace(/(<meta name="description" content=")[^"]*(")/, `$1${escHtml(m.desc)}$2`)340    .replace(/(<meta property="og:title" content=")[^"]*(")/, `$1${escHtml(m.title)}$2`)341    .replace(/(<meta property="og:description" content=")[^"]*(")/, `$1${escHtml(m.desc)}$2`)342    .replace(/(<meta name="twitter:title" content=")[^"]*(")/, `$1${escHtml(m.title)}$2`)343    .replace(/(<meta property="og:url" content=")[^"]*(")/, `$1${url}$2`)344    .replace("<!--SEO-->", `<link rel="canonical" href="${url}" />\n  <script type="application/ld+json">${jsonld}</script>`);345}346347function sendFile(res, file) {348  const ext = path.extname(file).toLowerCase();349  const mime = MIME[ext] || "application/octet-stream";350  const cacheCtl = ext === ".html" ? "no-cache" : "public, max-age=3600";351  fs.readFile(file, (err, buf) => {352    if (err) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("404"); return; }353    res.writeHead(200, { "Content-Type": mime, "Cache-Control": cacheCtl });354    res.end(buf);355  });356}357358const server = http.createServer(async (req, res) => {359  const u = new URL(req.url, "http://x");360  const p = u.pathname;361  const q = Object.fromEntries(u.searchParams.entries());362  try {363    if (p === "/healthz" || p === "/api/health") return sendJSON(res, 200, { ok: true, app: "ka-stats", sites: SITES.length, uptime: process.uptime() });364    if (p === "/api/sites") return sendJSON(res, 200, { sites: SITES });365    if (p === "/api/overview") return sendJSON(res, 200, await getOverview(q));366    if (p === "/api/catalog") return sendJSON(res, 200, await getCatalog(q));367    if (p === "/api/metric") {368      const m = await getMetric(q.ref || "", q);369      return m ? sendJSON(res, 200, m) : sendJSON(res, 404, { error: "métrique introuvable", ref: q.ref || "" });370    }371    if (p.startsWith("/api/dashboard/")) {372      const id = p.slice("/api/dashboard/".length).replace(/[^a-z0-9-]/g, "");373      if (!SITE_BY_ID[id]) return sendJSON(res, 404, { error: "plateforme inconnue", id });374      const r = await getDashboard(id, q);375      return sendJSON(res, r.ok ? 200 : 502, r);376    }377    if (p.startsWith("/api/")) return sendJSON(res, 404, { error: "route inconnue" });378    // Statique + repli SPA avec metas SSR (routes /, /site/:id, /indicateurs, /comparer, /studio, /palmares)379    const file = path.normalize(path.join(PUB, p === "/" ? "index.html" : p));380    if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }381    if (p === "/" || p === "/index.html" || !path.extname(file) || !fs.existsSync(file)) {382      const html = renderIndex(p);383      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });384      return res.end(html);385    }386    return sendFile(res, file);387  } catch (e) {388    return sendJSON(res, 500, { error: String((e && e.message) || e) });389  }390});391392server.listen(PORT, "0.0.0.0", () => {393  console.log(`[ka-stats] en écoute sur :${PORT} — ${SITES.length} plateformes agrégées, cache ${TTL_MS / 60000} min`);394});395