Connecteur incrémental « nouvelles ventes » (JdM/api.qub.ca)
Ajoute les ventes récentes directement dans data/vraiprix.db sur le nœud, sans reconstruire la base (le pipeline batch reste sur le laptop). - scripts/qub-token.mjs : Bearer QUB via login Scrapfly (connect.qub.ca, Akamai + reCAPTCHA v3, réessais sur sessions neuves) puis /api/checklogin. - scripts/ingest-jdm.mjs : balayage des 1478 secteurs /v1/locations/all → /v1/map (quadtree si plafond), mapping vers schéma transactions, spatial- join units (id_provinc/valeur_role), INSERT OR IGNORE (dédup par id). - Planifié quotidiennement via pm2 (vrai-prix-ingest, cron 08:00). - Gap 2026-07-27 → 2026-08-12 comblé province-wide (+4189 ventes, match 100%). - Doc : PIPELINE-DONNEES.md section 9. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 changed files +491 −3
modified
docs/PIPELINE-DONNEES.md
+62 −3
@@ -136,9 +136,14 @@ hédoniques les traitent comme valeurs manquantes natives LightGBM. | ||
| 136 | 136 | publication « 2027 ») exigera de rejouer tout le pipeline : re-télécharger |
| 137 | 137 | les FGDB/GPKG, revalider les schémas (les noms de couches/champs bougent |
| 138 | 138 | entre millésimes), ré-entraîner les 4 modèles. |
| 139 | −4. **Décalage de fraîcheur** : la BD servie date du dernier build (2026-08-08) ; | |
| 140 | − l'app n'a aucun mécanisme de mise à jour incrémentale — c'est voulu | |
| 141 | − (batch + livraison atomique d'un seul fichier). | |
| 139 | +4. **Décalage de fraîcheur** : la BD servie date du dernier build complet | |
| 140 | + (2026-08-08). Le **pipeline batch** (rôles + modèles hédoniques) reste sans | |
| 141 | + mise à jour incrémentale — c'est voulu (livraison atomique d'un fichier). | |
| 142 | + **Depuis 2026-08-22, les ventes récentes sont toutefois rafraîchies en | |
| 143 | + continu directement sur le nœud** par le connecteur incrémental | |
| 144 | + `scripts/ingest-jdm.mjs` (voir section 9) : la table `transactions` avance | |
| 145 | + donc au fil de l'eau, alors que les `units`/`est_*`/`market_index` restent | |
| 146 | + figées jusqu'au prochain build complet. | |
| 142 | 147 | |
| 143 | 148 | ## 7. Procédure de rafraîchissement pas à pas |
| 144 | 149 | |
@@ -173,3 +178,57 @@ Les données du rôle d'évaluation proviennent du **MAMH via Données Québec** | ||
| 173 | 178 | (licence ouverte — attribution requise). Les transactions servent au calcul |
| 174 | 179 | de modèles et d'indices agrégés ; les pages publiques n'exposent pas la |
| 175 | 180 | source brute qub.ca. |
| 181 | + | |
| 182 | +## 9. Connecteur incrémental « nouvelles ventes » (sur le nœud) — 2026-08-22 | |
| 183 | + | |
| 184 | +Contrairement au reste du pipeline (batch, laptop), ce connecteur tourne | |
| 185 | +**directement sur le nœud** et **ajoute les ventes récentes dans | |
| 186 | +`data/vraiprix.db` sans reconstruire la base**. Il alimente la même source que | |
| 187 | +le widget « Transactions immobilières » du Journal de Montréal | |
| 188 | +(https://www.journaldemontreal.com/argent/immobilier/transactions-immobilieres), | |
| 189 | +c.-à-d. l'API `api.qub.ca/real-estate-service` — la même que `scrape_province.py`, | |
| 190 | +donc les `id` sont compatibles et le dédoublonnage est naturel. | |
| 191 | + | |
| 192 | +### Fichiers | |
| 193 | +- `scripts/qub-token.mjs` — obtient un Bearer QUB (id-token Cognito). Se connecte | |
| 194 | + au compte Québecor via **Scrapfly** (login `connect.qub.ca` protégé par Akamai + | |
| 195 | + reCAPTCHA v3 → réessais sur sessions neuves), puis lit le jeton dans | |
| 196 | + `GET /api/checklogin` (champ `userToken`). Jeton valide 1 h. | |
| 197 | +- `scripts/ingest-jdm.mjs` — le connecteur : liste les ~1478 secteurs de | |
| 198 | + `/v1/locations/all`, interroge `/v1/map` par secteur (résultats triés du plus | |
| 199 | + récent au plus ancien, plafond 500 → **quadtree** si saturé), mappe vers le | |
| 200 | + schéma `transactions`, **joint spatialement à `units`** (id_provinc, valeur_role) | |
| 201 | + et `INSERT OR IGNORE` (dédup par `id`). Charge automatiquement `.env.local`. | |
| 202 | + | |
| 203 | +### Mapping API → colonnes `transactions` | |
| 204 | +`id`→id · `date`→date · `amount`→amount · `address.street/city`→street/city · | |
| 205 | +`geometries[0].coordinates`→lng,lat · `propertyType`→property_type · | |
| 206 | +`ar.yearBuilt`→year_built · `ar.floorArea`→floor_area · `ar.buildingType`→ | |
| 207 | +building_type · `ar.parcelArea`→land_area · unité appariée→id_provinc,valeur_role. | |
| 208 | + | |
| 209 | +### Utilisation | |
| 210 | +```bash | |
| 211 | +cd ~/apps/vrai-prix | |
| 212 | +node scripts/ingest-jdm.mjs # défaut : ventes depuis max(date)-45j | |
| 213 | +node scripts/ingest-jdm.mjs --since=2026-07-15 # date plancher explicite | |
| 214 | +node scripts/ingest-jdm.mjs --region="Montréal" # un secteur (sous-chaîne) | |
| 215 | +node scripts/ingest-jdm.mjs --dry-run # ne rien écrire (audit) | |
| 216 | +``` | |
| 217 | +L'app lit la BD en WAL : les nouvelles lignes sont visibles **sans redémarrage**. | |
| 218 | + | |
| 219 | +### Planification | |
| 220 | +Exécution quotidienne via **pm2** (08:00, nœud) : | |
| 221 | +`pm2 start scripts/ingest-jdm.mjs --name vrai-prix-ingest --cwd ~/apps/vrai-prix --no-autorestart --cron-restart="0 8 * * *" --interpreter node` (puis `pm2 save`). | |
| 222 | +Logs : `pm2 logs vrai-prix-ingest`. | |
| 223 | + | |
| 224 | +### Secrets (dans `.env.local`, non versionné) | |
| 225 | +`SCRAPFLY_KEY`, `QUB_EMAIL`, `QUB_PASSWORD`, `QUB_SESSION` (préfixe de session). | |
| 226 | + | |
| 227 | +### Limites | |
| 228 | +- Login QUB reCAPTCHA v3 : réussite probabiliste (boucle de 6 essais sur | |
| 229 | + sessions neuves ; ~1 essai suffit en général). | |
| 230 | +- N'alimente QUE `transactions`. Les estimations hédoniques (`est_*`, `p10/p90`) | |
| 231 | + et `market_index` d'une vente toute neuve restent celles du dernier build | |
| 232 | + complet tant que le pipeline batch n'a pas été rejoué. | |
| 233 | +- Historique de premier comblement : gap 2026-07-27 → 2026-08-12 rempli | |
| 234 | + province-wide le 2026-08-22 (+4189 ventes, appariement units 100 %). | |
added
scripts/ingest-jdm.mjs
+285 −0
@@ -0,0 +1,285 @@ | ||
| 1 | +// Connecteur incrémental « nouvelles ventes » — source api.qub.ca | |
| 2 | +// (le même feed que le widget « Transactions immobilières » du Journal de | |
| 3 | +// Montréal, https://www.journaldemontreal.com/argent/immobilier/transactions-immobilieres). | |
| 4 | +// | |
| 5 | +// Contrairement au pipeline batch complet (docs/PIPELINE-DONNEES.md, qui vit sur | |
| 6 | +// le laptop et reconstruit tout le .db), ce connecteur tourne SUR LE NŒUD et | |
| 7 | +// ajoute uniquement les ventes récentes directement dans data/vraiprix.db : | |
| 8 | +// | |
| 9 | +// 1. Récupère un jeton Bearer QUB (scripts/qub-token.mjs, via Scrapfly). | |
| 10 | +// 2. Liste les ~1478 secteurs de /real-estate-service/v1/locations/all. | |
| 11 | +// 3. Pour chaque secteur, interroge /v1/map (résultats triés du plus récent | |
| 12 | +// au plus ancien, plafonnés à `nb_transactions`). Si un secteur sature le | |
| 13 | +// plafond sans atteindre la date plancher, on subdivise sa bbox (quadtree). | |
| 14 | +// 4. Mappe chaque vente vers le schéma `transactions`, joint spatialement à | |
| 15 | +// `units` pour id_provinc / valeur_role, puis INSERT OR IGNORE (dédup par | |
| 16 | +// id — identique au pipeline batch, la source fournit le même id nanoid). | |
| 17 | +// | |
| 18 | +// Les `id` déjà connus sont ignorés : lancer ce script régulièrement (cron/pm2) | |
| 19 | +// garde la base à jour sans doublon. L'app lit le .db en WAL, les nouvelles | |
| 20 | +// lignes sont visibles immédiatement, sans redémarrage. | |
| 21 | +// | |
| 22 | +// Env : SCRAPFLY_KEY, QUB_EMAIL, QUB_PASSWORD (login), VRAIPRIX_DB (optionnel). | |
| 23 | +// Args : --since=YYYY-MM-DD date plancher (défaut : max(date en base) - 45 j) | |
| 24 | +// --region="Nom" ne traiter que ce(s) secteur(s) (sous-chaîne) | |
| 25 | +// --dry-run n'écrit rien, affiche seulement le décompte | |
| 26 | +// --concurrency=N requêtes /map simultanées (défaut 6) | |
| 27 | + | |
| 28 | +import Database from 'better-sqlite3'; | |
| 29 | +import fs from 'node:fs'; | |
| 30 | +import path from 'node:path'; | |
| 31 | +import { fileURLToPath } from 'node:url'; | |
| 32 | +import { getQubToken } from './qub-token.mjs'; | |
| 33 | + | |
| 34 | +// Charge .env.local (racine du repo) si présent — rend le script autonome sous | |
| 35 | +// cron/pm2 (SCRAPFLY_KEY, QUB_EMAIL, QUB_PASSWORD, QUB_SESSION, VRAIPRIX_DB). | |
| 36 | +(function loadEnvLocal() { | |
| 37 | + const envPath = path.join(fileURLToPath(new URL('..', import.meta.url)), '.env.local'); | |
| 38 | + if (!fs.existsSync(envPath)) return; | |
| 39 | + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { | |
| 40 | + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); | |
| 41 | + if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); | |
| 42 | + } | |
| 43 | +})(); | |
| 44 | + | |
| 45 | +const API = 'https://api.qub.ca/real-estate-service/v1'; | |
| 46 | +const PAGE_CAP = 500; // plafond de /map ; si atteint on subdivise | |
| 47 | +const HDRS = { | |
| 48 | + Origin: 'https://www.journaldemontreal.com', | |
| 49 | + Referer: 'https://www.journaldemontreal.com/', | |
| 50 | + 'User-Agent': | |
| 51 | + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36', | |
| 52 | +}; | |
| 53 | + | |
| 54 | +// ---------- args ---------- | |
| 55 | +const args = Object.fromEntries( | |
| 56 | + process.argv.slice(2).map((a) => { | |
| 57 | + const m = a.match(/^--([^=]+)(?:=(.*))?$/); | |
| 58 | + return m ? [m[1], m[2] ?? true] : [a, true]; | |
| 59 | + }), | |
| 60 | +); | |
| 61 | +const DRY = !!args['dry-run']; | |
| 62 | +const CONC = Math.max(1, parseInt(args.concurrency || '6', 10)); | |
| 63 | +const REGION = args.region ? String(args.region).toLowerCase() : null; | |
| 64 | + | |
| 65 | +// ---------- db ---------- | |
| 66 | +const DB_PATH = | |
| 67 | + process.env.VRAIPRIX_DB || path.join(process.cwd(), 'data', 'vraiprix.db'); | |
| 68 | +const db = new Database(DB_PATH); | |
| 69 | +db.pragma('journal_mode = WAL'); | |
| 70 | +db.pragma('busy_timeout = 15000'); | |
| 71 | + | |
| 72 | +const sinceArg = typeof args.since === 'string' ? args.since : null; | |
| 73 | +const maxDate = db.prepare('SELECT MAX(date) d FROM transactions').get().d; | |
| 74 | +function minusDays(iso, n) { | |
| 75 | + const d = new Date(iso + 'T00:00:00Z'); | |
| 76 | + d.setUTCDate(d.getUTCDate() - n); | |
| 77 | + return d.toISOString().slice(0, 10); | |
| 78 | +} | |
| 79 | +const SINCE = sinceArg || minusDays(maxDate || '2026-01-01', 45); | |
| 80 | + | |
| 81 | +// ---------- helpers ---------- | |
| 82 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 83 | + | |
| 84 | +async function apiGet(url, token, tries = 4) { | |
| 85 | + for (let i = 0; i < tries; i++) { | |
| 86 | + let res; | |
| 87 | + try { | |
| 88 | + res = await fetch(url, { headers: { ...HDRS, Authorization: `Bearer ${token}` } }); | |
| 89 | + } catch (e) { | |
| 90 | + if (i === tries - 1) throw e; | |
| 91 | + await sleep(500 * (i + 1)); | |
| 92 | + continue; | |
| 93 | + } | |
| 94 | + if (res.ok) return res.json(); | |
| 95 | + if (res.status === 401 || res.status === 403) | |
| 96 | + throw Object.assign(new Error(`auth ${res.status}`), { authError: true }); | |
| 97 | + if (res.status === 429 || res.status >= 500) { | |
| 98 | + await sleep(800 * (i + 1)); | |
| 99 | + continue; | |
| 100 | + } | |
| 101 | + throw new Error(`HTTP ${res.status} sur ${url}`); | |
| 102 | + } | |
| 103 | + throw new Error(`échec après ${tries} essais : ${url}`); | |
| 104 | +} | |
| 105 | + | |
| 106 | +async function fetchLocations(token) { | |
| 107 | + const list = await apiGet(`${API}/locations/all`, token); | |
| 108 | + return list | |
| 109 | + .map((l) => { | |
| 110 | + // shape.coordinates est un anneau « plat » [[lng,lat],…] chez cette API | |
| 111 | + // (parfois imbriqué [[[lng,lat],…]] en GeoJSON standard) — on gère les deux. | |
| 112 | + let ring = l.shape?.coordinates || []; | |
| 113 | + if (Array.isArray(ring[0]?.[0])) ring = ring[0]; | |
| 114 | + if (!ring.length || typeof ring[0]?.[0] !== 'number') return null; | |
| 115 | + const lngs = ring.map((c) => c[0]); | |
| 116 | + const lats = ring.map((c) => c[1]); | |
| 117 | + return { | |
| 118 | + name: l.name, | |
| 119 | + sw_lat: Math.min(...lats), | |
| 120 | + ne_lat: Math.max(...lats), | |
| 121 | + sw_lng: Math.min(...lngs), | |
| 122 | + ne_lng: Math.max(...lngs), | |
| 123 | + }; | |
| 124 | + }) | |
| 125 | + .filter(Boolean); | |
| 126 | +} | |
| 127 | + | |
| 128 | +// Récupère les ventes d'une bbox, subdivise (quadtree) si le plafond est saturé | |
| 129 | +// alors que les ventes retournées sont encore >= SINCE (⇒ il en manque). | |
| 130 | +async function fetchBox(box, token, out, depth = 0) { | |
| 131 | + const url = | |
| 132 | + `${API}/map?ne_lat=${box.ne_lat}&ne_lng=${box.ne_lng}` + | |
| 133 | + `&sw_lat=${box.sw_lat}&sw_lng=${box.sw_lng}&nb_transactions=${PAGE_CAP}&cid=0`; | |
| 134 | + const rows = await apiGet(url, token); | |
| 135 | + for (const t of rows) if (t.date >= SINCE) out.set(t.id, t); | |
| 136 | + | |
| 137 | + const oldest = rows.length ? rows[rows.length - 1].date : null; | |
| 138 | + const saturated = rows.length >= PAGE_CAP && oldest && oldest >= SINCE; | |
| 139 | + if (saturated && depth < 6) { | |
| 140 | + const mlat = (box.ne_lat + box.sw_lat) / 2; | |
| 141 | + const mlng = (box.ne_lng + box.sw_lng) / 2; | |
| 142 | + const quads = [ | |
| 143 | + { sw_lat: box.sw_lat, sw_lng: box.sw_lng, ne_lat: mlat, ne_lng: mlng }, | |
| 144 | + { sw_lat: box.sw_lat, sw_lng: mlng, ne_lat: mlat, ne_lng: box.ne_lng }, | |
| 145 | + { sw_lat: mlat, sw_lng: box.sw_lng, ne_lat: box.ne_lat, ne_lng: mlng }, | |
| 146 | + { sw_lat: mlat, sw_lng: mlng, ne_lat: box.ne_lat, ne_lng: box.ne_lng }, | |
| 147 | + ]; | |
| 148 | + for (const q of quads) await fetchBox(q, token, out, depth + 1); | |
| 149 | + } | |
| 150 | +} | |
| 151 | + | |
| 152 | +// ---------- spatial join vers units (id_provinc, valeur_role) ---------- | |
| 153 | +const unitsInBox = db.prepare( | |
| 154 | + `SELECT id_provinc, lat, lng, valeur_role, type_prop FROM units | |
| 155 | + WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`, | |
| 156 | +); | |
| 157 | +function haversine(aLat, aLng, bLat, bLng) { | |
| 158 | + const R = 6371000, toR = Math.PI / 180; | |
| 159 | + const dLat = (bLat - aLat) * toR, dLng = (bLng - aLng) * toR; | |
| 160 | + const s = | |
| 161 | + Math.sin(dLat / 2) ** 2 + | |
| 162 | + Math.cos(aLat * toR) * Math.cos(bLat * toR) * Math.sin(dLng / 2) ** 2; | |
| 163 | + return 2 * R * Math.asin(Math.sqrt(s)); | |
| 164 | +} | |
| 165 | +// Rayons d'appariement croissants (~65 m, ~220 m, ~550 m). | |
| 166 | +const DEGS = [0.0006, 0.002, 0.005]; | |
| 167 | +function matchUnit(lat, lng, propertyType) { | |
| 168 | + for (const d of DEGS) { | |
| 169 | + const cands = unitsInBox.all(lat - d, lat + d, lng - d, lng + d); | |
| 170 | + if (!cands.length) continue; | |
| 171 | + let best = null, bestDist = Infinity; | |
| 172 | + for (const u of cands) { | |
| 173 | + let dist = haversine(lat, lng, u.lat, u.lng); | |
| 174 | + if (propertyType && u.type_prop && u.type_prop !== propertyType) dist += 25; // léger biais type | |
| 175 | + if (dist < bestDist) { bestDist = dist; best = u; } | |
| 176 | + } | |
| 177 | + if (best) return best; | |
| 178 | + } | |
| 179 | + return null; | |
| 180 | +} | |
| 181 | + | |
| 182 | +function toRow(t) { | |
| 183 | + const coords = t.geometries?.[0]?.coordinates || []; | |
| 184 | + const lng = coords[0], lat = coords[1]; | |
| 185 | + if (typeof lat !== 'number' || typeof lng !== 'number') return null; | |
| 186 | + const ar = t.ar || {}; | |
| 187 | + const yb = ar.yearBuilt != null ? parseInt(ar.yearBuilt, 10) : null; | |
| 188 | + const unit = matchUnit(lat, lng, t.propertyType); | |
| 189 | + return { | |
| 190 | + id: t.id, | |
| 191 | + date: t.date, | |
| 192 | + amount: t.amount, | |
| 193 | + street: t.address?.street ?? null, | |
| 194 | + city: t.address?.city ?? null, | |
| 195 | + lat, | |
| 196 | + lng, | |
| 197 | + property_type: t.propertyType ?? null, | |
| 198 | + year_built: Number.isFinite(yb) ? yb : null, | |
| 199 | + floor_area: ar.floorArea ?? null, | |
| 200 | + building_type: ar.buildingType ?? null, | |
| 201 | + id_provinc: unit?.id_provinc ?? null, | |
| 202 | + valeur_role: unit?.valeur_role ?? ar.totalArValue ?? null, | |
| 203 | + land_area: ar.parcelArea ?? null, | |
| 204 | + }; | |
| 205 | +} | |
| 206 | + | |
| 207 | +const insert = db.prepare( | |
| 208 | + `INSERT OR IGNORE INTO transactions | |
| 209 | + (id,date,amount,street,city,lat,lng,property_type,year_built,floor_area, | |
| 210 | + building_type,id_provinc,valeur_role,land_area) | |
| 211 | + VALUES (@id,@date,@amount,@street,@city,@lat,@lng,@property_type,@year_built, | |
| 212 | + @floor_area,@building_type,@id_provinc,@valeur_role,@land_area)`, | |
| 213 | +); | |
| 214 | + | |
| 215 | +// ---------- main ---------- | |
| 216 | +async function main() { | |
| 217 | + console.log(`[ingest-jdm] base : ${DB_PATH}`); | |
| 218 | + console.log(`[ingest-jdm] date max en base : ${maxDate} → plancher SINCE=${SINCE}`); | |
| 219 | + if (DRY) console.log('[ingest-jdm] DRY-RUN : aucune écriture'); | |
| 220 | + | |
| 221 | + const token = await getQubToken({ | |
| 222 | + session: process.env.QUB_SESSION || 'qub-vraiprix', | |
| 223 | + log: (m) => console.log('[ingest-jdm]', m), | |
| 224 | + }); | |
| 225 | + | |
| 226 | + let locations = await fetchLocations(token); | |
| 227 | + if (REGION) locations = locations.filter((l) => l.name.toLowerCase().includes(REGION)); | |
| 228 | + console.log(`[ingest-jdm] secteurs à balayer : ${locations.length}`); | |
| 229 | + | |
| 230 | + // Balayage concurrent par secteur. | |
| 231 | + const collected = new Map(); | |
| 232 | + let done = 0; | |
| 233 | + async function worker(queue) { | |
| 234 | + for (;;) { | |
| 235 | + const box = queue.pop(); | |
| 236 | + if (!box) return; | |
| 237 | + try { | |
| 238 | + await fetchBox(box, token, collected); | |
| 239 | + } catch (e) { | |
| 240 | + if (e.authError) throw e; | |
| 241 | + console.warn(`[ingest-jdm] secteur "${box.name}" : ${e.message}`); | |
| 242 | + } | |
| 243 | + if (++done % 100 === 0) | |
| 244 | + console.log(`[ingest-jdm] ${done}/${locations.length} secteurs, ${collected.size} ventes ≥ ${SINCE}`); | |
| 245 | + } | |
| 246 | + } | |
| 247 | + const queue = [...locations]; | |
| 248 | + await Promise.all(Array.from({ length: CONC }, () => worker(queue))); | |
| 249 | + console.log(`[ingest-jdm] ventes candidates (≥ ${SINCE}) : ${collected.size}`); | |
| 250 | + | |
| 251 | + // Mapping + insertion. | |
| 252 | + const before = db.prepare('SELECT COUNT(*) n FROM transactions').get().n; | |
| 253 | + let inserted = 0, skippedGeo = 0, matched = 0; | |
| 254 | + const rows = []; | |
| 255 | + for (const t of collected.values()) { | |
| 256 | + const r = toRow(t); | |
| 257 | + if (!r) { skippedGeo++; continue; } | |
| 258 | + if (r.id_provinc) matched++; | |
| 259 | + rows.push(r); | |
| 260 | + } | |
| 261 | + | |
| 262 | + if (!DRY) { | |
| 263 | + const tx = db.transaction((batch) => { | |
| 264 | + for (const r of batch) inserted += insert.run(r).changes; | |
| 265 | + }); | |
| 266 | + tx(rows); | |
| 267 | + } | |
| 268 | + | |
| 269 | + const after = DRY ? before : db.prepare('SELECT COUNT(*) n FROM transactions').get().n; | |
| 270 | + console.log('[ingest-jdm] ---------- résumé ----------'); | |
| 271 | + console.log(` candidates mappées : ${rows.length}`); | |
| 272 | + console.log(` appariées à une unité: ${matched} (${rows.length ? ((matched / rows.length) * 100).toFixed(1) : 0} %)`); | |
| 273 | + console.log(` sans géométrie : ${skippedGeo}`); | |
| 274 | + console.log(` NOUVELLES insérées : ${DRY ? '(dry-run)' : inserted}`); | |
| 275 | + console.log(` transactions totales : ${before} → ${after}`); | |
| 276 | + const nd = db.prepare('SELECT MAX(date) d FROM transactions').get().d; | |
| 277 | + console.log(` date max en base : ${nd}`); | |
| 278 | + db.close(); | |
| 279 | +} | |
| 280 | + | |
| 281 | +main().catch((e) => { | |
| 282 | + console.error('[ingest-jdm] ERREUR', e); | |
| 283 | + db.close(); | |
| 284 | + process.exit(1); | |
| 285 | +}); | |
added
scripts/qub-token.mjs
+144 −0
@@ -0,0 +1,144 @@ | ||
| 1 | +// Récupération d'un jeton Bearer QUB (Cognito id-token) pour api.qub.ca. | |
| 2 | +// | |
| 3 | +// Le widget « Transactions immobilières » du Journal de Montréal appelle | |
| 4 | +// api.qub.ca/real-estate-service/* avec un Bearer obtenu après connexion au | |
| 5 | +// compte Québecor. Le login passe par connect.qub.ca (Akamai anti-bot) : on le | |
| 6 | +// franchit via Scrapfly (asp=true) en gardant une SESSION persistante, ce qui | |
| 7 | +// mémorise les cookies (silent-authentication valide jusqu'en 2027). Une fois | |
| 8 | +// connecté, GET /api/checklogin renvoie le Bearer dans son champ `userToken` | |
| 9 | +// (le jeton lui-même expire au bout d'1 h — largement assez pour un balayage). | |
| 10 | +// Le login connect.qub.ca est protégé par reCAPTCHA v3 : il réussit de façon | |
| 11 | +// probabiliste, d'où la boucle de réessais. | |
| 12 | +// | |
| 13 | +// Requiert SCRAPFLY_KEY dans l'environnement (présent dans ~/.claude/.env). | |
| 14 | +// Identifiants du compte : QUB_EMAIL / QUB_PASSWORD (env). | |
| 15 | + | |
| 16 | +const SCRAPFLY = 'https://api.scrapfly.io/scrape'; | |
| 17 | +// Endpoint JdM qui renvoie, une fois connecté, le Bearer Cognito dans son champ | |
| 18 | +// `userToken` — bien plus fiable que de scraper l'appel XHR du widget carto. | |
| 19 | +const CHECKLOGIN = 'https://www.journaldemontreal.com/api/checklogin'; | |
| 20 | +const ENTRY = | |
| 21 | + 'https://www.journaldemontreal.com/connexion?currentPath=/argent/immobilier/transactions-immobilieres'; | |
| 22 | + | |
| 23 | +function b64scenario(steps) { | |
| 24 | + return Buffer.from(JSON.stringify(steps)).toString('base64url'); | |
| 25 | +} | |
| 26 | + | |
| 27 | +async function scrape({ url, session, scenario, wait, scroll, render = true }) { | |
| 28 | + const key = process.env.SCRAPFLY_KEY; | |
| 29 | + if (!key) throw new Error('SCRAPFLY_KEY manquant dans l’environnement'); | |
| 30 | + const params = new URLSearchParams({ | |
| 31 | + key, | |
| 32 | + asp: 'true', | |
| 33 | + render_js: render ? 'true' : 'false', | |
| 34 | + country: 'ca', | |
| 35 | + session, | |
| 36 | + session_sticky_proxy: 'true', | |
| 37 | + url, | |
| 38 | + }); | |
| 39 | + if (render) { | |
| 40 | + if (scenario) params.set('js_scenario', b64scenario(scenario)); | |
| 41 | + else params.set('rendering_wait', String(wait ?? 6000)); | |
| 42 | + if (scroll) params.set('auto_scroll', 'true'); | |
| 43 | + } | |
| 44 | + | |
| 45 | + const resp = await fetch(`${SCRAPFLY}?${params}`); | |
| 46 | + const data = await resp.json(); | |
| 47 | + return data.result || {}; | |
| 48 | +} | |
| 49 | + | |
| 50 | + | |
| 51 | +// Charge /api/checklogin dans la session et renvoie userToken (le Bearer) si la | |
| 52 | +// session est connectée, sinon null. C'est un endpoint JSON pur : pas de rendu | |
| 53 | +// JS (sinon Scrapfly enroberait le JSON dans du HTML). | |
| 54 | +async function grabFromCheckLogin(session) { | |
| 55 | + const res = await scrape({ url: CHECKLOGIN, session, render: false }); | |
| 56 | + const raw = res?.content || ''; | |
| 57 | + // Réponse JSON directe, ou éventuellement enrobée — on extrait userToken. | |
| 58 | + try { | |
| 59 | + const j = JSON.parse(raw); | |
| 60 | + if (j.userToken) return j.userToken; | |
| 61 | + } catch { | |
| 62 | + const m = raw.match(/"userToken"\s*:\s*"([^"]+)"/); | |
| 63 | + if (m) return m[1]; | |
| 64 | + } | |
| 65 | + return null; | |
| 66 | +} | |
| 67 | + | |
| 68 | +// Construit au moment de l'appel (pas au chargement du module) : les imports ES | |
| 69 | +// sont évalués avant l'IIFE qui charge .env.local dans le script appelant, donc | |
| 70 | +// process.env.QUB_* n'est peuplé qu'à l'exécution de getQubToken(). | |
| 71 | +function formScenario() { | |
| 72 | + return [ | |
| 73 | + { wait_for_selector: { selector: '#email', timeout: 12000 } }, | |
| 74 | + { fill: { selector: '#email', value: process.env.QUB_EMAIL } }, | |
| 75 | + { fill: { selector: '[data-qa="password-field"]', value: process.env.QUB_PASSWORD } }, | |
| 76 | + { click: { selector: 'button[type="submit"]' } }, | |
| 77 | + { wait_for_navigation: { timeout: 10000 } }, | |
| 78 | + // Laisser l'OAuth (connect.qub.ca → callback JdM) poser le cookie de session | |
| 79 | + // JdM avant la fin du scénario, sinon /api/checklogin répond non connecté. | |
| 80 | + { wait: 9000 }, | |
| 81 | + ]; | |
| 82 | +} | |
| 83 | + | |
| 84 | +const CONTINUE_SCENARIO = [ | |
| 85 | + { wait_for_selector: { selector: "//button[contains(., 'Continuer')]", timeout: 12000 } }, | |
| 86 | + { click: { selector: "//button[contains(., 'Continuer')]" } }, | |
| 87 | + { wait_for_navigation: { timeout: 10000 } }, | |
| 88 | + { wait: 8000 }, | |
| 89 | +]; | |
| 90 | + | |
| 91 | +function tokenExp(tok) { | |
| 92 | + try { | |
| 93 | + const p = JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString()); | |
| 94 | + return p.exp ? new Date(p.exp * 1000) : null; | |
| 95 | + } catch { | |
| 96 | + return null; | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +// Retourne un Bearer valide. | |
| 101 | +// baseSession : préfixe de session Scrapfly. On rate une session Scrapfly | |
| 102 | +// FRAÎCHE à chaque essai : d'expérience, réutiliser une session ayant accumulé | |
| 103 | +// des échecs (ou l'avoir « amorcée » par un appel API sans navigateur) fait | |
| 104 | +// bloquer l'IP par Akamai et casse les logins suivants. Un login navigateur | |
| 105 | +// complet sur session neuve passe, puis /api/checklogin réutilise ses cookies. | |
| 106 | +export async function getQubToken({ session = 'qub-vraiprix', log = () => {} } = {}) { | |
| 107 | + const base = session || 'qub-vraiprix'; | |
| 108 | + if (!process.env.QUB_EMAIL || !process.env.QUB_PASSWORD) | |
| 109 | + throw new Error('QUB_EMAIL/QUB_PASSWORD absents'); | |
| 110 | + | |
| 111 | + // La connexion connect.qub.ca passe par Akamai + reCAPTCHA v3 : elle réussit | |
| 112 | + // de façon probabiliste (~1 essai sur 2), d'où la boucle sur sessions neuves. | |
| 113 | + const ATTEMPTS = 6; | |
| 114 | + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { | |
| 115 | + const s = `${base}-${Date.now()}-${attempt}`; | |
| 116 | + // 1) Login navigateur complet (établit les cookies Akamai + la session JdM). | |
| 117 | + log(`login QUB (essai ${attempt}/${ATTEMPTS}) : soumission du formulaire…`); | |
| 118 | + const form = await scrape({ url: ENTRY, session: s, scenario: formScenario() }); | |
| 119 | + let tok = await grabFromCheckLogin(s); | |
| 120 | + if (tok) { | |
| 121 | + log(`token obtenu après formulaire (exp ${tokenExp(tok)?.toISOString() || '?'})`); | |
| 122 | + return tok; | |
| 123 | + } | |
| 124 | + // 2) Fallback : écran de confirmation du compte (« Continuer »), si le login | |
| 125 | + // n'a pas abouti directement à la page connectée. | |
| 126 | + if (!/journaldemontreal\.com\/argent/.test(form.url || '')) { | |
| 127 | + log(`login QUB (essai ${attempt}/${ATTEMPTS}) : confirmation du compte (Continuer)…`); | |
| 128 | + await scrape({ url: ENTRY, session: s, scenario: CONTINUE_SCENARIO }); | |
| 129 | + tok = await grabFromCheckLogin(s); | |
| 130 | + if (tok) { | |
| 131 | + log(`token obtenu après Continuer (exp ${tokenExp(tok)?.toISOString() || '?'})`); | |
| 132 | + return tok; | |
| 133 | + } | |
| 134 | + } | |
| 135 | + if (attempt < ATTEMPTS) await new Promise((r) => setTimeout(r, 1500)); | |
| 136 | + } | |
| 137 | + throw new Error('Impossible d’obtenir un jeton Bearer QUB après connexion'); | |
| 138 | +} | |
| 139 | + | |
| 140 | +// Exécution directe : imprime le jeton. | |
| 141 | +if (import.meta.url === `file://${process.argv[1]}`) { | |
| 142 | + const tok = await getQubToken({ log: (m) => console.error('[qub-token]', m) }); | |
| 143 | + console.log(tok); | |
| 144 | +} | |
| 145 | ||