// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Rapport quotidien du Groupe KA — généré chaque matin à 06:00 (PM2 cron). // Pipeline : données API·Ka (collecte de 02:00) → rédaction par Claude // (JSON structuré) → data/rapports/YYYY-MM-DD.json → PDF via le Chromium // headless de Playwright qui « imprime » la page /rapports/[date] du site // (une seule source de rendu, @media print dans globals.css). // Usage : node scripts/rapport-quotidien.mjs [--date YYYY-MM-DD] [--force] [--pdf-only] import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import os from "node:os"; import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const RAPPORTS_DIR = path.join(ROOT, "data", "rapports"); const API_KA = "http://127.0.0.1:8000"; const TROUVE_KA = "https://www.trouve-ka.com"; const SITE = "http://127.0.0.1:8110"; const MODEL = "claude-sonnet-4-6"; const HEADLESS_SHELL = path.join( os.homedir(), "Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell", ); const SERVICES = [ ["louka", "Lou·Ka", "logements à louer"], ["immoka", "Immo·Ka", "propriétés à vendre"], ["autoka", "Auto·Ka", "voitures usagées"], ["fabrika", "Fabri·Ka", "produits québécois"], ["foodka", "Food·Ka", "prix d'épicerie"], ["sortika", "Sorti·Ka", "sorties et événements"], ["jobka", "Job·Ka", "offres d'emploi"], ]; // ---------- utilitaires ---------- function loadEnv() { const file = path.join(ROOT, ".env.local"); if (!existsSync(file)) return; for (const line of readFileSync(file, "utf8").split("\n")) { const m = line.match(/^([A-Z0-9_]+)=(.*)$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2]; } } function todayLocal() { return new Intl.DateTimeFormat("en-CA", { timeZone: "America/Toronto", }).format(new Date()); } async function getJson(url, timeoutMs = 20000) { const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`); return res.json(); } async function tryJson(url, timeoutMs) { try { return await getJson(url, timeoutMs); } catch (e) { console.warn(`⚠️ ${url} : ${e.message}`); return null; } } // ---------- 1. collecte des données API·Ka ---------- async function collecter(dateRapport) { const [health, dash7j, runsRes, trouveStatus, ...statsParService] = await Promise.all([ tryJson(`${API_KA}/health`), tryJson(`${API_KA}/api/stats/dashboard?period=7j`), tryJson(`${API_KA}/api/v1/runs?limit=60`), tryJson(`${TROUVE_KA}/api/status`, 10000), ...SERVICES.map(([s]) => tryJson(`${API_KA}/api/v1/${s}/stats`)), ]); const services = SERVICES.map(([service, nom, description], i) => { const d = statsParService[i]?.data; const days = (d?.days ?? []).slice(0, 15); // du plus récent au plus ancien const jour = days[0] ?? null; const veille = days[1] ?? null; const delta = jour && veille && veille.records > 0 ? ((jour.records - veille.records) / veille.records) * 100 : null; return { service, nom, description, total: d?.total_records ?? null, date_jour: jour?.date_key ?? null, jour: jour?.records ?? null, veille: veille?.records ?? null, delta_pct: delta === null ? null : Math.round(delta * 100) / 100, statut: d?.last_success?.status ?? "inconnu", serie_14j: days .slice(0, 14) .map((x) => ({ d: x.date_key, n: x.records })) .reverse(), }; }); const dateDonnees = services.map((s) => s.date_jour).filter(Boolean).sort().at(-1) ?? dateRapport; const runsJour = (runsRes?.data ?? []).filter( (r) => r.date_key === dateDonnees, ); const kpi = (id) => dash7j?.data?.kpis?.find((k) => k.id === id)?.value ?? null; const snapshot = { date_donnees: dateDonnees, services, totaux: { records_jour: services.reduce((a, s) => a + (s.jour ?? 0), 0), records_total: services.reduce((a, s) => a + (s.total ?? 0), 0), }, connecteurs: health?.data?.connectors ?? null, // Moteur de recherche interne (pivot 2026-08-23 : index limité aux sites du groupe) moteur_recherche: trouveStatus ? { plateforme: "Trouve·Ka", pages_indexees: trouveStatus.pages_indexed ?? null, sites_indexes: trouveStatus.domains_count ?? null, indexees_derniere_heure: trouveStatus.indexed_last_hour ?? null, erreurs_derniere_heure: trouveStatus.errors_last_hour ?? null, file_de_crawl: trouveStatus.frontier_pending ?? null, robot: trouveStatus.crawler_state ?? null, recherche_ok: trouveStatus.search_ok ?? null, } : null, api_7j: { appels: kpi("calls"), latence_ms: kpi("latency"), erreurs_pct: kpi("errors"), }, runs_du_jour: runsJour.map((r) => ({ service: r.service, statut: r.status, records: r.records_count, duree_s: r.duration_seconds ? Math.round(r.duration_seconds * 10) / 10 : null, })), }; return snapshot; } // ---------- 2. rédaction par Claude ---------- const 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. Chaque 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é. Cadences 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. Le 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). Règles absolues : - 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. - Nombres en format canadien-français (espace pour les milliers, virgule décimale) : « 45 073 », « 2,3 % ». - Tu réponds UNIQUEMENT avec un objet JSON valide (aucun texte avant/après, aucune clôture markdown), au schéma exact : { "titre": "titre du jour, accrocheur, max 70 caractères, sans le mot 'rapport'", "sous_titre": "une phrase qui résume la nuit", "meteo": { "etat": "beau" | "variable" | "orage", "resume": "l'état de l'écosystème en une phrase" }, "chiffre_du_jour": { "valeur": "le nombre formaté", "label": "ce que c'est", "contexte": "pourquoi c'est LE chiffre du jour" }, "edito": ["2 à 3 paragraphes d'éditorial sur la nuit de collecte, le mouvement d'ensemble, ce qui mérite attention"], "faits_saillants": [{ "plateforme": "Nom·Ka", "texte": "1-2 phrases : le fait marquant du jour pour cette plateforme, avec chiffres" }], "analyse": [{ "titre": "titre de section", "contenu": ["1-2 paragraphes d'analyse : tendances 14 jours, comparaisons entre plateformes, santé technique (runs, durées, connecteurs, API)"] }], "vigilance": ["points à surveiller (collectes échouées, baisses inhabituelles, connecteurs dégradés) — liste vide si tout va bien"], "mot_de_la_fin": "une phrase de clôture avec du panache" } - "faits_saillants" : une entrée par plateforme qui a quelque chose à dire (6 à 10 entrées compris s'il a du nouveau). - "analyse" : 2 ou 3 sections. - Utilise les vrais noms : Lou·Ka, Immo·Ka, Auto·Ka, Fabri·Ka, Food·Ka, Sorti·Ka, Job·Ka, API·Ka.`; async function rediger(snapshot, dateRapport) { const key = process.env.ANTHROPIC_API_KEY; if (!key) throw new Error("ANTHROPIC_API_KEY manquante (.env.local)"); const user = `Date du rapport : ${dateRapport}. Données de la collecte (date_donnees = ${snapshot.date_donnees}) :\n${JSON.stringify(snapshot)}`; const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json", }, body: JSON.stringify({ model: MODEL, max_tokens: 8000, system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }], messages: [{ role: "user", content: user }], }), signal: AbortSignal.timeout(300000), }); if (!res.ok) { throw new Error(`API Anthropic → HTTP ${res.status} : ${await res.text()}`); } const data = await res.json(); const texte = (data.content ?? []) .filter((b) => b.type === "text") .map((b) => b.text) .join(""); const brut = texte.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, ""); const debut = brut.indexOf("{"); const fin = brut.lastIndexOf("}"); if (debut === -1 || fin === -1) throw new Error(`Réponse sans JSON : ${texte.slice(0, 200)}`); const contenu = JSON.parse(brut.slice(debut, fin + 1)); for (const champ of ["titre", "edito", "faits_saillants", "analyse"]) { if (!contenu[champ]) throw new Error(`Champ manquant dans la réponse : ${champ}`); } return { contenu, usage: data.usage }; } // ---------- 3. rendu PDF (imprime la page du site) ---------- function genererPdf(dateRapport) { if (!existsSync(HEADLESS_SHELL)) { console.warn(`⚠️ Chromium headless introuvable (${HEADLESS_SHELL}) — pas de PDF.`); return false; } const pdfPath = path.join(RAPPORTS_DIR, `${dateRapport}.pdf`); execFileSync( HEADLESS_SHELL, [ "--headless", "--disable-gpu", "--no-margins", "--run-all-compositor-stages-before-draw", "--virtual-time-budget=20000", "--timeout=60000", `--print-to-pdf=${pdfPath}`, "--no-pdf-header-footer", `${SITE}/rapports/${dateRapport}`, ], { stdio: "pipe", timeout: 120000 }, ); if (!existsSync(pdfPath)) throw new Error("le PDF n'a pas été produit"); console.log(`✅ PDF : ${pdfPath}`); return true; } // ---------- main ---------- async function main() { loadEnv(); const args = process.argv.slice(2); const force = args.includes("--force"); const pdfOnly = args.includes("--pdf-only"); const dateArg = args[args.indexOf("--date") + 1]; const dateRapport = args.includes("--date") && /^\d{4}-\d{2}-\d{2}$/.test(dateArg ?? "") ? dateArg : todayLocal(); const jsonPath = path.join(RAPPORTS_DIR, `${dateRapport}.json`); mkdirSync(RAPPORTS_DIR, { recursive: true }); if (!pdfOnly) { if (existsSync(jsonPath) && !force) { console.log(`Rapport ${dateRapport} déjà généré (--force pour refaire).`); } else { console.log(`📡 Collecte des données API·Ka…`); const snapshot = await collecter(dateRapport); if (!snapshot.services.some((s) => s.total !== null)) { throw new Error("API·Ka injoignable — aucune donnée, rapport annulé."); } console.log( `✍️ Rédaction par ${MODEL} (${snapshot.totaux.records_jour} enregistrements le ${snapshot.date_donnees})…`, ); const { contenu, usage } = await rediger(snapshot, dateRapport); const rapport = { date: dateRapport, genere_le: new Date().toISOString(), modele: MODEL, usage, contenu, snapshot, }; writeFileSync(jsonPath, JSON.stringify(rapport, null, 2)); console.log(`✅ Rapport : ${jsonPath} (« ${contenu.titre} »)`); } } genererPdf(dateRapport); } main().catch((e) => { console.error(`❌ ${e.message}`); process.exit(1); });