SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
17.1 KB · 404 lines python
Raw Blame History
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("&amp;", "&")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 &#39;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