SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
20 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
5.8 KB · 145 lines python
Raw Blame History
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