# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/providers/bmo.py : BMO — 3 JSON publics public-data (spéciaux, # grille affichée EPM, prime). ⚠️ bmo.com filtre l'empreinte TLS (Akamai) : # cascade requests → curl_cffi (si dispo) → Scrapfly asp. Les clés Over25 # sont les variantes amortissement > 25 ans ; 18YearOpen et 12Variable* # sont des reliquats hérités (valeurs figées) et sont ignorés. # ----------------------------------------------------------------------------- from __future__ import annotations import json from .base import RateProvider URLS = { "ca-mortgages-special-rates": "https://www.bmo.com/public-data/api/v2.0/bmo-ca-mortgages-rates.json", "epm-mortgage": "https://www.bmo.com/public-data/api/epm/v1.0/bmo-epm-mortgage.json", "epm-prime": "https://www.bmo.com/public-data/api/epm/v1.0/bmo-epm-prime.json", } PAGE_URL = "https://www.bmo.com/en-ca/main/personal/mortgages/mortgage-rates/" # clé spéciale -> (rate_type, terme mois, nom, clé APR, amort max, insured) SPECIAL_MAP: dict[str, tuple] = { "fixed3YearClosedSpecial": ("fixed", 36, "Fixe fermé 3 ans (offre spéciale)", "fixed3YearClosedSpecialApr", 25, "unknown"), "fixed3YearClosedSpecialOver25": ("fixed", 36, "Fixe fermé 3 ans (offre spéciale, amort. >25 ans)", "fixed3YearClosedSpecialOver25Apr", 30, "uninsured"), "smartFixed5YearClosedSpecial": ("fixed", 60, "Smart Fixed fermé 5 ans (offre spéciale)", "smartFixed5YearClosedSpecialApr", 25, "uninsured"), "smartFixed5YearClosedHighRatioSpecial": ("fixed", 60, "Smart Fixed fermé 5 ans (offre spéciale, ratio élevé)", "smartFixed5YearClosedHighRatioSpecialApr", 25, "insured"), "variable5YearClosedSpecial": ("variable", 60, "Variable fermé 5 ans (offre spéciale)", "variable5YearClosedSpecialApr", 25, "unknown"), "variable5YearClosedSpecialOver25": ("variable", 60, "Variable fermé 5 ans (offre spéciale, amort. >25 ans)", "variable5YearClosedSpecialOver25Apr", 30, "uninsured"), } EPM_SKIP = {"18YearOpen", "12VariableLimited", "12VariableOpen"} def _num(v) -> float | None: try: return float(str(v).strip()) except (TypeError, ValueError): return None class BmoProvider(RateProvider): provider_id = "bmo" institution = "BMO Banque de Montréal" source_url = PAGE_URL request_delay = 1.2 def _get_json_text(self, url: str) -> str: try: return self.get(url).text except Exception: # noqa: BLE001 — TLS Akamai : on escalade pass try: from curl_cffi import requests as curl_requests resp = curl_requests.get(url, impersonate="chrome", timeout=self.timeout) resp.raise_for_status() return resp.text except ImportError: pass return self.get_scrapfly(url, render_js=False, asp=True) def fetch(self) -> list[dict]: docs: dict[str, dict] = {} for key, url in URLS.items(): text = self._get_json_text(url) docs[key] = json.loads(text) return self.parse_docs(docs) def parse(self, payload: str) -> list[dict]: """Pour les tests fixtures : payload = JSON {clé: {url, payload}}.""" data = json.loads(payload) docs: dict[str, dict] = {} for key, entry in data.items(): doc = entry.get("payload") if isinstance(entry, dict) and \ "payload" in entry else entry if isinstance(doc, str): doc = json.loads(doc) docs[key] = doc return self.parse_docs(docs) def parse_docs(self, docs: dict[str, dict]) -> list[dict]: out: list[dict] = [] specials = docs.get("ca-mortgages-special-rates") or {} for key, (rtype, term, name, apr_key, amort, insured) in \ SPECIAL_MAP.items(): rate = _num(specials.get(key)) if rate is None or rate <= 0: continue out.append(self.make_product( rate=rate, rate_type=rtype, term_months=term, kind="special", product_name=name, apr=_num(specials.get(apr_key)), insured_status=insured, amortization_max_years=amort, raw={"key": key, "value": specials.get(key)})) epm = docs.get("epm-mortgage") or {} grid = epm.get("mortgageRates") or epm for rtype_key, rtype, prefix in (("fixed", "fixed", "Fixe"), ("variable", "variable", "Variable")): for key, cell in (grid.get(rtype_key) or {}).items(): if key in EPM_SKIP or not isinstance(cell, dict): continue rate = _num(cell.get("value")) term = _num(cell.get("term_months")) if rate is None or rate <= 0 or not term: continue label = cell.get("fr") or cell.get("en") or key out.append(self.make_product( rate=rate, rate_type=rtype, term_months=int(term), kind="posted", product_name=f"{prefix} {label}", raw={"key": key, "cell": cell})) prime = docs.get("epm-prime") or {} prime_rate = _num((prime.get("caPrimeRate") or {}).get("value")) if prime_rate and prime_rate > 0: out.append(self.make_product( rate=prime_rate, rate_type="other", term_months=12, kind="posted", product_name="Taux préférentiel BMO", purpose="unknown", raw={"caPrimeRate": prime.get("caPrimeRate")})) return out