Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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