# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/buildium.py : connecteur GÉNÉRIQUE Buildium (managebuilding.com) # La longue traîne des petits gestionnaires ontariens (Sarnia, Chatham-Kent, # Cobourg, Kingston, Ottawa, le Nord…) publie ses annonces sur le portail # Buildium : `https://.managebuilding.com/Resident/public/rentals`. # Contrairement à ce qu'on pouvait craindre, PAS besoin de rendu JS : Buildium # sert deux mises en page, toutes deux exploitables en HTML brut : # - « moderne » : cartes `a.featured-listing` rendues serveur, avec # data-bedrooms/-bathrooms/-rent/-square-feet/-location/-type ; # - « héritée » : redirection 302 vers PublicPages/ApartmentSearch.aspx dont # le viewmodel DotVVM (`__dot_viewmodel_root`) embarque les annonces en # JSON complet (city/state/postalcode, description, contact…). # Plutôt que N modules copiés-collés, ce module lit le registre # data/buildium_clients.json ({id, name, subdomain…}, sous-domaines découverts # via Serper/sites vitrines et validés en live le 2026-08-27) et GÉNÈRE une # sous-classe de BaseConnector par client (source_id = "bld_"), déposée # dans les globals du module pour le registre auto-découvrant # (connectors/__init__.py). Même gabarit que liftsystem.py. # Les portails sont pancanadiens/US : filtre province strict (ON/QC) sur le # data-location (moderne) ou le champ state (héritée), et filtre résidentiel # longue durée (exclusion parking, entreposage, commercial, court terme). # Galerie photo de la fiche via le cache BD self.detail() (revisitée quand la # ligne de la liste change) ; miniatures `_LxH` remises en pleine résolution. # # Expansion Ontario — activer via LOUKA_ONTARIO=1 # (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les # classes générées sont `disabled` et donc exclues du registre / de la prod. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as htmllib import json import os import re from datetime import datetime from pathlib import Path from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector # Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=1 _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \ "buildium_clients.json" # Provinces couvertes par Lou-Ka — les portails Buildium servent tout le # Canada et les États-Unis (codes d'état à 2 lettres aussi) : tout le reste # est ignoré au fetch. _PROVINCES = {"ON", "QC"} # Types de propriété NON résidentiels de la mise en page moderne (data-type) — # stationnements, entreposage et commercial sont hors sujet pour Lou-Ka. _NON_RESIDENTIAL_TYPES = { "parkingspace", "parking", "storage", "commercial", "commercialother", "office", "retail", "industrial", "warehouse", "shoppingcenter", "land", } # Filet complémentaire sur le titre/type d'unité (la mise en page héritée n'a # pas de data-type fiable ; certains clients titrent « PARKING - … »). _NON_RESIDENTIAL_RE = re.compile( r"\b(parking|stationnement|storage|entreposage|locker|garage only|" r"commercial|office space|retail|warehouse)\b", re.I) # Locations court terme / vacances (hors sujet : Lou-Ka = logements au mois) _SHORT_TERM_RE = re.compile( r"\b(short[\s-]?term|nightly|per night|par nuit|vacation rental|" r"weekly rate|airbnb|court terme|campsites?|campground|nature camp)\b", re.I) # miniatures Buildium : « _406x539.jpeg » -> pleine résolution sans suffixe _THUMB_SUFFIX_RE = re.compile(r"_\d{2,4}x\d{2,4}(\.[a-z]{3,4})$", re.I) # date de disponibilité de la mise en page héritée (« Friday, August 15, 2025 ») _LEGACY_DATE_FMT = "%A, %B %d, %Y" def _load_registry() -> list[dict]: """Entrées validées du registre (subdomain présent, status == valide).""" try: data = json.loads(REGISTRY_PATH.read_text("utf-8")) except (OSError, ValueError): return [] return [c for c in data.get("clients") or [] if c.get("subdomain") and c.get("status") == "valide"] def _full_image(url: str) -> str: """Remet une miniature Buildium (…_406x539.jpeg) en pleine résolution.""" return _THUMB_SUFFIX_RE.sub(r"\1", url.replace("&", "&") .replace("&isPreview=true", "")) class BuildiumConnector(BaseConnector): """Base commune des connecteurs Buildium générés — non enregistrée elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de registre dans l'attribut de classe `client`.""" source_id = "" # les sous-classes générées le définissent client: dict = {} # entrée du registre (name, subdomain, site…) request_delay = 1.0 # politesse — petits gestionnaires, on y va mollo max_listings = 300 # garde-fou (plus gros client : Sleepwell, 130) max_images = 20 @property def base_url(self) -> str: return f"https://{self.client['subdomain']}.managebuilding.com" def fetch(self) -> list[Listing]: resp = self.get(self.base_url + "/Resident/public/rentals") page = resp.text soup = BeautifulSoup(page, "html.parser") cards = soup.select("a.featured-listing") if cards: # mise en page moderne rows = [self._listing_modern(c) for c in cards] elif "__dot_viewmodel_root" in page: # mise en page héritée rows = [self._listing_legacy(d) for d in self._legacy_rows(page)] else: # portail sans annonces publiques (login…) : rien à faire return [] listings: list[Listing] = [] for lst in rows: if lst is None: continue if len(listings) >= self.max_listings: # garde-fou APRÈS filtres break listings.append(lst) return listings # -- mise en page moderne : une annonce par carte a.featured-listing ------- def _listing_modern(self, card) -> Listing | None: try: href = card.get("href") or "" m = re.search(r"/Resident/public/rentals/(\d+)", href) if not m: return None pid = m.group(1) # data-location = « Windsor,ON|N9B 2N6 » — filtre province strict loc = card.get("data-location") or "" city, _, rest = loc.partition(",") prov, _, postal = rest.partition("|") prov = prov.strip().upper() if prov not in _PROVINCES: return None city, postal = city.strip(), postal.strip() ptype = (card.get("data-type") or "").strip().lower() if ptype in _NON_RESIDENTIAL_TYPES: return None # stationnement/commerce : hors sujet def txt(sel: str) -> str: el = card.select_one(sel) return el.get_text(" ", strip=True) if el else "" title = txt(".featured-listing__title") desc_excerpt = txt(".featured-listing__description") if _NON_RESIDENTIAL_RE.search(f"{title} {desc_excerpt}") or \ _SHORT_TERM_RE.search(f"{title} {desc_excerpt}"): return None # chambres / salles de bain / loyer / pi² : attributs structurés — # jamais de valeur inventée (0 pi² = non renseigné chez Buildium) def num(attr: str) -> float | None: try: return float(card.get(attr)) except (TypeError, ValueError): return None bedrooms = num("data-bedrooms") bathrooms = num("data-bathrooms") price = num("data-rent") if price is not None and price <= 0: price = None area = num("data-square-feet") if area is not None and not 80 <= area <= 20000: area = None unit_type = "" if bedrooms is not None: unit_type = "Studio" if bedrooms == 0 else \ normalize_unit_type(f"{bedrooms:g} chambres") availability = txt(".featured-listing__availability") # adresse civique : le titre Buildium EST la rue (+ n° d'unité) address = ", ".join(x for x in (title, city) if x) if address and prov: address += f", {prov} {postal}".rstrip() url = urljoin(self.base_url, href) # aperçu photo de la carte (miniature -> pleine résolution) preview = "" img = card.select_one("img.featured-listing__image") if img and img.get("src"): preview = _full_image(urljoin(self.base_url, img["src"])) # fiche détail (description complète + galerie) via le cache BD : # revisitée seulement quand la carte de la liste change feed_key = hashlib.sha1("|".join(str(x) for x in ( card.get("data-rent"), availability, preview, card.get("data-bedrooms"), desc_excerpt[:80], )).encode("utf-8")).hexdigest() d = self.detail(pid, feed_key, lambda: self._fetch_detail(url)) description = (d.get("description") or desc_excerpt).strip() images = [u for u in [preview] + list(d.get("images") or []) if u] images = list(dict.fromkeys(images)) # dédup en gardant l'ordre return Listing( source=self.source_id, external_id=pid, url=url, title=title, address=address, city=city, province=prov, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=f"{price:.0f} $ /mois" if price else "", availability=availability, area_sqft=area, description=description[:1200], images=images[: self.max_images], ) except Exception: return None def _fetch_detail(self, url: str) -> dict: """Description complète + galerie de la fiche (mise en page moderne).""" out: dict = {"images": [], "description": ""} if not url: return out try: soup = BeautifulSoup(self.get(url).text, "html.parser") except Exception: return out desc = soup.select_one(".unit-detail__description") if desc: out["description"] = desc.get_text("\n", strip=True) gallery = soup.select_one(".unit-detail__gallery") if gallery: images: list[str] = [] for el in gallery.find_all(("img", "a")): u = el.get("src") or el.get("href") or "" if "files/download" not in u: continue u = _full_image(urljoin(self.base_url, u)) if u not in images: images.append(u) out["images"] = images[: self.max_images] return out # -- mise en page héritée : annonces embarquées dans le viewmodel DotVVM --- def _legacy_rows(self, page: str) -> list[dict]: """Extrait ContentViewModel.Listings du JSON __dot_viewmodel_root.""" marker = "__dot_viewmodel_root value='" i = page.find(marker) if i < 0: return [] start = i + len(marker) end = page.find("'", start) # le JSON encode ses ' en ' try: vm = json.loads(htmllib.unescape(page[start:end])) except ValueError: return [] rows = (((vm.get("viewModel") or {}).get("ContentViewModel") or {}) .get("Listings") or []) # chaque ligne est une liste de paires {Key, Value} return [{kv.get("Key"): kv.get("Value") for kv in row if isinstance(kv, dict)} for row in rows if isinstance(row, list)] def _listing_legacy(self, d: dict) -> Listing | None: try: pid = str(d.get("listingid") or "").strip() prov = str(d.get("state") or "").strip().upper() if not pid or prov not in _PROVINCES: return None title = (d.get("buildingname") or d.get("propertyname") or "").strip() unittype_src = (d.get("unittype") or "").strip() # « 3 Bed - 1 Bath » description = (d.get("unitdescription") or d.get("propertydescription") or "").strip() if _NON_RESIDENTIAL_RE.search(f"{title} {unittype_src}") or \ _SHORT_TERM_RE.search(f"{title} {unittype_src} " f"{description[:400]}"): return None city = (d.get("city") or "").strip() postal = (d.get("postalcode") or "").strip() street = (d.get("line1") or "").strip() or title address = ", ".join(x for x in (street, city) if x) if address: address += f", {prov} {postal}".rstrip() # chambres/bains : « 3 Bed » / « 1.5 Bath » — sinon None (inconnu) def count(text: str) -> float | None: m = re.match(r"\s*(\d+(?:\.\d+)?)", str(text or "")) return float(m.group(1)) if m else None bedrooms = count(d.get("bedtypedesc")) bathrooms = count(d.get("bathtypedesc")) unit_type = "" if bedrooms is not None: unit_type = "Studio" if bedrooms == 0 else \ normalize_unit_type(f"{bedrooms:g} chambres") try: price = float(d.get("listingrent") or 0) or None except (TypeError, ValueError): price = None try: area = float(d.get("squarefootage") or 0) area = area if 80 <= area <= 20000 else None except (TypeError, ValueError): area = None # disponibilité : format anglais long et fixe du viewmodel availability = str(d.get("availabilitydate") or "").strip() avail_date = None try: avail_date = datetime.strptime( availability, _LEGACY_DATE_FMT).date().isoformat() except ValueError: pass details: dict = {} contact = {k: str(d.get(f"contact{k}") or "").strip() for k in ("phone", "email")} contact = {k: v for k, v in contact.items() if v} if contact: details["contact"] = contact url = (f"{self.base_url}/Resident/PublicPages/apartmentdetail.aspx" f"?listingId={pid}&unitid={d.get('unitid') or ''}" f"&buildingid={d.get('buildingid') or ''}") images = [] img = str(d.get("imageUrl") or "").strip() if img and "files/download" in img: images.append(_full_image(urljoin(self.base_url, img))) return Listing( source=self.source_id, external_id=pid, url=url, title=title, address=address, city=city, province=prov, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=f"{price:.0f} $ /mois" if price else "", availability=availability, availability_date=avail_date, area_sqft=area, description=description[:1200], details=details, images=images[: self.max_images], ) except Exception: return None # ----------------------------------------------------------------------------- # Génération : une sous-classe par client du registre, déposée dans les globals # du module — connectors/__init__.py (scan de vars(module)) les découvre alors # comme n'importe quel connecteur écrit à la main. # ----------------------------------------------------------------------------- def _make_connector(entry: dict) -> type[BuildiumConnector]: cls = type( f"Bld{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector", (BuildiumConnector,), { "source_id": f"bld_{entry['id']}", "client": entry, # Expansion Ontario — activer via LOUKA_ONTARIO=1 # (voir gestion-immobiliere-ontario.md) "disabled": not _ONTARIO, "__doc__": f"Connecteur Buildium généré — {entry.get('name')} " f"({entry.get('subdomain')}.managebuilding.com).", }, ) return cls def _register_all() -> None: for entry in _load_registry(): cls = _make_connector(entry) globals()[cls.__name__] = cls _register_all()