Python 67%
TypeScript 18.2%
CSS 14.4%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# mortgage/providers/first_national.py : First National — table HTML statique5# (server-side, aucun anti-bot). Taux fixes fermés par catégorie6# assuré/assurable(LTV)/conventionnel, ARM « Prime - X% », prime maison.7# -----------------------------------------------------------------------------8from __future__ import annotations910import re1112from bs4 import BeautifulSoup1314from .base import RateProvider, parse_rate, term_to_months151617def _insured_status(category: str) -> str:18 c = category.lower()19 if c.startswith("insured"):20 return "insured"21 if c.startswith("insurable"):22 return "insurable"23 if c.startswith("conventional"):24 return "uninsured"25 return "unknown"262728class FirstNationalProvider(RateProvider):29 provider_id = "first_national"30 institution = "First National"31 source_url = "https://www.firstnational.ca/residential/mortgage-rates"32 request_delay = 1.03334 def fetch(self) -> list[dict]:35 return self.parse(self.get(self.source_url).text)3637 def parse(self, payload: str) -> list[dict]:38 soup = BeautifulSoup(payload, "html.parser")39 out: list[dict] = []40 prime = None41 m = re.search(r"First National Prime Rate:\s*(?:</?\w+[^>]*>\s*)*([\d.]+)",42 payload)43 if m:44 prime = float(m.group(1))45 # -- taux fixes fermés (table avec aria-label par terme) --------------46 for h3 in soup.find_all("h3"):47 title = h3.get_text(" ", strip=True)48 table = h3.find_next("table")49 if table is None:50 continue51 if title.lower().startswith("fixed rate mortgages"):52 for tr in table.select("tbody tr"):53 th = tr.find("th")54 if th is None:55 continue56 category = re.sub(r"\s+", " ", th.get_text(" ", strip=True))57 status = _insured_status(category)58 for td in tr.find_all("td"):59 term = term_to_months(td.get("aria-label") or "")60 rate = parse_rate(td.get_text(strip=True))61 if term is None or rate is None:62 continue # cellule N/A : produit non offert63 out.append(self.make_product(64 rate=rate, rate_type="fixed", term_months=term,65 kind="posted",66 product_name=f"Fixe fermé — {category}",67 insured_status=status, conditions=category,68 raw={"category": category,69 "cell": td.get_text(strip=True)}))70 elif title.lower().startswith("adjustable rate"):71 if prime is None:72 continue # sans prime confirmée, ne rien deviner73 for tr in table.select("tbody tr"):74 th = tr.find("th")75 td = tr.find("td")76 if th is None or td is None:77 continue78 category = re.sub(r"\s+", " ", th.get_text(" ", strip=True))79 cell = td.get_text(" ", strip=True)80 dm = re.search(r"Prime\s*([+-])\s*([\d.]+)\s*%", cell)81 if not dm:82 continue83 delta = float(dm.group(2)) * (1 if dm.group(1) == "+" else -1)84 out.append(self.make_product(85 rate=round(prime + delta, 2), rate_type="adjustable",86 term_months=60, kind="special",87 product_name=f"ARM 5 ans — {category}",88 insured_status=_insured_status(category),89 conditions=f"{cell} (prime First National {prime} %)",90 raw={"category": category, "cell": cell,91 "prime": prime, "delta": delta}))92 if prime is not None:93 out.append(self.make_product(94 rate=prime, rate_type="other", term_months=12, kind="posted",95 product_name="Taux préférentiel First National",96 purpose="unknown", raw={"prime": prime}))97 return out98