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%
11.3 KB · 266 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/accommod8u.py : connecteur Accommod8u (accommod8u.com)5#   Gros gestionnaire de Waterloo ON (~10 immeubles/villages au moment de6#   l'écriture : tours Albert/Lester/Sunview, THE LINQ, villages étudiants7#   Linden/Spring/Walnut au bail mensuel — longue durée, donc pertinent).8#   Site « corporate » Yardi RentCafe derrière Cloudflare : curl nu = 403,9#   Scrapfly asp=true suffit (rendu serveur, PAS de render_js). Comme Killam,10#   la page /residential/apartments embarque TOUT le portefeuille dans11#   l'input caché `#available_prop` (JSON doublement encodé) : nom, adresse,12#   ville/ON/code postal, lat/lng, fourchette de loyer, politique animaux,13#   téléphone, vignette, occupation et l'URL du MICROSITE RentCafe de chaque14#   immeuble (apartments-waterloo-228albert.com…).15#   La page /floorplans du microsite (même anti-bot, via self.detail() +16#   Scrapfly) donne les cartes plans d'étage : « N Bed / N Bath / N Sq. Ft. /17#   Starting at $X /Month » quand des unités sont disponibles, « Call for18#   details » sinon (carte sans lien « Availability » → écartée, rien19#   d'inventé). Une annonce PAR PLAN D'ÉTAGE DISPONIBLE, avec repli « une20#   annonce par immeuble » (loyer plancher du flux) si aucune carte n'a de21#   prix mais que l'immeuble n'est pas complet. Les villages étudiants22#   affichent des loyers à la chambre (~600 $) : le nom du plan (« Room »,23#   « 4 Bedroom »…) donne le type d'unité, jamais de valeur inventée.24#   Immeubles IsFullyOccupied (ex. « Fir Village (Fully Leased) ») : ignorés.25# -----------------------------------------------------------------------------26from __future__ import annotations2728import hashlib29import json30import os31import re3233from bs4 import BeautifulSoup3435from ..schema import Listing, normalize_unit_type, strip_accents36from .base import BaseConnector3738BASE = "https://www.accommod8u.com"39SEARCH_PAGE = f"{BASE}/residential/apartments"4041# Rent-Ka: connector always active.42_ONTARIO = True  # Rent-Ka: always on (ROC scope)4344# blob JSON du portefeuille (input caché RentCafe, doublement encodé)45_PROP_BLOB_RE = re.compile(r"id=\"available_prop\"\s+value='(.*?)'", re.S)4647# cartes plans d'étage du microsite : « 1 Bed », « 2 Bath », « 660 Sq. Ft. »48_BED_RE = re.compile(r"([\d.]+)\s*Bed\b")49_BATH_RE = re.compile(r"([\d.]+)\s*Bath")50_SQFT_RE = re.compile(r"([\d,]+)(?:-\s*to\s*[\d,]+)?\s*Sq\.?\s*Ft", re.I)51_PRICE_RE = re.compile(r"\$\s*([\d,]+(?:\.\d{2})?)")525354class Accommod8uConnector(BaseConnector):55    source_id = "accommod8u"56    request_delay = 1.5       # tout passe par Scrapfly ASP : rester très poli57    disabled = False58    max_properties = 20       # garde-fou (10 immeubles au 2026-08)59    max_images = 156061    # -- portefeuille : blob embarqué de la page de recherche --------------------62    def _properties(self) -> list[dict]:63        page = self.get_scrapfly(SEARCH_PAGE, render_js=False, asp=True)64        m = _PROP_BLOB_RE.search(page or "")65        if not m:66            raise RuntimeError("Accommod8u : blob #available_prop introuvable "67                               "(gabarit RentCafe modifié ou échec ASP)")68        return json.loads(json.loads(m.group(1)))6970    def fetch(self) -> list[Listing]:71        listings: list[Listing] = []72        count = 073        for p in self._properties():74            if (p.get("propertyState") or "").strip().upper() != "ON":75                continue76            if p.get("IsFullyOccupied") is True:77                continue          # complet (« Fully Leased ») : rien à publier78            if count >= self.max_properties:79                break80            count += 181            try:82                listings.extend(self._listings(p))83            except Exception:84                continue85        return listings8687    # -- annonces d'un immeuble (une par plan d'étage disponible) ----------------88    def _listings(self, p: dict) -> list[Listing]:89        pid = str(p.get("propertyid"))90        name = (p.get("propertyName") or "").strip()91        # microsite RentCafe de l'immeuble (sans le paramètre de tracking)92        site = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")93                .split("?")[0].rstrip("/"))94        url = f"{site}/floorplans" if site.startswith("http") else SEARCH_PAGE9596        city = (p.get("propertyCity") or "").strip()97        street = (p.get("propertyAddress") or "").strip()98        postal = (p.get("propertyZipCode") or "").strip()99        address = ", ".join(x for x in (street, city) if x) + ", ON"100        if postal:101            address += f" {postal}"102103        try:104            lat = float(p.get("propertyLat")) or None105            lng = float(p.get("propertyLng")) or None106        except (TypeError, ValueError):107            lat = lng = None108109        # politique animaux structurée du flux110        pets = None111        try:112            pol = p.get("bPetPolicy") or {}113            if isinstance(pol, str):114                pol = json.loads(pol)115            if pol.get("bNoPetsAllowed") is True:116                pets = "non"117            elif pol.get("bCats") and pol.get("bDogs"):118                pets = "oui"119            elif pol.get("bCats") or pol.get("bDogs"):120                pets = "conditions"121        except (ValueError, TypeError):122            pass123124        details: dict = {}125        if (p.get("phone") or "").strip():126            details["contact"] = {"phone": p["phone"].strip()}127128        # page /floorplans du microsite via le cache BD : revisitée seulement129        # quand la ligne du flux change (loyers, occupation, vignette)130        feed_key = hashlib.sha1("|".join(str(p.get(k)) for k in (131            "propertyMinRent", "propertyMaxRent", "propertyMinBed",132            "propertyMaxBed", "IsFullyOccupied", "dtMinUnitAvailable",133            "propertyThumb", "PropertySiteUrl",134        )).encode("utf-8")).hexdigest()135        d = self.detail(pid, feed_key, lambda: self._fetch_floorplans(url))136137        thumb = (p.get("propertyThumb") or "").strip()138        base_images = [thumb] if thumb else []139140        common = dict(141            source=self.source_id, url=url, address=address, city=city,142            province="ON", pets=pets, lat=lat, lng=lng,143        )144145        out: list[Listing] = []146        for fp in d.get("floorplans") or []:147            if fp.get("price") is None:148                continue    # « Call for details » sans lien Availability :149                # aucune unité annoncée disponible — rien d'inventé150            images = [u for u in base_images + (fp.get("images") or [])151                      if u][: self.max_images]152            beds = fp.get("beds")153            slug = re.sub(r"[^a-z0-9]+", "-",154                          strip_accents((fp.get("name") or "").lower())155                          ).strip("-")156            out.append(Listing(157                external_id=f"{pid}-{slug or 'u'}",158                title=f"{name} — {fp['name']}" if fp.get("name") else name,159                unit_type=self._unit_type(fp.get("name") or "", beds),160                bedrooms=beds,161                bathrooms=fp.get("baths"),162                price=fp["price"],163                price_label=f"À partir de {fp['price']:.0f} $ /mois",164                availability="Unités disponibles",165                area_sqft=fp.get("sqft"),166                details=dict(details),167                images=images,168                **common,169            ))170        if out:171            return out172173        # repli : une annonce par immeuble avec le loyer plancher du flux174        price = None175        try:176            price = float(p.get("propertyMinRent") or 0) or None177        except (TypeError, ValueError):178            pass179        if price is None:180            return []      # ni plan disponible ni loyer affiché : rien181        beds = None182        try:183            bmin = float(p.get("propertyMinBed") or -1)184            if bmin >= 0 and bmin == float(p.get("propertyMaxBed") or -1):185                beds = bmin186        except (TypeError, ValueError):187            pass188        return [Listing(189            external_id=pid,190            title=name,191            unit_type=("Studio" if beds == 0 else normalize_unit_type(192                f"{int(beds)} chambres") if beds is not None else ""),193            bedrooms=beds,194            price=price,195            price_label=f"À partir de {price:.0f} $ /mois",196            availability="Unités disponibles",197            details=details,198            images=base_images[: self.max_images],199            **common,200        )]201202    @staticmethod203    def _unit_type(fp_name: str, beds: float | None) -> str:204        """Type d'unité : nom du plan (« Room » -> Chambre) sinon dérivé cc."""205        ut = normalize_unit_type(fp_name)206        if ut and ut != fp_name.strip():207            return ut208        if beds == 0:209            return "Studio"210        if beds is not None:211            return normalize_unit_type(f"{int(beds)} chambres")212        return ut213214    # -- page /floorplans du microsite RentCafe ----------------------------------215    def _fetch_floorplans(self, url: str) -> dict:216        out: dict = {"floorplans": []}217        if not url.startswith("http"):218            return out219        try:220            page = self.get_scrapfly(url, render_js=False, asp=True)221        except Exception:222            return out223        if not page:224            return out225        soup = BeautifulSoup(page, "html.parser")226        seen: set[str] = set()227        for card in soup.select(".fp-container"):228            txt = card.get_text(" ", strip=True)229            h = card.find(["h2", "h3", "h4"])230            fp_name = h.get_text(" ", strip=True) if h else ""231            if not fp_name:232                # premier segment avant « N Bed » (gabarit compact)233                fp_name = re.split(r"\d+\s*Bed", txt)[0].strip()234            if not fp_name or fp_name.lower() in seen:235                continue236            seen.add(fp_name.lower())237            mb = _BED_RE.search(txt)238            mba = _BATH_RE.search(txt)239            msq = _SQFT_RE.search(txt)240            sqft = float(msq.group(1).replace(",", "")) if msq else None241            # prix seulement si des unités sont disponibles (lien Availability)242            price = None243            has_avail = card.find(244                "a", href=re.compile(r"/floorplans/")) is not None245            mp = _PRICE_RE.search(txt)246            if mp and has_avail:247                price = float(mp.group(1).replace(",", ""))248            images = []249            for img in card.find_all("img"):250                src = (img.get("src") or img.get("data-src") or "").strip()251                if src and "resource.rentcafe.com" in src \252                        and src not in images:253                    src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",254                                 "c_limit,w_1200", src)255                    images.append(src)256            out["floorplans"].append({257                "name": fp_name,258                "beds": (float(mb.group(1)) if mb259                         else 0.0 if "Studio" in txt else None),260                "baths": float(mba.group(1)) if mba else None,261                "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,262                "price": price,263                "images": images[:5],264            })265        return out266