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%
12.9 KB · 297 lines javascript
Raw Blame History
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