Python 67%
TypeScript 18.2%
CSS 14.4%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# mortgage/providers/bmo.py : BMO — 3 JSON publics public-data (spéciaux,5# grille affichée EPM, prime). ⚠️ bmo.com filtre l'empreinte TLS (Akamai) :6# cascade requests → curl_cffi (si dispo) → Scrapfly asp. Les clés Over257# sont les variantes amortissement > 25 ans ; 18YearOpen et 12Variable*8# sont des reliquats hérités (valeurs figées) et sont ignorés.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import json1314from .base import RateProvider1516URLS = {17 "ca-mortgages-special-rates":18 "https://www.bmo.com/public-data/api/v2.0/bmo-ca-mortgages-rates.json",19 "epm-mortgage":20 "https://www.bmo.com/public-data/api/epm/v1.0/bmo-epm-mortgage.json",21 "epm-prime":22 "https://www.bmo.com/public-data/api/epm/v1.0/bmo-epm-prime.json",23}24PAGE_URL = "https://www.bmo.com/en-ca/main/personal/mortgages/mortgage-rates/"2526# clé spéciale -> (rate_type, terme mois, nom, clé APR, amort max, insured)27SPECIAL_MAP: dict[str, tuple] = {28 "fixed3YearClosedSpecial":29 ("fixed", 36, "Fixe fermé 3 ans (offre spéciale)",30 "fixed3YearClosedSpecialApr", 25, "unknown"),31 "fixed3YearClosedSpecialOver25":32 ("fixed", 36, "Fixe fermé 3 ans (offre spéciale, amort. >25 ans)",33 "fixed3YearClosedSpecialOver25Apr", 30, "uninsured"),34 "smartFixed5YearClosedSpecial":35 ("fixed", 60, "Smart Fixed fermé 5 ans (offre spéciale)",36 "smartFixed5YearClosedSpecialApr", 25, "uninsured"),37 "smartFixed5YearClosedHighRatioSpecial":38 ("fixed", 60, "Smart Fixed fermé 5 ans (offre spéciale, ratio élevé)",39 "smartFixed5YearClosedHighRatioSpecialApr", 25, "insured"),40 "variable5YearClosedSpecial":41 ("variable", 60, "Variable fermé 5 ans (offre spéciale)",42 "variable5YearClosedSpecialApr", 25, "unknown"),43 "variable5YearClosedSpecialOver25":44 ("variable", 60, "Variable fermé 5 ans (offre spéciale, amort. >25 ans)",45 "variable5YearClosedSpecialOver25Apr", 30, "uninsured"),46}4748EPM_SKIP = {"18YearOpen", "12VariableLimited", "12VariableOpen"}495051def _num(v) -> float | None:52 try:53 return float(str(v).strip())54 except (TypeError, ValueError):55 return None565758class BmoProvider(RateProvider):59 provider_id = "bmo"60 institution = "BMO Banque de Montréal"61 source_url = PAGE_URL62 request_delay = 1.26364 def _get_json_text(self, url: str) -> str:65 try:66 return self.get(url).text67 except Exception: # noqa: BLE001 — TLS Akamai : on escalade68 pass69 try:70 from curl_cffi import requests as curl_requests71 resp = curl_requests.get(url, impersonate="chrome",72 timeout=self.timeout)73 resp.raise_for_status()74 return resp.text75 except ImportError:76 pass77 return self.get_scrapfly(url, render_js=False, asp=True)7879 def fetch(self) -> list[dict]:80 docs: dict[str, dict] = {}81 for key, url in URLS.items():82 text = self._get_json_text(url)83 docs[key] = json.loads(text)84 return self.parse_docs(docs)8586 def parse(self, payload: str) -> list[dict]:87 """Pour les tests fixtures : payload = JSON {clé: {url, payload}}."""88 data = json.loads(payload)89 docs: dict[str, dict] = {}90 for key, entry in data.items():91 doc = entry.get("payload") if isinstance(entry, dict) and \92 "payload" in entry else entry93 if isinstance(doc, str):94 doc = json.loads(doc)95 docs[key] = doc96 return self.parse_docs(docs)9798 def parse_docs(self, docs: dict[str, dict]) -> list[dict]:99 out: list[dict] = []100 specials = docs.get("ca-mortgages-special-rates") or {}101 for key, (rtype, term, name, apr_key, amort, insured) in \102 SPECIAL_MAP.items():103 rate = _num(specials.get(key))104 if rate is None or rate <= 0:105 continue106 out.append(self.make_product(107 rate=rate, rate_type=rtype, term_months=term, kind="special",108 product_name=name, apr=_num(specials.get(apr_key)),109 insured_status=insured, amortization_max_years=amort,110 raw={"key": key, "value": specials.get(key)}))111 epm = docs.get("epm-mortgage") or {}112 grid = epm.get("mortgageRates") or epm113 for rtype_key, rtype, prefix in (("fixed", "fixed", "Fixe"),114 ("variable", "variable", "Variable")):115 for key, cell in (grid.get(rtype_key) or {}).items():116 if key in EPM_SKIP or not isinstance(cell, dict):117 continue118 rate = _num(cell.get("value"))119 term = _num(cell.get("term_months"))120 if rate is None or rate <= 0 or not term:121 continue122 label = cell.get("fr") or cell.get("en") or key123 out.append(self.make_product(124 rate=rate, rate_type=rtype, term_months=int(term),125 kind="posted", product_name=f"{prefix} {label}",126 raw={"key": key, "cell": cell}))127 prime = docs.get("epm-prime") or {}128 prime_rate = _num((prime.get("caPrimeRate") or {}).get("value"))129 if prime_rate and prime_rate > 0:130 out.append(self.make_product(131 rate=prime_rate, rate_type="other", term_months=12,132 kind="posted", product_name="Taux préférentiel BMO",133 purpose="unknown",134 raw={"caPrimeRate": prime.get("caPrimeRate")}))135 return out136