# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/ferrovia.py : connecteur Ferrovia (ferroviamirabel.com) # Projet de condos locatifs neufs à Mirabel (secteur Saint-Janvier), 4 phases. # WordPress + wpDataTables rendues côté serveur : chaque page « Disponibilités » # contient un (UNITÉ, MODÈLE, ÉTAGE, PIÈCES, SUPERFICIE, # SALLE D'EAU SUPP., DISPONIBILITÉ, PRIX, STATUT, PLAN). On ne retient que les # lignes STATUT = « Disponible » ; external_id = phase + numéro d'unité. # Le site précise que les logements sont non-fumeurs et sans animaux. # Prix : colonne présente mais vide à ce jour — jamais inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.ferroviamirabel.com" PHASES = [ ("1", f"{BASE}/disponibilites-prix-plans-phase1/"), ("3", f"{BASE}/disponibilites-phase-3/"), ("4", f"{BASE}/disponibilites-phase-4/"), ] class FerroviaConnector(BaseConnector): source_id = "ferrovia" request_delay = 0.7 def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for phase, url in PHASES: try: html = self.get(url).text except Exception: continue self._parse_phase(phase, url, html, listings) return list(listings.values()) def _parse_phase(self, phase: str, url: str, html: str, listings: dict[str, Listing]) -> None: soup = BeautifulSoup(html, "html.parser") table = soup.select_one("table.wpDataTable") if table is None: return heads = [th.get_text(" ", strip=True).lower() for th in table.select("thead th")] def col(row_cells, *keys): for key in keys: for i, h in enumerate(heads): if key in h and i < len(row_cells): return row_cells[i] return None for tr in table.select("tbody tr"): cells = tr.find_all("td") texts = [re.sub(r"\s+", " ", td.get_text(" ", strip=True)) for td in cells] statut = col(texts, "statut") or "" if not re.match(r"(?i)disponible", statut): continue # loué / réservé : on saute unit = col(texts, "unité", "unite") or "" if not unit: continue pieces = col(texts, "pièces", "pieces") or "" unit_type = normalize_unit_type(pieces) if not re.fullmatch(r"\d½|Studio|Loft", unit_type or ""): continue # ligne non résidentielle modele = col(texts, "modèle", "modele") or "" etage = col(texts, "étage", "etage") or "" superficie = col(texts, "superficie") or "" demi_sdb = col(texts, "salle d'eau", "salle d’eau") or "" dispo = col(texts, "disponibilité", "disponibilite") or "" prix = col(texts, "prix") or "" # vide à ce jour sur le site area = None m = re.match(r"([\d\s,.]+)$", superficie) if m: try: area = float(m.group(1).replace(" ", "").replace(",", "")) except ValueError: area = None plan_td = col(cells, "plan") plan_url = "" if plan_td is not None: a = plan_td.find("a", href=True) if a: plan_url = a["href"] details = {"phase": phase, "smoking": False} if modele: details["model"] = modele if etage: details["floor"] = etage if plan_url: details["plan_pdf"] = plan_url amenities = [] if re.match(r"(?i)oui", demi_sdb): amenities.append("Salle d'eau supplémentaire") ext = f"phase{phase}-{unit}" listings[ext] = Listing( source=self.source_id, external_id=ext, url=url, title=f"Ferrovia phase {phase} — unité {unit} ({pieces})", sector="Saint-Janvier", city="Mirabel", unit_type=unit_type, price=parse_price(prix), price_label=prix, availability=dispo, # « Automne 2026 », « ÉTÉ 2026 » area_sqft=area, pets="non", # note du site : animaux non admis description=("Condos neufs à louer du projet Ferrovia, à Mirabel " "dans le secteur de Saint-Janvier. Les logements sont " "non-fumeurs et les animaux ne sont pas admis."), amenities=amenities, details=details, )