spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/akelius.py : connecteur Akelius Residential (rent.akelius.com)5# Page de recherche Canada rendue côté serveur (Angular SSR) : l'état6# TransferState (<script id="akeliusWebsite-state">) contient le JSON complet7# des unités canadiennes (adresse, loyer, pi², chambres, photos). On filtre8# sur le Grand Montréal (Montréal, Westmount, Mont-Royal, Saint-Lambert,9# Greenfield Park) — Toronto/Ottawa/Gatineau exclus.10# Chaque unité a aussi un JSON détail (/lettings/marketing/v2/CA/<id>.json)11# avec les keyfacts complets : inclusions (chauffage, eau chaude,12# électricité, internet), électroménagers, animaux, meublé, ascenseur,13# piscine, gym, année de construction, téléphone — visité via le cache14# self.detail() (max ~150 nouveaux fetchs par synchronisation).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import re2122from ..schema import Listing, strip_accents23from .base import BaseConnector2425BASE = "https://rent.akelius.com"26SEARCH_URL = f"{BASE}/en/search/canada/apartment/montreal"27DETAIL_URL = BASE + "/lettings/marketing/v2/CA/{uid}.json"28STATE_RE = re.compile(29 r'<script id="akeliusWebsite-state" type="application/json">(.*?)</script>',30 re.S)3132# Villes admissibles (Grand Montréal) -> (ville normalisée, secteur imposé)33_GM_CITIES = {34 "montreal": ("Montréal", None), # secteur = borough du flux35 "westmount": ("Westmount", "Westmount"),36 "mont-royal": ("Mont-Royal", "Mont-Royal"),37 "saint-lambert": ("Saint-Lambert", "Saint-Lambert"),38 "greenfield park": ("Longueuil", "Greenfield Park"),39}4041_BED_TYPES = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}4243# keyfacts booléens du JSON détail -> libellé de commodité (si vrai)44_KF_AMENITIES = {45 "has-air-conditioning": "Climatisation",46 "has-central-air-conditioning": "Climatisation centrale",47 "has-balcony": "Balcon",48 "has-terrace": "Terrasse",49 "has-bicycle-racks": "Supports à vélos",50 "has-blinds": "Stores",51 "has-bosch-built-in-appliances": "Électroménagers encastrés Bosch",52 "has-canada-post-parcel-locker": "Casier à colis Postes Canada",53 "has-central-vacuuming-system": "Aspirateur central",54 "has-concierge": "Concierge",55 "has-dishwasher": "Lave-vaisselle",56 "has-dryer": "Sécheuse",57 "has-washing-machine": "Laveuse",58 "has-washer-dryer": "Laveuse-sécheuse",59 "has-elevator": "Ascenseur",60 "has-fitness-centre": "Salle d'entraînement",61 "has-heated-bathroom-floor": "Plancher de salle de bain chauffant",62 "has-indoor-pool": "Piscine intérieure",63 "has-outdoor-pool": "Piscine extérieure",64 "has-laundry-room": "Buanderie",65 "has-microwave": "Micro-ondes",66 "has-openplan-kitchen": "Cuisine à aire ouverte",67 "has-sauna": "Sauna",68 "has-wine-fridge": "Cellier à vin",69 "is-broadband-included-in-rent": "Internet inclus",70 "is-electricity-included-in-rent": "Électricité incluse",71 "is-gas-included-in-rent": "Gaz inclus",72 "is-heating-included-in-rent": "Chauffage inclus",73 "is-hot-water-included-in-rent": "Eau chaude incluse",74 "is-water-included-in-rent": "Eau incluse",75 "is-refurbished": "Rénové",76 "is-smart-home": "Logement intelligent",77}787980class _DetailBudget(Exception):81 """Budget de nouvelles pages détail épuisé pour cette synchronisation."""828384class AkeliusConnector(BaseConnector):85 source_id = "akelius"86 request_delay = 0.687 max_units = 400 # garde-fou88 max_details = 150 # nouveaux JSON détail max par synchronisation8990 def fetch(self) -> list[Listing]:91 html = self.get(SEARCH_URL).text92 m = STATE_RE.search(html)93 if not m:94 return []95 # TransferState Angular : les guillemets sont encodés « &q; »96 state = json.loads(m.group(1).replace("&q;", '"'))9798 # La clé du cache API est un hash variable : on repère la liste d'unités99 units: list[dict] = []100 for val in state.values():101 body = val.get("b") if isinstance(val, dict) else None102 if (isinstance(body, list) and body103 and isinstance(body[0], dict) and "keyfacts" in body[0]):104 units = body105 break106107 self._detail_fetches = 0108 listings: list[Listing] = []109 for u in units[: self.max_units]:110 try:111 lst = self._unit_listing(u)112 if lst:113 self._enrich(lst, u)114 listings.append(lst)115 except Exception:116 continue117 return listings118119 # -- JSON détail (keyfacts complets) ---------------------------------------120 def _fetch_detail(self, uid: str) -> dict:121 try:122 resp = self.get(DETAIL_URL.format(uid=uid))123 data = json.loads(resp.content.decode("utf-8-sig"))124 except Exception:125 return {}126 if not isinstance(data, dict):127 return {}128 docs = data.get("documents") or []129 images = [d.get("mediumUrl") or d.get("originalImageUrl")130 for d in docs if isinstance(d, dict) and not d.get("isExample")]131 return {132 "keyfacts": data.get("keyfacts") or {},133 "contact": data.get("contactDetails") or {},134 "images": [i for i in images if isinstance(i, str)][:30],135 }136137 def _enrich(self, lst: Listing, u: dict) -> None:138 """Complète l'annonce avec le JSON détail (via cache self.detail)."""139 key = hashlib.sha1(json.dumps(140 {"rent": (u.get("keyfacts") or {}).get("total-rent"),141 "avail": (u.get("keyfacts") or {}).get("available-from-date"),142 "pub": u.get("lastPublishedDate")},143 sort_keys=True).encode("utf-8")).hexdigest()144145 def fetch_fn():146 if self._detail_fetches >= self.max_details:147 raise _DetailBudget()148 self._detail_fetches += 1149 return self._fetch_detail(lst.external_id)150151 try:152 payload = self.detail(lst.external_id, key, fetch_fn)153 except _DetailBudget:154 return155 kf = payload.get("keyfacts") or {}156 if not kf:157 return158159 # commodités (libellés français, seulement les keyfacts vrais)160 for k, label in _KF_AMENITIES.items():161 if kf.get(k) and label not in lst.amenities:162 lst.amenities.append(label)163164 # animaux / meublé (valeurs structurées de la source)165 pets = str(kf.get("pets-allowed") or "").strip().lower()166 if pets == "yes":167 lst.pets = "oui"168 elif pets == "no":169 lst.pets = "non"170 elif pets:171 lst.pets = "conditions"172 furn = str(kf.get("furnished-state") or "").strip().lower()173 if furn == "furnished":174 lst.furnished = True175 elif furn == "unfurnished":176 lst.furnished = False177178 # details structurés (booléens explicites du JSON — jamais devinés)179 details: dict = {}180 inclusions = {}181 for src, dst in (("is-heating-included-in-rent", "heating"),182 ("is-electricity-included-in-rent", "electricity"),183 ("is-hot-water-included-in-rent", "hot_water"),184 ("is-broadband-included-in-rent", "internet")):185 if isinstance(kf.get(src), bool):186 inclusions[dst] = kf[src]187 if inclusions:188 details["inclusions"] = inclusions189 appliances = {}190 if isinstance(kf.get("has-dishwasher"), bool):191 appliances["dishwasher"] = kf["has-dishwasher"]192 if isinstance(kf.get("has-washer-dryer"), bool):193 wd = kf["has-washer-dryer"] or (194 bool(kf.get("has-washing-machine")) and bool(kf.get("has-dryer")))195 appliances["washer_dryer"] = wd196 if appliances:197 details["appliances"] = appliances198 if isinstance(kf.get("has-air-conditioning"), bool):199 details["ac"] = (kf["has-air-conditioning"]200 or bool(kf.get("has-central-air-conditioning")))201 if isinstance(kf.get("has-elevator"), bool):202 details["elevator"] = kf["has-elevator"]203 if isinstance(kf.get("has-balcony"), bool):204 details["balcony"] = kf["has-balcony"] or bool(kf.get("has-terrace"))205 if isinstance(kf.get("has-indoor-pool"), bool) or \206 isinstance(kf.get("has-outdoor-pool"), bool):207 details["pool"] = bool(kf.get("has-indoor-pool")) or \208 bool(kf.get("has-outdoor-pool"))209 if isinstance(kf.get("has-fitness-centre"), bool):210 details["gym"] = kf["has-fitness-centre"]211 if isinstance(kf.get("has-laundry-room"), bool):212 details["laundry"] = kf["has-laundry-room"]213 year = kf.get("construction-year")214 if isinstance(year, int):215 details["construction_year"] = year216 phone = ((payload.get("contact") or {}).get("phoneNumber") or "").strip()217 if phone:218 details["contact"] = {"phone": phone}219 if details:220 lst.details = details221222 # description libre éventuelle (keyfact « free-text »)223 free = str(kf.get("free-text") or "").strip()224 if free:225 lst.description = (lst.description + " — " + free)[:600] \226 if lst.description else free[:600]227228 # photos pleine résolution du détail (600 px au lieu de 400 px)229 if payload.get("images"):230 lst.images = payload["images"][:30]231232 def _unit_listing(self, u: dict) -> Listing | None:233 addr = u.get("address") or {}234 kf = u.get("keyfacts") or {}235 if (addr.get("province") or "").upper() != "QC":236 return None237 city_key = strip_accents((addr.get("city") or "").strip().lower())238 if city_key not in _GM_CITIES:239 return None240 city, forced_sector = _GM_CITIES[city_key]241 sector = forced_sector or (addr.get("borough") or "").strip()242243 uid = str(u.get("id") or "").strip()244 if not uid:245 return None246 street = (addr.get("streetName") or "").strip()247 postal = (addr.get("postalCode") or "").strip()248249 beds = kf.get("number-of-bedrooms")250 unit_type = _BED_TYPES.get(beds, "") if isinstance(beds, int) else ""251 apt_type = (kf.get("apartment-type") or "").strip()252 if not unit_type and apt_type == "loft":253 unit_type = "Loft"254255 rent = kf.get("total-rent")256 price = float(rent) if isinstance(rent, (int, float)) and rent else None257258 if kf.get("is-available-from-now-on"):259 availability = "Libre maintenant"260 else:261 availability = (kf.get("available-from-date") or "")[:10]262 if availability:263 availability = f"Disponible le {availability}"264265 size = kf.get("unit-size")266 baths = kf.get("number-of-bathrooms")267 floor = kf.get("floor")268 desc_bits = []269 if apt_type:270 desc_bits.append(f"Type : {apt_type}")271 if size:272 desc_bits.append(f"{size} pi²")273 if baths:274 desc_bits.append(f"{baths} salle(s) de bain")275 if floor is not None:276 desc_bits.append(f"étage {floor}")277 if kf.get("free-rent"):278 desc_bits.append(f"promotion : {kf['free-rent']}")279280 amenities = []281 if size:282 amenities.append(f"{size} pi²")283 if baths:284 amenities.append(f"{baths} sdb")285286 images = [i for i in (u.get("imageUrls") or [])287 if isinstance(i, str) and i.startswith("http")][:30]288289 title = f"{street} — unité {uid.split('-')[-1]}" if street else uid290 return Listing(291 source=self.source_id,292 external_id=uid,293 url=f"{BASE}/en/search/canada/detail/{uid}",294 title=title,295 address=", ".join(x for x in [street, city, postal] if x),296 sector=sector,297 city=city,298 unit_type=unit_type,299 price=price,300 price_label=f"{int(rent)} $/mois" if price else "",301 availability=availability,302 description=" — ".join(desc_bits)[:600],303 amenities=amenities,304 images=images,305 lat=addr.get("latitude"),306 lng=addr.get("longitude"),307 )308