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/cibc.py : CIBC — pseudo-JSON JS productRatesLegacy5# (blocs `var CODE = {...}` avec lignes [terme, ?, colId, valeur, …]).6# colId 1 = affiché, 18 = spécial, 2 = APR du spécial ; sentinelle7# -99.999999991 et spécial 0.00 = non publié ; colId 34 = colonne8# d'identité inconnue, jamais devinée. MICRO/MICROVAR ne portent que9# l'offre spéciale (leur « affiché » duplique FRCM).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from .base import RateProvider1617BASE_URL = ("https://www.cibconline.cibc.com/ebm-pno/api/v1/json/"18 "productRatesLegacy?lobId={lob}&sourceProductCode={codes}")19LOB5_CODES = "FRCM,FOM,CCM,5YRVARCLO,MICRO,MICROVAR,VROM"20PAGE_URL = "https://www.cibc.com/en/interest-rates/mortgage-rates.html"2122COL_POSTED, COL_APR, COL_SPECIAL = 1, 2, 1823SENTINEL = -99.02425# code -> (rate_type, libellé de base, spécial seulement, libellé du spécial)26PRODUCTS: dict[str, tuple] = {27 "FRCM": ("fixed", "Fixe fermé", False, "offre spéciale"),28 "FOM": ("fixed", "Fixe ouvert", False, "offre spéciale"),29 "CCM": ("fixed", "Fermé convertible", False, "offre spéciale"),30 "5YRVARCLO": ("variable", "Variable Flex fermé", False, "offre spéciale"),31 "VROM": ("variable", "Variable ouvert", False, "offre spéciale"),32 # MICRO/MICROVAR : l'offre mise en avant sur la page des taux — libellé33 # distinct pour ne pas entrer en collision avec le spécial FRCM/5YRVARCLO.34 "MICRO": ("fixed", "Fixe fermé", True, "offre annoncée"),35 "MICROVAR": ("variable", "Variable Flex fermé", True, "offre annoncée"),36}3738BLOCK_RX = re.compile(r"var\s+(\w+)\s*=\s*\{(.*?)\]\s*\}", re.S)39ROW_RX = re.compile(r"\[([^\[\]]*)\]")40TERM_RX = re.compile(r"^(\d{1,3})_.*_(Year|Years|Month|Months)_T$")414243def _cells(row: str) -> list:44 out = []45 for cell in row.split(","):46 c = cell.strip().strip("'\"")47 out.append(None if c == "null" else c)48 return out495051def _term_months(token) -> int | None:52 m = TERM_RX.match(str(token or ""))53 if not m:54 return None55 n = int(m.group(1))56 return n * 12 if m.group(2).startswith("Year") else n575859def _num(v) -> float | None:60 try:61 return float(str(v).strip())62 except (TypeError, ValueError):63 return None646566def _label(months: int) -> str:67 if months < 12:68 return f"{months} mois"69 years = months // 1270 return f"{years} an" if years == 1 else f"{years} ans"717273class CibcProvider(RateProvider):74 provider_id = "cibc"75 institution = "CIBC"76 source_url = PAGE_URL77 request_delay = 1.27879 def fetch(self) -> list[dict]:80 text = self.get(BASE_URL.format(lob=5, codes=LOB5_CODES)).text81 try:82 text += "\n" + self.get(BASE_URL.format(lob=1, codes="PRIME")).text83 except Exception: # noqa: BLE001 — prime facultatif84 pass85 return self.parse(text)8687 def parse(self, payload: str) -> list[dict]:88 out: list[dict] = []89 for var_name, body in BLOCK_RX.findall(payload):90 name = var_name[1:] if var_name[:1] == "p" and \91 var_name[1:2].isdigit() else var_name92 if name == "PRIME":93 out.extend(self._parse_prime(body))94 continue95 if name not in PRODUCTS:96 continue97 rtype, base_label, special_only, special_label = PRODUCTS[name]98 # (terme -> {colId: valeur})99 grid: dict[int, dict[int, float]] = {}100 for row in ROW_RX.findall(body):101 cells = _cells(row)102 if len(cells) < 4:103 continue104 term = _term_months(cells[0])105 col = _num(cells[2])106 val = _num(cells[3])107 if term is None or col is None or val is None:108 continue109 if val < SENTINEL + 1 or val <= 0:110 continue # sentinelle -99.999999991 ou 0.00 : non publié111 grid.setdefault(term, {})[int(col)] = val112 for term, cols in sorted(grid.items()):113 posted = cols.get(COL_POSTED)114 special = cols.get(COL_SPECIAL)115 if posted and not special_only:116 out.append(self.make_product(117 rate=posted, rate_type=rtype, term_months=term,118 kind="posted",119 product_name=f"{base_label} {_label(term)}",120 conditions="Base = taux préférentiel CIBC"121 if rtype == "variable" and name == "5YRVARCLO"122 else None,123 raw={"code": name, "cols": cols}))124 if special:125 out.append(self.make_product(126 rate=special, rate_type=rtype, term_months=term,127 kind="special", apr=cols.get(COL_APR),128 product_name=f"{base_label} {_label(term)} "129 f"({special_label})",130 raw={"code": name, "cols": cols}))131 return out132133 def _parse_prime(self, body: str) -> list[dict]:134 for row in ROW_RX.findall(body):135 cells = _cells(row)136 if len(cells) < 4:137 continue138 rate = _num(cells[3])139 if rate and rate > 0:140 return [self.make_product(141 rate=rate, rate_type="other", term_months=12,142 kind="posted", product_name="Taux préférentiel CIBC",143 purpose="unknown", raw={"code": "PRIME", "value": rate})]144 return []145