# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/providers/national_bank.py : Banque Nationale — JSON double-échappé # setProductMap(JSON.parse("…")) dans la page des taux. c1 = produits # hypothécaires (par productConditionName). ⚠️ Séparateurs décimaux mixtes : # affichés « 6.090 » (point), promos « 4,84 » (virgule) — les tacNans sont # les APR des promos. Le tauxBase du produit variable est un ÉCART, pas la # prime : la prime vient du tauxBase du produit fixe. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from .base import RateProvider PAGE_URL = "https://www.nbc.ca/personal/mortgages/rates.html" MAP_RX = re.compile(r'setProductMap\(JSON\.parse\("(.*?)"\)\)', re.S) # champs affichés fixes (fermés) -> terme mois ; taux1moisF exclu (< 3 mois) FIXED_POSTED = { "taux3moisF": 3, "taux6moisF": 6, "taux1anF": 12, "taux2ansF": 24, "taux3ansF": 36, "taux4ansF": 48, "taux5ansF": 60, "taux6ansF": 72, "taux7ansF": 84, "taux10ansF": 120, } FIXED_OPEN = {"taux6moisO": 6, "taux1anO": 12} # promo -> (terme mois, champ APR) FIXED_PROMO = { "tauxPromo3ansF": (36, "tac3ans"), "tauxPromo4ansF": (48, "tac4ans"), "tauxPromo5ansF": (60, "tac5ans"), } def _num(v) -> float | None: if v in (None, ""): return None try: return float(str(v).replace("\xa0", "").replace(",", ".").strip()) except ValueError: return None class NationalBankProvider(RateProvider): provider_id = "national_bank" institution = "Banque Nationale" source_url = PAGE_URL request_delay = 1.5 def fetch(self) -> list[dict]: return self.parse(self.get(self.source_url).text) def parse(self, payload: str) -> list[dict]: m = MAP_RX.search(payload) if not m: raise ValueError("setProductMap introuvable (structure changée)") dec = (m.group(1).replace("\\x22", '"') .replace("\\\\", "\\").replace("\\/", "/")) # la chaîne JS se termine par «"), true);» : raw_decode ignore la suite obj, _ = json.JSONDecoder().raw_decode(dec) products = json.loads(obj.get("c1") or "[]") by_name = {p.get("productConditionName"): p for p in products if isinstance(p, dict)} out: list[dict] = [] fixed = by_name.get("Mortgage fixed rate") or {} for field, term in FIXED_POSTED.items(): rate = _num(fixed.get(field)) if rate and rate > 0: out.append(self.make_product( rate=rate, rate_type="fixed", term_months=term, kind="posted", product_name=f"Fixe fermé {_label(term)}", raw={"field": field, "value": fixed.get(field)})) for field, term in FIXED_OPEN.items(): rate = _num(fixed.get(field)) if rate and rate > 0: out.append(self.make_product( rate=rate, rate_type="fixed", term_months=term, kind="posted", product_name=f"Fixe ouvert {_label(term)}", raw={"field": field, "value": fixed.get(field)})) for field, (term, apr_field) in FIXED_PROMO.items(): rate = _num(fixed.get(field)) if rate and rate > 0: out.append(self.make_product( rate=rate, rate_type="fixed", term_months=term, kind="special", apr=_num(fixed.get(apr_field)), product_name=f"Fixe fermé {_label(term)} (promotion)", raw={"field": field, "value": fixed.get(field)})) prime = _num(fixed.get("tauxBase")) if prime and prime > 0: out.append(self.make_product( rate=prime, rate_type="other", term_months=12, kind="posted", product_name="Taux préférentiel BNC", purpose="unknown", raw={"field": "tauxBase", "value": fixed.get("tauxBase")})) variable = by_name.get("Mortgage variable rate") or {} v_posted = _num(variable.get("taux5ansO")) if v_posted and v_posted > 0: out.append(self.make_product( rate=v_posted, rate_type="variable", term_months=60, kind="posted", product_name="Variable 5 ans", conditions="Base = taux préférentiel BNC", raw={"field": "taux5ansO", "value": variable.get("taux5ansO")})) v_promo = _num(variable.get("tauxPromo5ansF")) if v_promo and v_promo > 0: out.append(self.make_product( rate=v_promo, rate_type="variable", term_months=60, kind="special", apr=_num(variable.get("tac5ans")), product_name="Variable 5 ans (promotion)", raw={"field": "tauxPromo5ansF", "value": variable.get("tauxPromo5ansF")})) capped = by_name.get("Variable capped-rate mortgage") or {} c_rate = _num(capped.get("taux5ansO")) if c_rate and c_rate > 0: cap = _num(capped.get("tauxPlafond")) out.append(self.make_product( rate=c_rate, rate_type="variable", term_months=60, kind="posted", product_name="Variable plafonné 5 ans", conditions=f"Taux plafond {cap} %" if cap else None, raw={"field": "taux5ansO", "value": capped.get("taux5ansO"), "tauxPlafond": capped.get("tauxPlafond")})) return out def _label(months: int) -> str: if months < 12: return f"{months} mois" years = months // 12 return f"{years} an" if years == 1 else f"{years} ans"