# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/providers/cibc.py : CIBC — pseudo-JSON JS productRatesLegacy # (blocs `var CODE = {...}` avec lignes [terme, ?, colId, valeur, …]). # colId 1 = affiché, 18 = spécial, 2 = APR du spécial ; sentinelle # -99.999999991 et spécial 0.00 = non publié ; colId 34 = colonne # d'identité inconnue, jamais devinée. MICRO/MICROVAR ne portent que # l'offre spéciale (leur « affiché » duplique FRCM). # ----------------------------------------------------------------------------- from __future__ import annotations import re from .base import RateProvider BASE_URL = ("https://www.cibconline.cibc.com/ebm-pno/api/v1/json/" "productRatesLegacy?lobId={lob}&sourceProductCode={codes}") LOB5_CODES = "FRCM,FOM,CCM,5YRVARCLO,MICRO,MICROVAR,VROM" PAGE_URL = "https://www.cibc.com/en/interest-rates/mortgage-rates.html" COL_POSTED, COL_APR, COL_SPECIAL = 1, 2, 18 SENTINEL = -99.0 # code -> (rate_type, libellé de base, spécial seulement, libellé du spécial) PRODUCTS: dict[str, tuple] = { "FRCM": ("fixed", "Fixe fermé", False, "offre spéciale"), "FOM": ("fixed", "Fixe ouvert", False, "offre spéciale"), "CCM": ("fixed", "Fermé convertible", False, "offre spéciale"), "5YRVARCLO": ("variable", "Variable Flex fermé", False, "offre spéciale"), "VROM": ("variable", "Variable ouvert", False, "offre spéciale"), # MICRO/MICROVAR : l'offre mise en avant sur la page des taux — libellé # distinct pour ne pas entrer en collision avec le spécial FRCM/5YRVARCLO. "MICRO": ("fixed", "Fixe fermé", True, "offre annoncée"), "MICROVAR": ("variable", "Variable Flex fermé", True, "offre annoncée"), } BLOCK_RX = re.compile(r"var\s+(\w+)\s*=\s*\{(.*?)\]\s*\}", re.S) ROW_RX = re.compile(r"\[([^\[\]]*)\]") TERM_RX = re.compile(r"^(\d{1,3})_.*_(Year|Years|Month|Months)_T$") def _cells(row: str) -> list: out = [] for cell in row.split(","): c = cell.strip().strip("'\"") out.append(None if c == "null" else c) return out def _term_months(token) -> int | None: m = TERM_RX.match(str(token or "")) if not m: return None n = int(m.group(1)) return n * 12 if m.group(2).startswith("Year") else n 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 CibcProvider(RateProvider): provider_id = "cibc" institution = "CIBC" source_url = PAGE_URL request_delay = 1.2 def fetch(self) -> list[dict]: text = self.get(BASE_URL.format(lob=5, codes=LOB5_CODES)).text try: text += "\n" + self.get(BASE_URL.format(lob=1, codes="PRIME")).text except Exception: # noqa: BLE001 — prime facultatif pass return self.parse(text) def parse(self, payload: str) -> list[dict]: out: list[dict] = [] for var_name, body in BLOCK_RX.findall(payload): name = var_name[1:] if var_name[:1] == "p" and \ var_name[1:2].isdigit() else var_name if name == "PRIME": out.extend(self._parse_prime(body)) continue if name not in PRODUCTS: continue rtype, base_label, special_only, special_label = PRODUCTS[name] # (terme -> {colId: valeur}) grid: dict[int, dict[int, float]] = {} for row in ROW_RX.findall(body): cells = _cells(row) if len(cells) < 4: continue term = _term_months(cells[0]) col = _num(cells[2]) val = _num(cells[3]) if term is None or col is None or val is None: continue if val < SENTINEL + 1 or val <= 0: continue # sentinelle -99.999999991 ou 0.00 : non publié grid.setdefault(term, {})[int(col)] = val for term, cols in sorted(grid.items()): posted = cols.get(COL_POSTED) special = cols.get(COL_SPECIAL) if posted and not special_only: out.append(self.make_product( rate=posted, rate_type=rtype, term_months=term, kind="posted", product_name=f"{base_label} {_label(term)}", conditions="Base = taux préférentiel CIBC" if rtype == "variable" and name == "5YRVARCLO" else None, raw={"code": name, "cols": cols})) if special: out.append(self.make_product( rate=special, rate_type=rtype, term_months=term, kind="special", apr=cols.get(COL_APR), product_name=f"{base_label} {_label(term)} " f"({special_label})", raw={"code": name, "cols": cols})) return out def _parse_prime(self, body: str) -> list[dict]: for row in ROW_RX.findall(body): cells = _cells(row) if len(cells) < 4: continue rate = _num(cells[3]) if rate and rate > 0: return [self.make_product( rate=rate, rate_type="other", term_months=12, kind="posted", product_name="Taux préférentiel CIBC", purpose="unknown", raw={"code": "PRIME", "value": rate})] return []