Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/buildium.py : connecteur GÉNÉRIQUE Buildium (managebuilding.com)5# La longue traîne des petits gestionnaires ontariens (Sarnia, Chatham-Kent,6# Cobourg, Kingston, Ottawa, le Nord…) publie ses annonces sur le portail7# Buildium : `https://<sous-domaine>.managebuilding.com/Resident/public/rentals`.8# Contrairement à ce qu'on pouvait craindre, PAS besoin de rendu JS : Buildium9# sert deux mises en page, toutes deux exploitables en HTML brut :10# - « moderne » : cartes `a.featured-listing` rendues serveur, avec11# data-bedrooms/-bathrooms/-rent/-square-feet/-location/-type ;12# - « héritée » : redirection 302 vers PublicPages/ApartmentSearch.aspx dont13# le viewmodel DotVVM (`__dot_viewmodel_root`) embarque les annonces en14# JSON complet (city/state/postalcode, description, contact…).15# Plutôt que N modules copiés-collés, ce module lit le registre16# data/buildium_clients.json ({id, name, subdomain…}, sous-domaines découverts17# via Serper/sites vitrines et validés en live le 2026-08-27) et GÉNÈRE une18# sous-classe de BaseConnector par client (source_id = "bld_<id>"), déposée19# dans les globals du module pour le registre auto-découvrant20# (connectors/__init__.py). Même gabarit que liftsystem.py.21# Les portails sont pancanadiens/US : filtre province strict (ON/QC) sur le22# data-location (moderne) ou le champ state (héritée), et filtre résidentiel23# longue durée (exclusion parking, entreposage, commercial, court terme).24# Galerie photo de la fiche via le cache BD self.detail() (revisitée quand la25# ligne de la liste change) ; miniatures `_LxH` remises en pleine résolution.26#27# Expansion Ontario — activer via LOUKA_ONTARIO=128# (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les29# classes générées sont `disabled` et donc exclues du registre / de la prod.30# -----------------------------------------------------------------------------31from __future__ import annotations3233import hashlib34import html as htmllib35import json36import os37import re38from datetime import datetime39from pathlib import Path40from urllib.parse import urljoin4142from bs4 import BeautifulSoup4344from ..schema import Listing, normalize_unit_type45from .base import BaseConnector4647# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=148_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4950REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \51 "buildium_clients.json"5253# Provinces couvertes par Lou-Ka — les portails Buildium servent tout le54# Canada et les États-Unis (codes d'état à 2 lettres aussi) : tout le reste55# est ignoré au fetch.56_PROVINCES = {"ON", "QC"}5758# Types de propriété NON résidentiels de la mise en page moderne (data-type) —59# stationnements, entreposage et commercial sont hors sujet pour Lou-Ka.60_NON_RESIDENTIAL_TYPES = {61 "parkingspace", "parking", "storage", "commercial", "commercialother",62 "office", "retail", "industrial", "warehouse", "shoppingcenter", "land",63}6465# Filet complémentaire sur le titre/type d'unité (la mise en page héritée n'a66# pas de data-type fiable ; certains clients titrent « PARKING - … »).67_NON_RESIDENTIAL_RE = re.compile(68 r"\b(parking|stationnement|storage|entreposage|locker|garage only|"69 r"commercial|office space|retail|warehouse)\b", re.I)7071# Locations court terme / vacances (hors sujet : Lou-Ka = logements au mois)72_SHORT_TERM_RE = re.compile(73 r"\b(short[\s-]?term|nightly|per night|par nuit|vacation rental|"74 r"weekly rate|airbnb|court terme|campsites?|campground|nature camp)\b",75 re.I)7677# miniatures Buildium : « <hash>_406x539.jpeg » -> pleine résolution sans suffixe78_THUMB_SUFFIX_RE = re.compile(r"_\d{2,4}x\d{2,4}(\.[a-z]{3,4})$", re.I)7980# date de disponibilité de la mise en page héritée (« Friday, August 15, 2025 »)81_LEGACY_DATE_FMT = "%A, %B %d, %Y"828384def _load_registry() -> list[dict]:85 """Entrées validées du registre (subdomain présent, status == valide)."""86 try:87 data = json.loads(REGISTRY_PATH.read_text("utf-8"))88 except (OSError, ValueError):89 return []90 return [c for c in data.get("clients") or []91 if c.get("subdomain") and c.get("status") == "valide"]929394def _full_image(url: str) -> str:95 """Remet une miniature Buildium (…_406x539.jpeg) en pleine résolution."""96 return _THUMB_SUFFIX_RE.sub(r"\1", url.replace("&", "&")97 .replace("&isPreview=true", ""))9899100class BuildiumConnector(BaseConnector):101 """Base commune des connecteurs Buildium générés — non enregistrée102 elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de103 registre dans l'attribut de classe `client`."""104105 source_id = "" # les sous-classes générées le définissent106 client: dict = {} # entrée du registre (name, subdomain, site…)107 request_delay = 1.0 # politesse — petits gestionnaires, on y va mollo108 max_listings = 300 # garde-fou (plus gros client : Sleepwell, 130)109 max_images = 20110111 @property112 def base_url(self) -> str:113 return f"https://{self.client['subdomain']}.managebuilding.com"114115 def fetch(self) -> list[Listing]:116 resp = self.get(self.base_url + "/Resident/public/rentals")117 page = resp.text118 soup = BeautifulSoup(page, "html.parser")119120 cards = soup.select("a.featured-listing")121 if cards: # mise en page moderne122 rows = [self._listing_modern(c) for c in cards]123 elif "__dot_viewmodel_root" in page: # mise en page héritée124 rows = [self._listing_legacy(d) for d in self._legacy_rows(page)]125 else: # portail sans annonces publiques (login…) : rien à faire126 return []127128 listings: list[Listing] = []129 for lst in rows:130 if lst is None:131 continue132 if len(listings) >= self.max_listings: # garde-fou APRÈS filtres133 break134 listings.append(lst)135 return listings136137 # -- mise en page moderne : une annonce par carte a.featured-listing -------138 def _listing_modern(self, card) -> Listing | None:139 try:140 href = card.get("href") or ""141 m = re.search(r"/Resident/public/rentals/(\d+)", href)142 if not m:143 return None144 pid = m.group(1)145146 # data-location = « Windsor,ON|N9B 2N6 » — filtre province strict147 loc = card.get("data-location") or ""148 city, _, rest = loc.partition(",")149 prov, _, postal = rest.partition("|")150 prov = prov.strip().upper()151 if prov not in _PROVINCES:152 return None153 city, postal = city.strip(), postal.strip()154155 ptype = (card.get("data-type") or "").strip().lower()156 if ptype in _NON_RESIDENTIAL_TYPES:157 return None # stationnement/commerce : hors sujet158159 def txt(sel: str) -> str:160 el = card.select_one(sel)161 return el.get_text(" ", strip=True) if el else ""162163 title = txt(".featured-listing__title")164 desc_excerpt = txt(".featured-listing__description")165 if _NON_RESIDENTIAL_RE.search(f"{title} {desc_excerpt}") or \166 _SHORT_TERM_RE.search(f"{title} {desc_excerpt}"):167 return None168169 # chambres / salles de bain / loyer / pi² : attributs structurés —170 # jamais de valeur inventée (0 pi² = non renseigné chez Buildium)171 def num(attr: str) -> float | None:172 try:173 return float(card.get(attr))174 except (TypeError, ValueError):175 return None176 bedrooms = num("data-bedrooms")177 bathrooms = num("data-bathrooms")178 price = num("data-rent")179 if price is not None and price <= 0:180 price = None181 area = num("data-square-feet")182 if area is not None and not 80 <= area <= 20000:183 area = None184185 unit_type = ""186 if bedrooms is not None:187 unit_type = "Studio" if bedrooms == 0 else \188 normalize_unit_type(f"{bedrooms:g} chambres")189190 availability = txt(".featured-listing__availability")191192 # adresse civique : le titre Buildium EST la rue (+ n° d'unité)193 address = ", ".join(x for x in (title, city) if x)194 if address and prov:195 address += f", {prov} {postal}".rstrip()196197 url = urljoin(self.base_url, href)198199 # aperçu photo de la carte (miniature -> pleine résolution)200 preview = ""201 img = card.select_one("img.featured-listing__image")202 if img and img.get("src"):203 preview = _full_image(urljoin(self.base_url, img["src"]))204205 # fiche détail (description complète + galerie) via le cache BD :206 # revisitée seulement quand la carte de la liste change207 feed_key = hashlib.sha1("|".join(str(x) for x in (208 card.get("data-rent"), availability, preview,209 card.get("data-bedrooms"), desc_excerpt[:80],210 )).encode("utf-8")).hexdigest()211 d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))212 description = (d.get("description") or desc_excerpt).strip()213 images = [u for u in [preview] + list(d.get("images") or []) if u]214 images = list(dict.fromkeys(images)) # dédup en gardant l'ordre215216 return Listing(217 source=self.source_id,218 external_id=pid,219 url=url,220 title=title,221 address=address,222 city=city,223 province=prov,224 unit_type=unit_type,225 bedrooms=bedrooms,226 bathrooms=bathrooms,227 price=price,228 price_label=f"{price:.0f} $ /mois" if price else "",229 availability=availability,230 area_sqft=area,231 description=description[:1200],232 images=images[: self.max_images],233 )234 except Exception:235 return None236237 def _fetch_detail(self, url: str) -> dict:238 """Description complète + galerie de la fiche (mise en page moderne)."""239 out: dict = {"images": [], "description": ""}240 if not url:241 return out242 try:243 soup = BeautifulSoup(self.get(url).text, "html.parser")244 except Exception:245 return out246 desc = soup.select_one(".unit-detail__description")247 if desc:248 out["description"] = desc.get_text("\n", strip=True)249 gallery = soup.select_one(".unit-detail__gallery")250 if gallery:251 images: list[str] = []252 for el in gallery.find_all(("img", "a")):253 u = el.get("src") or el.get("href") or ""254 if "files/download" not in u:255 continue256 u = _full_image(urljoin(self.base_url, u))257 if u not in images:258 images.append(u)259 out["images"] = images[: self.max_images]260 return out261262 # -- mise en page héritée : annonces embarquées dans le viewmodel DotVVM ---263 def _legacy_rows(self, page: str) -> list[dict]:264 """Extrait ContentViewModel.Listings du JSON __dot_viewmodel_root."""265 marker = "__dot_viewmodel_root value='"266 i = page.find(marker)267 if i < 0:268 return []269 start = i + len(marker)270 end = page.find("'", start) # le JSON encode ses ' en '271 try:272 vm = json.loads(htmllib.unescape(page[start:end]))273 except ValueError:274 return []275 rows = (((vm.get("viewModel") or {}).get("ContentViewModel") or {})276 .get("Listings") or [])277 # chaque ligne est une liste de paires {Key, Value}278 return [{kv.get("Key"): kv.get("Value") for kv in row279 if isinstance(kv, dict)}280 for row in rows if isinstance(row, list)]281282 def _listing_legacy(self, d: dict) -> Listing | None:283 try:284 pid = str(d.get("listingid") or "").strip()285 prov = str(d.get("state") or "").strip().upper()286 if not pid or prov not in _PROVINCES:287 return None288289 title = (d.get("buildingname") or d.get("propertyname") or "").strip()290 unittype_src = (d.get("unittype") or "").strip() # « 3 Bed - 1 Bath »291 description = (d.get("unitdescription")292 or d.get("propertydescription") or "").strip()293 if _NON_RESIDENTIAL_RE.search(f"{title} {unittype_src}") or \294 _SHORT_TERM_RE.search(f"{title} {unittype_src} "295 f"{description[:400]}"):296 return None297298 city = (d.get("city") or "").strip()299 postal = (d.get("postalcode") or "").strip()300 street = (d.get("line1") or "").strip() or title301 address = ", ".join(x for x in (street, city) if x)302 if address:303 address += f", {prov} {postal}".rstrip()304305 # chambres/bains : « 3 Bed » / « 1.5 Bath » — sinon None (inconnu)306 def count(text: str) -> float | None:307 m = re.match(r"\s*(\d+(?:\.\d+)?)", str(text or ""))308 return float(m.group(1)) if m else None309 bedrooms = count(d.get("bedtypedesc"))310 bathrooms = count(d.get("bathtypedesc"))311 unit_type = ""312 if bedrooms is not None:313 unit_type = "Studio" if bedrooms == 0 else \314 normalize_unit_type(f"{bedrooms:g} chambres")315316 try:317 price = float(d.get("listingrent") or 0) or None318 except (TypeError, ValueError):319 price = None320 try:321 area = float(d.get("squarefootage") or 0)322 area = area if 80 <= area <= 20000 else None323 except (TypeError, ValueError):324 area = None325326 # disponibilité : format anglais long et fixe du viewmodel327 availability = str(d.get("availabilitydate") or "").strip()328 avail_date = None329 try:330 avail_date = datetime.strptime(331 availability, _LEGACY_DATE_FMT).date().isoformat()332 except ValueError:333 pass334335 details: dict = {}336 contact = {k: str(d.get(f"contact{k}") or "").strip()337 for k in ("phone", "email")}338 contact = {k: v for k, v in contact.items() if v}339 if contact:340 details["contact"] = contact341342 url = (f"{self.base_url}/Resident/PublicPages/apartmentdetail.aspx"343 f"?listingId={pid}&unitid={d.get('unitid') or ''}"344 f"&buildingid={d.get('buildingid') or ''}")345346 images = []347 img = str(d.get("imageUrl") or "").strip()348 if img and "files/download" in img:349 images.append(_full_image(urljoin(self.base_url, img)))350351 return Listing(352 source=self.source_id,353 external_id=pid,354 url=url,355 title=title,356 address=address,357 city=city,358 province=prov,359 unit_type=unit_type,360 bedrooms=bedrooms,361 bathrooms=bathrooms,362 price=price,363 price_label=f"{price:.0f} $ /mois" if price else "",364 availability=availability,365 availability_date=avail_date,366 area_sqft=area,367 description=description[:1200],368 details=details,369 images=images[: self.max_images],370 )371 except Exception:372 return None373374375# -----------------------------------------------------------------------------376# Génération : une sous-classe par client du registre, déposée dans les globals377# du module — connectors/__init__.py (scan de vars(module)) les découvre alors378# comme n'importe quel connecteur écrit à la main.379# -----------------------------------------------------------------------------380def _make_connector(entry: dict) -> type[BuildiumConnector]:381 cls = type(382 f"Bld{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",383 (BuildiumConnector,),384 {385 "source_id": f"bld_{entry['id']}",386 "client": entry,387 # Expansion Ontario — activer via LOUKA_ONTARIO=1388 # (voir gestion-immobiliere-ontario.md)389 "disabled": not _ONTARIO,390 "__doc__": f"Connecteur Buildium généré — {entry.get('name')} "391 f"({entry.get('subdomain')}.managebuilding.com).",392 },393 )394 return cls395396397def _register_all() -> None:398 for entry in _load_registry():399 cls = _make_connector(entry)400 globals()[cls.__name__] = cls401402403_register_all()404