Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/morguard.py : connecteur Morguard (morguardliving.ca)5# REIT pancanadien (~126 immeubles) sur plateforme Rentsync : le site est une6# SPA mais son backend officiel (website-gateway.rentsync.com, sans auth) est7# interrogé directement — /properties pour les immeubles, /units pour les8# unités disponibles, /photos pour les galeries. Seul le Québec est conservé9# (code postal H/J/G) : 15 immeubles — centre-ville de Montréal (Maisonneuve,10# Crescent, Mackay, des Pins…), Dollard-des-Ormeaux, Côte-Saint-Luc et11# Pointe-Claire. Une annonce par unité disponible (available == 1).12# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,13# les immeubles ON des marchés cibles (Toronto/GTA, Ottawa, Hamilton, KW)14# sont aussi conservés via leur cityId Rentsync (le gateway n'expose pas de15# nom de ville ni de province — mapping cityId->ville validé par FSA postal ;16# London/Windsor hors marchés cibles exclus). province="ON", adresse « ON ».17# Sans la variable, comportement strictement identique (QC seulement).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import html as htmllib22import os23import re2425from ..schema import Listing26from .base import BaseConnector2728SITE = "https://www.morguardliving.ca"29API = "https://website-gateway.rentsync.com/v1/morguard_invest"30IMG_BASE = "https://s3.amazonaws.com/lws_lift/morguard_invest/images/gallery/1152"3132# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, QC seulement33_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"3435# cityId Rentsync -> ville (le gateway n'expose pas les noms de villes)36_CITIES = {37 1863: "Montréal",38 765: "Dollard-des-Ormeaux",39 33093: "Côte-Saint-Luc",40 2213: "Pointe-Claire",41}4243# cityId Rentsync -> ville Ontario (expansion 2026-08, sous LOUKA_ONTARIO).44# Marchés cibles : Toronto/GTA, Ottawa, Hamilton, Kitchener-Waterloo.45# Mapping validé par FSA des codes postaux du gateway (2026-08-26) ;46# 1607 (London, N6A) et 3370 (Windsor, N8Y) volontairement exclus.47_CITIES_ON = {48 3133: "Toronto", # M6K (Parkdale…)49 2015: "Toronto", # M3A (North York)50 1837: "Mississauga", # L4X51 329: "Brampton", # L6W52 2042: "Oakville", # L6K53 387: "Burlington", # L7T54 1174: "Hamilton", # L8S55 2084: "Ottawa", # K1V…56 1425: "Kitchener", # N2C57 3284: "Waterloo", # N2L58}5960# Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres)61_BED_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}6263_TAG_RE = re.compile(r"<[^>]+>")64_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")656667def _clean(txt: str) -> str:68 txt = htmllib.unescape(htmllib.unescape(txt or ""))69 txt = _TAG_RE.sub(" ", txt)70 return re.sub(r"\s+", " ", txt).strip()717273class MorguardConnector(BaseConnector):74 source_id = "morguard"75 request_delay = 0.676 # garde-fou (84 unités QC dispo à l'écriture ; plafond relevé avec l'ON)77 max_units = 900 if _ONTARIO else 3007879 def fetch(self) -> list[Listing]:80 props = (self.get(f"{API}/properties", params={"limit": "300"})81 .json().get("data") or [])82 # Québec : codes postaux H (Mtl), J (couronnes), G (est) ;83 # Ontario (sous LOUKA_ONTARIO) : cityId des marchés cibles.84 buildings: dict[int, dict] = {}85 for p in props:86 postal = (p.get("postal") or "").strip().upper()87 is_qc = postal[:1] in ("H", "J", "G")88 is_on = _ONTARIO and int(p.get("cityId") or 0) in _CITIES_ON89 if not (is_qc or is_on):90 continue91 if (p.get("status") or "") != "enabled":92 continue93 buildings[int(p["id"])] = p94 if not buildings:95 return []9697 ids = "|".join(str(i) for i in sorted(buildings))98 units = (self.get(f"{API}/units", params={99 "where": f"buildingId~in:{ids},status~in:enabled",100 # ON inclus : ~6x plus d'immeubles, plafond relevé en conséquence101 "limit": "1500" if _ONTARIO else "500",102 }).json().get("data") or [])103104 listings: list[Listing] = []105 photo_cache: dict[int, list[str]] = {}106 count = 0107 for u in units:108 try:109 if int(u.get("available") or 0) != 1:110 continue111 b = buildings.get(int(u.get("buildingId") or 0))112 if b is None:113 continue114 if count >= self.max_units:115 break116 count += 1117 listings.append(self._unit_listing(u, b, photo_cache))118 except Exception:119 continue120 return listings121122 # -- galerie photos de l'immeuble (endpoint /photos, mise en cache BD) -----123 def _building_photos(self, bid: int, cache: dict[int, list[str]],124 key: str) -> list[str]:125 if bid in cache:126 return cache[bid]127128 def _fetch() -> dict:129 data = (self.get(f"{API}/photos", params={130 "relations": "buildingsHasPhotos:bhp",131 "where": f"bhp.buildingId~in:{bid}",132 "limit": "40",133 }).json().get("data") or [])134 imgs = [f"{IMG_BASE}/{ph['image']}" for ph in data135 if ph.get("image") and ph.get("active")]136 return {"images": imgs[:25]}137138 payload = self.detail(f"photos-{bid}", key, _fetch)139 cache[bid] = payload.get("images") or []140 return cache[bid]141142 # -- annonce par unité disponible -------------------------------------------143 def _unit_listing(self, u: dict, b: dict,144 photo_cache: dict[int, list[str]]) -> Listing:145 bid = int(b["id"])146 name = _clean(b.get("buildingName") or "")147 street = _clean(f"{b.get('streetNumber') or ''} "148 f"{b.get('streetName') or ''}")149 cid = int(b.get("cityId") or 0)150 if _ONTARIO and cid in _CITIES_ON:151 prov, city = "ON", _CITIES_ON[cid]152 else:153 prov, city = "QC", _CITIES.get(cid, "Montréal")154 postal = (b.get("postal") or "").strip().upper()155 address = f"{street}, {city}, {prov} {postal}".strip(" ,")156 sector = _clean(b.get("neighbourhood") or "")157158 try:159 lat, lng = float(b["latitude"]), float(b["longitude"])160 except (TypeError, KeyError, ValueError):161 lat = lng = None162163 type_name = _clean(u.get("typeName") or "")164 bed = u.get("bed")165 unit_type = _BED_TYPE.get(int(bed), "") if bed is not None else ""166167 rate = u.get("rateMin") or u.get("rate")168 try:169 price = float(rate) if rate else None170 except (TypeError, ValueError):171 price = None172 if price is not None and not (100 <= price <= 20000):173 price = None174175 try:176 bath = float(u.get("bath")) if u.get("bath") is not None else None177 except (TypeError, ValueError):178 bath = None179 try:180 sqft = float(u.get("sqFtMin") or u.get("sqFt") or 0) or None181 except (TypeError, ValueError):182 sqft = None183184 # date de disponibilité ISO (ex. "2026-09-01T00:00:00.000Z")185 avail_date = None186 m = _DATE_RE.match(str(u.get("availabilityDate") or ""))187 if m:188 avail_date = m.group(1)189190 desc = _clean(u.get("description") or "")191 overview = _clean(b.get("buildingOverview") or "")192 description = (f"{desc} {overview}".strip())[:600]193194 furnished = True if int(u.get("furnished") or 0) == 1 else None195 pets = None196 if b.get("petFriendly") is not None:197 pets = "oui" if int(b.get("petFriendly") or 0) == 1 else "non"198199 details: dict = {}200 contact = {}201 if _clean(b.get("phone") or ""):202 contact["phone"] = _clean(b["phone"])203 if _clean(b.get("email") or ""):204 contact["email"] = _clean(b["email"])205 if contact:206 details["contact"] = contact207 parking = _clean(b.get("parking") or "")208 if parking:209 details["parking"] = {"available": True, "notes": parking[:120]}210211 # galerie : revisitée seulement quand l'immeuble est modifié212 images = self._building_photos(213 bid, photo_cache, key=str(b.get("modified") or ""))214215 perma = (b.get("permaLink") or "").strip("/")216 url = f"{SITE}/residential/{perma}" if perma else SITE217218 return Listing(219 source=self.source_id,220 external_id=str(u["id"]),221 url=url,222 title=f"{name} — {type_name}" if type_name else name,223 address=address,224 sector=sector,225 city=city,226 province=prov,227 unit_type=unit_type,228 bedrooms=float(bed) if bed is not None else None,229 bathrooms=bath,230 price=price,231 price_label=f"À partir de {int(price)} $/mois" if price else "",232 availability_date=avail_date,233 area_sqft=sqft,234 pets=pets,235 furnished=furnished,236 description=description,237 details=details,238 images=images,239 lat=lat,240 lng=lng,241 )242