SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%
9.4 KB · 304 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Métriques LIVE de l'écosystème — mesurées sur les plateformes elles-mêmes3// (endpoint public /api/stats de chaque site, cache serveur 10 min). Chaque4// valeur a un repli figé (dernier état connu du 2026-08-18) : si une5// plateforme ne répond pas, le site mère affiche le repli plutôt que zéro.6// Les champs « riches » (moyennes, géo, santé des syncs, croisement7// Immo·Ka × Vrai-Prix…) suivent le même contrat : repli champ par champ,8// jamais de rendu cassé.910type Json = Record<string, unknown>;1112async function fetchJson(url: string): Promise<Json | null> {13  try {14    const res = await fetch(url, {15      next: { revalidate: 600 },16      signal: AbortSignal.timeout(6000),17      headers: { accept: "application/json" },18    });19    if (!res.ok) return null;20    return (await res.json()) as Json;21  } catch {22    return null;23  }24}2526const num = (o: Json | null, path: string, fallback: number): number => {27  let cur: unknown = o;28  for (const k of path.split(".")) {29    if (cur == null || typeof cur !== "object") return fallback;30    cur = (cur as Json)[k];31  }32  return typeof cur === "number" && Number.isFinite(cur) ? cur : fallback;33};3435const str = (o: Json | null, path: string, fallback: string | null): string | null => {36  let cur: unknown = o;37  for (const k of path.split(".")) {38    if (cur == null || typeof cur !== "object") return fallback;39    cur = (cur as Json)[k];40  }41  return typeof cur === "string" && cur.length > 0 ? cur : fallback;42};4344/** Nombre de clés d'un objet de répartition (by_platform, by_niche…). */45const keyCount = (o: Json | null, key: string, fallback: number): number =>46  o && typeof o[key] === "object" && o[key] !== null && !Array.isArray(o[key])47    ? Object.keys(o[key] as Json).length48    : fallback;4950/** Santé des dernières synchros d'un connecteur (recent_syncs / sync_log). */51export type SyncHealth = {52  runs: number;53  ok: number;54  added: number;55  updated: number;56  removed: number;57} | null;5859function syncSummary(o: Json | null, key = "recent_syncs"): SyncHealth {60  const arr = o?.[key];61  if (!Array.isArray(arr) || arr.length === 0) return null;62  const s = { runs: 0, ok: 0, added: 0, updated: 0, removed: 0 };63  for (const it of arr) {64    if (!it || typeof it !== "object") continue;65    const r = it as Json;66    s.runs += 1;67    if (r.ok === 1 || r.ok === true || r.status === "ok") s.ok += 1;68    for (const k of ["added", "updated", "removed"] as const) {69      const v = r[k];70      if (typeof v === "number" && Number.isFinite(v)) s[k] += v;71    }72  }73  return s.runs > 0 ? s : null;74}7576// Sorti·Ka n'expose pas (encore) de comptage de connecteurs sur /api/stats77// (vérifié 2026-08-18 : total_active, upcoming, free_upcoming, cities,78// by_region, by_category seulement).79// TODO : lire le champ dès qu'il sera publié côté sorti-ka.80export const SORTIKA_CONNECTORS = 8; // billetteries & données ouvertes actives8182export type LiveMetrics = {83  louka: {84    total: number;85    sources: number;86    avgPrice: number;87    geo: { quebec: number; levis: number; montreal: number };88    syncs: SyncHealth;89  };90  immoka: {91    total: number;92    sources: number;93    cities: number;94    avgPrice: number;95    // Croisement inter-plateformes calculé par Immo·Ka : écart entre le prix96    // demandé et l'évaluation municipale (Vrai-Prix), toutes bannières.97    vraiprix: {98      n: number;99      medianDeltaPct: number;100      pctSur10: number;101      pctJuste: number;102      pctSous5: number;103    };104    syncs: SyncHealth;105  };106  autoka: {107    total: number;108    sources: number;109    regions: number;110    avgPrice: number;111    avgKm: number;112    avgYear: number;113    syncs: SyncHealth;114  };115  fabrika: {116    products: number;117    stores: number;118    storesRegistry: number;119    regions: number;120    syncs: SyncHealth;121  };122  foodka: {123    total: number;124    sources: number;125    onSale: number;126    avgPrice: number;127    syncs: SyncHealth;128  };129  sortika: {130    active: number;131    free: number;132    upcoming: number;133    cities: number;134    regions: number;135  };136  vraiprix: {137    units: number;138    transactions: number;139    valueTotal: number;140    valueMedian: number;141    growthPct: number;142    municipalities: number;143    modelVersion: string | null;144    updated: string | null;145  };146  jobka: {147    total: number;148    employers: number;149    withSalary: number;150    avgSalaryYear: number;151    remote: number;152    topCities: { city: string; n: number }[];153    syncs: SyncHealth;154  };155  totals: { connectors: number; items: number };156};157158const topCities = (o: Json | null): { city: string; n: number }[] => {159  const arr = o?.top_cities;160  if (Array.isArray(arr)) {161    const out = arr162      .filter(163        (c): c is { city: string; n: number } =>164          !!c &&165          typeof c === "object" &&166          typeof (c as Json).city === "string" &&167          typeof (c as Json).n === "number",168      )169      .slice(0, 3);170    if (out.length > 0) return out;171  }172  // repli figé (2026-08-18)173  return [174    { city: "Montréal", n: 1671 },175    { city: "Québec", n: 814 },176    { city: "Laval", n: 145 },177  ];178};179180export async function getLiveMetrics(): Promise<LiveMetrics> {181  const [lou, immo, auto, fab, food, sorti, vp, job] =182    await Promise.all([183      fetchJson("https://www.lou-ka.com/api/stats"),184      fetchJson("https://www.immo-ka.com/api/stats"),185      fetchJson("https://www.auto-ka.com/api/stats"),186      fetchJson("https://www.fabri-ka.com/api/stats"),187      fetchJson("https://www.food-ka.com/api/stats"),188      fetchJson("https://www.sorti-ka.com/api/stats"),189      fetchJson("https://www.vrai-prix.com/api/stats"),190      fetchJson("https://www.job-ka.com/api/stats"),191    ]);192193  const m: LiveMetrics = {194    louka: {195      total: num(lou, "total", 33115),196      sources: num(lou, "sources", 207),197      avgPrice: num(lou, "avg_price", 1786),198      geo: {199        quebec: num(lou, "quebec", 3117),200        levis: num(lou, "levis", 1557),201        montreal: num(lou, "montreal", 16466),202      },203      syncs: syncSummary(lou),204    },205    immoka: {206      total: num(immo, "total", 67079),207      sources: num(immo, "sources", 38),208      cities: num(immo, "cities", 3114),209      avgPrice: num(immo, "avg_price", 686690),210      vraiprix: {211        n: num(immo, "vraiprix.ensemble.n", 44396),212        medianDeltaPct: num(immo, "vraiprix.ensemble.median_delta_pct", 9.0),213        pctSur10: num(immo, "vraiprix.ensemble.pct_sur10", 48.6),214        pctJuste: num(immo, "vraiprix.ensemble.pct_juste", 24.2),215        pctSous5: num(immo, "vraiprix.ensemble.pct_sous5", 27.2),216      },217      syncs: syncSummary(immo),218    },219    autoka: {220      total: num(auto, "total", 17098),221      sources: num(auto, "sources", 125),222      regions: num(auto, "regions", 18),223      avgPrice: num(auto, "avg_price", 28576),224      avgKm: num(auto, "avg_km", 82680),225      avgYear: num(auto, "avg_year", 2021),226      syncs: syncSummary(auto),227    },228    fabrika: {229      products: num(fab, "totals.products", 291557),230      stores: num(fab, "totals.stores_live", 1265),231      storesRegistry: num(fab, "totals.stores_registry", 3224),232      regions: num(fab, "totals.regions", 17),233      syncs: syncSummary(fab, "sync_log"),234    },235    foodka: {236      total: num(food, "total", 28050),237      sources: num(food, "sources", 43),238      onSale: num(food, "on_sale", 4922),239      avgPrice: num(food, "avg_price", 10.48),240      syncs: syncSummary(food),241    },242    sortika: {243      active: num(sorti, "total_active", 15229),244      free: num(sorti, "free_upcoming", 3159),245      upcoming: num(sorti, "upcoming", 11666),246      cities: num(sorti, "cities", 428),247      regions: keyCount(sorti, "by_region", 17),248    },249    vraiprix: {250      units: num(vp, "units_total", 3747008),251      transactions: num(vp, "transactions", 745119),252      valueTotal: num(vp, "value_total_2026", 2009452277400),253      valueMedian: num(vp, "value_median_2026", 444300),254      growthPct: num(vp, "growth_2021_2026_pct", 43.1),255      municipalities: num(vp, "municipalities", 1097),256      modelVersion: str(vp, "model_version", null),257      updated: str(vp, "updated", null),258    },259    jobka: {260      total: num(job, "total", 4449),261      employers: num(job, "employers", 135),262      withSalary: num(job, "with_salary", 1069),263      avgSalaryYear: num(job, "avg_salary_year", 72499),264      remote: num(job, "remote", 351),265      topCities: topCities(job),266      syncs: syncSummary(job),267    },268    totals: { connectors: 0, items: 0 },269  };270271  m.totals.connectors =272    m.louka.sources +273    m.immoka.sources +274    m.autoka.sources +275    m.foodka.sources +276    m.jobka.employers +277    SORTIKA_CONNECTORS;278  m.totals.items =279    m.louka.total +280    m.immoka.total +281    m.autoka.total +282    m.fabrika.products +283    m.foodka.total +284    m.sortika.active +285    m.jobka.total;286287  return m;288}289290/* ---- Formatage fr-CA ---- */291const NBSP = " ";292export const fmtInt = (n: number): string =>293  Math.round(n).toLocaleString("fr-CA").replace(/\s/g, NBSP);294295/** Nombre décimal fr-CA (1 décimale max) — pour les pourcentages. */296export const fmtDec = (n: number): string =>297  n.toLocaleString("fr-CA", { maximumFractionDigits: 1 }).replace(/\s/g, NBSP);298299/** Arrondi « éditorial » vers le bas + suffixe « + » : 33 115 → « 33 100+ ». */300export const fmtPlus = (n: number): string => {301  const step = n >= 1000000 ? 10000 : n >= 100000 ? 1000 : n >= 10000 ? 100 : n >= 1000 ? 100 : 10;302  return `${fmtInt(Math.floor(n / step) * step)}+`;303};304