# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lelaureat.py : connecteur Résidences Le Lauréat # (residences-lelaureat.com) — 107 studios étudiants tout inclus rénovés au # 2145, rue Galt Ouest, Sherbrooke (bail Régie du logement, non-fumeur, # occupation simple). Site WordPress WPBakery d'une seule page : la section # « Dimensions des studios » publie 5 TYPES d'unités (Studio Type, Centre # Arrière, Coin Est, Coin Ouest, Loft) avec dimensions et prix par variante # (étage / demi-sous-sol). Granularité = type d'unité (aucune unité # individuelle publiée), comme sig.py. Les inclusions communes (« Notre # service tout inclus englobe ») sont reprises en commodités. 1 requête. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, strip_accents from .base import BaseConnector BASE = "https://www.residences-lelaureat.com" ADDRESS = "2145 Galt Ouest, Sherbrooke, J1K 3A8" _AMOUNT_RE = re.compile(r"\$\s?(\d{3,4})") _VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) def _slug(name: str) -> str: s = strip_accents(name.strip().lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") class LelaureatConnector(BaseConnector): source_id = "lelaureat" request_delay = 0.6 def fetch(self) -> list[Listing]: html = self.get(f"{BASE}/").text soup = BeautifulSoup(html, "html.parser") # inclusions communes : listes de la section « tout inclus » common: list[str] = [] for marker in ("Notre service tout inclus englobe", "Nos appartements sont équipés"): el = soup.find(string=re.compile(marker)) if not el: continue ul = el.parent.find_next("ul") if ul: for li in ul.select("li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip(" ►") if t and t not in common: common.append(t) # galerie du site (photos des studios) gallery: list[str] = [] for img in soup.select("img[src*='/wp-content/uploads/']"): src = _VARIANT_IMG.sub("", str(img.get("src") or "")) if src.startswith("http") and src not in gallery \ and re.search(r"room-|logement|Un-logement", src): gallery.append(src) # 5 types d'unités : titres « Studio … » / « Loft » de la section # dimensions, chacun précédé de sa dimension et suivi de sa liste # (prix par variante, particularités) listings: list[Listing] = [] seen: set[str] = set() for h in soup.find_all(["h3", "h4", "h5"]): name = re.sub(r"\s+", " ", h.get_text(" ", strip=True)).strip() if not re.match(r"^(Studio|Loft)", name) or len(name) > 40: continue sid = _slug(name) if sid in seen: continue seen.add(sid) dim_el = h.find_previous(string=re.compile(r"\d+\s*'")) dimension = re.sub(r"\s+", " ", str(dim_el)).strip() if dim_el else "" # toutes les puces entre ce titre de type et le suivant : prix par # variante (étage / demi-sous-sol) + particularités bullets: list[str] = [] for el in h.find_all_next(): if el.name in ("h3", "h4", "h5") and re.match( r"^(Studio|Loft|Façade|Galerie|Localisation)", el.get_text(" ", strip=True)): break if el.name == "li" or (el.name == "p" and "►" in el.get_text()): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) t = t.replace("►", " ").strip() t = re.sub(r"\s+", " ", t) if t and t not in bullets: bullets.append(t) # prix : « $855 /$825 » (étage) et « $810 » (demi-sous-sol) -> # le plus bas des variantes du type price_lines = [b for b in bullets if "$" in b] price_label = " / ".join(price_lines)[:80] amounts = [int(a) for a in _AMOUNT_RE.findall(" ".join(price_lines))] price = float(min(amounts)) if amounts else None # disponibilité : seulement si écrite (ex. Loft « Disponibilité à # partir de janvier 2026 ») availability = "" for b in bullets: m = re.search(r"Disponibilit[eé].*$", b) if m: availability = m.group(0).strip() break desc_parts = [f"Dimensions : {dimension}"] if dimension else [] desc_parts += [b for b in bullets if b] amenities = [f"Dimensions : {dimension}"] if dimension else [] amenities += common[:12] listings.append(Listing( source=self.source_id, external_id=sid, url=f"{BASE}/", title=f"Résidences Le Lauréat — {name} (étudiant tout inclus)", address=ADDRESS, sector="", city="Sherbrooke", unit_type="Loft" if name.lower().startswith("loft") else "Studio", price=price, price_label=price_label, availability=availability, description=" | ".join(desc_parts)[:1000], amenities=amenities, images=gallery[:12], )) return listings