SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.7 KB · 141 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/majestic.py : connecteur Le Majestic du Lac Boivin5#   (majesticlacboivin.ca) — 1 complexe de condos locatifs haut de gamme à6#   Granby (150 rue de la Lobélie, adresse affichée sur la page « Nous7#   joindre », schema.org PostalAddress). Site custom (non-WordPress) : la8#   page /fr/condos-locatifs.html rend côté serveur la liste des unités9#   libres (div.uniteItem[data-id] dans .listeResultatUnite) avec unité,10#   étage, chambres, salles de bain, superficie, loyer, disponibilité, plan11#   PDF et bouton « Info » — les unités réservées portent span.nonDispo12#   (« Réservé ») et sont écartées. Les inclusions/avantages de l'immeuble13#   (listes .services-liste) complètent les commodités. external_id =14#   data-id de l'unité (identifiant interne stable). Le loyer est parfois15#   affiché sans « $ » (« 1795 ») : la colonne étant explicitement « Loyer »,16#   le montant est extrait tel quel. robots.txt : /inc/, /Gestion/ et /auto/17#   interdits — la page des condos est permise.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import re2223from bs4 import BeautifulSoup2425from ..schema import Listing26from .base import BaseConnector2728BASE = "https://www.majesticlacboivin.ca"29LIST_URL = f"{BASE}/fr/condos-locatifs.html"3031# adresse du complexe, telle qu'affichée sur /fr/nous-joindre.html32ADDRESS = "150 rue de la Lobélie"33CITY = "Granby"343536def _col_value(item, label: str) -> str:37    """Valeur d'une colonne uniteItemCol dont le titre est `label`."""38    for col in item.select(".uniteItemCol"):39        t = col.select_one(".uniteItemTitre")40        if t and t.get_text(" ", strip=True).lower() == label.lower():41            val = col.get_text(" ", strip=True)42            val = re.sub(re.escape(t.get_text(" ", strip=True)), "", val,43                         count=1).strip()44            val = re.sub(r"\s+", " ", val)45            return re.sub(r"\bpi\s*2\b", "pi²", val)  # « 735 pi<sup>2</sup> »46    return ""474849class MajesticConnector(BaseConnector):50    source_id = "majestic"51    request_delay = 0.75253    def fetch(self) -> list[Listing]:54        html = self.get(LIST_URL).text55        soup = BeautifulSoup(html, "html.parser")5657        # inclusions et avantages de l'immeuble (commodités communes)58        building_amenities: list[str] = []59        for ul in soup.select("ul.services-liste"):60            for li in ul.select("li.services-liste_item"):61                t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()62                if t and t not in building_amenities:63                    building_amenities.append(t)6465        listings: list[Listing] = []66        for item in soup.select(".listeResultatUnite .uniteItem[data-id]"):67            try:68                lst = self._parse_unit(item, building_amenities)69            except Exception:70                continue71            if lst and lst.external_id not in {l.external_id for l in listings}:72                listings.append(lst)73        return listings7475    def _parse_unit(self, item, building_amenities: list[str]) -> Listing | None:76        # unités réservées : bouton remplacé par span.nonDispo « Réservé »77        if item.select_one(".nonDispo"):78            return None79        ext_id = str(item.get("data-id") or "").strip()80        if not ext_id:81            return None8283        # « Unité 308 » : première colonne, sans titre84        unit_no = ""85        first = item.select_one(".uniteItemCol.c2 .uniteItemValeur")86        if first:87            unit_no = re.sub(r"\s+", " ", first.get_text(" ", strip=True)).strip()8889        etage = _col_value(item, "Étage")90        chambres = _col_value(item, "Chambres")91        sdb = _col_value(item, "Salles de bain")92        superficie = _col_value(item, "Superficie")   # « 735 pi 2 »93        loyer = _col_value(item, "Loyer")             # « 1695 $ » / « 1795 »94        dispo = _col_value(item, "Disponibilité")95        dispo = re.sub(r"\b(\d+)\s+(er|re|e)\b", r"\1\2", dispo)  # « 1 er » -> « 1er »9697        # colonne « Loyer » explicite : montant extrait même sans « $ »98        price = None99        m = re.search(r"(\d[\d\s]{2,7})", loyer.replace(",", " "))100        if m:101            try:102                val = float(m.group(1).replace(" ", ""))103                if 100 <= val <= 20000:104                    price = val105            except ValueError:106                pass107108        amenities = []109        for label, val in [("Chambres", chambres), ("Salle(s) de bain", sdb),110                           ("Superficie", superficie), ("Étage", etage)]:111            if val:112                amenities.append(f"{label} : {val}")113        amenities += [a for a in building_amenities if a not in amenities]114115        area = None116        m_area = re.search(r"(\d{3,5})\s*pi", superficie)117        if m_area:118            area = float(m_area.group(1))119120        plan = item.select_one("a.planPDF[href]")121        details = {}122        if plan:123            details["plan_pdf"] = f"{BASE}/{plan['href'].lstrip('/')}"124125        title = f"Le Majestic du Lac Boivin — {unit_no}" if unit_no \126            else "Le Majestic du Lac Boivin"127        return Listing(128            source=self.source_id,129            external_id=ext_id,130            url=LIST_URL,131            title=title,132            address=ADDRESS,133            city=CITY,134            price=price,135            price_label=loyer,136            availability=dispo,137            area_sqft=area,138            amenities=amenities[:25],139            details=details,140        )141