SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
17.1 KB · 404 lines python
Raw Blame History
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("&amp;", "&")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 &#39;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