Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/groupe_maurice.py : Le Groupe Maurice — 37 résidences pour aînés5# (RPA) au Québec (Montréal, Laval, Rive-Sud/Nord, Québec, Gatineau…).6# WordPress avec API REST maison : /wp-json/api/v1/residences retourne les7# 37 résidences géolocalisées (adresse, lat/lng, ville, url, vignette).8# Chaque page résidence (SSR) expose la grille des types d'unités dans des9# <article class="y-bordered"> : « Studio | 408 pi.ca à 628 pi.ca |10# À partir de 2 286 $ par mois ». Une annonce Lou-Ka par type × résidence,11# prix plancher « à partir de », étiquetée Résidence pour aînés.12# La même page (déjà téléchargée) fournit aussi la galerie de la résidence13# (photos des appartements et des aires communes — carousel360 +14# gallery--large + alentours), le texte de présentation et la liste des15# caractéristiques des unités (« Le confort à votre mesure ») : on les16# applique à chaque annonce sans aucune requête supplémentaire.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type25from .base import BaseConnector2627API = "https://www.legroupemaurice.com/wp-json/api/v1/residences"2829_ROW_RE = re.compile(30 r"^(?P<type>.+?)\s+(?P<a1>[\d\s]+)\s*pi\.?\s*ca(?:\s*à\s*(?P<a2>[\d\s]+)\s*pi\.?\s*ca)?"31 r"\s*À partir de\s*(?P<prix>[\d\s]+)\s*\$\s*par mois", re.S)32_TAG_RE = re.compile(r"<[^>]+>")33_IMG_EXT_RE = re.compile(r"\.(?:jpe?g|webp|png)$", re.I)34# visuels génériques partagés entre résidences (le carrousel « Les35# appartements » mélange des photos SEV/LJM/STH… d'autres résidences) et UI36_IMG_SKIP_RE = re.compile(r"(?:Carrousel_Appartements|Bloc_Votre_Appartement|"37 r"logo|icon|favicon|picto)", re.I)38_SIZE_SUFFIX_RE = re.compile(r"-\d+x\d+(?=\.)")39# « …_3.5-Salon-… » dans le nom de fichier : photo d'un type d'unité précis,40# priorisée pour l'annonce du type correspondant41_TYPE_IN_FN_RE = re.compile(r"[_-](\d\.\d)-")424344class GroupeMauriceConnector(BaseConnector):45 source_id = "groupe_maurice"46 request_delay = 0.647 max_images = 304849 def _rows(self, soup: BeautifulSoup) -> list[dict]:50 out = []51 for art in soup.find_all("article"):52 if "y-bordered" not in (art.get("class") or []):53 continue54 text = re.sub(r"\s+", " ", art.get_text(" ", strip=True))55 m = _ROW_RE.match(text)56 if not m:57 continue58 try:59 price = float(m.group("prix").replace(" ", ""))60 area = float(m.group("a1").replace(" ", ""))61 except ValueError:62 continue63 area_max = None64 if m.group("a2"):65 try:66 area_max = float(m.group("a2").replace(" ", ""))67 except ValueError:68 area_max = None69 out.append({"type": m.group("type").strip(),70 "price": price, "area": area, "area_max": area_max})71 return out7273 # -- galerie de la résidence -------------------------------------------------74 def _gallery(self, soup: BeautifulSoup) -> list[str]:75 """Photos de la résidence, dans l'ordre des carrousels de la page :76 visite « carousel360 » (pièces des appartements types + communs),77 galerie des aires communes, puis les alentours. Les visuels78 génériques inter-résidences et les variantes redimensionnées sont79 écartés (dédoublonnage par nom de fichier)."""80 imgs: list[str] = []81 seen: set[str] = set()82 for sel in ("div.carousel360 img", "div.gallery.gallery--large img",83 "div.carousel-residence__carousel img"):84 for im in soup.select(sel):85 src = (im.get("src") or im.get("data-src") or "").split("?")[0]86 fn = src.rsplit("/", 1)[-1]87 if not src.startswith("http") or not _IMG_EXT_RE.search(fn):88 continue89 if _IMG_SKIP_RE.search(fn):90 continue91 key = _SIZE_SUFFIX_RE.sub("", fn).lower()92 if key in seen:93 continue94 seen.add(key)95 imgs.append(src)96 return imgs9798 # -- présentation + caractéristiques des unités --------------------------------99 @staticmethod100 def _presentation(soup: BeautifulSoup) -> tuple[str, str, list[str]]:101 """(intro, alentours, caractéristiques des unités) de la page.102103 Intro = premier paragraphe long de la page (texte d'accueil de la104 résidence) ; alentours = paragraphe sous « Aux alentours de la105 résidence » ; caractéristiques = liste « Le confort à votre mesure »106 (cuisine, balcon, insonorisation…)."""107 intro = alentours = ""108 for p in soup.find_all("p"):109 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()110 if len(t) >= 120:111 intro = t112 break113 for h in soup.find_all("h2"):114 if "alentours" not in h.get_text(" ", strip=True).lower():115 continue116 sec = h117 for _ in range(5):118 sec = sec.parent119 if sec is None:120 break121 for p in sec.find_all("p"):122 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()123 if len(t) >= 120:124 alentours = t125 break126 if alentours:127 break128 break129 feats: list[str] = []130 for h in soup.find_all("h2"):131 if "confort à votre mesure" not in h.get_text(" ", strip=True).lower():132 continue133 sec = h134 for _ in range(5):135 sec = sec.parent136 if sec is None:137 break138 lis = sec.find_all("li")139 if lis:140 for li in lis[:20]:141 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()142 if 3 <= len(t) <= 90 and t not in feats:143 feats.append(t)144 break145 break146 return intro, alentours, feats147148 def fetch(self) -> list[Listing]:149 data = self.get(API).json()150 residences = (data.get("result") or {})151 if isinstance(residences, dict):152 residences = list(residences.values())153 out: list[Listing] = []154 for res in residences:155 url = res.get("url") or ""156 if not url:157 continue158 slug = url.rstrip("/").rsplit("/", 1)[-1]159 try:160 soup = BeautifulSoup(self.get(url).text, "html.parser")161 except Exception:162 continue163 rows = self._rows(soup)164 gallery = self._gallery(soup)165 intro, alentours, feats = self._presentation(soup)166167 excerpt = _TAG_RE.sub("", res.get("excerpt") or "").strip()168 # description : accueil de la page + extrait API + quartier —169 # sans doublon (l'extrait reprend parfois le début de l'intro)170 paras = []171 for t in (intro, excerpt, alentours):172 if t and all(t[:80] not in p for p in paras):173 paras.append(t)174 desc = "\n\n".join(paras)175176 try:177 lat, lng = float(res.get("latitude")), float(res.get("longitude"))178 except (TypeError, ValueError):179 lat = lng = None180 for row in rows:181 raw_type = row["type"]182 unit_type = normalize_unit_type(raw_type)183 details = {"Résidence pour aînés": "oui"}184 if "den" in raw_type.lower() or "+" in raw_type:185 details["Type d'unité (résidence)"] = raw_type186 if row.get("area_max") and row["area_max"] > row["area"]:187 details["Superficie"] = (f"{row['area']:.0f} à "188 f"{row['area_max']:.0f} pi²")189 eid = f"{slug}-{re.sub(r'[^a-z0-9]+', '-', raw_type.lower()).strip('-')}"190191 # galerie : vignette (façade) d'abord, puis les photos du type192 # d'unité de l'annonce (« 3.5-Salon »…), puis le reste193 images: list[str] = []194 if res.get("thumbnail"):195 images.append(res["thumbnail"])196 token = ""197 m = re.search(r"(\d)\s*[½]", raw_type)198 if m:199 token = f"{m.group(1)}.5"200 matched, rest = [], []201 for u in gallery:202 tm = _TYPE_IN_FN_RE.search(u.rsplit("/", 1)[-1])203 if token and tm and tm.group(1) == token:204 matched.append(u)205 else:206 rest.append(u)207 for u in matched + rest:208 if u not in images:209 images.append(u)210211 out.append(Listing(212 source=self.source_id,213 external_id=eid,214 url=url,215 title=f"{raw_type} — Résidence {res.get('residence', slug)}",216 address=res.get("address") or "",217 city=res.get("city") or "",218 unit_type=unit_type,219 price=row["price"],220 price_label=f"À partir de {row['price']:,.0f} $ par mois"221 .replace(",", " "),222 area_sqft=row["area"],223 description=desc,224 amenities=list(feats),225 details=details,226 images=images[: self.max_images],227 lat=lat,228 lng=lng,229 ))230 return out231