SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
5.2 KB · 127 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/ferrovia.py : connecteur Ferrovia (ferroviamirabel.com)5#   Projet de condos locatifs neufs à Mirabel (secteur Saint-Janvier), 4 phases.6#   WordPress + wpDataTables rendues côté serveur : chaque page « Disponibilités »7#   contient un <table.wpDataTable> (UNITÉ, MODÈLE, ÉTAGE, PIÈCES, SUPERFICIE,8#   SALLE D'EAU SUPP., DISPONIBILITÉ, PRIX, STATUT, PLAN). On ne retient que les9#   lignes STATUT = « Disponible » ; external_id = phase + numéro d'unité.10#   Le site précise que les logements sont non-fumeurs et sans animaux.11#   Prix : colonne présente mais vide à ce jour — jamais inventé.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://www.ferroviamirabel.com"23PHASES = [24    ("1", f"{BASE}/disponibilites-prix-plans-phase1/"),25    ("3", f"{BASE}/disponibilites-phase-3/"),26    ("4", f"{BASE}/disponibilites-phase-4/"),27]282930class FerroviaConnector(BaseConnector):31    source_id = "ferrovia"32    request_delay = 0.73334    def fetch(self) -> list[Listing]:35        listings: dict[str, Listing] = {}36        for phase, url in PHASES:37            try:38                html = self.get(url).text39            except Exception:40                continue41            self._parse_phase(phase, url, html, listings)42        return list(listings.values())4344    def _parse_phase(self, phase: str, url: str, html: str,45                     listings: dict[str, Listing]) -> None:46        soup = BeautifulSoup(html, "html.parser")47        table = soup.select_one("table.wpDataTable")48        if table is None:49            return50        heads = [th.get_text(" ", strip=True).lower()51                 for th in table.select("thead th")]5253        def col(row_cells, *keys):54            for key in keys:55                for i, h in enumerate(heads):56                    if key in h and i < len(row_cells):57                        return row_cells[i]58            return None5960        for tr in table.select("tbody tr"):61            cells = tr.find_all("td")62            texts = [re.sub(r"\s+", " ", td.get_text(" ", strip=True))63                     for td in cells]64            statut = col(texts, "statut") or ""65            if not re.match(r"(?i)disponible", statut):66                continue                          # loué / réservé : on saute67            unit = col(texts, "unité", "unite") or ""68            if not unit:69                continue70            pieces = col(texts, "pièces", "pieces") or ""71            unit_type = normalize_unit_type(pieces)72            if not re.fullmatch(r"\d½|Studio|Loft", unit_type or ""):73                continue                          # ligne non résidentielle74            modele = col(texts, "modèle", "modele") or ""75            etage = col(texts, "étage", "etage") or ""76            superficie = col(texts, "superficie") or ""77            demi_sdb = col(texts, "salle d'eau", "salle d’eau") or ""78            dispo = col(texts, "disponibilité", "disponibilite") or ""79            prix = col(texts, "prix") or ""       # vide à ce jour sur le site8081            area = None82            m = re.match(r"([\d\s,.]+)$", superficie)83            if m:84                try:85                    area = float(m.group(1).replace(" ", "").replace(",", ""))86                except ValueError:87                    area = None8889            plan_td = col(cells, "plan")90            plan_url = ""91            if plan_td is not None:92                a = plan_td.find("a", href=True)93                if a:94                    plan_url = a["href"]9596            details = {"phase": phase, "smoking": False}97            if modele:98                details["model"] = modele99            if etage:100                details["floor"] = etage101            if plan_url:102                details["plan_pdf"] = plan_url103            amenities = []104            if re.match(r"(?i)oui", demi_sdb):105                amenities.append("Salle d'eau supplémentaire")106107            ext = f"phase{phase}-{unit}"108            listings[ext] = Listing(109                source=self.source_id,110                external_id=ext,111                url=url,112                title=f"Ferrovia phase {phase} — unité {unit} ({pieces})",113                sector="Saint-Janvier",114                city="Mirabel",115                unit_type=unit_type,116                price=parse_price(prix),117                price_label=prix,118                availability=dispo,               # « Automne 2026 », « ÉTÉ 2026 »119                area_sqft=area,120                pets="non",                       # note du site : animaux non admis121                description=("Condos neufs à louer du projet Ferrovia, à Mirabel "122                             "dans le secteur de Saint-Janvier. Les logements sont "123                             "non-fumeurs et les animaux ne sont pas admis."),124                amenities=amenities,125                details=details,126            )127