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%
12.7 KB · 317 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/akelius.py : Akelius Residential (rent.akelius.com)5#   The Canada search page is server-side rendered (Angular SSR): the6#   TransferState (<script id="akeliusWebsite-state">) holds the full JSON of7#   every Canadian unit (address, rent, sqft, bedrooms, photos). Rent-Ka keeps8#   the Ontario units (Toronto + amalgamated boroughs, Ottawa); Akelius'9#   remaining Canadian portfolio (Montréal) is out of scope.10#   Each unit also has a detail JSON (/lettings/marketing/v2/CA/<id>.json)11#   with the complete keyfacts: inclusions (heat, hot water, electricity,12#   internet), appliances, pets, furnished, elevator, pool, gym, construction13#   year, phone — fetched through the self.detail() cache14#   (max ~150 new fetches per sync).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import os21import re2223from ..schema import Listing, strip_accents24from .base import BaseConnector2526BASE = "https://rent.akelius.com"27SEARCH_URL = f"{BASE}/en/search/canada/apartment/montreal"28DETAIL_URL = BASE + "/lettings/marketing/v2/CA/{uid}.json"29STATE_RE = re.compile(30    r'<script id="akeliusWebsite-state" type="application/json">(.*?)</script>',31    re.S)3233# Eligible Ontario cities (Toronto + amalgamated boroughs, Ottawa)34# -> (normalized city, forced sector or None)35_ON_CITIES = {36    "toronto": ("Toronto", None),            # secteur = borough du flux37    "north york": ("Toronto", "North York"),38    "east york": ("Toronto", "East York"),39    "etobicoke": ("Toronto", "Etobicoke"),40    "scarborough": ("Toronto", "Scarborough"),41    "york": ("Toronto", "York"),42    "ottawa": ("Ottawa", None),43}4445_BED_TYPES = {0: "Studio", 1: "1 bedroom", 2: "2 bedrooms", 3: "3 bedrooms",46              4: "4 bedrooms"}4748# keyfacts booléens du JSON détail -> libellé de commodité (si vrai)49_KF_AMENITIES = {50    "has-air-conditioning": "Climatisation",51    "has-central-air-conditioning": "Climatisation centrale",52    "has-balcony": "Balcon",53    "has-terrace": "Terrasse",54    "has-bicycle-racks": "Supports à vélos",55    "has-blinds": "Stores",56    "has-bosch-built-in-appliances": "Électroménagers encastrés Bosch",57    "has-canada-post-parcel-locker": "Casier à colis Postes Canada",58    "has-central-vacuuming-system": "Aspirateur central",59    "has-concierge": "Concierge",60    "has-dishwasher": "Lave-vaisselle",61    "has-dryer": "Sécheuse",62    "has-washing-machine": "Laveuse",63    "has-washer-dryer": "Laveuse-sécheuse",64    "has-elevator": "Ascenseur",65    "has-fitness-centre": "Salle d'entraînement",66    "has-heated-bathroom-floor": "Plancher de salle de bain chauffant",67    "has-indoor-pool": "Piscine intérieure",68    "has-outdoor-pool": "Piscine extérieure",69    "has-laundry-room": "Buanderie",70    "has-microwave": "Micro-ondes",71    "has-openplan-kitchen": "Cuisine à aire ouverte",72    "has-sauna": "Sauna",73    "has-wine-fridge": "Cellier à vin",74    "is-broadband-included-in-rent": "Internet inclus",75    "is-electricity-included-in-rent": "Électricité incluse",76    "is-gas-included-in-rent": "Gaz inclus",77    "is-heating-included-in-rent": "Chauffage inclus",78    "is-hot-water-included-in-rent": "Eau chaude incluse",79    "is-water-included-in-rent": "Eau incluse",80    "is-refurbished": "Rénové",81    "is-smart-home": "Logement intelligent",82}838485class _DetailBudget(Exception):86    """Budget de nouvelles pages détail épuisé pour cette synchronisation."""878889class AkeliusConnector(BaseConnector):90    source_id = "akelius"91    request_delay = 0.692    max_units = 400          # garde-fou93    max_details = 150        # nouveaux JSON détail max par synchronisation9495    def fetch(self) -> list[Listing]:96        html = self.get(SEARCH_URL).text97        m = STATE_RE.search(html)98        if not m:99            return []100        # TransferState Angular : les guillemets sont encodés « &q; »101        state = json.loads(m.group(1).replace("&q;", '"'))102103        # La clé du cache API est un hash variable : on repère la liste d'unités104        units: list[dict] = []105        for val in state.values():106            body = val.get("b") if isinstance(val, dict) else None107            if (isinstance(body, list) and body108                    and isinstance(body[0], dict) and "keyfacts" in body[0]):109                units = body110                break111112        self._detail_fetches = 0113        listings: list[Listing] = []114        for u in units[: self.max_units]:115            try:116                lst = self._unit_listing(u)117                if lst:118                    self._enrich(lst, u)119                    listings.append(lst)120            except Exception:121                continue122        return listings123124    # -- JSON détail (keyfacts complets) ---------------------------------------125    def _fetch_detail(self, uid: str) -> dict:126        try:127            resp = self.get(DETAIL_URL.format(uid=uid))128            data = json.loads(resp.content.decode("utf-8-sig"))129        except Exception:130            return {}131        if not isinstance(data, dict):132            return {}133        docs = data.get("documents") or []134        images = [d.get("mediumUrl") or d.get("originalImageUrl")135                  for d in docs if isinstance(d, dict) and not d.get("isExample")]136        return {137            "keyfacts": data.get("keyfacts") or {},138            "contact": data.get("contactDetails") or {},139            "images": [i for i in images if isinstance(i, str)][:30],140        }141142    def _enrich(self, lst: Listing, u: dict) -> None:143        """Complète l'annonce avec le JSON détail (via cache self.detail)."""144        key = hashlib.sha1(json.dumps(145            {"rent": (u.get("keyfacts") or {}).get("total-rent"),146             "avail": (u.get("keyfacts") or {}).get("available-from-date"),147             "pub": u.get("lastPublishedDate")},148            sort_keys=True).encode("utf-8")).hexdigest()149150        def fetch_fn():151            if self._detail_fetches >= self.max_details:152                raise _DetailBudget()153            self._detail_fetches += 1154            return self._fetch_detail(lst.external_id)155156        try:157            payload = self.detail(lst.external_id, key, fetch_fn)158        except _DetailBudget:159            return160        kf = payload.get("keyfacts") or {}161        if not kf:162            return163164        # commodités (libellés français, seulement les keyfacts vrais)165        for k, label in _KF_AMENITIES.items():166            if kf.get(k) and label not in lst.amenities:167                lst.amenities.append(label)168169        # animaux / meublé (valeurs structurées de la source)170        pets = str(kf.get("pets-allowed") or "").strip().lower()171        if pets == "yes":172            lst.pets = "oui"173        elif pets == "no":174            lst.pets = "non"175        elif pets:176            lst.pets = "conditions"177        furn = str(kf.get("furnished-state") or "").strip().lower()178        if furn == "furnished":179            lst.furnished = True180        elif furn == "unfurnished":181            lst.furnished = False182183        # details structurés (booléens explicites du JSON — jamais devinés)184        details: dict = {}185        inclusions = {}186        for src, dst in (("is-heating-included-in-rent", "heating"),187                         ("is-electricity-included-in-rent", "electricity"),188                         ("is-hot-water-included-in-rent", "hot_water"),189                         ("is-broadband-included-in-rent", "internet")):190            if isinstance(kf.get(src), bool):191                inclusions[dst] = kf[src]192        if inclusions:193            details["inclusions"] = inclusions194        appliances = {}195        if isinstance(kf.get("has-dishwasher"), bool):196            appliances["dishwasher"] = kf["has-dishwasher"]197        if isinstance(kf.get("has-washer-dryer"), bool):198            wd = kf["has-washer-dryer"] or (199                bool(kf.get("has-washing-machine")) and bool(kf.get("has-dryer")))200            appliances["washer_dryer"] = wd201        if appliances:202            details["appliances"] = appliances203        if isinstance(kf.get("has-air-conditioning"), bool):204            details["ac"] = (kf["has-air-conditioning"]205                             or bool(kf.get("has-central-air-conditioning")))206        if isinstance(kf.get("has-elevator"), bool):207            details["elevator"] = kf["has-elevator"]208        if isinstance(kf.get("has-balcony"), bool):209            details["balcony"] = kf["has-balcony"] or bool(kf.get("has-terrace"))210        if isinstance(kf.get("has-indoor-pool"), bool) or \211                isinstance(kf.get("has-outdoor-pool"), bool):212            details["pool"] = bool(kf.get("has-indoor-pool")) or \213                bool(kf.get("has-outdoor-pool"))214        if isinstance(kf.get("has-fitness-centre"), bool):215            details["gym"] = kf["has-fitness-centre"]216        if isinstance(kf.get("has-laundry-room"), bool):217            details["laundry"] = kf["has-laundry-room"]218        year = kf.get("construction-year")219        if isinstance(year, int):220            details["construction_year"] = year221        phone = ((payload.get("contact") or {}).get("phoneNumber") or "").strip()222        if phone:223            details["contact"] = {"phone": phone}224        if details:225            lst.details = details226227        # description libre éventuelle (keyfact « free-text »)228        free = str(kf.get("free-text") or "").strip()229        if free:230            lst.description = (lst.description + " — " + free)[:600] \231                if lst.description else free[:600]232233        # photos pleine résolution du détail (600 px au lieu de 400 px)234        if payload.get("images"):235            lst.images = payload["images"][:30]236237    def _unit_listing(self, u: dict) -> Listing | None:238        addr = u.get("address") or {}239        kf = u.get("keyfacts") or {}240        prov = (addr.get("province") or "").upper()241        city_key = strip_accents((addr.get("city") or "").strip().lower())242        if prov == "ON":243            if city_key not in _ON_CITIES:244                return None245            city, forced_sector = _ON_CITIES[city_key]246        else:247            return None   # QC (Montréal) is out of Rent-Ka's scope248        sector = forced_sector or (addr.get("borough") or "").strip()249250        uid = str(u.get("id") or "").strip()251        if not uid:252            return None253        street = (addr.get("streetName") or "").strip()254        postal = (addr.get("postalCode") or "").strip()255256        beds = kf.get("number-of-bedrooms")257        unit_type = _BED_TYPES.get(beds, "") if isinstance(beds, int) else ""258        apt_type = (kf.get("apartment-type") or "").strip()259        if not unit_type and apt_type == "loft":260            unit_type = "Loft"261262        rent = kf.get("total-rent")263        price = float(rent) if isinstance(rent, (int, float)) and rent else None264265        if kf.get("is-available-from-now-on"):266            availability = "Libre maintenant"267        else:268            availability = (kf.get("available-from-date") or "")[:10]269            if availability:270                availability = f"Disponible le {availability}"271272        size = kf.get("unit-size")273        baths = kf.get("number-of-bathrooms")274        floor = kf.get("floor")275        desc_bits = []276        if apt_type:277            desc_bits.append(f"Type : {apt_type}")278        if size:279            desc_bits.append(f"{size} pi²")280        if baths:281            desc_bits.append(f"{baths} salle(s) de bain")282        if floor is not None:283            desc_bits.append(f"étage {floor}")284        if kf.get("free-rent"):285            desc_bits.append(f"promotion : {kf['free-rent']}")286287        amenities = []288        if size:289            amenities.append(f"{size} pi²")290        if baths:291            amenities.append(f"{baths} sdb")292293        images = [i for i in (u.get("imageUrls") or [])294                  if isinstance(i, str) and i.startswith("http")][:30]295296        last = f"ON {postal}".strip()297        title = f"{street} — unité {uid.split('-')[-1]}" if street else uid298        return Listing(299            source=self.source_id,300            external_id=uid,301            url=f"{BASE}/en/search/canada/detail/{uid}",302            title=title,303            address=", ".join(x for x in [street, city, last] if x),304            sector=sector,305            city=city,306            province=prov,307            unit_type=unit_type,308            price=price,309            price_label=f"{int(rent)} $/mois" if price else "",310            availability=availability,311            description=" — ".join(desc_bits)[:600],312            amenities=amenities,313            images=images,314            lat=addr.get("latitude"),315            lng=addr.get("longitude"),316        )317