# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/morguard.py : connecteur Morguard (morguardliving.ca) # REIT pancanadien (~126 immeubles) sur plateforme Rentsync : le site est une # SPA mais son backend officiel (website-gateway.rentsync.com, sans auth) est # interrogé directement — /properties pour les immeubles, /units pour les # unités disponibles, /photos pour les galeries. Seul le Québec est conservé # (code postal H/J/G) : 15 immeubles — centre-ville de Montréal (Maisonneuve, # Crescent, Mackay, des Pins…), Dollard-des-Ormeaux, Côte-Saint-Luc et # Pointe-Claire. Une annonce par unité disponible (available == 1). # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1, # les immeubles ON des marchés cibles (Toronto/GTA, Ottawa, Hamilton, KW) # sont aussi conservés via leur cityId Rentsync (le gateway n'expose pas de # nom de ville ni de province — mapping cityId->ville validé par FSA postal ; # London/Windsor hors marchés cibles exclus). province="ON", adresse « ON ». # Sans la variable, comportement strictement identique (QC seulement). # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import os import re from ..schema import Listing from .base import BaseConnector SITE = "https://www.morguardliving.ca" API = "https://website-gateway.rentsync.com/v1/morguard_invest" IMG_BASE = "https://s3.amazonaws.com/lws_lift/morguard_invest/images/gallery/1152" # Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, QC seulement _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" # cityId Rentsync -> ville (le gateway n'expose pas les noms de villes) _CITIES = { 1863: "Montréal", 765: "Dollard-des-Ormeaux", 33093: "Côte-Saint-Luc", 2213: "Pointe-Claire", } # cityId Rentsync -> ville Ontario (expansion 2026-08, sous LOUKA_ONTARIO). # Marchés cibles : Toronto/GTA, Ottawa, Hamilton, Kitchener-Waterloo. # Mapping validé par FSA des codes postaux du gateway (2026-08-26) ; # 1607 (London, N6A) et 3370 (Windsor, N8Y) volontairement exclus. _CITIES_ON = { 3133: "Toronto", # M6K (Parkdale…) 2015: "Toronto", # M3A (North York) 1837: "Mississauga", # L4X 329: "Brampton", # L6W 2042: "Oakville", # L6K 387: "Burlington", # L7T 1174: "Hamilton", # L8S 2084: "Ottawa", # K1V… 1425: "Kitchener", # N2C 3284: "Waterloo", # N2L } # Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres) _BED_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"} _TAG_RE = re.compile(r"<[^>]+>") _DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})") def _clean(txt: str) -> str: txt = htmllib.unescape(htmllib.unescape(txt or "")) txt = _TAG_RE.sub(" ", txt) return re.sub(r"\s+", " ", txt).strip() class MorguardConnector(BaseConnector): source_id = "morguard" request_delay = 0.6 # garde-fou (84 unités QC dispo à l'écriture ; plafond relevé avec l'ON) max_units = 900 if _ONTARIO else 300 def fetch(self) -> list[Listing]: props = (self.get(f"{API}/properties", params={"limit": "300"}) .json().get("data") or []) # Québec : codes postaux H (Mtl), J (couronnes), G (est) ; # Ontario (sous LOUKA_ONTARIO) : cityId des marchés cibles. buildings: dict[int, dict] = {} for p in props: postal = (p.get("postal") or "").strip().upper() is_qc = postal[:1] in ("H", "J", "G") is_on = _ONTARIO and int(p.get("cityId") or 0) in _CITIES_ON if not (is_qc or is_on): continue if (p.get("status") or "") != "enabled": continue buildings[int(p["id"])] = p if not buildings: return [] ids = "|".join(str(i) for i in sorted(buildings)) units = (self.get(f"{API}/units", params={ "where": f"buildingId~in:{ids},status~in:enabled", # ON inclus : ~6x plus d'immeubles, plafond relevé en conséquence "limit": "1500" if _ONTARIO else "500", }).json().get("data") or []) listings: list[Listing] = [] photo_cache: dict[int, list[str]] = {} count = 0 for u in units: try: if int(u.get("available") or 0) != 1: continue b = buildings.get(int(u.get("buildingId") or 0)) if b is None: continue if count >= self.max_units: break count += 1 listings.append(self._unit_listing(u, b, photo_cache)) except Exception: continue return listings # -- galerie photos de l'immeuble (endpoint /photos, mise en cache BD) ----- def _building_photos(self, bid: int, cache: dict[int, list[str]], key: str) -> list[str]: if bid in cache: return cache[bid] def _fetch() -> dict: data = (self.get(f"{API}/photos", params={ "relations": "buildingsHasPhotos:bhp", "where": f"bhp.buildingId~in:{bid}", "limit": "40", }).json().get("data") or []) imgs = [f"{IMG_BASE}/{ph['image']}" for ph in data if ph.get("image") and ph.get("active")] return {"images": imgs[:25]} payload = self.detail(f"photos-{bid}", key, _fetch) cache[bid] = payload.get("images") or [] return cache[bid] # -- annonce par unité disponible ------------------------------------------- def _unit_listing(self, u: dict, b: dict, photo_cache: dict[int, list[str]]) -> Listing: bid = int(b["id"]) name = _clean(b.get("buildingName") or "") street = _clean(f"{b.get('streetNumber') or ''} " f"{b.get('streetName') or ''}") cid = int(b.get("cityId") or 0) if _ONTARIO and cid in _CITIES_ON: prov, city = "ON", _CITIES_ON[cid] else: prov, city = "QC", _CITIES.get(cid, "Montréal") postal = (b.get("postal") or "").strip().upper() address = f"{street}, {city}, {prov} {postal}".strip(" ,") sector = _clean(b.get("neighbourhood") or "") try: lat, lng = float(b["latitude"]), float(b["longitude"]) except (TypeError, KeyError, ValueError): lat = lng = None type_name = _clean(u.get("typeName") or "") bed = u.get("bed") unit_type = _BED_TYPE.get(int(bed), "") if bed is not None else "" rate = u.get("rateMin") or u.get("rate") try: price = float(rate) if rate else None except (TypeError, ValueError): price = None if price is not None and not (100 <= price <= 20000): price = None try: bath = float(u.get("bath")) if u.get("bath") is not None else None except (TypeError, ValueError): bath = None try: sqft = float(u.get("sqFtMin") or u.get("sqFt") or 0) or None except (TypeError, ValueError): sqft = None # date de disponibilité ISO (ex. "2026-09-01T00:00:00.000Z") avail_date = None m = _DATE_RE.match(str(u.get("availabilityDate") or "")) if m: avail_date = m.group(1) desc = _clean(u.get("description") or "") overview = _clean(b.get("buildingOverview") or "") description = (f"{desc} {overview}".strip())[:600] furnished = True if int(u.get("furnished") or 0) == 1 else None pets = None if b.get("petFriendly") is not None: pets = "oui" if int(b.get("petFriendly") or 0) == 1 else "non" details: dict = {} contact = {} if _clean(b.get("phone") or ""): contact["phone"] = _clean(b["phone"]) if _clean(b.get("email") or ""): contact["email"] = _clean(b["email"]) if contact: details["contact"] = contact parking = _clean(b.get("parking") or "") if parking: details["parking"] = {"available": True, "notes": parking[:120]} # galerie : revisitée seulement quand l'immeuble est modifié images = self._building_photos( bid, photo_cache, key=str(b.get("modified") or "")) perma = (b.get("permaLink") or "").strip("/") url = f"{SITE}/residential/{perma}" if perma else SITE return Listing( source=self.source_id, external_id=str(u["id"]), url=url, title=f"{name} — {type_name}" if type_name else name, address=address, sector=sector, city=city, province=prov, unit_type=unit_type, bedrooms=float(bed) if bed is not None else None, bathrooms=bath, price=price, price_label=f"À partir de {int(price)} $/mois" if price else "", availability_date=avail_date, area_sqft=sqft, pets=pets, furnished=furnished, description=description, details=details, images=images, lat=lat, lng=lng, )