Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 70.4%
HTML 18.4%
JavaScript 4%
Python 3.8%
CSS 3.4%
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