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 · 132 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/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