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.3 KB · 134 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/base.py : classe de base des connecteurs de taux.5#   Contrat : fetch() retourne la liste des produits NORMALISÉS de6#   l'institution (dicts au format de make_product). Aucune écriture BD ici —7#   le scheduler valide puis enregistre. Jamais de taux inventé : un produit8#   non confirmé est simplement omis.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import os13import re14import time1516import requests1718USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "19              "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "20              "ImmoKaBot/1.0 (+https://www.immo-ka.com/bot; contact@spboucher.ai)")2122SCRAPFLY_API = "https://api.scrapfly.io/scrape"232425class RateProvider:26    provider_id: str = ""          # slug (rbc, td, desjardins…)27    institution: str = ""          # nom d'affichage28    source_url: str = ""           # page officielle des taux (lien public)29    request_delay: float = 1.0     # politesse30    timeout: int = 303132    def __init__(self) -> None:33        self.session = requests.Session()34        self.session.headers["User-Agent"] = USER_AGENT35        self._last_request = 0.03637    # -- backends -------------------------------------------------------------38    def get(self, url: str, **kw) -> requests.Response:39        wait = self.request_delay - (time.time() - self._last_request)40        if wait > 0:41            time.sleep(wait)42        resp = self.session.get(url, timeout=self.timeout, **kw)43        self._last_request = time.time()44        resp.raise_for_status()45        return resp4647    def get_scrapfly(self, url: str, render_js: bool = False,48                     asp: bool = True, country: str = "ca") -> str:49        """HTML via Scrapfly (anti-bot) — dernier recours seulement."""50        key = os.environ.get("SCRAPFLY_KEY") or os.environ.get("SCRAPFLY_API_KEY")51        if not key:52            raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")53        params: dict = {"key": key, "url": url, "country": country}54        if asp:55            params["asp"] = "true"56        if render_js:57            params["render_js"] = "true"58        wait = self.request_delay - (time.time() - self._last_request)59        if wait > 0:60            time.sleep(wait)61        resp = requests.get(SCRAPFLY_API, params=params, timeout=180)62        self._last_request = time.time()63        try:64            return (resp.json().get("result") or {}).get("content") or ""65        except ValueError:66            return ""6768    # -- normalisation --------------------------------------------------------69    def make_product(self, *, rate: float, rate_type: str, term_months: int,70                     kind: str, product_name: str | None = None,71                     apr: float | None = None,72                     insured_status: str = "unknown",73                     purpose: str = "purchase",74                     amortization_max_years: int | None = None,75                     conditions: str | None = None,76                     source_url: str | None = None,77                     confidence: float = 1.0,78                     raw=None) -> dict:79        """Observation normalisée commune à tous les providers."""80        return {81            "provider": self.provider_id,82            "institution": self.institution,83            "product_name": product_name,84            "rate_type": rate_type,85            "term_months": int(term_months),86            "kind": kind,87            "rate": round(float(rate), 4),88            "apr": round(float(apr), 4) if apr is not None else None,89            "insured_status": insured_status,90            "purpose": purpose,91            "amortization_max_years": amortization_max_years,92            "conditions": conditions,93            "source_url": source_url or self.source_url,94            "confidence": confidence,95            "raw": raw,96        }9798    # -- contrat --------------------------------------------------------------99    def fetch(self) -> list[dict]:100        raise NotImplementedError101102    # Point d'entrée pour les tests avec fixtures : si le provider parse un103    # payload texte, il expose parse(payload) et fetch() = parse(download()).104    def parse(self, payload: str) -> list[dict]:  # pragma: no cover105        raise NotImplementedError106107108def parse_rate(text: str) -> float | None:109    """Extrait un taux en % d'un texte : '4,19 %', '4.19%', ' 4.19 '.110    Retourne None si introuvable — jamais de valeur devinée."""111    if text is None:112        return None113    m = re.search(r"(\d{1,2}(?:[.,]\d{1,4})?)\s*%?", str(text).strip())114    if not m:115        return None116    try:117        return float(m.group(1).replace(",", "."))118    except ValueError:119        return None120121122def term_to_months(text: str) -> int | None:123    """'5 ans', '5 year', '5-year', '6 months', '6 mois' → mois."""124    if not text:125        return None126    t = str(text).lower()127    m = re.search(r"(\d{1,3})\s*(?:-|\s)?\s*(an|ans|année|year|yr)", t)128    if m:129        return int(m.group(1)) * 12130    m = re.search(r"(\d{1,3})\s*(?:-|\s)?\s*(mois|month)", t)131    if m:132        return int(m.group(1))133    return None134