SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
20 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
5.8 KB · 136 lines python
Raw Blame History
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