spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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