# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/providers/first_national.py : First National — table HTML statique # (server-side, aucun anti-bot). Taux fixes fermés par catégorie # assuré/assurable(LTV)/conventionnel, ARM « Prime - X% », prime maison. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from .base import RateProvider, parse_rate, term_to_months def _insured_status(category: str) -> str: c = category.lower() if c.startswith("insured"): return "insured" if c.startswith("insurable"): return "insurable" if c.startswith("conventional"): return "uninsured" return "unknown" class FirstNationalProvider(RateProvider): provider_id = "first_national" institution = "First National" source_url = "https://www.firstnational.ca/residential/mortgage-rates" request_delay = 1.0 def fetch(self) -> list[dict]: return self.parse(self.get(self.source_url).text) def parse(self, payload: str) -> list[dict]: soup = BeautifulSoup(payload, "html.parser") out: list[dict] = [] prime = None m = re.search(r"First National Prime Rate:\s*(?:]*>\s*)*([\d.]+)", payload) if m: prime = float(m.group(1)) # -- taux fixes fermés (table avec aria-label par terme) -------------- for h3 in soup.find_all("h3"): title = h3.get_text(" ", strip=True) table = h3.find_next("table") if table is None: continue if title.lower().startswith("fixed rate mortgages"): for tr in table.select("tbody tr"): th = tr.find("th") if th is None: continue category = re.sub(r"\s+", " ", th.get_text(" ", strip=True)) status = _insured_status(category) for td in tr.find_all("td"): term = term_to_months(td.get("aria-label") or "") rate = parse_rate(td.get_text(strip=True)) if term is None or rate is None: continue # cellule N/A : produit non offert out.append(self.make_product( rate=rate, rate_type="fixed", term_months=term, kind="posted", product_name=f"Fixe fermé — {category}", insured_status=status, conditions=category, raw={"category": category, "cell": td.get_text(strip=True)})) elif title.lower().startswith("adjustable rate"): if prime is None: continue # sans prime confirmée, ne rien deviner for tr in table.select("tbody tr"): th = tr.find("th") td = tr.find("td") if th is None or td is None: continue category = re.sub(r"\s+", " ", th.get_text(" ", strip=True)) cell = td.get_text(" ", strip=True) dm = re.search(r"Prime\s*([+-])\s*([\d.]+)\s*%", cell) if not dm: continue delta = float(dm.group(2)) * (1 if dm.group(1) == "+" else -1) out.append(self.make_product( rate=round(prime + delta, 2), rate_type="adjustable", term_months=60, kind="special", product_name=f"ARM 5 ans — {category}", insured_status=_insured_status(category), conditions=f"{cell} (prime First National {prime} %)", raw={"category": category, "cell": cell, "prime": prime, "delta": delta})) if prime is not None: out.append(self.make_product( rate=prime, rate_type="other", term_months=12, kind="posted", product_name="Taux préférentiel First National", purpose="unknown", raw={"prime": prime})) return out