Connecteurs enrichis : champs riches /api/stats captés et affichés
- live.ts : avg_price (Lou/Immo/Auto/Food), géo Lou·Ka, salaires+remote+top villes Job·Ka, croissance/médiane/modèle Vrai-Prix, indexed_last_hour/frontier/ embedding_coverage Trouve·Ka, tiers+niches Créa·Ka, bloc vraiprix d'Immo·Ka (écart prix demandé vs évaluation municipale), santé des syncs (recent_syncs/ sync_log) — repli figé champ par champ, jamais de rendu cassé - Sorti·Ka : constante SORTIKA_CONNECTORS=8 (TODO : lire le champ quand publié) au lieu du « 4 » codé en dur ; ticker plex daté (ValoPlex sans /api/stats) - Accueil : chips enrichies (loyer/prix/salaire moyens, niches, croissance), carte « santé des syncs », insight Immo·Ka × Vrai-Prix, file de crawl et couverture sémantique Trouve·Ka - /stats : section « répartitions » (breakdowns + geo du SPEC) des deux plus grosses plateformes jointes ; ecostats.ts typé breakdowns/geo - Ticker : loyer/salaire/prix moyens, +43,1 % 2021→2026, pages/heure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 changed files +481 −28
modified
src/app/layout.tsx
+9 −5
@@ -2,7 +2,7 @@ | ||
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google"; |
| 4 | 4 | import UserButton from "./UserButton"; |
| 5 | −import { getLiveMetrics, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live"; | |
| 5 | +import { getLiveMetrics, fmtDec, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live"; | |
| 6 | 6 | import "./globals.css"; |
| 7 | 7 | |
| 8 | 8 | const spaceGrotesk = Space_Grotesk({ |
@@ -58,19 +58,23 @@ const NAV_ITEMS = [ | ||
| 58 | 58 | const tickerItems = (m: LiveMetrics): string[] => [ |
| 59 | 59 | "·Ka = agréger — mille sites, un seul endroit", |
| 60 | 60 | `Trouve·Ka cherche le Québec — ${fmtPlus(m.trouveka.pages)} pages du web d'ici indexées`, |
| 61 | + `Trouve·Ka indexe ${fmtInt(m.trouveka.indexedLastHour)} pages à l'heure — le robot ne dort pas`, | |
| 61 | 62 | "Dix agrégateurs · une obsession : la transparence", |
| 62 | 63 | `${fmtInt(m.totals.connectors)}+ connecteurs lisent les sources jour et nuit`, |
| 63 | − `${fmtPlus(m.louka.total)} logements à louer agrégés en continu`, | |
| 64 | + `${fmtPlus(m.louka.total)} logements à louer agrégés en continu — loyer moyen ${fmtInt(m.louka.avgPrice)} $`, | |
| 64 | 65 | `${fmtPlus(m.immoka.total)} propriétés à vendre, lues à la source`, |
| 65 | − `${fmtPlus(m.autoka.total)} voitures usagées chez ${m.autoka.sources} concessionnaires`, | |
| 66 | + `Valeur résidentielle du Québec : +${fmtDec(m.vraiprix.growthPct)} % de 2021 à 2026 — vrai-prix.com`, | |
| 67 | + `${fmtPlus(m.autoka.total)} voitures usagées chez ${m.autoka.sources} concessionnaires — prix moyen ${fmtInt(m.autoka.avgPrice)} $`, | |
| 66 | 68 | `${fmtPlus(m.fabrika.products)} produits québécois de ${fmtInt(m.fabrika.stores)} boutiques`, |
| 67 | 69 | `${fmtPlus(m.foodka.total)} prix d'épicerie suivis chez ${m.foodka.sources} bannières`, |
| 68 | 70 | `${fmtPlus(m.restoka.items)} plats et prix réels chez ${fmtPlus(m.restoka.restaurants)} restos — resto-ka.com`, |
| 69 | 71 | `${fmtPlus(m.sortika.active)} sorties et événements dans les 17 régions — sorti-ka.com`, |
| 70 | 72 | `${fmtPlus(m.creaka.creators)} créateurs québécois, tous leurs liens regroupés — crea-ka.com`, |
| 71 | − `${fmtPlus(m.jobka.total)} offres d'emploi chez ${m.jobka.employers} employeurs — job-ka.com`, | |
| 73 | + `${fmtPlus(m.jobka.total)} offres d'emploi chez ${m.jobka.employers} employeurs — salaire moyen ${fmtInt(m.jobka.avgSalaryYear)} $/an`, | |
| 72 | 74 | `${fmtInt(m.vraiprix.units)} propriétés évaluées au Québec`, |
| 73 | − "393 867 plex · 1 733 744 portes", | |
| 75 | + // ValoPlex n'expose pas de /api/stats (vérifié 2026-08-18) : chiffres du | |
| 76 | + // parc au rôle foncier 2026, datés plutôt qu'inventés. | |
| 77 | + "393 867 plex · 1 733 744 portes au rôle foncier 2026 — valoplex.com", | |
| 74 | 78 | "Ka2 & Ka4 cartographient le web québécois — ka2.bot · ka4.bot", |
| 75 | 79 | "Zéro boîte noire — tout est automatisé, rien n'est inventé", |
| 76 | 80 | ]; |
modified
src/app/page.tsx
+115 −4
@@ -8,7 +8,14 @@ import { | ||
| 8 | 8 | ConnectorMatrix, |
| 9 | 9 | CoverageTiles, |
| 10 | 10 | } from "./figures"; |
| 11 | −import { getLiveMetrics, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live"; | |
| 11 | +import { | |
| 12 | + getLiveMetrics, | |
| 13 | + fmtDec, | |
| 14 | + fmtInt, | |
| 15 | + fmtPlus, | |
| 16 | + SORTIKA_CONNECTORS, | |
| 17 | + type LiveMetrics, | |
| 18 | +} from "@/lib/live"; | |
| 12 | 19 | |
| 13 | 20 | // Métriques live re-mesurées au plus toutes les 10 minutes (ISR). |
| 14 | 21 | export const revalidate = 600; |
@@ -566,17 +573,20 @@ function liveChips(m: LiveMetrics): Record<string, string[]> { | ||
| 566 | 573 | "www.lou-ka.com": [ |
| 567 | 574 | `${fmtPlus(m.louka.total)} annonces`, |
| 568 | 575 | `${m.louka.sources} connecteurs actifs`, |
| 576 | + `Loyer moyen ${fmtInt(m.louka.avgPrice)} $`, | |
| 569 | 577 | "MàJ horaire", |
| 570 | 578 | ], |
| 571 | 579 | "www.immo-ka.com": [ |
| 572 | 580 | `${fmtPlus(m.immoka.total)} propriétés`, |
| 573 | 581 | `${m.immoka.sources} connecteurs`, |
| 574 | 582 | `${fmtInt(m.immoka.cities)} villes`, |
| 583 | + `Prix moyen ${fmtInt(m.immoka.avgPrice)} $`, | |
| 575 | 584 | ], |
| 576 | 585 | "www.auto-ka.com": [ |
| 577 | 586 | `${fmtPlus(m.autoka.total)} véhicules`, |
| 578 | 587 | `${m.autoka.sources} concessionnaires`, |
| 579 | 588 | `${m.autoka.regions} régions`, |
| 589 | + `Prix moyen ${fmtInt(m.autoka.avgPrice)} $ · ${fmtInt(m.autoka.avgKm)} km`, | |
| 580 | 590 | ], |
| 581 | 591 | "www.fabri-ka.com": [ |
| 582 | 592 | `${fmtPlus(m.fabrika.products)} produits`, |
@@ -602,20 +612,75 @@ function liveChips(m: LiveMetrics): Record<string, string[]> { | ||
| 602 | 612 | `${fmtPlus(m.creaka.creators)} créateurs`, |
| 603 | 613 | `${fmtPlus(m.creaka.accounts)} comptes reliés`, |
| 604 | 614 | `${m.creaka.platforms} plateformes`, |
| 615 | + `${m.creaka.niches} niches`, | |
| 605 | 616 | ], |
| 606 | 617 | "www.job-ka.com": [ |
| 607 | 618 | `${fmtPlus(m.jobka.total)} offres`, |
| 608 | 619 | `${m.jobka.employers} employeurs`, |
| 609 | − `${fmtPlus(m.jobka.withSalary)} salaires publiés`, | |
| 620 | + `Salaire moyen ${fmtInt(m.jobka.avgSalaryYear)} $/an`, | |
| 621 | + `${fmtInt(m.jobka.remote)} en télétravail`, | |
| 610 | 622 | ], |
| 611 | 623 | "www.vrai-prix.com": [ |
| 612 | 624 | `${fmtInt(m.vraiprix.units)} propriétés estimées`, |
| 613 | 625 | `${fmtPlus(m.vraiprix.transactions)} transactions réelles`, |
| 626 | + `+${fmtDec(m.vraiprix.growthPct)} % de valeur 2021→2026`, | |
| 614 | 627 | "Fourchette P10–P90", |
| 615 | 628 | ], |
| 616 | 629 | }; |
| 617 | 630 | } |
| 618 | 631 | |
| 632 | +/** Santé des dernières synchros, plateforme par plateforme (recent_syncs). | |
| 633 | + * Une plateforme qui n'expose pas (ou plus) son journal disparaît de la | |
| 634 | + * liste — jamais de ligne inventée. */ | |
| 635 | +function SyncHealthCard({ m }: { m: LiveMetrics }) { | |
| 636 | + const rows = [ | |
| 637 | + { name: "Lou·Ka", syncs: m.louka.syncs }, | |
| 638 | + { name: "Immo·Ka", syncs: m.immoka.syncs }, | |
| 639 | + { name: "Auto·Ka", syncs: m.autoka.syncs }, | |
| 640 | + { name: "Fabri·Ka", syncs: m.fabrika.syncs }, | |
| 641 | + { name: "Food·Ka", syncs: m.foodka.syncs }, | |
| 642 | + { name: "Resto·Ka", syncs: m.restoka.syncs }, | |
| 643 | + { name: "Job·Ka", syncs: m.jobka.syncs }, | |
| 644 | + ].filter((r): r is { name: string; syncs: NonNullable<LiveMetrics["louka"]["syncs"]> } => | |
| 645 | + r.syncs !== null, | |
| 646 | + ); | |
| 647 | + if (rows.length === 0) return null; | |
| 648 | + return ( | |
| 649 | + <div className="gk-card p-5 sm:p-7"> | |
| 650 | + <p className="klabel">Santé des syncs · dernières passes des connecteurs</p> | |
| 651 | + <h3 className="gk-display mt-2 text-[17px] font-bold uppercase"> | |
| 652 | + La machine, prise sur le fait | |
| 653 | + </h3> | |
| 654 | + <div className="mt-5 space-y-3"> | |
| 655 | + {rows.map(({ name, syncs: s }) => ( | |
| 656 | + <div | |
| 657 | + key={name} | |
| 658 | + className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-dashed border-[rgba(20,24,20,0.18)] pb-2" | |
| 659 | + > | |
| 660 | + <span className="gk-display text-[13.5px] font-bold whitespace-nowrap"> | |
| 661 | + {name} | |
| 662 | + </span> | |
| 663 | + <span className="gk-mono text-[11.5px] whitespace-nowrap"> | |
| 664 | + <span className={s.ok === s.runs ? "font-bold text-green" : "font-bold"}> | |
| 665 | + {s.ok}/{s.runs} OK | |
| 666 | + </span> | |
| 667 | + <span className="text-ink-3"> | |
| 668 | + {" "} | |
| 669 | + · +{fmtInt(s.added)} · ~{fmtInt(s.updated)} · −{fmtInt(s.removed)} | |
| 670 | + </span> | |
| 671 | + </span> | |
| 672 | + </div> | |
| 673 | + ))} | |
| 674 | + </div> | |
| 675 | + <p className="gk-mono mt-4 text-[10.5px] leading-relaxed text-ink-3"> | |
| 676 | + Journal réel des dernières synchronisations publié par chaque | |
| 677 | + plateforme : passes réussies, annonces ajoutées (+), mises à jour (~) | |
| 678 | + et retirées (−). Lu en direct, cache 10 min. | |
| 679 | + </p> | |
| 680 | + </div> | |
| 681 | + ); | |
| 682 | +} | |
| 683 | + | |
| 619 | 684 | export default async function Home() { |
| 620 | 685 | const m = await getLiveMetrics(); |
| 621 | 686 | const chips = liveChips(m); |
@@ -726,12 +791,15 @@ export default async function Home() { | ||
| 726 | 791 | <span className="stat-chip !text-[11px]"> |
| 727 | 792 | {fmtPlus(m.trouveka.domains)} domaines d'ici |
| 728 | 793 | </span> |
| 794 | + <span className="stat-chip !text-[11px]"> | |
| 795 | + {fmtInt(m.trouveka.indexedLastHour)} pages indexées / heure | |
| 796 | + </span> | |
| 729 | 797 | <span className="stat-chip !text-[11px]">100 % Québec</span> |
| 730 | 798 | </div> |
| 731 | 799 | <ul className="mt-5 space-y-2 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.25)] pt-5"> |
| 732 | 800 | {[ |
| 733 | 801 | "Résultats 100 % québécois — l'index ne contient que le web d'ici", |
| 734 | − "État du moteur public : pages, domaines et file de crawl à découvert", | |
| 802 | + `État du moteur public : ${fmtInt(m.trouveka.frontierPending)} pages en file de crawl, ${fmtDec(m.trouveka.embeddingCoverage * 100)} % de l'index couvert par la recherche sémantique`, | |
| 735 | 803 | "Soumettez votre site — l'indexation sur demande, ouverte à tous", |
| 736 | 804 | "Robot dédié, documenté, qui respecte robots.txt", |
| 737 | 805 | ].map((f) => ( |
@@ -863,7 +931,7 @@ export default async function Home() { | ||
| 863 | 931 | { name: "Immo·Ka", count: m.immoka.sources, kind: "agences" }, |
| 864 | 932 | { name: "Resto·Ka", count: m.restoka.chains, kind: "chaînes de restos" }, |
| 865 | 933 | { name: "Job·Ka", count: m.jobka.employers, kind: "employeurs (pages carrières)" }, |
| 866 | − { name: "Sorti·Ka", count: 4, kind: "billetteries & données ouvertes" }, | |
| 934 | + { name: "Sorti·Ka", count: SORTIKA_CONNECTORS, kind: "billetteries & données ouvertes" }, | |
| 867 | 935 | ]} |
| 868 | 936 | total={m.totals.connectors} |
| 869 | 937 | /> |
@@ -886,6 +954,49 @@ export default async function Home() { | ||
| 886 | 954 | </div> |
| 887 | 955 | <CoverageTiles vraiPrixUnits={m.vraiprix.units} /> |
| 888 | 956 | </div> |
| 957 | + <div className="mt-6 grid items-stretch gap-6 lg:grid-cols-2"> | |
| 958 | + <SyncHealthCard m={m} /> | |
| 959 | + {/* Croisement inter-plateformes calculé par Immo·Ka : prix demandé | |
| 960 | + vs évaluation municipale (Vrai-Prix), toutes bannières. */} | |
| 961 | + <div className="gk-card !bg-ink p-6 text-paper sm:p-7"> | |
| 962 | + <p className="kicker !text-lime"> | |
| 963 | + Immo·Ka × Vrai-Prix · le croisement | |
| 964 | + </p> | |
| 965 | + <p className="gk-display mt-4 text-[clamp(26px,3.5vw,38px)] leading-[1.05] font-bold tracking-[-0.03em] uppercase"> | |
| 966 | + Prix demandé : | |
| 967 | + <br /> | |
| 968 | + <span className="text-lime"> | |
| 969 | + +{fmtDec(m.immoka.vraiprix.medianDeltaPct)} % | |
| 970 | + </span>{" "} | |
| 971 | + au-dessus | |
| 972 | + <br /> | |
| 973 | + de l'évaluation | |
| 974 | + </p> | |
| 975 | + <p className="mt-4 max-w-md text-[13.5px] text-[rgba(245,243,238,0.75)]"> | |
| 976 | + Sur {fmtInt(m.immoka.vraiprix.n)} annonces croisées avec | |
| 977 | + l'évaluation municipale (Vrai-Prix), l'écart médian | |
| 978 | + entre le prix demandé et l'évaluation est de + | |
| 979 | + {fmtDec(m.immoka.vraiprix.medianDeltaPct)} %. C'est ce que | |
| 980 | + deux agrégateurs qui se parlent peuvent mesurer — personne | |
| 981 | + d'autre ne le publie. | |
| 982 | + </p> | |
| 983 | + <ul className="mt-5 space-y-2 border-t border-[rgba(245,243,238,0.15)] pt-5 text-[13px] text-[rgba(245,243,238,0.75)]"> | |
| 984 | + {[ | |
| 985 | + `${fmtDec(m.immoka.vraiprix.pctSur10)} % des annonces à plus de +10 % de l'évaluation`, | |
| 986 | + `${fmtDec(m.immoka.vraiprix.pctJuste)} % proches de l'évaluation`, | |
| 987 | + `${fmtDec(m.immoka.vraiprix.pctSous5)} % nettement sous l'évaluation`, | |
| 988 | + ].map((f) => ( | |
| 989 | + <li key={f} className="flex gap-3"> | |
| 990 | + <span | |
| 991 | + aria-hidden="true" | |
| 992 | + className="mt-[6px] h-[5px] w-[5px] flex-none rotate-45 bg-lime" | |
| 993 | + /> | |
| 994 | + {f} | |
| 995 | + </li> | |
| 996 | + ))} | |
| 997 | + </ul> | |
| 998 | + </div> | |
| 999 | + </div> | |
| 889 | 1000 | </Section> |
| 890 | 1001 | |
| 891 | 1002 | {/* ---------- Produits ---------- */} |
modified
src/app/stats/page.tsx
+91 −0
@@ -8,10 +8,14 @@ import type { Metadata } from "next"; | ||
| 8 | 8 | import { fmtInt, getLiveMetrics } from "@/lib/live"; |
| 9 | 9 | import { |
| 10 | 10 | PERIODS, |
| 11 | + type Breakdown, | |
| 11 | 12 | type Kpi, |
| 12 | 13 | type PeriodKey, |
| 13 | 14 | type PlatformStats, |
| 15 | + breakdownItems, | |
| 14 | 16 | consolidate, |
| 17 | + firstBreakdown, | |
| 18 | + geoBreakdown, | |
| 15 | 19 | getEcosystemStats, |
| 16 | 20 | isPeriod, |
| 17 | 21 | mainKpi, |
@@ -153,6 +157,67 @@ function PlatformCard({ p }: { p: PlatformStats }) { | ||
| 153 | 157 | ); |
| 154 | 158 | } |
| 155 | 159 | |
| 160 | +/** Format compact d'un nombre de répartition (réutilise la logique KPI). */ | |
| 161 | +const fmtNum = (v: number): string => fmtVal({ label: "", value: v }); | |
| 162 | + | |
| 163 | +/** Liste à barres d'une répartition SPEC (breakdown ou geo). */ | |
| 164 | +function BreakdownBars({ b, n = 6 }: { b: Breakdown; n?: number }) { | |
| 165 | + const items = breakdownItems(b, n); | |
| 166 | + if (items.length === 0) return null; | |
| 167 | + const max = Math.max(...items.map((it) => it.value)); | |
| 168 | + return ( | |
| 169 | + <div> | |
| 170 | + <p className="klabel">{b.title ?? b.label ?? b.id ?? "Répartition"}</p> | |
| 171 | + <div className="mt-3 space-y-3"> | |
| 172 | + {items.map((it) => ( | |
| 173 | + <div key={it.label}> | |
| 174 | + <div className="flex items-baseline justify-between gap-3"> | |
| 175 | + <span className="min-w-0 truncate text-[12px] text-ink-2"> | |
| 176 | + {it.label} | |
| 177 | + </span> | |
| 178 | + <span className="gk-mono flex-none text-[11.5px] font-bold"> | |
| 179 | + {fmtNum(it.value)} | |
| 180 | + </span> | |
| 181 | + </div> | |
| 182 | + <div className="mt-[4px] h-[9px] w-full rounded-r-[3px] bg-[rgba(20,24,20,0.06)]"> | |
| 183 | + <div | |
| 184 | + className="h-full rounded-r-[3px] bg-green" | |
| 185 | + style={{ width: `${Math.max((it.value / max) * 100, 1.5)}%` }} | |
| 186 | + /> | |
| 187 | + </div> | |
| 188 | + </div> | |
| 189 | + ))} | |
| 190 | + </div> | |
| 191 | + </div> | |
| 192 | + ); | |
| 193 | +} | |
| 194 | + | |
| 195 | +/** Carte « répartitions » d'une plateforme phare : 1er breakdown + geo. */ | |
| 196 | +function BreakdownCard({ p }: { p: PlatformStats }) { | |
| 197 | + const bd = firstBreakdown(p.dash); | |
| 198 | + const geo = geoBreakdown(p.dash); | |
| 199 | + if (!bd && !geo) return null; | |
| 200 | + return ( | |
| 201 | + <article className="gk-card p-5 sm:p-6"> | |
| 202 | + <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"> | |
| 203 | + <span className="flex min-w-0 items-center gap-2"> | |
| 204 | + <Pastille accent={p.site.accent} /> | |
| 205 | + <span className="gk-display truncate text-[17px] font-bold tracking-[-0.02em]"> | |
| 206 | + {p.site.wordmark} | |
| 207 | + </span> | |
| 208 | + </span> | |
| 209 | + <span className="gk-mono text-[10.5px] font-bold text-ink-3"> | |
| 210 | + {p.site.domain} | |
| 211 | + </span> | |
| 212 | + </div> | |
| 213 | + <div className="mt-5 grid gap-6 sm:grid-cols-2"> | |
| 214 | + {bd ? <BreakdownBars b={bd} /> : null} | |
| 215 | + {geo ? <BreakdownBars b={geo} n={5} /> : null} | |
| 216 | + </div> | |
| 217 | + </article> | |
| 218 | + ); | |
| 219 | +} | |
| 220 | + | |
| 156 | 221 | export default async function StatsPage({ |
| 157 | 222 | searchParams, |
| 158 | 223 | }: { |
@@ -178,6 +243,12 @@ export default async function StatsPage({ | ||
| 178 | 243 | const reportHref = (mode: "complet" | "synthese") => |
| 179 | 244 | `/api/stats/ecosystem-report?period=${period}&mode=${mode}`; |
| 180 | 245 | |
| 246 | + // Plateformes phares dont le tableau de bord publie des répartitions | |
| 247 | + // (breakdowns / geo du contrat SPEC) — les 2 plus grosses jointes. | |
| 248 | + const richPlatforms = c.ranked | |
| 249 | + .filter((p) => firstBreakdown(p.dash) || geoBreakdown(p.dash)) | |
| 250 | + .slice(0, 2); | |
| 251 | + | |
| 181 | 252 | const banner: { label: string; value: string; note?: string }[] = [ |
| 182 | 253 | { |
| 183 | 254 | label: "Volume principal agrégé", |
@@ -353,6 +424,26 @@ export default async function StatsPage({ | ||
| 353 | 424 | </p> |
| 354 | 425 | </section> |
| 355 | 426 | |
| 427 | + {/* 3b — répartitions des plateformes phares (breakdowns/geo du SPEC) */} | |
| 428 | + {richPlatforms.length > 0 ? ( | |
| 429 | + <section className="mt-14"> | |
| 430 | + <p className="kicker">Sous le capot · répartitions</p> | |
| 431 | + <h2 className="gk-display mt-3 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]"> | |
| 432 | + Les plus grosses, décomposées | |
| 433 | + </h2> | |
| 434 | + <p className="mt-3 max-w-2xl text-[13px] text-ink-2"> | |
| 435 | + Les tableaux de bord des plateformes publient aussi leurs | |
| 436 | + répartitions — par type, par ville, par région. Voici celles des | |
| 437 | + plus gros inventaires joints, lues telles quelles. | |
| 438 | + </p> | |
| 439 | + <div className="mt-5 grid gap-5 lg:grid-cols-2"> | |
| 440 | + {richPlatforms.map((p) => ( | |
| 441 | + <BreakdownCard key={p.site.id} p={p} /> | |
| 442 | + ))} | |
| 443 | + </div> | |
| 444 | + </section> | |
| 445 | + ) : null} | |
| 446 | + | |
| 356 | 447 | {/* 4 — un bloc résumé par plateforme */} |
| 357 | 448 | <section className="mt-14"> |
| 358 | 449 | <p className="kicker">Plateforme par plateforme</p> |
modified
src/lib/ecostats.ts
+39 −0
@@ -18,11 +18,23 @@ export type Kpi = { | ||
| 18 | 18 | |
| 19 | 19 | export type KaRecord = { label: string; value: string; date?: string }; |
| 20 | 20 | |
| 21 | +export type BreakdownItem = { label: string; value: number }; | |
| 22 | + | |
| 23 | +export type Breakdown = { | |
| 24 | + id?: string; | |
| 25 | + title?: string; | |
| 26 | + label?: string; | |
| 27 | + kind?: string; | |
| 28 | + items?: BreakdownItem[]; | |
| 29 | +}; | |
| 30 | + | |
| 21 | 31 | export type Dashboard = { |
| 22 | 32 | updated?: string; |
| 23 | 33 | period?: { from?: string; to?: string; label?: string }; |
| 24 | 34 | kpis?: Kpi[]; |
| 25 | 35 | records?: KaRecord[]; |
| 36 | + breakdowns?: Breakdown[]; | |
| 37 | + geo?: Breakdown | null; | |
| 26 | 38 | }; |
| 27 | 39 | |
| 28 | 40 | export type Site = { |
@@ -89,6 +101,33 @@ export function topKpis(dash: Dashboard | null, n = 3): Kpi[] { | ||
| 89 | 101 | return (dash?.kpis ?? []).filter((k) => isNum(k.value)).slice(0, n); |
| 90 | 102 | } |
| 91 | 103 | |
| 104 | +/** Items valides d'un bloc de répartition (breakdowns[] / geo du SPEC). */ | |
| 105 | +export function breakdownItems(b: Breakdown | null | undefined, n = 6): BreakdownItem[] { | |
| 106 | + if (!b || !Array.isArray(b.items)) return []; | |
| 107 | + return b.items | |
| 108 | + .filter( | |
| 109 | + (it): it is BreakdownItem => | |
| 110 | + !!it && | |
| 111 | + typeof it === "object" && | |
| 112 | + typeof it.label === "string" && | |
| 113 | + isNum(it.value), | |
| 114 | + ) | |
| 115 | + .slice(0, n); | |
| 116 | +} | |
| 117 | + | |
| 118 | +/** Première répartition exploitable d'un tableau de bord SPEC. */ | |
| 119 | +export function firstBreakdown(dash: Dashboard | null): Breakdown | null { | |
| 120 | + for (const b of dash?.breakdowns ?? []) | |
| 121 | + if (breakdownItems(b, 1).length > 0) return b; | |
| 122 | + return null; | |
| 123 | +} | |
| 124 | + | |
| 125 | +/** Bloc géographique du SPEC (top villes / régions), s'il est publié. */ | |
| 126 | +export function geoBreakdown(dash: Dashboard | null): Breakdown | null { | |
| 127 | + const g = dash?.geo; | |
| 128 | + return g && breakdownItems(g, 1).length > 0 ? g : null; | |
| 129 | +} | |
| 130 | + | |
| 92 | 131 | export const dataSites: Site[] = (eco.sites as Site[]).filter( |
| 93 | 132 | (s) => s.id !== "groupe-ka", |
| 94 | 133 | ); |
modified
src/lib/live.ts
+227 −19
@@ -1,8 +1,11 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | // Métriques LIVE de l'écosystème — mesurées sur les plateformes elles-mêmes |
| 3 | 3 | // (endpoint public /api/stats de chaque site, cache serveur 10 min). Chaque |
| 4 | −// valeur a un repli figé (dernier état connu du 2026-08-17) : si une | |
| 4 | +// valeur a un repli figé (dernier état connu du 2026-08-18) : si une | |
| 5 | 5 | // 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, croisement | |
| 7 | +// Immo·Ka × Vrai-Prix…) suivent le même contrat : repli champ par champ, | |
| 8 | +// jamais de rendu cassé. | |
| 6 | 9 | |
| 7 | 10 | type Json = Record<string, unknown>; |
| 8 | 11 | |
@@ -29,21 +32,174 @@ const num = (o: Json | null, path: string, fallback: number): number => { | ||
| 29 | 32 | return typeof cur === "number" && Number.isFinite(cur) ? cur : fallback; |
| 30 | 33 | }; |
| 31 | 34 | |
| 35 | +const 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 | +}; | |
| 43 | + | |
| 44 | +/** Nombre de clés d'un objet de répartition (by_platform, by_niche…). */ | |
| 45 | +const 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).length | |
| 48 | + : fallback; | |
| 49 | + | |
| 50 | +/** Santé des dernières synchros d'un connecteur (recent_syncs / sync_log). */ | |
| 51 | +export type SyncHealth = { | |
| 52 | + runs: number; | |
| 53 | + ok: number; | |
| 54 | + added: number; | |
| 55 | + updated: number; | |
| 56 | + removed: number; | |
| 57 | +} | null; | |
| 58 | + | |
| 59 | +function 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 | +} | |
| 75 | + | |
| 76 | +// Sorti·Ka n'expose pas (encore) de comptage de connecteurs sur /api/stats | |
| 77 | +// (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. | |
| 80 | +export const SORTIKA_CONNECTORS = 8; // billetteries & données ouvertes actives | |
| 81 | + | |
| 32 | 82 | export type LiveMetrics = { |
| 33 | − louka: { total: number; sources: number }; | |
| 34 | − immoka: { total: number; sources: number; cities: number }; | |
| 35 | − autoka: { total: number; sources: number; regions: number }; | |
| 36 | − fabrika: { products: number; stores: number; regions: number }; | |
| 37 | − foodka: { total: number; sources: number; onSale: number }; | |
| 38 | − restoka: { restaurants: number; items: number; chains: number; regions: number }; | |
| 39 | − sortika: { active: number; free: number; cities: number }; | |
| 40 | − creaka: { creators: number; accounts: number; platforms: number }; | |
| 41 | − trouveka: { pages: number; domains: number; running: boolean }; | |
| 42 | − vraiprix: { units: number; transactions: number; valueTotal: number }; | |
| 43 | − jobka: { total: number; employers: number; withSalary: number }; | |
| 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 prix | |
| 96 | + // 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 | + restoka: { | |
| 130 | + restaurants: number; | |
| 131 | + items: number; | |
| 132 | + chains: number; | |
| 133 | + regions: number; | |
| 134 | + syncs: SyncHealth; | |
| 135 | + }; | |
| 136 | + sortika: { | |
| 137 | + active: number; | |
| 138 | + free: number; | |
| 139 | + upcoming: number; | |
| 140 | + cities: number; | |
| 141 | + regions: number; | |
| 142 | + }; | |
| 143 | + creaka: { | |
| 144 | + creators: number; | |
| 145 | + accounts: number; | |
| 146 | + platforms: number; | |
| 147 | + niches: number; | |
| 148 | + tiers: { nano: number; micro: number; macro: number; mega: number }; | |
| 149 | + }; | |
| 150 | + trouveka: { | |
| 151 | + pages: number; | |
| 152 | + domains: number; | |
| 153 | + running: boolean; | |
| 154 | + indexedLastHour: number; | |
| 155 | + errorsLastHour: number; | |
| 156 | + frontierPending: number; | |
| 157 | + embeddingCoverage: number; | |
| 158 | + }; | |
| 159 | + vraiprix: { | |
| 160 | + units: number; | |
| 161 | + transactions: number; | |
| 162 | + valueTotal: number; | |
| 163 | + valueMedian: number; | |
| 164 | + growthPct: number; | |
| 165 | + municipalities: number; | |
| 166 | + modelVersion: string | null; | |
| 167 | + updated: string | null; | |
| 168 | + }; | |
| 169 | + jobka: { | |
| 170 | + total: number; | |
| 171 | + employers: number; | |
| 172 | + withSalary: number; | |
| 173 | + avgSalaryYear: number; | |
| 174 | + remote: number; | |
| 175 | + topCities: { city: string; n: number }[]; | |
| 176 | + syncs: SyncHealth; | |
| 177 | + }; | |
| 44 | 178 | totals: { connectors: number; items: number }; |
| 45 | 179 | }; |
| 46 | 180 | |
| 181 | +const topCities = (o: Json | null): { city: string; n: number }[] => { | |
| 182 | + const arr = o?.top_cities; | |
| 183 | + if (Array.isArray(arr)) { | |
| 184 | + const out = arr | |
| 185 | + .filter( | |
| 186 | + (c): c is { city: string; n: number } => | |
| 187 | + !!c && | |
| 188 | + typeof c === "object" && | |
| 189 | + typeof (c as Json).city === "string" && | |
| 190 | + typeof (c as Json).n === "number", | |
| 191 | + ) | |
| 192 | + .slice(0, 3); | |
| 193 | + if (out.length > 0) return out; | |
| 194 | + } | |
| 195 | + // repli figé (2026-08-18) | |
| 196 | + return [ | |
| 197 | + { city: "Montréal", n: 1671 }, | |
| 198 | + { city: "Québec", n: 814 }, | |
| 199 | + { city: "Laval", n: 145 }, | |
| 200 | + ]; | |
| 201 | +}; | |
| 202 | + | |
| 47 | 203 | export async function getLiveMetrics(): Promise<LiveMetrics> { |
| 48 | 204 | const [lou, immo, auto, fab, food, resto, sorti, crea, trouve, vp, job] = |
| 49 | 205 | await Promise.all([ |
@@ -61,59 +217,107 @@ export async function getLiveMetrics(): Promise<LiveMetrics> { | ||
| 61 | 217 | ]); |
| 62 | 218 | |
| 63 | 219 | const m: LiveMetrics = { |
| 64 | − louka: { total: num(lou, "total", 33115), sources: num(lou, "sources", 207) }, | |
| 220 | + louka: { | |
| 221 | + total: num(lou, "total", 33115), | |
| 222 | + sources: num(lou, "sources", 207), | |
| 223 | + avgPrice: num(lou, "avg_price", 1786), | |
| 224 | + geo: { | |
| 225 | + quebec: num(lou, "quebec", 3117), | |
| 226 | + levis: num(lou, "levis", 1557), | |
| 227 | + montreal: num(lou, "montreal", 16466), | |
| 228 | + }, | |
| 229 | + syncs: syncSummary(lou), | |
| 230 | + }, | |
| 65 | 231 | immoka: { |
| 66 | 232 | total: num(immo, "total", 67079), |
| 67 | 233 | sources: num(immo, "sources", 38), |
| 68 | 234 | cities: num(immo, "cities", 3114), |
| 235 | + avgPrice: num(immo, "avg_price", 686690), | |
| 236 | + vraiprix: { | |
| 237 | + n: num(immo, "vraiprix.ensemble.n", 44396), | |
| 238 | + medianDeltaPct: num(immo, "vraiprix.ensemble.median_delta_pct", 9.0), | |
| 239 | + pctSur10: num(immo, "vraiprix.ensemble.pct_sur10", 48.6), | |
| 240 | + pctJuste: num(immo, "vraiprix.ensemble.pct_juste", 24.2), | |
| 241 | + pctSous5: num(immo, "vraiprix.ensemble.pct_sous5", 27.2), | |
| 242 | + }, | |
| 243 | + syncs: syncSummary(immo), | |
| 69 | 244 | }, |
| 70 | 245 | autoka: { |
| 71 | 246 | total: num(auto, "total", 17098), |
| 72 | 247 | sources: num(auto, "sources", 125), |
| 73 | 248 | regions: num(auto, "regions", 18), |
| 249 | + avgPrice: num(auto, "avg_price", 28576), | |
| 250 | + avgKm: num(auto, "avg_km", 82680), | |
| 251 | + avgYear: num(auto, "avg_year", 2021), | |
| 252 | + syncs: syncSummary(auto), | |
| 74 | 253 | }, |
| 75 | 254 | fabrika: { |
| 76 | 255 | products: num(fab, "totals.products", 291557), |
| 77 | 256 | stores: num(fab, "totals.stores_live", 1265), |
| 257 | + storesRegistry: num(fab, "totals.stores_registry", 3224), | |
| 78 | 258 | regions: num(fab, "totals.regions", 17), |
| 259 | + syncs: syncSummary(fab, "sync_log"), | |
| 79 | 260 | }, |
| 80 | 261 | foodka: { |
| 81 | 262 | total: num(food, "total", 28050), |
| 82 | 263 | sources: num(food, "sources", 43), |
| 83 | 264 | onSale: num(food, "on_sale", 4922), |
| 265 | + avgPrice: num(food, "avg_price", 10.48), | |
| 266 | + syncs: syncSummary(food), | |
| 84 | 267 | }, |
| 85 | 268 | restoka: { |
| 86 | 269 | restaurants: num(resto, "restaurants", 14091), |
| 87 | 270 | items: num(resto, "items", 79159), |
| 88 | 271 | chains: num(resto, "chains", 24), |
| 89 | 272 | regions: num(resto, "regions", 17), |
| 273 | + syncs: syncSummary(resto), | |
| 90 | 274 | }, |
| 91 | 275 | sortika: { |
| 92 | 276 | active: num(sorti, "total_active", 15229), |
| 93 | 277 | free: num(sorti, "free_upcoming", 3159), |
| 278 | + upcoming: num(sorti, "upcoming", 11666), | |
| 94 | 279 | cities: num(sorti, "cities", 428), |
| 280 | + regions: keyCount(sorti, "by_region", 17), | |
| 95 | 281 | }, |
| 96 | 282 | creaka: { |
| 97 | 283 | creators: num(crea, "creators", 5103), |
| 98 | 284 | accounts: num(crea, "accounts", 5664), |
| 99 | − platforms: crea && typeof crea.by_platform === "object" && crea.by_platform | |
| 100 | − ? Object.keys(crea.by_platform as Json).length | |
| 101 | − : 10, | |
| 285 | + platforms: keyCount(crea, "by_platform", 10), | |
| 286 | + niches: keyCount(crea, "by_niche", 20), | |
| 287 | + tiers: { | |
| 288 | + nano: num(crea, "by_tier.nano", 4671), | |
| 289 | + micro: num(crea, "by_tier.micro", 284), | |
| 290 | + macro: num(crea, "by_tier.macro", 112), | |
| 291 | + mega: num(crea, "by_tier.mega", 36), | |
| 292 | + }, | |
| 102 | 293 | }, |
| 103 | 294 | trouveka: { |
| 104 | 295 | pages: num(trouve, "pages_indexed", 1170502), |
| 105 | 296 | domains: num(trouve, "domains_count", 72280), |
| 106 | 297 | running: (trouve?.crawler_state ?? "running") === "running", |
| 298 | + indexedLastHour: num(trouve, "indexed_last_hour", 22505), | |
| 299 | + errorsLastHour: num(trouve, "errors_last_hour", 257), | |
| 300 | + frontierPending: num(trouve, "frontier_pending", 2500000), | |
| 301 | + embeddingCoverage: num(trouve, "embedding_coverage", 1.0), | |
| 107 | 302 | }, |
| 108 | 303 | vraiprix: { |
| 109 | 304 | units: num(vp, "units_total", 3747008), |
| 110 | 305 | transactions: num(vp, "transactions", 745119), |
| 111 | 306 | valueTotal: num(vp, "value_total_2026", 2009452277400), |
| 307 | + valueMedian: num(vp, "value_median_2026", 444300), | |
| 308 | + growthPct: num(vp, "growth_2021_2026_pct", 43.1), | |
| 309 | + municipalities: num(vp, "municipalities", 1097), | |
| 310 | + modelVersion: str(vp, "model_version", null), | |
| 311 | + updated: str(vp, "updated", null), | |
| 112 | 312 | }, |
| 113 | 313 | jobka: { |
| 114 | 314 | total: num(job, "total", 4449), |
| 115 | 315 | employers: num(job, "employers", 135), |
| 116 | 316 | withSalary: num(job, "with_salary", 1069), |
| 317 | + avgSalaryYear: num(job, "avg_salary_year", 72499), | |
| 318 | + remote: num(job, "remote", 351), | |
| 319 | + topCities: topCities(job), | |
| 320 | + syncs: syncSummary(job), | |
| 117 | 321 | }, |
| 118 | 322 | totals: { connectors: 0, items: 0 }, |
| 119 | 323 | }; |
@@ -125,7 +329,7 @@ export async function getLiveMetrics(): Promise<LiveMetrics> { | ||
| 125 | 329 | m.foodka.sources + |
| 126 | 330 | m.restoka.chains + |
| 127 | 331 | m.jobka.employers + |
| 128 | − 4; // Sorti·Ka : billetteries & données ouvertes (pas d'API de comptage) | |
| 332 | + SORTIKA_CONNECTORS; | |
| 129 | 333 | m.totals.items = |
| 130 | 334 | m.louka.total + |
| 131 | 335 | m.immoka.total + |
@@ -141,9 +345,13 @@ export async function getLiveMetrics(): Promise<LiveMetrics> { | ||
| 141 | 345 | } |
| 142 | 346 | |
| 143 | 347 | /* ---- Formatage fr-CA ---- */ |
| 144 | −const NBSP = " "; | |
| 348 | +const NBSP = " "; | |
| 145 | 349 | export const fmtInt = (n: number): string => |
| 146 | − n.toLocaleString("fr-CA").replace(/\s/g, NBSP); | |
| 350 | + Math.round(n).toLocaleString("fr-CA").replace(/\s/g, NBSP); | |
| 351 | + | |
| 352 | +/** Nombre décimal fr-CA (1 décimale max) — pour les pourcentages. */ | |
| 353 | +export const fmtDec = (n: number): string => | |
| 354 | + n.toLocaleString("fr-CA", { maximumFractionDigits: 1 }).replace(/\s/g, NBSP); | |
| 147 | 355 | |
| 148 | 356 | /** Arrondi « éditorial » vers le bas + suffixe « + » : 33 115 → « 33 100+ ». */ |
| 149 | 357 | export const fmtPlus = (n: number): string => { |
| 150 | 358 | |