// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Connecteur détaillant générique (Canac, BMR, Patrick Morin, RONA, Home Depot) : * articles avec `retail_query` → URL produit (cost_item_sources ou découverte * par recherche + matching) → fiche (Firecrawl) → prix affiché (métadonnées * product:price:amount ou texte) → conversion vers l'unité canonique → * observation historisée (une par jour, par article et par source). * Prix régulier préféré au prix promotionnel ; jamais d'écrasement. */ import type Database from "better-sqlite3"; import { getCostDb, sourceIdByKey } from "../db"; import { convertPrice, isKnownUnit } from "../units"; import type { CanonicalUnit } from "../types"; import { contentHash, parseMoney, scrape, search, type ScrapeResult } from "./firecrawl"; import { aiMatch, deterministicMatch, upsertMapping, type Candidate } from "./matching"; import { lastHashFor, recentPricesFor, referencePriceFor } from "./store"; import type { CanonicalObservation, ConnectorConfig, ConnectorKey, CostConnector, RawDocument, RawObservation, RunOptions, SourceDocument, ValidationResult } from "./types"; import { validateObservations } from "./validate"; export const RETAIL_PARSER_VERSION = "retail-1.0"; export interface RetailAdapter { key: ConnectorKey; name: string; domain: string; searchPrefix: string; // ex. « canac.ca » isProductUrl(url: string): boolean; /** extrait prix affiché / régulier / promo / unité affichée depuis la fiche */ parse(r: ScrapeResult): { title: string; price: number | null; regularPrice: number | null; salePrice: number | null; unitText: string | null; currency: string }; active: boolean; config: Partial; } interface ItemRow { id: number; canonical_code: string; name_fr: string; unit: CanonicalUnit; retail_query: string; retail_pack_qty: number | null; retail_pack_unit: string | null; reference_price: number | null } /* -------------------------------------------------------- aides d'analyse */ /** Détecte un prix promotionnel dans le markdown (prix barré, « Spécial », « Économisez », « Prix régulier »). */ export function detectPromotion(mdFull: string, displayed: number | null): { regular: number | null; sale: number | null } { // seule la zone du produit principal compte (avant les produits associés / la description) const cut = mdFull.replace(/\u00a0/g, " ").search(/utile avec cet achat|produits (similaires|associ[ée]s|compl[ée]mentaires)|vous aimerez|articles connexes|\n#{1,3}\s*description/i); const md = cut > 0 ? mdFull.slice(0, cut) : mdFull.replace(/\u00a0/g, " ").slice(0, 3000); const regMatch = md.match(/(?:prix\s+r[ée]g(?:ulier)?\.?|r[ée]g\.?|was|prix\s+courant)\s*:?\s*(\d[\d\s]*(?:[.,]\d+)?)\s*\$/i) ?? md.match(/~~\s*(\d[\d\s]*(?:[.,]\d+)?)\s*\$\s*~~/); const regular = regMatch ? parseMoney(regMatch[1]) : null; // le mot « promo » ne compte que près du prix affiché (bandeaux de site exclus) const shown = displayed != null ? md.search(new RegExp(`${displayed.toFixed(2).replace(".", "[.,]")}\\s*\\$`)) : -1; const near = shown >= 0 ? md.slice(Math.max(0, shown - 250), shown + 250) : ""; const promo = /(sp[ée]cial|[ée]conomisez|rabais|en solde|liquidation|prix\s+promo)/i.test(near); if (regular != null && displayed != null && regular > displayed) return { regular, sale: displayed }; if (promo && displayed != null && regular == null) return { regular: null, sale: displayed }; return { regular: displayed, sale: null }; } /** Contenu d'emballage lu sur la fiche, dans l'unité canonique (ex. « couvre 39,8 pi² », « 100 pi », « pqt/50 »). */ export function detectPackQty(text: string, unit: CanonicalUnit): number | null { const t = text.replace(/ /g, " "); const num = (s: string) => parseMoney(s); if (unit === "pi2") { const m = t.match(/(?:couvr\w*|couverture|superficie)\s*(?:de|:)?\s*(?:jusqu.à\s*)?(\d[\d\s]*(?:[.,]\d+)?)\s*(?:pi|ft)\s*(?:²|2|carr)/i) ?? t.match(/(\d[\d\s]*(?:[.,]\d+)?)\s*(?:pi|ft)\s*(?:²|2)\s*(?:par|\/)\s*(?:paquet|pqt|boîte|boite|rouleau|sac)/i); if (m) return num(m[1]); const sheet = t.match(/\b(\d+)\s*(?:pi|')\s*x\s*(\d+)\s*(?:pi|')\b/i); if (sheet) { const a = Number(sheet[1]) * Number(sheet[2]); if (a >= 8 && a <= 200) return a; } return null; } if (unit === "pi_lin") { const m = t.match(/(\d[\d\s]*(?:[.,]\d+)?)\s*(?:pi|ft)\b(?!\s*(?:²|2))/i); if (m) { const v = num(m[1]); if (v != null && v >= 4 && v <= 1000) return v; } const mm = t.match(/(\d+(?:[.,]\d+)?)\s*m\b/i); if (mm) { const v = num(mm[1]); if (v != null && v >= 5 && v <= 500) return Math.round(v * 3.28084 * 10) / 10; } return null; } if (unit === "lb") { const m = t.match(/(\d+(?:[.,]\d+)?)\s*lb\b/i); if (m) return num(m[1]); const kg = t.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i); if (kg) { const v = num(kg[1]); return v != null ? Math.round(v * 2.20462 * 100) / 100 : null; } return null; } if (unit === "unit") { const m = t.match(/(?:pqt|paquet|bo[iî]te|ens\.?|ensemble|lot)\s*(?:de|\/|:)?\s*(\d{2,4})\b/i) ?? t.match(/\b(\d{2,4})\s*(?:unités|pièces|mcx|pcs|un\.)\b/i); if (m) { const v = Number(m[1]); return v >= 2 && v <= 5000 ? v : null; } } return null; } export function unitTextToSourceUnit(unitText: string | null, unit: CanonicalUnit, packQty: number | null, defaultPackUnit: string | null): string { const t = (unitText ?? "").toLowerCase(); if (/pi\s*²|pi2|sq\.?\s*ft/.test(t) && unit === "pi2") return "pi2"; if (/pi\s*lin|lin\.?\s*ft|\/\s*pi\b/.test(t) && unit === "pi_lin") return "pi_lin"; if (/m\s*²|m2/.test(t)) return "m2"; if (/m\s*³|m3/.test(t)) return "m3"; if (/\blb\b/.test(t)) return "lb"; if (/\bkg\b/.test(t)) return "kg"; if (/feuille|sheet/.test(t)) return "feuille"; if (/paquet|pqt|pack|ballot|bundle/.test(t)) return "paquet"; if (/rouleau|roll/.test(t)) return "rouleau"; if (/bo[iî]te|box/.test(t)) return "boite"; if (/sac|bag/.test(t)) return "sac"; if (/seau|pail/.test(t)) return "seau"; if (/chaque|each|\/\s*ch\b|unité/.test(t)) return packQty && unit !== "unit" ? (defaultPackUnit && isKnownUnit(defaultPackUnit) ? defaultPackUnit : "paquet") : "chaque"; if (t.trim()) return t.trim(); // unité affichée non reconnue → rejet explicite en validation if (packQty && unit !== "unit") return defaultPackUnit && isKnownUnit(defaultPackUnit) ? defaultPackUnit : "paquet"; return unit === "unit" ? "chaque" : unit; } /* ------------------------------------------------------------ connecteur */ /** Normalise les espaces insécables des fiches avant analyse. */ function clean(r: ScrapeResult): ScrapeResult { return { ...r, markdown: r.markdown.replace(/\u00a0/g, " ") }; } export function makeRetailConnector(a: RetailAdapter): CostConnector { const config: ConnectorConfig = { key: a.key, name: a.name, domain: a.domain, maxPages: 400, allowedPaths: [new RegExp(`^https?://(www\\.)?${a.domain.replace(/\./g, "\\.")}/`)], excludedPaths: [/\/(recherche|search|panier|cart|checkout|compte|account|sign-in|login)/i], refreshDays: 1, timeoutMs: 90_000, retries: 2, rateLimitMs: 1200, concurrency: 2, ...a.config, }; const itemsWithQuery = (d: Database.Database, only?: string[]): ItemRow[] => { const rows = d.prepare("SELECT id, canonical_code, name_fr, unit, retail_query, retail_pack_qty, retail_pack_unit, reference_price FROM cost_items WHERE active=1 AND retail_query IS NOT NULL ORDER BY canonical_code").all() as ItemRow[]; return only?.length ? rows.filter((r) => only.includes(r.canonical_code)) : rows; }; return { config, /** Une URL produit par article : source connue (cost_item_sources) sinon découverte par recherche + matching. */ async discover(opts) { const d = opts.db ?? getCostDb(); const sid = sourceIdByKey(a.key, d); const docs: SourceDocument[] = []; const known = d.prepare("SELECT s.product_url, s.pack_qty, s.pack_unit, i.canonical_code FROM cost_item_sources s JOIN cost_items i ON i.id=s.cost_item_id WHERE s.source_id=? AND s.active=1 AND s.approved=1").all(sid) as { product_url: string; pack_qty: number | null; pack_unit: string | null; canonical_code: string }[]; const knownByItem = new Map(known.map((k) => [k.canonical_code, k])); const rejected = new Set((d.prepare("SELECT proposed_item_code FROM product_mappings WHERE source_id=? AND status IN ('pending','rejected') AND created_at >= datetime('now','-7 days')").all(sid) as { proposed_item_code: string | null }[]).map((r) => r.proposed_item_code)); let discoveries = 0; const max = opts.maxPages ?? config.maxPages; for (const it of itemsWithQuery(d, opts.onlyItems)) { if (docs.length >= max) break; const k = knownByItem.get(it.canonical_code); if (k) { docs.push({ url: k.product_url, title: it.name_fr, meta: { itemCode: it.canonical_code, packQty: k.pack_qty ?? it.retail_pack_qty, packUnit: k.pack_unit ?? it.retail_pack_unit } }); continue; } if (rejected.has(it.canonical_code) && !opts.force) { opts.log?.({ connector: a.key, url: null, status: "skipped", duration: 0, observations: 0, validationErrors: 0, timestamp: new Date().toISOString(), message: `${it.canonical_code} : mapping en attente/rejeté récemment` }); continue; } const t0 = Date.now(); try { const hits = await search(`${a.searchPrefix} ${it.retail_query}`, 6, { timeoutMs: 60_000, retries: 1, rateLimitMs: config.rateLimitMs }); const cands: Candidate[] = hits.filter((h) => a.isProductUrl(h.url) && !config.excludedPaths.some((re) => re.test(h.url))).map((h) => ({ url: h.url.split("?")[0], title: h.title, description: h.description })); let dec = deterministicMatch(it.retail_query, cands); let packQty: number | null = null; if (dec.candidate && dec.confidence < 0.85 && cands.length && process.env.ANTHROPIC_API_KEY) { const ai = await aiMatch(it.name_fr, it.unit, it.retail_query, cands, d); dec = ai.candidate ? { ...ai, confidence: Math.max(ai.confidence, dec.confidence * 0.8) } : { candidate: null, confidence: 0, method: "ai", rationale: ai.rationale }; packQty = ai.packQty; } const status = upsertMapping(a.key, it.canonical_code, dec, packQty ?? it.retail_pack_qty, it.retail_pack_unit, d); discoveries++; opts.log?.({ connector: a.key, url: dec.candidate?.url ?? null, status: status === "approved" ? "ok" : "skipped", duration: Date.now() - t0, observations: 0, validationErrors: 0, timestamp: new Date().toISOString(), message: `${it.canonical_code} → ${status} (${dec.method}, ${dec.confidence.toFixed(2)}) ${dec.rationale}` }); if (status === "approved" && dec.candidate) docs.push({ url: dec.candidate.url, title: it.name_fr, meta: { itemCode: it.canonical_code, packQty: packQty ?? it.retail_pack_qty, packUnit: it.retail_pack_unit } }); } catch (e) { opts.log?.({ connector: a.key, url: null, status: "error", duration: Date.now() - t0, observations: 0, validationErrors: 0, timestamp: new Date().toISOString(), message: `${it.canonical_code} : recherche échouée — ${(e as Error).message}` }); } } void discoveries; return docs; }, async fetch(doc, opts) { const r = clean(await scrape(doc.url, { timeoutMs: config.timeoutMs, retries: config.retries, rateLimitMs: config.rateLimitMs, concurrency: config.concurrency })); const parsed = a.parse(r); // le hash inclut le prix affiché : un changement de prix = document changé const hash = contentHash(r.markdown.slice(0, 6000), { price: parsed.price, regular: parsed.regularPrice }); return { url: doc.url, fetchedAt: new Date().toISOString(), markdown: r.markdown, metadata: { ...r.metadata, parsed }, statusCode: r.statusCode, contentHash: hash, unchanged: !opts.force && lastHashFor(doc.url, opts.db) === hash, meta: doc.meta }; }, async extract(raw) { const p = (raw.metadata.parsed as ReturnType) ?? a.parse(clean({ markdown: raw.markdown, metadata: raw.metadata, statusCode: raw.statusCode, links: [] })); if (raw.statusCode != null && raw.statusCode >= 400) return []; if (p.price == null) return []; const promo = detectPromotion(raw.markdown, p.price); return [{ externalId: String(raw.metadata["product:retailer_item_id"] ?? raw.metadata.sku ?? raw.url), sourceUrl: raw.url, retrievedAt: raw.fetchedAt, effectiveDate: raw.fetchedAt.slice(0, 10), title: p.title, description: null, unit: p.unitText, price: p.price, regularPrice: p.regularPrice ?? promo.regular, salePrice: p.salePrice ?? promo.sale, currency: p.currency, location: "QC", category: null, payload: { itemCode: raw.meta?.itemCode ?? null, packQty: raw.meta?.packQty ?? null, packUnit: raw.meta?.packUnit ?? null, statusCode: raw.statusCode, metaKeys: Object.keys(raw.metadata).slice(0, 30) }, }]; }, async normalize(obs, raw, opts) { const d = opts.db ?? getCostDb(); const out: CanonicalObservation[] = []; for (const o of obs) { const code = o.payload.itemCode as string | null; if (!code) continue; const item = d.prepare("SELECT unit, retail_pack_qty, retail_pack_unit FROM cost_items WHERE canonical_code=?").get(code) as { unit: CanonicalUnit; retail_pack_qty: number | null; retail_pack_unit: string | null } | undefined; if (!item) continue; const regular = o.regularPrice != null && o.regularPrice > 0 ? o.regularPrice : null; const price = regular ?? o.price; if (price == null || price <= 0) continue; // contenu d'emballage lu sur le TITRE seulement (le corps de page mêle des produits associés) const detected = detectPackQty(o.title, item.unit); const packQty = (o.payload.packQty as number | null) ?? detected ?? item.retail_pack_qty; const sourceUnit = unitTextToSourceUnit(o.unit, item.unit, packQty, (o.payload.packUnit as string | null) ?? item.retail_pack_unit); try { const conv = convertPrice(price, sourceUnit, item.unit, packQty); out.push({ kind: "price", itemCode: code, sourceUrl: o.sourceUrl, observationDate: o.retrievedAt.slice(0, 10), sourceUnit: conv.conversion.sourceUnit, canonicalUnit: item.unit, conversionFactor: conv.conversion.factor, totalCost: Math.round(conv.price * 10000) / 10000, isRegularPrice: regular != null || o.salePrice == null, rawPrice: price, rawRegularPrice: o.regularPrice, rawSalePrice: o.salePrice, currency: o.currency, locationCode: "QC", title: o.title, raw: o }); } catch (e) { // unité inconnue → observation canonique invalide (rejet explicite en validation) out.push({ kind: "price", itemCode: code, sourceUrl: o.sourceUrl, observationDate: o.retrievedAt.slice(0, 10), sourceUnit: isKnownUnit(sourceUnit) ? sourceUnit : `?${sourceUnit}`, canonicalUnit: item.unit, conversionFactor: 0, totalCost: 0, isRegularPrice: true, rawPrice: price, rawRegularPrice: o.regularPrice, rawSalePrice: o.salePrice, currency: o.currency, locationCode: "QC", title: `${o.title} — ${(e as Error).message}`, raw: o }); } } return out; }, validate(obs, opts): ValidationResult { return validateObservations(obs, { recentPrices: (c) => recentPricesFor(c, 120, opts.db), referencePrice: (c) => referencePriceFor(c, opts.db) }); }, }; } /* ------------------------------------------------------------ adaptateurs */ function metaPrice(md: Record): number | null { for (const k of ["product:price:amount", "og:price:amount", "product:price", "price", "priceAmount"]) { const v = md[k]; const p = parseMoney(Array.isArray(v) ? v[0] : v); if (p != null && p > 0) return p; } return null; } function metaCurrency(md: Record): string { const v = md["product:price:currency"] ?? md["og:price:currency"] ?? md.priceCurrency; const s = Array.isArray(v) ? v[0] : v; return typeof s === "string" && s ? s.toUpperCase() : "CAD"; } function metaTitle(md: Record, fallback: string): string { const t = md.ogTitle ?? md["og:title"] ?? md.title; return typeof t === "string" && t ? t.replace(/\u00a0/g, " ").replace(/\s*[|\-–]\s*(Canac|BMR|Patrick Morin|RONA|Home Depot Canada)\s*$/i, "").trim() : fallback; } export const canacAdapter: RetailAdapter = { key: "canac", name: "Canac", domain: "canac.ca", searchPrefix: "canac.ca", active: true, config: { rateLimitMs: 1200 }, isProductUrl: (u) => /canac\.ca\/canac\/fr\/2\/p\//.test(u), parse(r0) { const r = clean(r0); const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$\s*\/\s*(?:Chaque|Feuille|Paquet|Boîte|Sac|Rouleau|Longueur|pi²|pi)/i)?.[1]); const unit = r.markdown.match(/\$\s*\/\s*([A-Za-zÀ-ÿ²]+(?:\s+de\s+\d+)?)/)?.[1] ?? null; const reg = r.markdown.match(/Prix\s+r[ée]gulier\s*:?\s*(\d[\d\s]*(?:[.,]\d{2}))\s*\$/i); return { title: metaTitle(r.metadata, r.markdown.match(/^#\s+(.+)$/m)?.[1] ?? ""), price, regularPrice: reg ? parseMoney(reg[1]) : null, salePrice: reg ? price : null, unitText: unit, currency: metaCurrency(r.metadata) }; }, }; export const bmrAdapter: RetailAdapter = { key: "bmr", name: "BMR", domain: "bmr.ca", searchPrefix: "bmr.ca", active: true, config: { rateLimitMs: 1500 }, isProductUrl: (u) => /bmr\.ca\/(fr|en)\/[^/?#]+\.html$/.test(u.split("?")[0]) && !/\/search\//.test(u), parse(r0) { const r = clean(r0); const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$/)?.[1]); const reg = r.markdown.match(/(?:Prix\s+r[ée]g(?:ulier)?\.?|Rég\.)\s*:?\s*(\d[\d\s]*(?:[.,]\d{2}))\s*\$/i); const unit = r.markdown.match(/\$\s*\/\s*([A-Za-zÀ-ÿ²]+)/)?.[1] ?? null; return { title: metaTitle(r.metadata, ""), price, regularPrice: reg ? parseMoney(reg[1]) : null, salePrice: reg ? price : null, unitText: unit, currency: metaCurrency(r.metadata) }; }, }; export const patrickMorinAdapter: RetailAdapter = { key: "patrickmorin", name: "Patrick Morin", domain: "patrickmorin.com", searchPrefix: "patrickmorin.com", active: true, config: { rateLimitMs: 1500 }, isProductUrl: (u) => /patrickmorin\.com\/(fr|en)\/[^/?#]+$/.test(u.split("?")[0]) && !/\/(fr|en)\/(recherche|search|catalogsearch)/.test(u), parse(r0) { const r = clean(r0); const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$/)?.[1]); const reg = r.markdown.match(/(?:Prix\s+r[ée]g(?:ulier)?\.?|Rég\.)\s*:?\s*(\d[\d\s]*(?:[.,]\d{2}))\s*\$/i); const unit = r.markdown.match(/\$\s*\/\s*([A-Za-zÀ-ÿ²]+)/)?.[1] ?? null; return { title: metaTitle(r.metadata, ""), price, regularPrice: reg ? parseMoney(reg[1]) : null, salePrice: reg ? price : null, unitText: unit, currency: metaCurrency(r.metadata) }; }, }; export const ronaAdapter: RetailAdapter = { key: "rona", name: "RONA", domain: "rona.ca", searchPrefix: "rona.ca", active: false, config: { rateLimitMs: 2500, concurrency: 1, retries: 1 }, isProductUrl: (u) => /rona\.ca\/(fr|en)\/(produit|product)\//.test(u), parse(r0) { const r = clean(r0); const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$/)?.[1]); const reg = r.markdown.match(/(?:Prix\s+r[ée]g(?:ulier)?\.?|Was)\s*:?\s*\$?\s*(\d[\d\s]*(?:[.,]\d{2}))/i); return { title: metaTitle(r.metadata, ""), price, regularPrice: reg ? parseMoney(reg[1]) : null, salePrice: reg ? price : null, unitText: null, currency: metaCurrency(r.metadata) }; }, }; export const homeDepotAdapter: RetailAdapter = { key: "homedepot", name: "Home Depot Canada", domain: "homedepot.ca", searchPrefix: "homedepot.ca", active: false, config: { rateLimitMs: 2500, concurrency: 1, retries: 1 }, isProductUrl: (u) => /homedepot\.ca\/(produit|product)\//.test(u), parse(r0) { const r = clean(r0); // prix dépendant du magasin sélectionné (défaut hors Québec) : conservé pour l'avenir, connecteur inactif const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$/)?.[1]); return { title: metaTitle(r.metadata, ""), price, regularPrice: null, salePrice: null, unitText: null, currency: metaCurrency(r.metadata) }; }, }; export type { RawDocument, RawObservation, RunOptions };