# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/labonte.py : connecteur Gestion Labonté (gestionlabonte.com) # Gestionnaire établi de Sherbrooke (66 ans). Site Wix : la page /logements # est rendue serveur — répéteur Wix dont chaque item # (div id="comp-…__") porte le prix (« À partir de N $/mois »), # l'adresse (h2), le nombre de pièces, le secteur, la photo et le lien vers # la page dynamique /logements/ (le GUID du CMS Wix = external_id # stable). Les fiches détail (via self.detail, cache BD) ajoutent la # description, les paires « Détails du logement » (secteur, pièces, # chambres, étage) et l'adresse complète du bloc « Emplacement ». La galerie # photos des fiches est chargée côté client (pro-gallery, non SSR) : seule # la photo de la carte est disponible. Aucun statut « loué » affiché — la # liste ne montre que l'inventaire courant. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.gestionlabonte.com" LIST_URL = f"{BASE}/logements" _GUID_RE = re.compile(r"/logements/([0-9a-f-]{36})") # villes possibles dans l'adresse « Emplacement » (jamais devinées) _CITY_RE = re.compile(r"\b(Sherbrooke|Magog|East Angus|Lennoxville)\b", re.I) def _clean_unit_type(raw: str) -> str: """normalize_unit_type, mais seulement si le motif est net (« 3.5 », « 4,5 », « studio »…) — jamais le texte brut en guise de type.""" ut = normalize_unit_type(raw) if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): return ut return "" def _lines(el) -> list[str]: """Lignes de texte nettoyées d'un sous-arbre.""" out = [] for raw in el.get_text("\n", strip=True).split("\n"): t = re.sub(r"\s+", " ", raw).strip() if t: out.append(t) return out class LabonteConnector(BaseConnector): source_id = "labonte" request_delay = 0.7 max_details = 25 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} # items du répéteur : conteneurs suffixés par le GUID de l'item CMS for item in soup.select('div[id*="__"]'): link = item.select_one('a[href*="/logements/"]') if not link: continue m = _GUID_RE.search(link.get("href") or "") if not m: continue guid = m.group(1) iid = (item.get("id") or "") if not iid.endswith(f"__{guid}") or guid in listings: continue # garder le conteneur d'item complet, une fois lines = _lines(item) # « À partir de | 1150 | $/mois | 40 rue St-François | # Nombre de pièces | 3.5 | Secteur | Centre-ville | En savoir plus » h2 = item.select_one("h2") address = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if h2 else "" price = None price_label = "" for i, t in enumerate(lines): if t.lower().startswith("à partir de") and i + 1 < len(lines): amount = lines[i + 1] unit = lines[i + 2] if i + 2 < len(lines) else "" price_label = f"À partir de {amount} {unit}".strip() price = parse_price(f"{amount} $") break pieces = sector = "" for i, t in enumerate(lines): if t.lower().startswith("nombre de pièces") and i + 1 < len(lines): pieces = lines[i + 1] elif t.lower() == "secteur" and i + 1 < len(lines): sector = lines[i + 1] img = item.select_one('img[src*="static.wixstatic.com/media"]') images = [] if img: images.append(img["src"].split("/v1/")[0]) listings[guid] = Listing( source=self.source_id, external_id=guid, url=f"{BASE}/logements/{guid}", title=address or f"Logement {guid[:8]}", address=address, sector=sector, city="Sherbrooke", unit_type=_clean_unit_type(pieces), price=price, price_label=price_label, availability="", # non publié par la source images=images, ) # fiches détail (cache BD) : description, chambres/étage, adresse complète self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.sector}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- fiche détail (page dynamique Wix) ------------------------------------------- def _fetch_detail(self, url: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") lines = _lines(soup.body or soup) out: dict = {} # description : entre « Description » et « Détails du logement » try: i = lines.index("Description") j = lines.index("Détails du logement") if 0 <= i < j: out["description"] = " ".join(lines[i + 1:j])[:1500] except ValueError: pass # paires « Détails du logement » : Secteur / pièces / chambres / Étage for i, t in enumerate(lines): low = t.lower() if i + 1 >= len(lines): break if low == "secteur": out["sector"] = lines[i + 1] elif low.startswith("nombre de pièces"): out["pieces"] = lines[i + 1] elif low.startswith("nombre de chambres"): out["bedrooms"] = lines[i + 1] elif low == "étage": out["floor"] = lines[i + 1] elif low == "emplacement": out["full_address"] = lines[i + 1] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("description"): lst.description = d["description"] if d.get("sector") and not lst.sector: lst.sector = d["sector"] if d.get("full_address"): full = d["full_address"] # « 49 Rue King Ouest, Sherbrooke, QC J1H 1P1, Canada » lst.address = ", ".join(p.strip() for p in full.split(",")[:2]) m = _CITY_RE.search(full) if m: lst.city = m.group(1).title() if not lst.unit_type and d.get("pieces"): lst.unit_type = _clean_unit_type(d["pieces"]) details: dict = {} if str(d.get("bedrooms") or "").isdigit(): details["bedrooms"] = int(d["bedrooms"]) if str(d.get("floor") or "").isdigit(): details["floor"] = int(d["floor"]) if details: lst.details = {**lst.details, **details}