# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/groupe_maurice.py : Le Groupe Maurice — 37 résidences pour aînés # (RPA) au Québec (Montréal, Laval, Rive-Sud/Nord, Québec, Gatineau…). # WordPress avec API REST maison : /wp-json/api/v1/residences retourne les # 37 résidences géolocalisées (adresse, lat/lng, ville, url, vignette). # Chaque page résidence (SSR) expose la grille des types d'unités dans des #
: « Studio | 408 pi.ca à 628 pi.ca | # À partir de 2 286 $ par mois ». Une annonce Lou-Ka par type × résidence, # prix plancher « à partir de », étiquetée Résidence pour aînés. # La même page (déjà téléchargée) fournit aussi la galerie de la résidence # (photos des appartements et des aires communes — carousel360 + # gallery--large + alentours), le texte de présentation et la liste des # caractéristiques des unités (« Le confort à votre mesure ») : on les # applique à chaque annonce sans aucune requête supplémentaire. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector API = "https://www.legroupemaurice.com/wp-json/api/v1/residences" _ROW_RE = re.compile( r"^(?P.+?)\s+(?P[\d\s]+)\s*pi\.?\s*ca(?:\s*à\s*(?P[\d\s]+)\s*pi\.?\s*ca)?" r"\s*À partir de\s*(?P[\d\s]+)\s*\$\s*par mois", re.S) _TAG_RE = re.compile(r"<[^>]+>") _IMG_EXT_RE = re.compile(r"\.(?:jpe?g|webp|png)$", re.I) # visuels génériques partagés entre résidences (le carrousel « Les # appartements » mélange des photos SEV/LJM/STH… d'autres résidences) et UI _IMG_SKIP_RE = re.compile(r"(?:Carrousel_Appartements|Bloc_Votre_Appartement|" r"logo|icon|favicon|picto)", re.I) _SIZE_SUFFIX_RE = re.compile(r"-\d+x\d+(?=\.)") # « …_3.5-Salon-… » dans le nom de fichier : photo d'un type d'unité précis, # priorisée pour l'annonce du type correspondant _TYPE_IN_FN_RE = re.compile(r"[_-](\d\.\d)-") class GroupeMauriceConnector(BaseConnector): source_id = "groupe_maurice" request_delay = 0.6 max_images = 30 def _rows(self, soup: BeautifulSoup) -> list[dict]: out = [] for art in soup.find_all("article"): if "y-bordered" not in (art.get("class") or []): continue text = re.sub(r"\s+", " ", art.get_text(" ", strip=True)) m = _ROW_RE.match(text) if not m: continue try: price = float(m.group("prix").replace(" ", "")) area = float(m.group("a1").replace(" ", "")) except ValueError: continue area_max = None if m.group("a2"): try: area_max = float(m.group("a2").replace(" ", "")) except ValueError: area_max = None out.append({"type": m.group("type").strip(), "price": price, "area": area, "area_max": area_max}) return out # -- galerie de la résidence ------------------------------------------------- def _gallery(self, soup: BeautifulSoup) -> list[str]: """Photos de la résidence, dans l'ordre des carrousels de la page : visite « carousel360 » (pièces des appartements types + communs), galerie des aires communes, puis les alentours. Les visuels génériques inter-résidences et les variantes redimensionnées sont écartés (dédoublonnage par nom de fichier).""" imgs: list[str] = [] seen: set[str] = set() for sel in ("div.carousel360 img", "div.gallery.gallery--large img", "div.carousel-residence__carousel img"): for im in soup.select(sel): src = (im.get("src") or im.get("data-src") or "").split("?")[0] fn = src.rsplit("/", 1)[-1] if not src.startswith("http") or not _IMG_EXT_RE.search(fn): continue if _IMG_SKIP_RE.search(fn): continue key = _SIZE_SUFFIX_RE.sub("", fn).lower() if key in seen: continue seen.add(key) imgs.append(src) return imgs # -- présentation + caractéristiques des unités -------------------------------- @staticmethod def _presentation(soup: BeautifulSoup) -> tuple[str, str, list[str]]: """(intro, alentours, caractéristiques des unités) de la page. Intro = premier paragraphe long de la page (texte d'accueil de la résidence) ; alentours = paragraphe sous « Aux alentours de la résidence » ; caractéristiques = liste « Le confort à votre mesure » (cuisine, balcon, insonorisation…).""" intro = alentours = "" for p in soup.find_all("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip() if len(t) >= 120: intro = t break for h in soup.find_all("h2"): if "alentours" not in h.get_text(" ", strip=True).lower(): continue sec = h for _ in range(5): sec = sec.parent if sec is None: break for p in sec.find_all("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip() if len(t) >= 120: alentours = t break if alentours: break break feats: list[str] = [] for h in soup.find_all("h2"): if "confort à votre mesure" not in h.get_text(" ", strip=True).lower(): continue sec = h for _ in range(5): sec = sec.parent if sec is None: break lis = sec.find_all("li") if lis: for li in lis[:20]: t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip() if 3 <= len(t) <= 90 and t not in feats: feats.append(t) break break return intro, alentours, feats def fetch(self) -> list[Listing]: data = self.get(API).json() residences = (data.get("result") or {}) if isinstance(residences, dict): residences = list(residences.values()) out: list[Listing] = [] for res in residences: url = res.get("url") or "" if not url: continue slug = url.rstrip("/").rsplit("/", 1)[-1] try: soup = BeautifulSoup(self.get(url).text, "html.parser") except Exception: continue rows = self._rows(soup) gallery = self._gallery(soup) intro, alentours, feats = self._presentation(soup) excerpt = _TAG_RE.sub("", res.get("excerpt") or "").strip() # description : accueil de la page + extrait API + quartier — # sans doublon (l'extrait reprend parfois le début de l'intro) paras = [] for t in (intro, excerpt, alentours): if t and all(t[:80] not in p for p in paras): paras.append(t) desc = "\n\n".join(paras) try: lat, lng = float(res.get("latitude")), float(res.get("longitude")) except (TypeError, ValueError): lat = lng = None for row in rows: raw_type = row["type"] unit_type = normalize_unit_type(raw_type) details = {"Résidence pour aînés": "oui"} if "den" in raw_type.lower() or "+" in raw_type: details["Type d'unité (résidence)"] = raw_type if row.get("area_max") and row["area_max"] > row["area"]: details["Superficie"] = (f"{row['area']:.0f} à " f"{row['area_max']:.0f} pi²") eid = f"{slug}-{re.sub(r'[^a-z0-9]+', '-', raw_type.lower()).strip('-')}" # galerie : vignette (façade) d'abord, puis les photos du type # d'unité de l'annonce (« 3.5-Salon »…), puis le reste images: list[str] = [] if res.get("thumbnail"): images.append(res["thumbnail"]) token = "" m = re.search(r"(\d)\s*[½]", raw_type) if m: token = f"{m.group(1)}.5" matched, rest = [], [] for u in gallery: tm = _TYPE_IN_FN_RE.search(u.rsplit("/", 1)[-1]) if token and tm and tm.group(1) == token: matched.append(u) else: rest.append(u) for u in matched + rest: if u not in images: images.append(u) out.append(Listing( source=self.source_id, external_id=eid, url=url, title=f"{raw_type} — Résidence {res.get('residence', slug)}", address=res.get("address") or "", city=res.get("city") or "", unit_type=unit_type, price=row["price"], price_label=f"À partir de {row['price']:,.0f} $ par mois" .replace(",", " "), area_sqft=row["area"], description=desc, amenities=list(feats), details=details, images=images[: self.max_images], lat=lat, lng=lng, )) return out