// Récupération d'un jeton Bearer QUB (Cognito id-token) pour api.qub.ca. // // Le widget « Transactions immobilières » du Journal de Montréal appelle // api.qub.ca/real-estate-service/* avec un Bearer obtenu après connexion au // compte Québecor. Le login passe par connect.qub.ca (Akamai anti-bot) : on le // franchit via Scrapfly (asp=true) en gardant une SESSION persistante, ce qui // mémorise les cookies (silent-authentication valide jusqu'en 2027). Une fois // connecté, GET /api/checklogin renvoie le Bearer dans son champ `userToken` // (le jeton lui-même expire au bout d'1 h — largement assez pour un balayage). // Le login connect.qub.ca est protégé par reCAPTCHA v3 : il réussit de façon // probabiliste, d'où la boucle de réessais. // // Requiert SCRAPFLY_KEY dans l'environnement (présent dans ~/.claude/.env). // Identifiants du compte : QUB_EMAIL / QUB_PASSWORD (env). const SCRAPFLY = 'https://api.scrapfly.io/scrape'; // Endpoint JdM qui renvoie, une fois connecté, le Bearer Cognito dans son champ // `userToken` — bien plus fiable que de scraper l'appel XHR du widget carto. const CHECKLOGIN = 'https://www.journaldemontreal.com/api/checklogin'; const ENTRY = 'https://www.journaldemontreal.com/connexion?currentPath=/argent/immobilier/transactions-immobilieres'; function b64scenario(steps) { return Buffer.from(JSON.stringify(steps)).toString('base64url'); } async function scrape({ url, session, scenario, wait, scroll, render = true }) { const key = process.env.SCRAPFLY_KEY; if (!key) throw new Error('SCRAPFLY_KEY manquant dans l’environnement'); const params = new URLSearchParams({ key, asp: 'true', render_js: render ? 'true' : 'false', country: 'ca', session, session_sticky_proxy: 'true', url, }); if (render) { if (scenario) params.set('js_scenario', b64scenario(scenario)); else params.set('rendering_wait', String(wait ?? 6000)); if (scroll) params.set('auto_scroll', 'true'); } const resp = await fetch(`${SCRAPFLY}?${params}`); const data = await resp.json(); return data.result || {}; } // Charge /api/checklogin dans la session et renvoie userToken (le Bearer) si la // session est connectée, sinon null. C'est un endpoint JSON pur : pas de rendu // JS (sinon Scrapfly enroberait le JSON dans du HTML). async function grabFromCheckLogin(session) { const res = await scrape({ url: CHECKLOGIN, session, render: false }); const raw = res?.content || ''; // Réponse JSON directe, ou éventuellement enrobée — on extrait userToken. try { const j = JSON.parse(raw); if (j.userToken) return j.userToken; } catch { const m = raw.match(/"userToken"\s*:\s*"([^"]+)"/); if (m) return m[1]; } return null; } // Construit au moment de l'appel (pas au chargement du module) : les imports ES // sont évalués avant l'IIFE qui charge .env.local dans le script appelant, donc // process.env.QUB_* n'est peuplé qu'à l'exécution de getQubToken(). function formScenario() { return [ { wait_for_selector: { selector: '#email', timeout: 12000 } }, { fill: { selector: '#email', value: process.env.QUB_EMAIL } }, { fill: { selector: '[data-qa="password-field"]', value: process.env.QUB_PASSWORD } }, { click: { selector: 'button[type="submit"]' } }, { wait_for_navigation: { timeout: 10000 } }, // Laisser l'OAuth (connect.qub.ca → callback JdM) poser le cookie de session // JdM avant la fin du scénario, sinon /api/checklogin répond non connecté. { wait: 9000 }, ]; } const CONTINUE_SCENARIO = [ { wait_for_selector: { selector: "//button[contains(., 'Continuer')]", timeout: 12000 } }, { click: { selector: "//button[contains(., 'Continuer')]" } }, { wait_for_navigation: { timeout: 10000 } }, { wait: 8000 }, ]; function tokenExp(tok) { try { const p = JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString()); return p.exp ? new Date(p.exp * 1000) : null; } catch { return null; } } // Retourne un Bearer valide. // baseSession : préfixe de session Scrapfly. On rate une session Scrapfly // FRAÎCHE à chaque essai : d'expérience, réutiliser une session ayant accumulé // des échecs (ou l'avoir « amorcée » par un appel API sans navigateur) fait // bloquer l'IP par Akamai et casse les logins suivants. Un login navigateur // complet sur session neuve passe, puis /api/checklogin réutilise ses cookies. export async function getQubToken({ session = 'qub-vraiprix', log = () => {} } = {}) { const base = session || 'qub-vraiprix'; if (!process.env.QUB_EMAIL || !process.env.QUB_PASSWORD) throw new Error('QUB_EMAIL/QUB_PASSWORD absents'); // La connexion connect.qub.ca passe par Akamai + reCAPTCHA v3 : elle réussit // de façon probabiliste (~1 essai sur 2), d'où la boucle sur sessions neuves. const ATTEMPTS = 6; for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { const s = `${base}-${Date.now()}-${attempt}`; // 1) Login navigateur complet (établit les cookies Akamai + la session JdM). log(`login QUB (essai ${attempt}/${ATTEMPTS}) : soumission du formulaire…`); const form = await scrape({ url: ENTRY, session: s, scenario: formScenario() }); let tok = await grabFromCheckLogin(s); if (tok) { log(`token obtenu après formulaire (exp ${tokenExp(tok)?.toISOString() || '?'})`); return tok; } // 2) Fallback : écran de confirmation du compte (« Continuer »), si le login // n'a pas abouti directement à la page connectée. if (!/journaldemontreal\.com\/argent/.test(form.url || '')) { log(`login QUB (essai ${attempt}/${ATTEMPTS}) : confirmation du compte (Continuer)…`); await scrape({ url: ENTRY, session: s, scenario: CONTINUE_SCENARIO }); tok = await grabFromCheckLogin(s); if (tok) { log(`token obtenu après Continuer (exp ${tokenExp(tok)?.toISOString() || '?'})`); return tok; } } if (attempt < ATTEMPTS) await new Promise((r) => setTimeout(r, 1500)); } throw new Error('Impossible d’obtenir un jeton Bearer QUB après connexion'); } // Exécution directe : imprime le jeton. if (import.meta.url === `file://${process.argv[1]}`) { const tok = await getQubToken({ log: (m) => console.error('[qub-token]', m) }); console.log(tok); }