# -----------------------------------------------------------------------------
# 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,
)