# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/providers/scotiabank.py : Banque Scotia — API JSON publique # dmtsms.scotiabank.com (celle que consomme leur propre page de taux). # nonspecialmortgage = taux affichés ; varmortgage = slots promotionnels # (le mapping slot -> produit vient de la page ; on ne garde que les slots # dont l'identité est connue, avec confiance réduite). # ----------------------------------------------------------------------------- from __future__ import annotations import json from .base import RateProvider POSTED_URL = "https://dmtsms.scotiabank.com/api/rates/daily/nonspecialmortgage" PROMO_URL = "https://dmtsms.scotiabank.com/api/rates/daily/varmortgage" PAGE_URL = ("https://www.scotiabank.com/ca/en/personal/rates-prices/" "mortgages-rates.html") # PRODUCT -> (rate_type, insured_status, nom, purpose) POSTED_MAP = { "N.H.A. RESIDENTIAL, ETC": ("fixed", "insured", "Fixe affiché (assuré LNH)"), "CONVENTIONAL RESIDENTIAL, ETC": ("fixed", "uninsured", "Fixe affiché (conventionnel)"), "ULTIMATE VARIABLE RATE": ("variable", "unknown", "Ultimate Variable"), "OPEN": ("fixed", "unknown", "Fixe ouvert"), "FLEXIBLE": ("fixed", "unknown", "Flexible"), } # Slots promotionnels dont l'identité est documentée sur la page Scotia # (2026-08). Confiance réduite : si Scotia réordonne ses slots, la validation # et la comparaison croisée limitent les dégâts. PROMO_SLOTS = { "MORTGAGE PROMOTIONAL 1": ("variable", 60, "Flex Value fermé 5 ans (variable)"), "MORTGAGE PROMOTIONAL 2": ("variable", 36, "Ultimate Variable 3 ans"), "MORTGAGE PROMOTIONAL 3": ("variable", 60, "Flex Value ouvert 5 ans (variable)"), } def _term_months(t: dict) -> int | None: try: v = int(t["TERM_VALUE"]) except (KeyError, ValueError, TypeError): return None unit = (t.get("TERM_UNIT") or "").upper() if unit == "Y": return v * 12 if unit == "M": return v return None class ScotiabankProvider(RateProvider): provider_id = "scotiabank" institution = "Banque Scotia" source_url = PAGE_URL request_delay = 1.0 def fetch(self) -> list[dict]: posted = self.get(POSTED_URL).text try: promo = self.get(PROMO_URL).text except Exception: # noqa: BLE001 — promos facultatives promo = "" return self.parse(posted) + (self.parse_promos(promo) if promo else []) def parse(self, payload: str) -> list[dict]: data = json.loads(payload) out: list[dict] = [] for prod in data.get("data") or []: name = (prod.get("PRODUCT") or "").strip() if name not in POSTED_MAP: continue # produits chalet/cap/right-rate : hors périmètre rtype, insured, label = POSTED_MAP[name] for t in prod.get("TERMS") or []: term = _term_months(t) rate = t.get("RATE") if term is None or not isinstance(rate, (int, float)) or rate <= 0: continue out.append(self.make_product( rate=float(rate), rate_type=rtype, term_months=term, kind="posted", product_name=label, insured_status=insured, conditions=name, raw={"product": name, "term": t})) return out def parse_promos(self, payload: str) -> list[dict]: data = json.loads(payload) out: list[dict] = [] for prod in data.get("data") or []: name = (prod.get("PRODUCT") or "").strip() if name not in PROMO_SLOTS: continue rate = prod.get("RATE") if not isinstance(rate, (int, float)) or rate <= 0: continue # slot inutilisé rtype, term, label = PROMO_SLOTS[name] out.append(self.make_product( rate=float(rate), rate_type=rtype, term_months=term, kind="special", product_name=label, confidence=0.8, conditions=f"Slot promotionnel Scotia ({name})", raw={"product": name, "rate": rate})) return out