Méthode du coût — connecteurs, sources et planificateur
Porté d'UQO Éval vers Vrai-Prix le 2026-09-08 — processus PM2
vrai-prix-cost-syncsur M3U96a.
Documentation du chantier « données réelles » de l'onglet Coût (2026-09-06). Code :
src/lib/cost/connectors/, scriptscripts/cost-sync.ts, API adminsrc/app/api/admin/cost/.
Principe
Sources externes → discover → fetch (Firecrawl / API / PDF, hash) → extract → normalize → validate → cost.db- Aucun connecteur ne tourne dans le chemin utilisateur : l'interface lit
data/cost.db; les synchronisations se font parnpm run cost:sync(PM2 cron sur le nœud) ou l'API admin. - Toute observation brute est historisée (
cost_raw_observations, statutaccepted/rejected/unchanged,content_hash,parser_version). Rien n'est effacé ; un document inchangé (même hash) n'est pas retraité. - Les rejets gardent leur raison (
reject_reason) : prix ≤ 0, devise ≠ CAD, unité inconnue, emballage mal interprété (prix par unité canonique hors bornes), hors bornes métier (×0,25…×4 du prix de référence), aberration statistique (MAD / ratio à la médiane,pricing.detectOutliers). - L'IA (Claude Haiku 4.5) ne fixe jamais un prix. Elle sert à (1) choisir le bon produit
parmi des résultats de recherche (
matching.ts, sortie JSON stricte, mapping en attente si confiance < 0,8) et (2) transcrire les grilles APCHQ dont la mise en page PDF est décalée, chaque ligne étant vérifiée contre l'extraction déterministe du même document (apchq-ai.ts: couple (taux, total) présent dans le regex ou somme des colonnes = total). Consommation enregistrée dansai_usage(GET /api/admin/cost/usage).
Sources
| Clé | Source | Licence (cost_sources.license_status) |
Fréquence | Ce qui est ingéré |
|---|---|---|---|---|
apchq |
APCHQ — grilles « Coût horaire de la main-d'œuvre » (PDF publics, media.apchq.com) | public_open (documents publics ; valeurs + URL, pas de copie du document) |
7 j + hash | labour_rates : taux horaire, vacances (13 %), avantages sociaux, cotisations employeur (AE, RQAP, RRQ, FSS, CNESST), autres prélèvements fixes (taxe assurance, équipement, CCQ, AECQ, fonds), total employeur, par métier × classification (compagnon, apprenti 1-5) × secteur (résidentiel léger, résidentiel lourd, IC), historique depuis 2023 (effective_from / effective_to). Spécialités dérivées : poseur de systèmes intérieurs ← charpentier-menuisier, poseur de bardeaux ← couvreur. Exclus : temps demi/double, R-2, chantiers isolés / Baie-James, exemples de paie. |
ccq |
CCQ — Salaire et taux | public_open |
7 j + hash | Détection de changement seulement. Site en incident de sécurité (fermé jusqu'au 2026-09-08) : le run se termine en unavailable, aucune valeur n'est écrite. Quand des tableaux « métier / taux $ » apparaîtront, hasRateTable passe à vrai et les observations brutes sont conservées (statut new) — la normalisation exige une confirmation de la structure (secteur, cotisations) avant écriture dans labour_rates. |
statcan |
Statistique Canada — tableau 18-10-0289-01 (indices des prix de la construction de bâtiments), API WDS | public_open (licence ouverte) |
30 j | construction_cost_indices : 38 séries × 40 trimestres — Québec (province), RMR de Québec, Montréal, Ottawa–Gatineau × bâtiments résidentiels / appartements / maison individuelle / maison en rangée (agrégat) + 11 divisions pour Montréal et Québec résidentiel. Codes statcan:18100289:<geo>:<type>:<division> ; statcan:18100289:10:1:1 = série d'actualisation par défaut, …:10:1:8 = proxy « bois, plastiques et composites ». Variations trimestrielle et annuelle calculées. |
canac |
Canac | public_open (robots.txt permissif, fiches publiques) |
1 j | cost_item_prices (observed) via fiches produit ; prix dans product:price:amount, unité « / Chaque ». |
bmr |
BMR | public_open (URL SEO /fr/….html ; /catalog/product/view interdit par robots.txt et non utilisé) |
1 j | idem (product:price:amount). |
patrickmorin |
Patrick Morin | public_open |
1 j | idem. |
rona |
RONA | public_restricted — inactif (Cloudflare, recherche interdite par robots.txt ; fiches permises). Activer via UPDATE cost_sources SET is_active=1 WHERE key='rona' si les fiches répondent de façon stable. |
1 j | adaptateur prêt (ronaAdapter). |
homedepot |
Home Depot Canada | public_restricted — inactif (prix dépendants du magasin sélectionné, défaut hors Québec). |
1 j | adaptateur prêt. |
altus |
Altus Group — Canadian Cost Guide | public_restricted : PDF gratuit derrière un formulaire → import manuel après téléchargement légal. Aucun scraping. |
annuel | cost_benchmarks ($/pi² low/high par type et marché). |
rsmeans |
RSMeans / Gordian | licensed : import CSV autorisé seulement, jamais redistribué ; inactif. |
manuel | cost_item_prices (price_kind reference, source rsmeans). |
Les prix de référence internes (cost_items.reference_price) restent la dernière solution de
repli du catalogue et sont toujours étiquetés « hypothèse » par le moteur.
Détaillants : de l'article à l'observation
- Chaque article du catalogue possède un
retail_query(ex. « panneau de gypse 1/2 po 4 x 8 »), un contenu d'emballage par défaut (retail_pack_qty, ex. 32 pi²) et son unité canonique. - Découverte : s'il existe déjà une URL approuvée (
cost_item_sources), elle est réutilisée ; sinon recherche Firecrawl« <domaine> <retail_query> »(6 résultats), filtrage des URL de fiche, score déterministe (matching.scoreCandidate: toutes les dimensions de la requête doivent apparaître, puis recouvrement des mots). Score ≥ 0,85 avec marge → approuvé ; 0,35–0,85 → arbitrage IA (choix d'un candidat ou aucun) ; < 0,35 → aucun. Résultat dansproduct_mappings(auto_approved/pending/rejected) ;pendingà valider dans l'admin. - Fiche : scrape Firecrawl (markdown + métadonnées), hash incluant le prix affiché.
- Extraction : prix affiché, prix régulier (« Prix régulier », prix barré
~~) et promotion (« Spécial », « Économisez »…) détectés uniquement dans la zone du produit principal (avant « Utile avec cet achat » / produits associés). Le prix régulier est préféré ; une promo sans prix régulier est marquéeis_regular_price = 0et n'entre dans le prix canonique que s'il n'existe rien d'autre (pricing.canonicalPrice). - Normalisation : contenu d'emballage lu sur la fiche (« couvre 39,8 pi² », « 100 pi »,
« 50 lb », « pqt/100 ») sinon défaut de l'article ; conversion
units.convertPrice(source_unit, canonical_unit, conversion_factor conservés). - Validation puis écriture : une observation par jour et par (article, source, URL).
Commandes
npm run cost:sync # connecteurs dus (fréquence nominale)
npx tsx scripts/cost-sync.ts apchq statcan # connecteurs choisis
npx tsx scripts/cost-sync.ts canac --force -v # ignore hash/fréquence, journal détaillé
npx tsx scripts/cost-sync.ts --items LUM-2X6-8,GYP-1/2-4X8 bmr
npx tsx scripts/cost-sync.ts --max 20 patrickmorinVariables (.env.local, serveur seulement) : FIRECRAWL_API_KEY, ANTHROPIC_API_KEY (matching,
lecture APCHQ), COST_ADMIN_TOKEN, optionnel AI_DAILY_BUDGET_USD / AI_MONTHLY_BUDGET_USD
(alertes de l'endpoint usage), COST_DB (chemin de la base, défaut data/cost.db).
PM2 (nœud) : processus vrai-prix-cost-sync avec cron_restart quotidien
(/opt/homebrew/bin/npx tsx scripts/cost-sync.ts, autorestart: false).
API admin (jeton COST_ADMIN_TOKEN, Authorization: Bearer … ou ?token=)
| Route | Rôle |
|---|---|
GET /api/admin/cost/connectors |
état par source (licence, actif, dernière synchro, dernière erreur, volumes, mappings en attente, dû ?), 40 derniers runs, indices, métriques (taux de succès 30 j, observations par source, âge du dernier prix, taux d'échec de mapping, taux d'aberrations, usage IA), runs en cours |
POST /api/admin/cost/sync/<connecteur>[?force=1&items=A,B] |
lance un run en arrière-plan (202 + runId, 409 si déjà en cours) |
GET /api/admin/cost/anomalies |
observations rejetées (aberrations…), mappings pending, sources sans synchro depuis 14 j, sauts de prix > 30 % entre deux observations, articles sans observation récente |
GET /api/admin/cost/mappings[?status=pending] · POST {source,itemCode,url,title?,packQty?,packUnit?} |
liste / création-approbation manuelle |
POST /api/admin/cost/mappings/<id>/approve · /reject |
validation humaine (met à jour cost_item_sources) |
POST /api/admin/cost/benchmarks/import |
{kind:"benchmarks"|"rsmeans", data: CSV texte ou tableau JSON} (ou corps text/csv + ?kind=) |
GET /api/admin/cost/usage |
ai_usage jour / mois / par usage, budgets et alertes |
Format d'import Altus (benchmarks)
source,building_type,market,unit,low,high,year,notes
Altus Group — Canadian Cost Guide 2026,single_family,Montréal,$/pi2,255,395,2026,Single family residential (custom)
Altus Group — Canadian Cost Guide 2026,townhouse,Montréal,$/pi2,190,300,2026,building_type ∈ {single_family, townhouse, plex, condo} ; market = Montréal | Québec |
Ottawa-Gatineau (le moteur se rabat sur Montréal). Les valeurs doivent être recopiées du guide
téléchargé légalement ; elles restent à usage informatif (avis de l'éditeur).
Format d'import RSMeans (prix licenciés)
item_code,price,unit,pack_qty,date,notes
LUM-2X6-8,6.12,chaque,,2026-06-01,RSMeans 2026 Q2 — Montréal CCITests
npx vitest run src/lib/cost/connectors — fixtures dans __fixtures__/ (grilles APCHQ 2025 et
2026, page APCHQ avec ses liens, grille IC 2026, fiches Canac / BMR / Patrick Morin, réponses WDS
synthétiques). Couvre : dates/secteurs, découverte et exclusions, rangées fusionnées/décalées,
réconciliation IA, StatCan (yoy/qoq), CCQ indisponible, prix/promo/emballage, page sans prix,
403 / contenu vide, unité inconnue, aberrations, hash inchangé, imports.
Dépannage
FIRECRAWL_API_KEY manquante: le script lit.env.localdu répertoire courant.- Run
unavailablepour CCQ : normal tant que l'incident dure ; rien n'est écrit. - Mapping
pending:GET /api/admin/cost/anomalies→POST …/mappings/<id>/approve. Un article rejeté récemment n'est pas recherché de nouveau avant 7 jours (sauf--force). - Prix aberrant légitime (nouveau niveau de marché) : il est rejeté tant que les autres sources ne suivent pas ; après une semaine d'observations cohérentes il sera accepté.
- Lecture IA APCHQ « insuffisante » : le parseur déterministe est conservé ; vérifier
connector_runs.log_json. - RONA / Home Depot : inactifs par conception (voir tableau) ; activer dans
cost_sourcesseulement après vérification manuelle de quelques fiches.