// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Connecteur Statistique Canada — Indices des prix de la construction de * bâtiments (tableau 18-10-0289-01, trimestriel, licence ouverte) via l'API * WDS. Séries : Québec (province), RMR de Québec, Montréal, Ottawa–Gatineau ; * bâtiments résidentiels, appartements, maison individuelle, maison en rangée ; * agrégat + divisions (Montréal et Québec résidentiel). * Code d'indice : statcan:18100289:::. */ import type { CanonicalIndexObservation, CanonicalObservation, ConnectorConfig, CostConnector, RawDocument, RawObservation, SourceDocument, ValidationResult } from "./types"; import { contentHash } from "./firecrawl"; import { validateObservations } from "./validate"; import { recentPricesFor, referencePriceFor } from "./store"; export const STATCAN_PARSER_VERSION = "statcan-1.0"; const PID = 18100289; export const WDS = "https://www150.statcan.gc.ca/t1/wds/rest/getDataFromCubePidCoordAndLatestNPeriods"; export const STATCAN_CONFIG: ConnectorConfig = { key: "statcan", name: "Statistique Canada — IPCB 18-10-0289-01", domain: "www150.statcan.gc.ca", maxPages: 5, allowedPaths: [/^https:\/\/www150\.statcan\.gc\.ca\/t1\/wds\/rest\//], excludedPaths: [], refreshDays: 30, timeoutMs: 60_000, retries: 2, rateLimitMs: 1000, concurrency: 1, }; export const GEOS: Record = { 8: "Québec (province)", 9: "Québec (RMR)", 10: "Montréal", 12: "Ottawa–Gatineau (partie ontarienne)" }; export const TYPES: Record = { 1: "Bâtiments résidentiels", 2: "Immeubles d'appartements", 5: "Maison individuelle", 6: "Maison en rangée" }; export const DIVISIONS: Record = { 1: "Agrégat des divisions", 4: "Béton", 5: "Maçonnerie", 8: "Bois, plastiques et composites", 9: "Isolation thermique et étanchéité", 10: "Ouvertures et fermetures", 11: "Revêtements de finition", 16: "Plomberie", 17: "Chauffage, ventilation et conditionnement d'air", 19: "Électricité", 22: "Terrassements", 23: "Aménagements extérieurs", }; export interface SeriesSpec { geo: number; type: number; division: number } export function seriesList(): SeriesSpec[] { const out: SeriesSpec[] = []; for (const geo of [8, 9, 10, 12]) for (const type of [1, 2, 5, 6]) out.push({ geo, type, division: 1 }); for (const geo of [9, 10]) for (const division of Object.keys(DIVISIONS).map(Number)) if (division !== 1) out.push({ geo, type: 1, division }); return out; } export const coordinate = (s: SeriesSpec) => `${s.geo}.${s.type}.${s.division}.0.0.0.0.0.0.0`; export const indexCode = (s: SeriesSpec) => `statcan:${PID}:${s.geo}:${s.type}:${s.division}`; interface WdsPoint { refPer: string; value: number | null; statusCode?: number } interface WdsItem { status: string; object: { coordinate: string; vectorId?: number; vectorDataPoint: WdsPoint[] } } export async function fetchWds(specs: SeriesSpec[], latestN = 40, timeoutMs = 60_000): Promise { const out: WdsItem[] = []; for (let i = 0; i < specs.length; i += 20) { const batch = specs.slice(i, i + 20).map((s) => ({ productId: PID, coordinate: coordinate(s), latestN })); const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(WDS, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(batch), signal: ctrl.signal }); if (!res.ok) throw new Error(`StatCan WDS HTTP ${res.status}`); out.push(...((await res.json()) as WdsItem[])); } finally { clearTimeout(t); } if (i + 20 < specs.length) await new Promise((r) => setTimeout(r, 1000)); } return out; } /** Points → observations d'indice avec variations trimestrielle et annuelle. */ export function pointsToObservations(spec: SeriesSpec, points: WdsPoint[], retrievedAt: string): CanonicalIndexObservation[] { const pts = points.filter((p) => p.value != null && Number.isFinite(p.value)).sort((a, b) => a.refPer.localeCompare(b.refPer)); return pts.map((p, i) => { const prev = i > 0 ? pts[i - 1] : null; const yearAgo = pts.find((q) => q.refPer === shiftYear(p.refPer, -1)) ?? null; return { kind: "index", indexCode: indexCode(spec), geography: GEOS[spec.geo] ?? String(spec.geo), buildingType: TYPES[spec.type] ?? String(spec.type), division: DIVISIONS[spec.division] ?? String(spec.division), period: p.refPer, value: p.value as number, pctQoq: prev?.value ? r1(((p.value as number) / prev.value - 1) * 100) : null, pctYoy: yearAgo?.value ? r1(((p.value as number) / yearAgo.value - 1) * 100) : null, retrievedAt, }; }); } const r1 = (x: number) => Math.round(x * 10) / 10; function shiftYear(iso: string, dy: number): string { return `${Number(iso.slice(0, 4)) + dy}${iso.slice(4)}`; } export const statcanConnector: CostConnector = { config: STATCAN_CONFIG, async discover() { return [{ url: WDS, title: "StatCan 18-10-0289-01 (WDS)", meta: { series: seriesList().length } }]; }, async fetch(doc) { const items = await fetchWds(seriesList(), 40, STATCAN_CONFIG.timeoutMs); const md = JSON.stringify(items.map((it) => ({ c: it.object?.coordinate, s: it.status, p: it.object?.vectorDataPoint?.map((p) => [p.refPer, p.value]) }))); // les indices changent à chaque diffusion : le hash porte sur les données, pas sur la date de récupération return { url: doc.url, fetchedAt: new Date().toISOString(), markdown: md, metadata: { items: items.length }, statusCode: 200, contentHash: contentHash(md), unchanged: false, meta: doc.meta }; }, async extract(raw) { const items = JSON.parse(raw.markdown) as { c: string; s: string; p: [string, number | null][] }[]; return items.filter((it) => it.s === "SUCCESS" && it.c).map((it) => ({ externalId: it.c, sourceUrl: `https://www150.statcan.gc.ca/t1/tbl1/fr/tv.action?pid=${PID}01`, retrievedAt: raw.fetchedAt, effectiveDate: it.p.at(-1)?.[0] ?? null, title: `IPCB ${it.c}`, description: null, unit: "index", price: it.p.at(-1)?.[1] ?? null, regularPrice: null, salePrice: null, currency: "CAD", location: it.c.split(".")[0], category: "index", payload: { points: it.p }, } satisfies RawObservation)); }, async normalize(obs, raw) { const out: CanonicalObservation[] = []; for (const o of obs) { const [geo, type, division] = (o.externalId ?? "").split(".").map(Number); const pts = (o.payload.points as [string, number | null][]).map(([refPer, value]) => ({ refPer, value })); out.push(...pointsToObservations({ geo, type, division }, pts, raw.fetchedAt)); } return out; }, validate(obs, opts): ValidationResult { return validateObservations(obs, { recentPrices: (c) => recentPricesFor(c, 120, opts.db), referencePrice: (c) => referencePriceFor(c, opts.db) }); }, }; export type { RawDocument, SourceDocument };