# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/providers/base.py : classe de base des connecteurs de taux. # Contrat : fetch() retourne la liste des produits NORMALISÉS de # l'institution (dicts au format de make_product). Aucune écriture BD ici — # le scheduler valide puis enregistre. Jamais de taux inventé : un produit # non confirmé est simplement omis. # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import time import requests USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 " "ImmoKaBot/1.0 (+https://www.immo-ka.com/bot; contact@spboucher.ai)") SCRAPFLY_API = "https://api.scrapfly.io/scrape" class RateProvider: provider_id: str = "" # slug (rbc, td, desjardins…) institution: str = "" # nom d'affichage source_url: str = "" # page officielle des taux (lien public) request_delay: float = 1.0 # politesse timeout: int = 30 def __init__(self) -> None: self.session = requests.Session() self.session.headers["User-Agent"] = USER_AGENT self._last_request = 0.0 # -- backends ------------------------------------------------------------- def get(self, url: str, **kw) -> requests.Response: wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.get(url, timeout=self.timeout, **kw) self._last_request = time.time() resp.raise_for_status() return resp def get_scrapfly(self, url: str, render_js: bool = False, asp: bool = True, country: str = "ca") -> str: """HTML via Scrapfly (anti-bot) — dernier recours seulement.""" key = os.environ.get("SCRAPFLY_KEY") or os.environ.get("SCRAPFLY_API_KEY") if not key: raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)") params: dict = {"key": key, "url": url, "country": country} if asp: params["asp"] = "true" if render_js: params["render_js"] = "true" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = requests.get(SCRAPFLY_API, params=params, timeout=180) self._last_request = time.time() try: return (resp.json().get("result") or {}).get("content") or "" except ValueError: return "" # -- normalisation -------------------------------------------------------- def make_product(self, *, rate: float, rate_type: str, term_months: int, kind: str, product_name: str | None = None, apr: float | None = None, insured_status: str = "unknown", purpose: str = "purchase", amortization_max_years: int | None = None, conditions: str | None = None, source_url: str | None = None, confidence: float = 1.0, raw=None) -> dict: """Observation normalisée commune à tous les providers.""" return { "provider": self.provider_id, "institution": self.institution, "product_name": product_name, "rate_type": rate_type, "term_months": int(term_months), "kind": kind, "rate": round(float(rate), 4), "apr": round(float(apr), 4) if apr is not None else None, "insured_status": insured_status, "purpose": purpose, "amortization_max_years": amortization_max_years, "conditions": conditions, "source_url": source_url or self.source_url, "confidence": confidence, "raw": raw, } # -- contrat -------------------------------------------------------------- def fetch(self) -> list[dict]: raise NotImplementedError # Point d'entrée pour les tests avec fixtures : si le provider parse un # payload texte, il expose parse(payload) et fetch() = parse(download()). def parse(self, payload: str) -> list[dict]: # pragma: no cover raise NotImplementedError def parse_rate(text: str) -> float | None: """Extrait un taux en % d'un texte : '4,19 %', '4.19%', ' 4.19 '. Retourne None si introuvable — jamais de valeur devinée.""" if text is None: return None m = re.search(r"(\d{1,2}(?:[.,]\d{1,4})?)\s*%?", str(text).strip()) if not m: return None try: return float(m.group(1).replace(",", ".")) except ValueError: return None def term_to_months(text: str) -> int | None: """'5 ans', '5 year', '5-year', '6 months', '6 mois' → mois.""" if not text: return None t = str(text).lower() m = re.search(r"(\d{1,3})\s*(?:-|\s)?\s*(an|ans|année|year|yr)", t) if m: return int(m.group(1)) * 12 m = re.search(r"(\d{1,3})\s*(?:-|\s)?\s*(mois|month)", t) if m: return int(m.group(1)) return None