# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lr_gestion.py : connecteur LR Gestion immobilière / LR Location # (lrgestion.com ; portail d'annonces lrlocation.ca) — plus gros acteur # multi-secteurs de l'Outaouais : Gatineau, Hull, Aylmer, Buckingham, # Cantley, Thurso, Perkins (Val-des-Monts)… Portail RentSync rendu serveur, # même plateforme que Girescom (girescom.py). Liste /fr/residential : une # carte par immeuble (adresse avec ville, prix « Louer de », chambres). # Page immeuble /fr/residential/ : tableau des unités disponibles # (li.unit-table-desktop__units--unit, colonnes column-field : numéro, # salles de bain, prix, pi², photos, disponibilité — pas de colonne # chambres sur ce portail), description « À propos de la propriété », # texte « Quartier », téléphone du bureau de location et galerie. # Une annonce Lou-Ka = une unité. Les immeubles d'Ottawa (Ontario) sont # exclus ; Hull/Aylmer/Buckingham/Masson-Angers sont des secteurs de # Gatineau (fusion 2002) -> sector = secteur, city = Gatineau. # Fiches immeuble via self.detail() (cache BD, max max_details/sync). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.lrlocation.ca" LIST_URL = f"{BASE}/fr/residential" # secteurs de la ville de Gatineau (fusion municipale de 2002) _GATINEAU_SECTORS = {"hull", "aylmer", "buckingham", "masson-angers", "masson"} # hors Québec : parc ontarien du gestionnaire, exclu du périmètre Lou-Ka _EXCLUDED_CITIES = {"ottawa"} def _city_key(city: str) -> str: return re.sub(r"[\s']+", "-", strip_accents(city.strip().lower())) class LRGestionConnector(BaseConnector): source_id = "lr_gestion" request_delay = 0.6 max_details = 60 # garde-fou pages immeuble (~48 immeubles QC au 2026-08) def fetch(self) -> list[Listing]: # le serveur RentSync n'annonce pas charset=utf-8 : forcer le décodage resp = self.get(LIST_URL) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") self._fetched = 0 listings: list[Listing] = [] for card in soup.select("ul.card-grouping-grid li.card-grouping-grid__item"): try: listings.extend(self._parse_building(card)) except Exception: continue return listings # -- carte immeuble (liste /fr/residential) ----------------------------------- def _parse_building(self, card) -> list[Listing]: link = card.select_one('a[href*="/residential/"]') if not link: return [] slug = link["href"].rstrip("/").rsplit("/", 1)[-1] url = f"{BASE}/fr/residential/{slug}" title_el = card.select_one(".card-property__title") title = title_el.get_text(strip=True) if title_el else slug addr_el = card.select_one(".card-property__address--text") address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)) \ if addr_el else "" # « 1072 Boul. Greber, Gatineau, QC » -> ville = avant-dernier segment parts = [p.strip() for p in address.split(",") if p.strip()] city = parts[-2] if len(parts) >= 2 else "" key = _city_key(city) if key in _EXCLUDED_CITIES: return [] # Ottawa (Ontario) : hors périmètre sector = "" if key in _GATINEAU_SECTORS: sector, city = city, "Gatineau" # secteur de la ville fusionnée rate_el = card.select_one(".card-property__rate-min") beds_el = card.select_one(".card-property__beds-content") card_sig = (f"{address}|{rate_el.get_text(strip=True) if rate_el else ''}" f"|{beds_el.get_text(strip=True) if beds_el else ''}") # page immeuble (cache BD, invalidée quand la carte liste change) cache_key = hashlib.sha1(card_sig.encode("utf-8")).hexdigest() def fetch_fn(): if self._fetched >= self.max_details: raise RuntimeError("budget de pages immeuble atteint") self._fetched += 1 return self._fetch_building(url) payload = self.detail(slug, cache_key, fetch_fn) if not payload: return [] building_addr = payload.get("address") or address details_base: dict = {"building": title} if payload.get("phone"): details_base["contact"] = {"phone": payload["phone"]} out: list[Listing] = [] for u in payload.get("units", []): numero = u.get("unit") or "" if not numero: continue # pas de colonne chambres par unité sur ce portail RentSync : # seule la salle de bain est structurée (texte source conservé) amenities: list[str] = [] if u.get("bath"): amenities.append(u["bath"]) area = None sqft = re.sub(r"[\s, ]", "", u.get("sqft") or "") if re.fullmatch(r"\d{2,5}", sqft) and 80 <= float(sqft) <= 20000: area = float(sqft) out.append(Listing( source=self.source_id, external_id=f"{slug}--{numero}", url=url, title=f"{title} — unité {numero}", address=building_addr, sector=sector, city=city, unit_type="", # chambres non publiées par unité price=parse_price(u.get("rate", "")), price_label=u.get("rate", ""), availability=u.get("availability", ""), area_sqft=area, description=payload.get("description", ""), amenities=amenities, details=dict(details_base), images=(u.get("photos") or payload.get("gallery") or [])[:20], )) return out # -- page immeuble -------------------------------------------------------------- def _fetch_building(self, url: str) -> dict: """Payload JSON-sérialisable : unités disponibles + infos immeuble.""" resp = self.get(url) resp.encoding = "utf-8" # charset absent des en-têtes html = resp.text soup = BeautifulSoup(html, "html.parser") payload: dict = {} # adresse de la bannière (identique à la liste, sans code postal) addr = soup.select_one(".banner-property__address--text") if addr: payload["address"] = re.sub(r"\s+", " ", addr.get_text(" ", strip=True)) # description « À propos de la propriété » + texte « Quartier » blocs: list[str] = [] about = soup.select_one(".property-about__content") if about: blocs.append(re.sub(r"\s+", " ", about.get_text(" ", strip=True))) quartier = soup.select_one(".property-neighbourhood__content") if quartier: blocs.append("Quartier : " + re.sub( r"\s+", " ", quartier.get_text(" ", strip=True))) payload["description"] = " | ".join(b for b in blocs if b)[:1500] # téléphone du bureau de location (liens tel:) for a in soup.select('a[href^="tel:"]'): digits = re.sub(r"\D", "", a["href"]) if len(digits) >= 10: d = digits[-10:] payload["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}" break # galerie de l'immeuble (S3 RentSync) : variante /512/, dédupliquée gallery: list[str] = [] seen: set[str] = set() for u in re.findall( r"https://s3\.amazonaws\.com/lws_lift/gestion_lr/images/" r"gallery/512/[^\"'\s]+", html): fname = u.rsplit("/", 1)[-1] if fname not in seen: seen.add(fname) gallery.append(u) payload["gallery"] = gallery[:20] # tableau des unités disponibles (colonnes structurées column-field) units: list[dict] = [] for row in soup.select("li.unit-table-desktop__units--unit"): cols = {li.get("column-field"): li for li in row.select("li[column-field]")} def txt(field: str) -> str: el = cols.get(field) return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else "" unit = { "unit": txt("typeName"), "bath": txt("bath"), "rate": txt("rate"), "sqft": txt("sqFt"), "availability": txt("availability"), } photos_el = cols.get("photos") if photos_el: unit["photos"] = list(dict.fromkeys( a["href"] for a in photos_el.select("a[href^='http']"))) if unit["unit"] and unit["unit"] != "-": # colonnes « - » (non publiées) -> chaîne vide, jamais inventé for k, v in list(unit.items()): if v == "-": unit[k] = "" units.append(unit) payload["units"] = units return payload