SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
3.8 KB · 63 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Connecteur CCQ — « Salaire et taux » (Commission de la construction du4 * Québec). Source de référence des taux réglementés. Au 2026-09-06 le site est5 * en incident de sécurité (services fermés jusqu'au 8 septembre) : la page6 * répond mais l'outil de taux n'est pas rendu. Le connecteur enregistre le7 * hash de la page (détection de changement) et renvoie l'état `unavailable`8 * tant qu'aucune grille tarifaire n'est détectable. Lorsque des tableaux9 * « métier | taux » apparaîtront, l'extraction générique ci-dessous les lira ;10 * la ventilation (secteur, cotisations) devra être confirmée avant activation.11 */12import { contentHash, parseMoney, scrape } from "./firecrawl";13import { lastHashFor, recentPricesFor, referencePriceFor } from "./store";14import type { CanonicalObservation, ConnectorConfig, CostConnector, RawObservation, ValidationResult } from "./types";15import { validateObservations } from "./validate";1617export const CCQ_PARSER_VERSION = "ccq-0.1";18export const CCQ_URLS = ["https://www.ccq.org/fr-CA/avantages-sociaux/salaire-taux", "https://www.ccq.org/fr-CA/loi-r20/conventions-collectives"];1920export const CCQ_CONFIG: ConnectorConfig = {21  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/],22  refreshDays: 7, timeoutMs: 90_000, retries: 1, rateLimitMs: 2000, concurrency: 1,23};2425/** Vrai si la page contient un tableau de taux exploitable (≥ 5 lignes « libellé | montant $ »). */26export function hasRateTable(md: string): boolean {27  const rows = md.split("\n").filter((l) => /^\|\s*[A-Za-zÉéèàçô][^|]{2,60}\|[^|]*\d+[,.]\d{2}\s*\$/.test(l));28  return rows.length >= 5;29}3031export function detectIncident(md: string): string | null {32  const m = md.match(/incident de sécurité[^.]*\./i);33  return m ? m[0].trim() : null;34}3536export const ccqConnector: CostConnector = {37  config: CCQ_CONFIG,38  async discover() {39    return CCQ_URLS.map((url) => ({ url, title: "CCQ" }));40  },41  async fetch(doc, opts) {42    const r = await scrape(doc.url, { timeoutMs: CCQ_CONFIG.timeoutMs, retries: CCQ_CONFIG.retries, rateLimitMs: CCQ_CONFIG.rateLimitMs, concurrency: 1 });43    const hash = contentHash(r.markdown);44    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 };45  },46  async extract(raw) {47    if (!hasRateTable(raw.markdown)) return [];48    // extraction générique prudente : « | Métier | 60,24 $ | … » — conservée comme observation brute seulement49    return raw.markdown.split("\n").filter((l) => /^\|\s*[A-Za-zÉ][^|]{2,60}\|[^|]*\d+[,.]\d{2}\s*\$/.test(l)).map((l) => {50      const cells = l.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());51      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;52    });53  },54  async normalize(): Promise<CanonicalObservation[]> {55    // Tant que la structure officielle (secteur, cotisations) n'est pas confirmée, aucune grille CCQ n'est écrite en base :56    // les observations brutes restent consultables dans cost_raw_observations (statut new).57    return [];58  },59  validate(obs, opts): ValidationResult {60    return validateObservations(obs, { recentPrices: (c) => recentPricesFor(c, 120, opts.db), referencePrice: (c) => referencePriceFor(c, opts.db) });61  },62};63