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%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 7e04cb8

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