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/lr_gestion.py : connecteur LR Gestion immobilière / LR Location5# (lrgestion.com ; portail d'annonces lrlocation.ca) — plus gros acteur6# multi-secteurs de l'Outaouais : Gatineau, Hull, Aylmer, Buckingham,7# Cantley, Thurso, Perkins (Val-des-Monts)… Portail RentSync rendu serveur,8# même plateforme que Girescom (girescom.py). Liste /fr/residential : une9# carte par immeuble (adresse avec ville, prix « Louer de », chambres).10# Page immeuble /fr/residential/<slug> : tableau des unités disponibles11# (li.unit-table-desktop__units--unit, colonnes column-field : numéro,12# salles de bain, prix, pi², photos, disponibilité — pas de colonne13# chambres sur ce portail), description « À propos de la propriété »,14# texte « Quartier », téléphone du bureau de location et galerie.15# Une annonce Lou-Ka = une unité. Les immeubles d'Ottawa (Ontario) sont16# exclus ; Hull/Aylmer/Buckingham/Masson-Angers sont des secteurs de17# Gatineau (fusion 2002) -> sector = secteur, city = Gatineau.18# Fiches immeuble via self.detail() (cache BD, max max_details/sync).19# -----------------------------------------------------------------------------20from __future__ import annotations2122import hashlib23import re2425from bs4 import BeautifulSoup2627from ..schema import Listing, parse_price, strip_accents28from .base import BaseConnector2930BASE = "https://www.lrlocation.ca"31LIST_URL = f"{BASE}/fr/residential"3233# secteurs de la ville de Gatineau (fusion municipale de 2002)34_GATINEAU_SECTORS = {"hull", "aylmer", "buckingham", "masson-angers", "masson"}35# hors Québec : parc ontarien du gestionnaire, exclu du périmètre Lou-Ka36_EXCLUDED_CITIES = {"ottawa"}373839def _city_key(city: str) -> str:40 return re.sub(r"[\s']+", "-", strip_accents(city.strip().lower()))414243class LRGestionConnector(BaseConnector):44 source_id = "lr_gestion"45 request_delay = 0.646 max_details = 60 # garde-fou pages immeuble (~48 immeubles QC au 2026-08)4748 def fetch(self) -> list[Listing]:49 # le serveur RentSync n'annonce pas charset=utf-8 : forcer le décodage50 resp = self.get(LIST_URL)51 resp.encoding = "utf-8"52 soup = BeautifulSoup(resp.text, "html.parser")5354 self._fetched = 055 listings: list[Listing] = []56 for card in soup.select("ul.card-grouping-grid li.card-grouping-grid__item"):57 try:58 listings.extend(self._parse_building(card))59 except Exception:60 continue61 return listings6263 # -- carte immeuble (liste /fr/residential) -----------------------------------64 def _parse_building(self, card) -> list[Listing]:65 link = card.select_one('a[href*="/residential/"]')66 if not link:67 return []68 slug = link["href"].rstrip("/").rsplit("/", 1)[-1]69 url = f"{BASE}/fr/residential/{slug}"7071 title_el = card.select_one(".card-property__title")72 title = title_el.get_text(strip=True) if title_el else slug73 addr_el = card.select_one(".card-property__address--text")74 address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)) \75 if addr_el else ""7677 # « 1072 Boul. Greber, Gatineau, QC » -> ville = avant-dernier segment78 parts = [p.strip() for p in address.split(",") if p.strip()]79 city = parts[-2] if len(parts) >= 2 else ""80 key = _city_key(city)81 if key in _EXCLUDED_CITIES:82 return [] # Ottawa (Ontario) : hors périmètre83 sector = ""84 if key in _GATINEAU_SECTORS:85 sector, city = city, "Gatineau" # secteur de la ville fusionnée8687 rate_el = card.select_one(".card-property__rate-min")88 beds_el = card.select_one(".card-property__beds-content")89 card_sig = (f"{address}|{rate_el.get_text(strip=True) if rate_el else ''}"90 f"|{beds_el.get_text(strip=True) if beds_el else ''}")9192 # page immeuble (cache BD, invalidée quand la carte liste change)93 cache_key = hashlib.sha1(card_sig.encode("utf-8")).hexdigest()9495 def fetch_fn():96 if self._fetched >= self.max_details:97 raise RuntimeError("budget de pages immeuble atteint")98 self._fetched += 199 return self._fetch_building(url)100101 payload = self.detail(slug, cache_key, fetch_fn)102 if not payload:103 return []104105 building_addr = payload.get("address") or address106 details_base: dict = {"building": title}107 if payload.get("phone"):108 details_base["contact"] = {"phone": payload["phone"]}109110 out: list[Listing] = []111 for u in payload.get("units", []):112 numero = u.get("unit") or ""113 if not numero:114 continue115 # pas de colonne chambres par unité sur ce portail RentSync :116 # seule la salle de bain est structurée (texte source conservé)117 amenities: list[str] = []118 if u.get("bath"):119 amenities.append(u["bath"])120121 area = None122 sqft = re.sub(r"[\s, ]", "", u.get("sqft") or "")123 if re.fullmatch(r"\d{2,5}", sqft) and 80 <= float(sqft) <= 20000:124 area = float(sqft)125126 out.append(Listing(127 source=self.source_id,128 external_id=f"{slug}--{numero}",129 url=url,130 title=f"{title} — unité {numero}",131 address=building_addr,132 sector=sector,133 city=city,134 unit_type="", # chambres non publiées par unité135 price=parse_price(u.get("rate", "")),136 price_label=u.get("rate", ""),137 availability=u.get("availability", ""),138 area_sqft=area,139 description=payload.get("description", ""),140 amenities=amenities,141 details=dict(details_base),142 images=(u.get("photos") or payload.get("gallery") or [])[:20],143 ))144 return out145146 # -- page immeuble --------------------------------------------------------------147 def _fetch_building(self, url: str) -> dict:148 """Payload JSON-sérialisable : unités disponibles + infos immeuble."""149 resp = self.get(url)150 resp.encoding = "utf-8" # charset absent des en-têtes151 html = resp.text152 soup = BeautifulSoup(html, "html.parser")153 payload: dict = {}154155 # adresse de la bannière (identique à la liste, sans code postal)156 addr = soup.select_one(".banner-property__address--text")157 if addr:158 payload["address"] = re.sub(r"\s+", " ",159 addr.get_text(" ", strip=True))160161 # description « À propos de la propriété » + texte « Quartier »162 blocs: list[str] = []163 about = soup.select_one(".property-about__content")164 if about:165 blocs.append(re.sub(r"\s+", " ", about.get_text(" ", strip=True)))166 quartier = soup.select_one(".property-neighbourhood__content")167 if quartier:168 blocs.append("Quartier : " + re.sub(169 r"\s+", " ", quartier.get_text(" ", strip=True)))170 payload["description"] = " | ".join(b for b in blocs if b)[:1500]171172 # téléphone du bureau de location (liens tel:)173 for a in soup.select('a[href^="tel:"]'):174 digits = re.sub(r"\D", "", a["href"])175 if len(digits) >= 10:176 d = digits[-10:]177 payload["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}"178 break179180 # galerie de l'immeuble (S3 RentSync) : variante /512/, dédupliquée181 gallery: list[str] = []182 seen: set[str] = set()183 for u in re.findall(184 r"https://s3\.amazonaws\.com/lws_lift/gestion_lr/images/"185 r"gallery/512/[^\"'\s]+", html):186 fname = u.rsplit("/", 1)[-1]187 if fname not in seen:188 seen.add(fname)189 gallery.append(u)190 payload["gallery"] = gallery[:20]191192 # tableau des unités disponibles (colonnes structurées column-field)193 units: list[dict] = []194 for row in soup.select("li.unit-table-desktop__units--unit"):195 cols = {li.get("column-field"): li196 for li in row.select("li[column-field]")}197198 def txt(field: str) -> str:199 el = cols.get(field)200 return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else ""201202 unit = {203 "unit": txt("typeName"),204 "bath": txt("bath"),205 "rate": txt("rate"),206 "sqft": txt("sqFt"),207 "availability": txt("availability"),208 }209 photos_el = cols.get("photos")210 if photos_el:211 unit["photos"] = list(dict.fromkeys(212 a["href"] for a in photos_el.select("a[href^='http']")))213 if unit["unit"] and unit["unit"] != "-":214 # colonnes « - » (non publiées) -> chaîne vide, jamais inventé215 for k, v in list(unit.items()):216 if v == "-":217 unit[k] = ""218 units.append(unit)219 payload["units"] = units220 return payload221