# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/providers/td.py : TD Canada Trust — API JSON publique getRates # (POST ratesType=resl, celle que consomme leur page de taux). Chaque code # MTG{F|V}{mois}{C|O} porte deux tableaux highRatio / nonHighRatio de la # forme [affiché, escompte, spécial, APR, drapeau]. Le « affiché » des # produits variables est le TD Mortgage Prime. ⚠️ Pour les termes sans offre # spéciale réelle, TD publie un « spécial » reconstruit (escompte négatif) # dont l'APR correspond en fait au taux AFFICHÉ : ces lignes incohérentes # (APR < spécial) sont écartées — jamais interprétées. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from .base import RateProvider API_URL = "https://psservice.td.com/ca/en/carate/getRates" PAGE_URL = ("https://www.td.com/ca/en/personal-banking/products/mortgages/" "mortgage-rates") CODE_RX = re.compile(r"^MTG([FV])(\d{3})([CO])$") IDX_POSTED, IDX_SPECIAL, IDX_APR = 0, 2, 3 def _num(v) -> float | None: try: return float(str(v).strip()) except (TypeError, ValueError): return None 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" class TdProvider(RateProvider): provider_id = "td" institution = "TD Canada Trust" source_url = PAGE_URL request_delay = 1.0 def fetch(self) -> list[dict]: wait_kw = {"timeout": self.timeout, "headers": {"Content-Type": "application/json"}} resp = self.session.post(API_URL, json={"ratesType": "resl"}, **wait_kw) resp.raise_for_status() return self.parse(resp.text) def parse(self, payload: str) -> list[dict]: data = json.loads(payload) if not isinstance(data, dict): raise ValueError("réponse TD inattendue (structure changée)") for wrapper in ("rates", "data", "result"): if wrapper in data and isinstance(data[wrapper], dict): data = data[wrapper] break out: list[dict] = [] prime: float | None = None for code, tables in data.items(): m = CODE_RX.match(str(code)) if not m or not isinstance(tables, dict): continue # FLT* (FlexLine/HELOC) et codes inconnus : ignorés rtype = "fixed" if m.group(1) == "F" else "variable" term = int(m.group(2)) openness = "fermé" if m.group(3) == "C" else "ouvert" base = ("Fixe" if rtype == "fixed" else "Variable") non_hr = tables.get("nonHighRatio") or [] high_r = tables.get("highRatio") or [] posted = _num(non_hr[IDX_POSTED]) if len(non_hr) > IDX_POSTED else None if posted and posted > 0: if rtype == "variable": # l'« affiché » des codes MTGV est le TD Mortgage Prime, # pas le taux du produit : jamais émis comme taux variable prime = posted else: out.append(self.make_product( rate=posted, rate_type=rtype, term_months=term, kind="posted", product_name=f"{base} {openness} {_label(term)}", raw={"code": code, "row": non_hr})) for arr, insured, suffix in ((non_hr, "uninsured", ""), (high_r, "insured", ", ratio élevé")): if len(arr) <= IDX_APR: continue special = _num(arr[IDX_SPECIAL]) if special is None or special <= 0: continue if posted is not None and special == posted and rtype == "fixed" \ and _num(arr[1]) in (0, None): continue # pas de spécial publié pour ce produit apr = _num(arr[IDX_APR]) if apr is not None and apr < special - 0.02: continue # « spécial » reconstruit : APR = celui du taux affiché out.append(self.make_product( rate=special, rate_type=rtype, term_months=term, kind="special", apr=apr, product_name=f"{base} {openness} {_label(term)} " f"(offre spéciale{suffix})", insured_status=insured, conditions="Base = TD Mortgage Prime" if rtype == "variable" else None, raw={"code": code, "row": arr})) if prime and prime > 0: out.append(self.make_product( rate=prime, rate_type="other", term_months=12, kind="posted", product_name="TD Mortgage Prime", purpose="unknown", raw={"field": "MTGV*[0]", "value": prime})) return out