// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Connecteur CCQ — « Salaire et taux » (Commission de la construction du * Québec). Source de référence des taux réglementés. Au 2026-09-06 le site est * en incident de sécurité (services fermés jusqu'au 8 septembre) : la page * répond mais l'outil de taux n'est pas rendu. Le connecteur enregistre le * hash de la page (détection de changement) et renvoie l'état `unavailable` * tant qu'aucune grille tarifaire n'est détectable. Lorsque des tableaux * « métier | taux » apparaîtront, l'extraction générique ci-dessous les lira ; * la ventilation (secteur, cotisations) devra être confirmée avant activation. */ import { contentHash, parseMoney, scrape } from "./firecrawl"; import { lastHashFor, recentPricesFor, referencePriceFor } from "./store"; import type { CanonicalObservation, ConnectorConfig, CostConnector, RawObservation, ValidationResult } from "./types"; import { validateObservations } from "./validate"; export const CCQ_PARSER_VERSION = "ccq-0.1"; export const CCQ_URLS = ["https://www.ccq.org/fr-CA/avantages-sociaux/salaire-taux", "https://www.ccq.org/fr-CA/loi-r20/conventions-collectives"]; export const CCQ_CONFIG: ConnectorConfig = { key: "ccq", name: "CCQ — Salaire et taux", domain: "ccq.org", maxPages: 4, allowedPaths: [/^https:\/\/www\.ccq\.org\/fr-CA\/(avantages-sociaux|loi-r20)\//], excludedPaths: [/Redirections\/SEL/], refreshDays: 7, timeoutMs: 90_000, retries: 1, rateLimitMs: 2000, concurrency: 1, }; /** Vrai si la page contient un tableau de taux exploitable (≥ 5 lignes « libellé | montant $ »). */ export function hasRateTable(md: string): boolean { const rows = md.split("\n").filter((l) => /^\|\s*[A-Za-zÉéèàçô][^|]{2,60}\|[^|]*\d+[,.]\d{2}\s*\$/.test(l)); return rows.length >= 5; } export function detectIncident(md: string): string | null { const m = md.match(/incident de sécurité[^.]*\./i); return m ? m[0].trim() : null; } export const ccqConnector: CostConnector = { config: CCQ_CONFIG, async discover() { return CCQ_URLS.map((url) => ({ url, title: "CCQ" })); }, async fetch(doc, opts) { const r = await scrape(doc.url, { timeoutMs: CCQ_CONFIG.timeoutMs, retries: CCQ_CONFIG.retries, rateLimitMs: CCQ_CONFIG.rateLimitMs, concurrency: 1 }); const hash = contentHash(r.markdown); return { url: doc.url, fetchedAt: new Date().toISOString(), markdown: r.markdown, metadata: { ...r.metadata, incident: detectIncident(r.markdown), hasRateTable: hasRateTable(r.markdown) }, statusCode: r.statusCode, contentHash: hash, unchanged: !opts.force && lastHashFor(doc.url, opts.db) === hash }; }, async extract(raw) { if (!hasRateTable(raw.markdown)) return []; // extraction générique prudente : « | Métier | 60,24 $ | … » — conservée comme observation brute seulement return raw.markdown.split("\n").filter((l) => /^\|\s*[A-Za-zÉ][^|]{2,60}\|[^|]*\d+[,.]\d{2}\s*\$/.test(l)).map((l) => { const cells = l.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); return { externalId: null, sourceUrl: raw.url, retrievedAt: raw.fetchedAt, effectiveDate: null, title: cells[0], description: null, unit: "h", price: parseMoney(cells[1]), regularPrice: null, salePrice: null, currency: "CAD", location: "QC", category: "labour", payload: { cells } } satisfies RawObservation; }); }, async normalize(): Promise { // Tant que la structure officielle (secteur, cotisations) n'est pas confirmée, aucune grille CCQ n'est écrite en base : // les observations brutes restent consultables dans cost_raw_observations (statut new). return []; }, validate(obs, opts): ValidationResult { return validateObservations(obs, { recentPrices: (c) => recentPricesFor(c, 120, opts.db), referencePrice: (c) => referencePriceFor(c, opts.db) }); }, };