Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
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 appelle4// api.qub.ca/real-estate-service/* avec un Bearer obtenu après connexion au5// compte Québecor. Le login passe par connect.qub.ca (Akamai anti-bot) : on le6// franchit via Scrapfly (asp=true) en gardant une SESSION persistante, ce qui7// mémorise les cookies (silent-authentication valide jusqu'en 2027). Une fois8// 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çon11// 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).1516const SCRAPFLY = 'https://api.scrapfly.io/scrape';17// Endpoint JdM qui renvoie, une fois connecté, le Bearer Cognito dans son champ18// `userToken` — bien plus fiable que de scraper l'appel XHR du widget carto.19const CHECKLOGIN = 'https://www.journaldemontreal.com/api/checklogin';20const ENTRY =21 'https://www.journaldemontreal.com/connexion?currentPath=/argent/immobilier/transactions-immobilieres';2223function b64scenario(steps) {24 return Buffer.from(JSON.stringify(steps)).toString('base64url');25}2627async 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 }4445 const resp = await fetch(`${SCRAPFLY}?${params}`);46 const data = await resp.json();47 return data.result || {};48}495051// Charge /api/checklogin dans la session et renvoie userToken (le Bearer) si la52// session est connectée, sinon null. C'est un endpoint JSON pur : pas de rendu53// JS (sinon Scrapfly enroberait le JSON dans du HTML).54async 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}6768// Construit au moment de l'appel (pas au chargement du module) : les imports ES69// sont évalués avant l'IIFE qui charge .env.local dans le script appelant, donc70// process.env.QUB_* n'est peuplé qu'à l'exécution de getQubToken().71function 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 session79 // JdM avant la fin du scénario, sinon /api/checklogin répond non connecté.80 { wait: 9000 },81 ];82}8384const 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];9091function 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}99100// Retourne un Bearer valide.101// baseSession : préfixe de session Scrapfly. On rate une session Scrapfly102// 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) fait104// bloquer l'IP par Akamai et casse les logins suivants. Un login navigateur105// complet sur session neuve passe, puis /api/checklogin réutilise ses cookies.106export 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');110111 // La connexion connect.qub.ca passe par Akamai + reCAPTCHA v3 : elle réussit112 // 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 login125 // 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}139140// Exécution directe : imprime le jeton.141if (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