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/national_bank.py : Banque Nationale — JSON double-échappé5# setProductMap(JSON.parse("…")) dans la page des taux. c1 = produits6# hypothécaires (par productConditionName). ⚠️ Séparateurs décimaux mixtes :7# affichés « 6.090 » (point), promos « 4,84 » (virgule) — les tacNans sont8# les APR des promos. Le tauxBase du produit variable est un ÉCART, pas la9# prime : la prime vient du tauxBase du produit fixe.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import json14import re1516from .base import RateProvider1718PAGE_URL = "https://www.nbc.ca/personal/mortgages/rates.html"1920MAP_RX = re.compile(r'setProductMap\(JSON\.parse\("(.*?)"\)\)', re.S)2122# champs affichés fixes (fermés) -> terme mois ; taux1moisF exclu (< 3 mois)23FIXED_POSTED = {24 "taux3moisF": 3, "taux6moisF": 6, "taux1anF": 12, "taux2ansF": 24,25 "taux3ansF": 36, "taux4ansF": 48, "taux5ansF": 60, "taux6ansF": 72,26 "taux7ansF": 84, "taux10ansF": 120,27}28FIXED_OPEN = {"taux6moisO": 6, "taux1anO": 12}29# promo -> (terme mois, champ APR)30FIXED_PROMO = {31 "tauxPromo3ansF": (36, "tac3ans"),32 "tauxPromo4ansF": (48, "tac4ans"),33 "tauxPromo5ansF": (60, "tac5ans"),34}353637def _num(v) -> float | None:38 if v in (None, ""):39 return None40 try:41 return float(str(v).replace("\xa0", "").replace(",", ".").strip())42 except ValueError:43 return None444546class NationalBankProvider(RateProvider):47 provider_id = "national_bank"48 institution = "Banque Nationale"49 source_url = PAGE_URL50 request_delay = 1.55152 def fetch(self) -> list[dict]:53 return self.parse(self.get(self.source_url).text)5455 def parse(self, payload: str) -> list[dict]:56 m = MAP_RX.search(payload)57 if not m:58 raise ValueError("setProductMap introuvable (structure changée)")59 dec = (m.group(1).replace("\\x22", '"')60 .replace("\\\\", "\\").replace("\\/", "/"))61 # la chaîne JS se termine par «"), true);» : raw_decode ignore la suite62 obj, _ = json.JSONDecoder().raw_decode(dec)63 products = json.loads(obj.get("c1") or "[]")64 by_name = {p.get("productConditionName"): p65 for p in products if isinstance(p, dict)}66 out: list[dict] = []67 fixed = by_name.get("Mortgage fixed rate") or {}68 for field, term in FIXED_POSTED.items():69 rate = _num(fixed.get(field))70 if rate and rate > 0:71 out.append(self.make_product(72 rate=rate, rate_type="fixed", term_months=term,73 kind="posted",74 product_name=f"Fixe fermé {_label(term)}",75 raw={"field": field, "value": fixed.get(field)}))76 for field, term in FIXED_OPEN.items():77 rate = _num(fixed.get(field))78 if rate and rate > 0:79 out.append(self.make_product(80 rate=rate, rate_type="fixed", term_months=term,81 kind="posted",82 product_name=f"Fixe ouvert {_label(term)}",83 raw={"field": field, "value": fixed.get(field)}))84 for field, (term, apr_field) in FIXED_PROMO.items():85 rate = _num(fixed.get(field))86 if rate and rate > 0:87 out.append(self.make_product(88 rate=rate, rate_type="fixed", term_months=term,89 kind="special", apr=_num(fixed.get(apr_field)),90 product_name=f"Fixe fermé {_label(term)} (promotion)",91 raw={"field": field, "value": fixed.get(field)}))92 prime = _num(fixed.get("tauxBase"))93 if prime and prime > 0:94 out.append(self.make_product(95 rate=prime, rate_type="other", term_months=12, kind="posted",96 product_name="Taux préférentiel BNC", purpose="unknown",97 raw={"field": "tauxBase", "value": fixed.get("tauxBase")}))98 variable = by_name.get("Mortgage variable rate") or {}99 v_posted = _num(variable.get("taux5ansO"))100 if v_posted and v_posted > 0:101 out.append(self.make_product(102 rate=v_posted, rate_type="variable", term_months=60,103 kind="posted", product_name="Variable 5 ans",104 conditions="Base = taux préférentiel BNC",105 raw={"field": "taux5ansO", "value": variable.get("taux5ansO")}))106 v_promo = _num(variable.get("tauxPromo5ansF"))107 if v_promo and v_promo > 0:108 out.append(self.make_product(109 rate=v_promo, rate_type="variable", term_months=60,110 kind="special", apr=_num(variable.get("tac5ans")),111 product_name="Variable 5 ans (promotion)",112 raw={"field": "tauxPromo5ansF",113 "value": variable.get("tauxPromo5ansF")}))114 capped = by_name.get("Variable capped-rate mortgage") or {}115 c_rate = _num(capped.get("taux5ansO"))116 if c_rate and c_rate > 0:117 cap = _num(capped.get("tauxPlafond"))118 out.append(self.make_product(119 rate=c_rate, rate_type="variable", term_months=60,120 kind="posted", product_name="Variable plafonné 5 ans",121 conditions=f"Taux plafond {cap} %" if cap else None,122 raw={"field": "taux5ansO", "value": capped.get("taux5ansO"),123 "tauxPlafond": capped.get("tauxPlafond")}))124 return out125126127def _label(months: int) -> str:128 if months < 12:129 return f"{months} mois"130 years = months // 12131 return f"{years} an" if years == 1 else f"{years} ans"132