# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/majestic.py : connecteur Le Majestic du Lac Boivin # (majesticlacboivin.ca) — 1 complexe de condos locatifs haut de gamme à # Granby (150 rue de la Lobélie, adresse affichée sur la page « Nous # joindre », schema.org PostalAddress). Site custom (non-WordPress) : la # page /fr/condos-locatifs.html rend côté serveur la liste des unités # libres (div.uniteItem[data-id] dans .listeResultatUnite) avec unité, # étage, chambres, salles de bain, superficie, loyer, disponibilité, plan # PDF et bouton « Info » — les unités réservées portent span.nonDispo # (« Réservé ») et sont écartées. Les inclusions/avantages de l'immeuble # (listes .services-liste) complètent les commodités. external_id = # data-id de l'unité (identifiant interne stable). Le loyer est parfois # affiché sans « $ » (« 1795 ») : la colonne étant explicitement « Loyer », # le montant est extrait tel quel. robots.txt : /inc/, /Gestion/ et /auto/ # interdits — la page des condos est permise. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://www.majesticlacboivin.ca" LIST_URL = f"{BASE}/fr/condos-locatifs.html" # adresse du complexe, telle qu'affichée sur /fr/nous-joindre.html ADDRESS = "150 rue de la Lobélie" CITY = "Granby" def _col_value(item, label: str) -> str: """Valeur d'une colonne uniteItemCol dont le titre est `label`.""" for col in item.select(".uniteItemCol"): t = col.select_one(".uniteItemTitre") if t and t.get_text(" ", strip=True).lower() == label.lower(): val = col.get_text(" ", strip=True) val = re.sub(re.escape(t.get_text(" ", strip=True)), "", val, count=1).strip() val = re.sub(r"\s+", " ", val) return re.sub(r"\bpi\s*2\b", "pi²", val) # « 735 pi2 » return "" class MajesticConnector(BaseConnector): source_id = "majestic" request_delay = 0.7 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") # inclusions et avantages de l'immeuble (commodités communes) building_amenities: list[str] = [] for ul in soup.select("ul.services-liste"): for li in ul.select("li.services-liste_item"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip() if t and t not in building_amenities: building_amenities.append(t) listings: list[Listing] = [] for item in soup.select(".listeResultatUnite .uniteItem[data-id]"): try: lst = self._parse_unit(item, building_amenities) except Exception: continue if lst and lst.external_id not in {l.external_id for l in listings}: listings.append(lst) return listings def _parse_unit(self, item, building_amenities: list[str]) -> Listing | None: # unités réservées : bouton remplacé par span.nonDispo « Réservé » if item.select_one(".nonDispo"): return None ext_id = str(item.get("data-id") or "").strip() if not ext_id: return None # « Unité 308 » : première colonne, sans titre unit_no = "" first = item.select_one(".uniteItemCol.c2 .uniteItemValeur") if first: unit_no = re.sub(r"\s+", " ", first.get_text(" ", strip=True)).strip() etage = _col_value(item, "Étage") chambres = _col_value(item, "Chambres") sdb = _col_value(item, "Salles de bain") superficie = _col_value(item, "Superficie") # « 735 pi 2 » loyer = _col_value(item, "Loyer") # « 1695 $ » / « 1795 » dispo = _col_value(item, "Disponibilité") dispo = re.sub(r"\b(\d+)\s+(er|re|e)\b", r"\1\2", dispo) # « 1 er » -> « 1er » # colonne « Loyer » explicite : montant extrait même sans « $ » price = None m = re.search(r"(\d[\d\s]{2,7})", loyer.replace(",", " ")) if m: try: val = float(m.group(1).replace(" ", "")) if 100 <= val <= 20000: price = val except ValueError: pass amenities = [] for label, val in [("Chambres", chambres), ("Salle(s) de bain", sdb), ("Superficie", superficie), ("Étage", etage)]: if val: amenities.append(f"{label} : {val}") amenities += [a for a in building_amenities if a not in amenities] area = None m_area = re.search(r"(\d{3,5})\s*pi", superficie) if m_area: area = float(m_area.group(1)) plan = item.select_one("a.planPDF[href]") details = {} if plan: details["plan_pdf"] = f"{BASE}/{plan['href'].lstrip('/')}" title = f"Le Majestic du Lac Boivin — {unit_no}" if unit_no \ else "Le Majestic du Lac Boivin" return Listing( source=self.source_id, external_id=ext_id, url=LIST_URL, title=title, address=ADDRESS, city=CITY, price=price, price_label=loyer, availability=dispo, area_sqft=area, amenities=amenities[:25], details=details, )