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// Rapport quotidien du Groupe KA — généré chaque matin à 06:00 (PM2 cron).3// Pipeline : données API·Ka (collecte de 02:00) → rédaction par Claude4// (JSON structuré) → data/rapports/YYYY-MM-DD.json → PDF via le Chromium5// headless de Playwright qui « imprime » la page /rapports/[date] du site6// (une seule source de rendu, @media print dans globals.css).7// Usage : node scripts/rapport-quotidien.mjs [--date YYYY-MM-DD] [--force] [--pdf-only]8import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";9import { execFileSync } from "node:child_process";10import path from "node:path";11import os from "node:os";12import { fileURLToPath } from "node:url";1314const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");15const RAPPORTS_DIR = path.join(ROOT, "data", "rapports");16const API_KA = "http://127.0.0.1:8000";17const TROUVE_KA = "https://www.trouve-ka.com";18const SITE = "http://127.0.0.1:8110";19const MODEL = "claude-sonnet-4-6";20const HEADLESS_SHELL = path.join(21 os.homedir(),22 "Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell",23);2425const SERVICES = [26 ["louka", "Lou·Ka", "logements à louer"],27 ["immoka", "Immo·Ka", "propriétés à vendre"],28 ["autoka", "Auto·Ka", "voitures usagées"],29 ["fabrika", "Fabri·Ka", "produits québécois"],30 ["foodka", "Food·Ka", "prix d'épicerie"],31 ["sortika", "Sorti·Ka", "sorties et événements"],32 ["jobka", "Job·Ka", "offres d'emploi"],33];3435// ---------- utilitaires ----------3637function loadEnv() {38 const file = path.join(ROOT, ".env.local");39 if (!existsSync(file)) return;40 for (const line of readFileSync(file, "utf8").split("\n")) {41 const m = line.match(/^([A-Z0-9_]+)=(.*)$/);42 if (m && !process.env[m[1]]) process.env[m[1]] = m[2];43 }44}4546function todayLocal() {47 return new Intl.DateTimeFormat("en-CA", {48 timeZone: "America/Toronto",49 }).format(new Date());50}5152async function getJson(url, timeoutMs = 20000) {53 const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });54 if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);55 return res.json();56}5758async function tryJson(url, timeoutMs) {59 try {60 return await getJson(url, timeoutMs);61 } catch (e) {62 console.warn(`⚠️ ${url} : ${e.message}`);63 return null;64 }65}6667// ---------- 1. collecte des données API·Ka ----------6869async function collecter(dateRapport) {70 const [health, dash7j, runsRes, trouveStatus, ...statsParService] = await Promise.all([71 tryJson(`${API_KA}/health`),72 tryJson(`${API_KA}/api/stats/dashboard?period=7j`),73 tryJson(`${API_KA}/api/v1/runs?limit=60`),74 tryJson(`${TROUVE_KA}/api/status`, 10000),75 ...SERVICES.map(([s]) => tryJson(`${API_KA}/api/v1/${s}/stats`)),76 ]);7778 const services = SERVICES.map(([service, nom, description], i) => {79 const d = statsParService[i]?.data;80 const days = (d?.days ?? []).slice(0, 15); // du plus récent au plus ancien81 const jour = days[0] ?? null;82 const veille = days[1] ?? null;83 const delta =84 jour && veille && veille.records > 085 ? ((jour.records - veille.records) / veille.records) * 10086 : null;87 return {88 service,89 nom,90 description,91 total: d?.total_records ?? null,92 date_jour: jour?.date_key ?? null,93 jour: jour?.records ?? null,94 veille: veille?.records ?? null,95 delta_pct: delta === null ? null : Math.round(delta * 100) / 100,96 statut: d?.last_success?.status ?? "inconnu",97 serie_14j: days98 .slice(0, 14)99 .map((x) => ({ d: x.date_key, n: x.records }))100 .reverse(),101 };102 });103104 const dateDonnees =105 services.map((s) => s.date_jour).filter(Boolean).sort().at(-1) ?? dateRapport;106107 const runsJour = (runsRes?.data ?? []).filter(108 (r) => r.date_key === dateDonnees,109 );110111 const kpi = (id) =>112 dash7j?.data?.kpis?.find((k) => k.id === id)?.value ?? null;113114 const snapshot = {115 date_donnees: dateDonnees,116 services,117 totaux: {118 records_jour: services.reduce((a, s) => a + (s.jour ?? 0), 0),119 records_total: services.reduce((a, s) => a + (s.total ?? 0), 0),120 },121 connecteurs: health?.data?.connectors ?? null,122 // Moteur de recherche interne (pivot 2026-08-23 : index limité aux sites du groupe)123 moteur_recherche: trouveStatus124 ? {125 plateforme: "Trouve·Ka",126 pages_indexees: trouveStatus.pages_indexed ?? null,127 sites_indexes: trouveStatus.domains_count ?? null,128 indexees_derniere_heure: trouveStatus.indexed_last_hour ?? null,129 erreurs_derniere_heure: trouveStatus.errors_last_hour ?? null,130 file_de_crawl: trouveStatus.frontier_pending ?? null,131 robot: trouveStatus.crawler_state ?? null,132 recherche_ok: trouveStatus.search_ok ?? null,133 }134 : null,135 api_7j: {136 appels: kpi("calls"),137 latence_ms: kpi("latency"),138 erreurs_pct: kpi("errors"),139 },140 runs_du_jour: runsJour.map((r) => ({141 service: r.service,142 statut: r.status,143 records: r.records_count,144 duree_s: r.duration_seconds145 ? Math.round(r.duration_seconds * 10) / 10146 : null,147 })),148 };149 return snapshot;150}151152// ---------- 2. rédaction par Claude ----------153154const SYSTEM = `Tu es le rédacteur en chef du rapport quotidien du Groupe KA, un holding québécois d'agrégateurs de produits et services entièrement automatisés (Lou·Ka : location ; Immo·Ka : propriétés ; Auto·Ka : autos usagées ; Fabri·Ka : produits d'ici ; Food·Ka : épicerie ; Resto·Ka : restos ; Sorti·Ka : sorties ; Créa·Ka : créateurs ; Job·Ka : emplois). Sa devise : zéro saisie manuelle, zéro boîte noire — rien d'inventé, tout est traçable.155156Chaque matin, tu écris LE rapport de la nuit : la collecte automatisée de 02:00 vient de passer sur les 9 plateformes et tu racontes ce qu'elle a trouvé. Ton style : français québécois soigné, précis, vivant, avec de l'aplomb — un rapport qu'on a du plaisir à lire, jamais un procès-verbal plate. Tu compares au jour précédent, tu repères les tendances sur 14 jours, les records, les séries en progression ou en recul, et tu le dis franchement quand une collecte a mal été.157158Cadences de rafraîchissement des sources (à connaître avant de crier à l'anomalie) : la plupart des plateformes resynchronisent leurs sources plusieurs fois par jour, mais Resto·Ka se synchronise UNE FOIS PAR SEMAINE (un compte d'enregistrements identique plusieurs jours d'affilée y est NORMAL, pas un cache figé) et Créa·Ka une fois par jour. Ne signale une stagnation en vigilance que si elle dépasse la cadence attendue de la plateforme.159160Le groupe a aussi son moteur de recherche : Trouve·Ka. Depuis le 23 août 2026, il n'indexe QUE les pages des sites de l'écosystème KA (pivot assumé — avant cette date il indexait le web québécois ouvert, ne compare pas avec ces anciens chiffres). Ses données du matin sont dans "moteur_recherche" (pages indexées, sites couverts, rythme horaire, file de crawl, état du robot). L'index se remplit progressivement depuis le pivot : une croissance rapide des pages indexées est NORMALE ces premières semaines et mérite d'être racontée comme telle. Ajoute un fait saillant Trouve·Ka quand il a quelque chose à dire (montée de l'index, robot arrêté, recherche dégradée → vigilance).161162Règles absolues :163- Tu n'inventes AUCUN chiffre : tout vient des données fournies. Si une donnée est nulle/absente, tu le dis ou tu n'en parles pas.164- Nombres en format canadien-français (espace pour les milliers, virgule décimale) : « 45 073 », « 2,3 % ».165- Tu réponds UNIQUEMENT avec un objet JSON valide (aucun texte avant/après, aucune clôture markdown), au schéma exact :166{167 "titre": "titre du jour, accrocheur, max 70 caractères, sans le mot 'rapport'",168 "sous_titre": "une phrase qui résume la nuit",169 "meteo": { "etat": "beau" | "variable" | "orage", "resume": "l'état de l'écosystème en une phrase" },170 "chiffre_du_jour": { "valeur": "le nombre formaté", "label": "ce que c'est", "contexte": "pourquoi c'est LE chiffre du jour" },171 "edito": ["2 à 3 paragraphes d'éditorial sur la nuit de collecte, le mouvement d'ensemble, ce qui mérite attention"],172 "faits_saillants": [{ "plateforme": "Nom·Ka", "texte": "1-2 phrases : le fait marquant du jour pour cette plateforme, avec chiffres" }],173 "analyse": [{ "titre": "titre de section", "contenu": ["1-2 paragraphes d'analyse : tendances 14 jours, comparaisons entre plateformes, santé technique (runs, durées, connecteurs, API)"] }],174 "vigilance": ["points à surveiller (collectes échouées, baisses inhabituelles, connecteurs dégradés) — liste vide si tout va bien"],175 "mot_de_la_fin": "une phrase de clôture avec du panache"176}177- "faits_saillants" : une entrée par plateforme qui a quelque chose à dire (6 à 10 entrées compris s'il a du nouveau).178- "analyse" : 2 ou 3 sections.179- Utilise les vrais noms : Lou·Ka, Immo·Ka, Auto·Ka, Fabri·Ka, Food·Ka, Sorti·Ka, Job·Ka, API·Ka.`;180181async function rediger(snapshot, dateRapport) {182 const key = process.env.ANTHROPIC_API_KEY;183 if (!key) throw new Error("ANTHROPIC_API_KEY manquante (.env.local)");184185 const user = `Date du rapport : ${dateRapport}. Données de la collecte (date_donnees = ${snapshot.date_donnees}) :\n${JSON.stringify(snapshot)}`;186187 const res = await fetch("https://api.anthropic.com/v1/messages", {188 method: "POST",189 headers: {190 "x-api-key": key,191 "anthropic-version": "2023-06-01",192 "content-type": "application/json",193 },194 body: JSON.stringify({195 model: MODEL,196 max_tokens: 8000,197 system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],198 messages: [{ role: "user", content: user }],199 }),200 signal: AbortSignal.timeout(300000),201 });202 if (!res.ok) {203 throw new Error(`API Anthropic → HTTP ${res.status} : ${await res.text()}`);204 }205 const data = await res.json();206 const texte = (data.content ?? [])207 .filter((b) => b.type === "text")208 .map((b) => b.text)209 .join("");210 const brut = texte.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");211 const debut = brut.indexOf("{");212 const fin = brut.lastIndexOf("}");213 if (debut === -1 || fin === -1) throw new Error(`Réponse sans JSON : ${texte.slice(0, 200)}`);214 const contenu = JSON.parse(brut.slice(debut, fin + 1));215 for (const champ of ["titre", "edito", "faits_saillants", "analyse"]) {216 if (!contenu[champ]) throw new Error(`Champ manquant dans la réponse : ${champ}`);217 }218 return { contenu, usage: data.usage };219}220221// ---------- 3. rendu PDF (imprime la page du site) ----------222223function genererPdf(dateRapport) {224 if (!existsSync(HEADLESS_SHELL)) {225 console.warn(`⚠️ Chromium headless introuvable (${HEADLESS_SHELL}) — pas de PDF.`);226 return false;227 }228 const pdfPath = path.join(RAPPORTS_DIR, `${dateRapport}.pdf`);229 execFileSync(230 HEADLESS_SHELL,231 [232 "--headless",233 "--disable-gpu",234 "--no-margins",235 "--run-all-compositor-stages-before-draw",236 "--virtual-time-budget=20000",237 "--timeout=60000",238 `--print-to-pdf=${pdfPath}`,239 "--no-pdf-header-footer",240 `${SITE}/rapports/${dateRapport}`,241 ],242 { stdio: "pipe", timeout: 120000 },243 );244 if (!existsSync(pdfPath)) throw new Error("le PDF n'a pas été produit");245 console.log(`✅ PDF : ${pdfPath}`);246 return true;247}248249// ---------- main ----------250251async function main() {252 loadEnv();253 const args = process.argv.slice(2);254 const force = args.includes("--force");255 const pdfOnly = args.includes("--pdf-only");256 const dateArg = args[args.indexOf("--date") + 1];257 const dateRapport =258 args.includes("--date") && /^\d{4}-\d{2}-\d{2}$/.test(dateArg ?? "")259 ? dateArg260 : todayLocal();261 const jsonPath = path.join(RAPPORTS_DIR, `${dateRapport}.json`);262 mkdirSync(RAPPORTS_DIR, { recursive: true });263264 if (!pdfOnly) {265 if (existsSync(jsonPath) && !force) {266 console.log(`Rapport ${dateRapport} déjà généré (--force pour refaire).`);267 } else {268 console.log(`📡 Collecte des données API·Ka…`);269 const snapshot = await collecter(dateRapport);270 if (!snapshot.services.some((s) => s.total !== null)) {271 throw new Error("API·Ka injoignable — aucune donnée, rapport annulé.");272 }273 console.log(274 `✍️ Rédaction par ${MODEL} (${snapshot.totaux.records_jour} enregistrements le ${snapshot.date_donnees})…`,275 );276 const { contenu, usage } = await rediger(snapshot, dateRapport);277 const rapport = {278 date: dateRapport,279 genere_le: new Date().toISOString(),280 modele: MODEL,281 usage,282 contenu,283 snapshot,284 };285 writeFileSync(jsonPath, JSON.stringify(rapport, null, 2));286 console.log(`✅ Rapport : ${jsonPath} (« ${contenu.titre} »)`);287 }288 }289290 genererPdf(dateRapport);291}292293main().catch((e) => {294 console.error(`❌ ${e.message}`);295 process.exit(1);296});297