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/lelaureat.py : connecteur Résidences Le Lauréat5# (residences-lelaureat.com) — 107 studios étudiants tout inclus rénovés au6# 2145, rue Galt Ouest, Sherbrooke (bail Régie du logement, non-fumeur,7# occupation simple). Site WordPress WPBakery d'une seule page : la section8# « Dimensions des studios » publie 5 TYPES d'unités (Studio Type, Centre9# Arrière, Coin Est, Coin Ouest, Loft) avec dimensions et prix par variante10# (étage / demi-sous-sol). Granularité = type d'unité (aucune unité11# individuelle publiée), comme sig.py. Les inclusions communes (« Notre12# service tout inclus englobe ») sont reprises en commodités. 1 requête.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, strip_accents21from .base import BaseConnector2223BASE = "https://www.residences-lelaureat.com"24ADDRESS = "2145 Galt Ouest, Sherbrooke, J1K 3A8"2526_AMOUNT_RE = re.compile(r"\$\s?(\d{3,4})")27_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)282930def _slug(name: str) -> str:31 s = strip_accents(name.strip().lower())32 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")333435class LelaureatConnector(BaseConnector):36 source_id = "lelaureat"37 request_delay = 0.63839 def fetch(self) -> list[Listing]:40 html = self.get(f"{BASE}/").text41 soup = BeautifulSoup(html, "html.parser")4243 # inclusions communes : listes de la section « tout inclus »44 common: list[str] = []45 for marker in ("Notre service tout inclus englobe",46 "Nos appartements sont équipés"):47 el = soup.find(string=re.compile(marker))48 if not el:49 continue50 ul = el.parent.find_next("ul")51 if ul:52 for li in ul.select("li"):53 t = re.sub(r"\s+", " ",54 li.get_text(" ", strip=True)).strip(" ►")55 if t and t not in common:56 common.append(t)5758 # galerie du site (photos des studios)59 gallery: list[str] = []60 for img in soup.select("img[src*='/wp-content/uploads/']"):61 src = _VARIANT_IMG.sub("", str(img.get("src") or ""))62 if src.startswith("http") and src not in gallery \63 and re.search(r"room-|logement|Un-logement", src):64 gallery.append(src)6566 # 5 types d'unités : titres « Studio … » / « Loft » de la section67 # dimensions, chacun précédé de sa dimension et suivi de sa liste68 # (prix par variante, particularités)69 listings: list[Listing] = []70 seen: set[str] = set()71 for h in soup.find_all(["h3", "h4", "h5"]):72 name = re.sub(r"\s+", " ", h.get_text(" ", strip=True)).strip()73 if not re.match(r"^(Studio|Loft)", name) or len(name) > 40:74 continue75 sid = _slug(name)76 if sid in seen:77 continue78 seen.add(sid)7980 dim_el = h.find_previous(string=re.compile(r"\d+\s*'"))81 dimension = re.sub(r"\s+", " ", str(dim_el)).strip() if dim_el else ""8283 # toutes les puces entre ce titre de type et le suivant : prix par84 # variante (étage / demi-sous-sol) + particularités85 bullets: list[str] = []86 for el in h.find_all_next():87 if el.name in ("h3", "h4", "h5") and re.match(88 r"^(Studio|Loft|Façade|Galerie|Localisation)",89 el.get_text(" ", strip=True)):90 break91 if el.name == "li" or (el.name == "p" and92 "►" in el.get_text()):93 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))94 t = t.replace("►", " ").strip()95 t = re.sub(r"\s+", " ", t)96 if t and t not in bullets:97 bullets.append(t)9899 # prix : « $855 /$825 » (étage) et « $810 » (demi-sous-sol) ->100 # le plus bas des variantes du type101 price_lines = [b for b in bullets if "$" in b]102 price_label = " / ".join(price_lines)[:80]103 amounts = [int(a) for a in _AMOUNT_RE.findall(" ".join(price_lines))]104 price = float(min(amounts)) if amounts else None105106 # disponibilité : seulement si écrite (ex. Loft « Disponibilité à107 # partir de janvier 2026 »)108 availability = ""109 for b in bullets:110 m = re.search(r"Disponibilit[eé].*$", b)111 if m:112 availability = m.group(0).strip()113 break114115 desc_parts = [f"Dimensions : {dimension}"] if dimension else []116 desc_parts += [b for b in bullets if b]117 amenities = [f"Dimensions : {dimension}"] if dimension else []118 amenities += common[:12]119120 listings.append(Listing(121 source=self.source_id,122 external_id=sid,123 url=f"{BASE}/",124 title=f"Résidences Le Lauréat — {name} (étudiant tout inclus)",125 address=ADDRESS,126 sector="",127 city="Sherbrooke",128 unit_type="Loft" if name.lower().startswith("loft") else "Studio",129 price=price,130 price_label=price_label,131 availability=availability,132 description=" | ".join(desc_parts)[:1000],133 amenities=amenities,134 images=gallery[:12],135 ))136 return listings137