// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Planificateur de la base de coûts (méthode du coût) : * npx tsx scripts/cost-sync.ts # tous les connecteurs dus (fréquence nominale) * npx tsx scripts/cost-sync.ts apchq statcan # connecteurs choisis (même si non dus) * npx tsx scripts/cost-sync.ts canac --force # ignore le cache de hash / la fréquence * npx tsx scripts/cost-sync.ts --items LUM-2X6-8,GYP-1/2-4X8 canac * npx tsx scripts/cost-sync.ts --max 20 bmr * Fréquences nominales : détaillants 1 j · APCHQ 7 j (+ hash) · CCQ 7 j · StatCan 30 j. * Charge .env.local sans dépendance. Conçu pour PM2 (cron_restart) sur le nœud. */ import fs from "fs"; import path from "path"; function loadEnv(): void { for (const f of [".env.local", ".env"]) { const p = path.join(process.cwd(), f); if (!fs.existsSync(p)) continue; for (const line of fs.readFileSync(p, "utf8").split("\n")) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (!m || line.trim().startsWith("#")) continue; if (process.env[m[1]] == null) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); } } } loadEnv(); async function main(): Promise { const { RUNNABLE_KEYS, isRunnable } = await import("../src/lib/cost/connectors/registry"); const { isDue, runConnector } = await import("../src/lib/cost/connectors/run"); const { getCostDb } = await import("../src/lib/cost/db"); const args = process.argv.slice(2); const force = args.includes("--force"); const itemsArg = args.find((a) => a.startsWith("--items")); const items = itemsArg ? (itemsArg.includes("=") ? itemsArg.split("=")[1] : args[args.indexOf(itemsArg) + 1]).split(",").map((s) => s.trim()).filter(Boolean) : undefined; const maxArg = args.find((a) => a.startsWith("--max")); const maxPages = maxArg ? Number(maxArg.includes("=") ? maxArg.split("=")[1] : args[args.indexOf(maxArg) + 1]) : undefined; const verbose = args.includes("--verbose") || args.includes("-v"); const requested = args.filter((a) => !a.startsWith("-") && isRunnable(a) && a !== (items ? items.join(",") : "") && a !== String(maxPages)); const d = getCostDb(); const keys = requested.length ? requested : RUNNABLE_KEYS.filter((k) => force || isDue(k, d)); if (!keys.length) { console.log("[cost-sync] rien à faire (aucun connecteur dû)."); return; } console.log(`[cost-sync] ${new Date().toISOString()} — connecteurs : ${keys.join(", ")}${force ? " (--force)" : ""}${items ? ` — articles : ${items.join(",")}` : ""}`); let failed = 0; for (const key of keys) { if (!isRunnable(key)) continue; const t0 = Date.now(); const stats = await runConnector(key, { force, onlyItems: items, maxPages, db: d, log: (e) => { if (verbose || e.status === "error" || e.status === "unavailable") console.log(` [${key}] ${e.status.padEnd(11)} ${e.url ?? ""} ${e.message ?? ""}`.trimEnd()); }, }); if (stats.status === "error") failed++; console.log(`[cost-sync] ${key.padEnd(13)} ${stats.status.padEnd(11)} pages ${stats.pages} · obs ${stats.observations} · acceptées ${stats.accepted} · rejetées ${stats.rejected} · inchangées ${stats.unchanged} · erreurs ${stats.errors} · ${Math.round((Date.now() - t0) / 1000)} s${stats.message ? ` — ${stats.message}` : ""}`); } const s = d.prepare("SELECT (SELECT COUNT(*) FROM cost_item_prices WHERE price_kind IN ('observed','official')) prices, (SELECT COUNT(DISTINCT cost_item_id) FROM cost_item_prices WHERE observation_date >= date('now','-120 days')) items, (SELECT COUNT(*) FROM labour_rates) labour, (SELECT COUNT(*) FROM construction_cost_indices) idx").get() as { prices: number; items: number; labour: number; idx: number }; console.log(`[cost-sync] base : ${s.prices} observations de prix · ${s.items} articles avec prix récent · ${s.labour} grilles de main-d'œuvre · ${s.idx} points d'indice`); if (failed) process.exitCode = 1; } main().catch((e) => { console.error("[cost-sync] échec :", e); process.exit(1); });