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%
6.2 KB · 145 lines javascript
Raw Blame History
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