Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: 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# (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les28# classes générées sont `disabled` et donc exclues du registre / de la prod.29# -----------------------------------------------------------------------------30from __future__ import annotations3132import hashlib33import html as htmllib34import json35import os36import re37from datetime import datetime38from pathlib import Path39from urllib.parse import urljoin4041from bs4 import BeautifulSoup4243from ..schema import Listing, normalize_unit_type44from .base import BaseConnector4546# Rent-Ka: connectors always active.47_ONTARIO = True # Rent-Ka: always on (ROC scope)4849REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \50 "buildium_clients.json"5152# Provinces couvertes par Rent-Ka — les portails Buildium servent tout le53# Canada et les États-Unis (codes d'état à 2 lettres aussi) : tout le reste54# est ignoré au fetch.55# Rent-Ka scope: every Canadian province/territory EXCEPT Québec.56_PROVINCES = {"ON", "BC", "AB", "SK", "MB", "NB", "NS", "PE", "NL",57 "YT", "NT", "NU"}5859# Types de propriété NON résidentiels de la mise en page moderne (data-type) —60# stationnements, entreposage et commercial sont hors sujet pour Rent-Ka.61_NON_RESIDENTIAL_TYPES = {62 "parkingspace", "parking", "storage", "commercial", "commercialother",63 "office", "retail", "industrial", "warehouse", "shoppingcenter", "land",64}6566# Filet complémentaire sur le titre/type d'unité (la mise en page héritée n'a67# pas de data-type fiable ; certains clients titrent « PARKING - … »).68_NON_RESIDENTIAL_RE = re.compile(69 r"\b(parking|stationnement|storage|entreposage|locker|garage only|"70 r"commercial|office space|retail|warehouse)\b", re.I)7172# Locations court terme / vacances (hors sujet : Rent-Ka = logements au mois)73_SHORT_TERM_RE = re.compile(74 r"\b(short[\s-]?term|nightly|per night|par nuit|vacation rental|"75 r"weekly rate|airbnb|court terme|campsites?|campground|nature camp)\b",76 re.I)7778# miniatures Buildium : « <hash>_406x539.jpeg » -> pleine résolution sans suffixe79_THUMB_SUFFIX_RE = re.compile(r"_\d{2,4}x\d{2,4}(\.[a-z]{3,4})$", re.I)8081# date de disponibilité de la mise en page héritée (« Friday, August 15, 2025 »)82_LEGACY_DATE_FMT = "%A, %B %d, %Y"838485def _load_registry() -> list[dict]:86 """Entrées validées du registre (subdomain présent, status == valide)."""87 try:88 data = json.loads(REGISTRY_PATH.read_text("utf-8"))89 except (OSError, ValueError):90 return []91 return [c for c in data.get("clients") or []92 if c.get("subdomain") and c.get("status") == "valide"]939495def _full_image(url: str) -> str:96 """Remet une miniature Buildium (…_406x539.jpeg) en pleine résolution."""97 return _THUMB_SUFFIX_RE.sub(r"\1", url.replace("&", "&")98 .replace("&isPreview=true", ""))99100101class BuildiumConnector(BaseConnector):102 """Base commune des connecteurs Buildium générés — non enregistrée103 elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de104 registre dans l'attribut de classe `client`."""105106 source_id = "" # les sous-classes générées le définissent107 client: dict = {} # entrée du registre (name, subdomain, site…)108 request_delay = 1.0 # politesse — petits gestionnaires, on y va mollo109 max_listings = 300 # garde-fou (plus gros client : Sleepwell, 130)110 max_images = 20111112 @property113 def base_url(self) -> str:114 return f"https://{self.client['subdomain']}.managebuilding.com"115116 def fetch(self) -> list[Listing]:117 resp = self.get(self.base_url + "/Resident/public/rentals")118 page = resp.text119 soup = BeautifulSoup(page, "html.parser")120121 cards = soup.select("a.featured-listing")122 if cards: # mise en page moderne123 rows = [self._listing_modern(c) for c in cards]124 elif "__dot_viewmodel_root" in page: # mise en page héritée125 rows = [self._listing_legacy(d) for d in self._legacy_rows(page)]126 else: # portail sans annonces publiques (login…) : rien à faire127 return []128129 listings: list[Listing] = []130 for lst in rows:131 if lst is None:132 continue133 if len(listings) >= self.max_listings: # garde-fou APRÈS filtres134 break135 listings.append(lst)136 return listings137138 # -- mise en page moderne : une annonce par carte a.featured-listing -------139 def _listing_modern(self, card) -> Listing | None:140 try:141 href = card.get("href") or ""142 m = re.search(r"/Resident/public/rentals/(\d+)", href)143 if not m:144 return None145 pid = m.group(1)146147 # data-location = « Windsor,ON|N9B 2N6 » — filtre province strict148 loc = card.get("data-location") or ""149 city, _, rest = loc.partition(",")150 prov, _, postal = rest.partition("|")151 prov = prov.strip().upper()152 if prov not in _PROVINCES:153 return None154 city, postal = city.strip(), postal.strip()155156 ptype = (card.get("data-type") or "").strip().lower()157 if ptype in _NON_RESIDENTIAL_TYPES:158 return None # stationnement/commerce : hors sujet159160 def txt(sel: str) -> str:161 el = card.select_one(sel)162 return el.get_text(" ", strip=True) if el else ""163164 title = txt(".featured-listing__title")165 desc_excerpt = txt(".featured-listing__description")166 if _NON_RESIDENTIAL_RE.search(f"{title} {desc_excerpt}") or \167 _SHORT_TERM_RE.search(f"{title} {desc_excerpt}"):168 return None169170 # chambres / salles de bain / loyer / pi² : attributs structurés —171 # jamais de valeur inventée (0 pi² = non renseigné chez Buildium)172 def num(attr: str) -> float | None:173 try:174 return float(card.get(attr))175 except (TypeError, ValueError):176 return None177 bedrooms = num("data-bedrooms")178 bathrooms = num("data-bathrooms")179 price = num("data-rent")180 if price is not None and price <= 0:181 price = None182 area = num("data-square-feet")183 if area is not None and not 80 <= area <= 20000:184 area = None185186 unit_type = ""187 if bedrooms is not None:188 unit_type = "Studio" if bedrooms == 0 else \189 normalize_unit_type(f"{bedrooms:g} chambres")190191 availability = txt(".featured-listing__availability")192193 # adresse civique : le titre Buildium EST la rue (+ n° d'unité)194 address = ", ".join(x for x in (title, city) if x)195 if address and prov:196 address += f", {prov} {postal}".rstrip()197198 url = urljoin(self.base_url, href)199200 # aperçu photo de la carte (miniature -> pleine résolution)201 preview = ""202 img = card.select_one("img.featured-listing__image")203 if img and img.get("src"):204 preview = _full_image(urljoin(self.base_url, img["src"]))205206 # fiche détail (description complète + galerie) via le cache BD :207 # revisitée seulement quand la carte de la liste change208 feed_key = hashlib.sha1("|".join(str(x) for x in (209 card.get("data-rent"), availability, preview,210 card.get("data-bedrooms"), desc_excerpt[:80],211 )).encode("utf-8")).hexdigest()212 d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))213 description = (d.get("description") or desc_excerpt).strip()214 images = [u for u in [preview] + list(d.get("images") or []) if u]215 images = list(dict.fromkeys(images)) # dédup en gardant l'ordre216217 return Listing(218 source=self.source_id,219 external_id=pid,220 url=url,221 title=title,222 address=address,223 city=city,224 province=prov,225 unit_type=unit_type,226 bedrooms=bedrooms,227 bathrooms=bathrooms,228 price=price,229 price_label=f"${price:,.0f}/mo" if price else "",230 availability=availability,231 area_sqft=area,232 description=description[:1200],233 images=images[: self.max_images],234 )235 except Exception:236 return None237238 def _fetch_detail(self, url: str) -> dict:239 """Description complète + galerie de la fiche (mise en page moderne)."""240 out: dict = {"images": [], "description": ""}241 if not url:242 return out243 try:244 soup = BeautifulSoup(self.get(url).text, "html.parser")245 except Exception:246 return out247 desc = soup.select_one(".unit-detail__description")248 if desc:249 out["description"] = desc.get_text("\n", strip=True)250 gallery = soup.select_one(".unit-detail__gallery")251 if gallery:252 images: list[str] = []253 for el in gallery.find_all(("img", "a")):254 u = el.get("src") or el.get("href") or ""255 if "files/download" not in u:256 continue257 u = _full_image(urljoin(self.base_url, u))258 if u not in images:259 images.append(u)260 out["images"] = images[: self.max_images]261 return out262263 # -- mise en page héritée : annonces embarquées dans le viewmodel DotVVM ---264 def _legacy_rows(self, page: str) -> list[dict]:265 """Extrait ContentViewModel.Listings du JSON __dot_viewmodel_root."""266 marker = "__dot_viewmodel_root value='"267 i = page.find(marker)268 if i < 0:269 return []270 start = i + len(marker)271 end = page.find("'", start) # le JSON encode ses ' en '272 try:273 vm = json.loads(htmllib.unescape(page[start:end]))274 except ValueError:275 return []276 rows = (((vm.get("viewModel") or {}).get("ContentViewModel") or {})277 .get("Listings") or [])278 # chaque ligne est une liste de paires {Key, Value}279 return [{kv.get("Key"): kv.get("Value") for kv in row280 if isinstance(kv, dict)}281 for row in rows if isinstance(row, list)]282283 def _listing_legacy(self, d: dict) -> Listing | None:284 try:285 pid = str(d.get("listingid") or "").strip()286 prov = str(d.get("state") or "").strip().upper()287 if not pid or prov not in _PROVINCES:288 return None289290 title = (d.get("buildingname") or d.get("propertyname") or "").strip()291 unittype_src = (d.get("unittype") or "").strip() # « 3 Bed - 1 Bath »292 description = (d.get("unitdescription")293 or d.get("propertydescription") or "").strip()294 if _NON_RESIDENTIAL_RE.search(f"{title} {unittype_src}") or \295 _SHORT_TERM_RE.search(f"{title} {unittype_src} "296 f"{description[:400]}"):297 return None298299 city = (d.get("city") or "").strip()300 postal = (d.get("postalcode") or "").strip()301 street = (d.get("line1") or "").strip() or title302 address = ", ".join(x for x in (street, city) if x)303 if address:304 address += f", {prov} {postal}".rstrip()305306 # chambres/bains : « 3 Bed » / « 1.5 Bath » — sinon None (inconnu)307 def count(text: str) -> float | None:308 m = re.match(r"\s*(\d+(?:\.\d+)?)", str(text or ""))309 return float(m.group(1)) if m else None310 bedrooms = count(d.get("bedtypedesc"))311 bathrooms = count(d.get("bathtypedesc"))312 unit_type = ""313 if bedrooms is not None:314 unit_type = "Studio" if bedrooms == 0 else \315 normalize_unit_type(f"{bedrooms:g} chambres")316317 try:318 price = float(d.get("listingrent") or 0) or None319 except (TypeError, ValueError):320 price = None321 try:322 area = float(d.get("squarefootage") or 0)323 area = area if 80 <= area <= 20000 else None324 except (TypeError, ValueError):325 area = None326327 # disponibilité : format anglais long et fixe du viewmodel328 availability = str(d.get("availabilitydate") or "").strip()329 avail_date = None330 try:331 avail_date = datetime.strptime(332 availability, _LEGACY_DATE_FMT).date().isoformat()333 except ValueError:334 pass335336 details: dict = {}337 contact = {k: str(d.get(f"contact{k}") or "").strip()338 for k in ("phone", "email")}339 contact = {k: v for k, v in contact.items() if v}340 if contact:341 details["contact"] = contact342343 url = (f"{self.base_url}/Resident/PublicPages/apartmentdetail.aspx"344 f"?listingId={pid}&unitid={d.get('unitid') or ''}"345 f"&buildingid={d.get('buildingid') or ''}")346347 images = []348 img = str(d.get("imageUrl") or "").strip()349 if img and "files/download" in img:350 images.append(_full_image(urljoin(self.base_url, img)))351352 return Listing(353 source=self.source_id,354 external_id=pid,355 url=url,356 title=title,357 address=address,358 city=city,359 province=prov,360 unit_type=unit_type,361 bedrooms=bedrooms,362 bathrooms=bathrooms,363 price=price,364 price_label=f"${price:,.0f}/mo" if price else "",365 availability=availability,366 availability_date=avail_date,367 area_sqft=area,368 description=description[:1200],369 details=details,370 images=images[: self.max_images],371 )372 except Exception:373 return None374375376# -----------------------------------------------------------------------------377# Génération : une sous-classe par client du registre, déposée dans les globals378# du module — connectors/__init__.py (scan de vars(module)) les découvre alors379# comme n'importe quel connecteur écrit à la main.380# -----------------------------------------------------------------------------381def _make_connector(entry: dict) -> type[BuildiumConnector]:382 cls = type(383 f"Bld{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",384 (BuildiumConnector,),385 {386 "source_id": f"bld_{entry['id']}",387 "client": entry,388 # (voir gestion-immobiliere-ontario.md)389 "disabled": False,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