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/scotiabank.py : Banque Scotia — API JSON publique5# dmtsms.scotiabank.com (celle que consomme leur propre page de taux).6# nonspecialmortgage = taux affichés ; varmortgage = slots promotionnels7# (le mapping slot -> produit vient de la page ; on ne garde que les slots8# dont l'identité est connue, avec confiance réduite).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import json1314from .base import RateProvider1516POSTED_URL = "https://dmtsms.scotiabank.com/api/rates/daily/nonspecialmortgage"17PROMO_URL = "https://dmtsms.scotiabank.com/api/rates/daily/varmortgage"18PAGE_URL = ("https://www.scotiabank.com/ca/en/personal/rates-prices/"19 "mortgages-rates.html")2021# PRODUCT -> (rate_type, insured_status, nom, purpose)22POSTED_MAP = {23 "N.H.A. RESIDENTIAL, ETC": ("fixed", "insured", "Fixe affiché (assuré LNH)"),24 "CONVENTIONAL RESIDENTIAL, ETC": ("fixed", "uninsured", "Fixe affiché (conventionnel)"),25 "ULTIMATE VARIABLE RATE": ("variable", "unknown", "Ultimate Variable"),26 "OPEN": ("fixed", "unknown", "Fixe ouvert"),27 "FLEXIBLE": ("fixed", "unknown", "Flexible"),28}2930# Slots promotionnels dont l'identité est documentée sur la page Scotia31# (2026-08). Confiance réduite : si Scotia réordonne ses slots, la validation32# et la comparaison croisée limitent les dégâts.33PROMO_SLOTS = {34 "MORTGAGE PROMOTIONAL 1": ("variable", 60, "Flex Value fermé 5 ans (variable)"),35 "MORTGAGE PROMOTIONAL 2": ("variable", 36, "Ultimate Variable 3 ans"),36 "MORTGAGE PROMOTIONAL 3": ("variable", 60, "Flex Value ouvert 5 ans (variable)"),37}383940def _term_months(t: dict) -> int | None:41 try:42 v = int(t["TERM_VALUE"])43 except (KeyError, ValueError, TypeError):44 return None45 unit = (t.get("TERM_UNIT") or "").upper()46 if unit == "Y":47 return v * 1248 if unit == "M":49 return v50 return None515253class ScotiabankProvider(RateProvider):54 provider_id = "scotiabank"55 institution = "Banque Scotia"56 source_url = PAGE_URL57 request_delay = 1.05859 def fetch(self) -> list[dict]:60 posted = self.get(POSTED_URL).text61 try:62 promo = self.get(PROMO_URL).text63 except Exception: # noqa: BLE001 — promos facultatives64 promo = ""65 return self.parse(posted) + (self.parse_promos(promo) if promo else [])6667 def parse(self, payload: str) -> list[dict]:68 data = json.loads(payload)69 out: list[dict] = []70 for prod in data.get("data") or []:71 name = (prod.get("PRODUCT") or "").strip()72 if name not in POSTED_MAP:73 continue # produits chalet/cap/right-rate : hors périmètre74 rtype, insured, label = POSTED_MAP[name]75 for t in prod.get("TERMS") or []:76 term = _term_months(t)77 rate = t.get("RATE")78 if term is None or not isinstance(rate, (int, float)) or rate <= 0:79 continue80 out.append(self.make_product(81 rate=float(rate), rate_type=rtype, term_months=term,82 kind="posted", product_name=label,83 insured_status=insured,84 conditions=name,85 raw={"product": name, "term": t}))86 return out8788 def parse_promos(self, payload: str) -> list[dict]:89 data = json.loads(payload)90 out: list[dict] = []91 for prod in data.get("data") or []:92 name = (prod.get("PRODUCT") or "").strip()93 if name not in PROMO_SLOTS:94 continue95 rate = prod.get("RATE")96 if not isinstance(rate, (int, float)) or rate <= 0:97 continue # slot inutilisé98 rtype, term, label = PROMO_SLOTS[name]99 out.append(self.make_product(100 rate=float(rate), rate_type=rtype, term_months=term,101 kind="special", product_name=label, confidence=0.8,102 conditions=f"Slot promotionnel Scotia ({name})",103 raw={"product": name, "rate": rate}))104 return out105