# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/morguard.py : Morguard (morguardliving.ca) # Pan-Canadian REIT (~126 buildings) on the Rentsync platform: the site is # an SPA but its official backend (website-gateway.rentsync.com, no auth) # is queried directly — /properties for buildings, /units for available # units, /photos for galleries. Rent-Ka keeps every building whose postal # code is OUTSIDE Québec (first letter not G/H/J); the province is derived # from the postal-code first letter (Canada Post scheme) since the gateway # exposes no city/province names. Known Rentsync cityIds map to display # city names (validated by postal FSA 2026-08-26); unknown cityIds keep an # empty city — the address + coordinates still locate the listing and the # geocoding/enrichment passes fill the rest. One listing per available # unit (available == 1). # ----------------------------------------------------------------------------- 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" # Postal-code first letter -> province (Canada Post; G/H/J = Québec excluded) _POSTAL_PROV = { "A": "NL", "B": "NS", "C": "PE", "E": "NB", "K": "ON", "L": "ON", "M": "ON", "N": "ON", "P": "ON", "R": "MB", "S": "SK", "T": "AB", "V": "BC", "X": "NT", "Y": "YT", } # Known Rentsync cityIds -> display city (the gateway has no city names) _CITY_BY_ID = { 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 1607: "London", # N6A 3370: "Windsor", # N8Y } # Bedroom count -> unit type _BED_TYPE = {0: "Studio", 1: "1 bedroom", 2: "2 bedrooms", 3: "3 bedrooms", 4: "4 bedrooms"} _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 max_units = 1200 # safety cap (whole portfolio outside QC) def fetch(self) -> list[Listing]: props = (self.get(f"{API}/properties", params={"limit": "300"}) .json().get("data") or []) # keep every enabled building outside Québec (postal not G/H/J) buildings: dict[int, dict] = {} for p in props: postal = (p.get("postal") or "").strip().upper() if postal[:1] not in _POSTAL_PROV: continue # Québec (G/H/J) or unknown scheme 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", "limit": "1500", }).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) postal = (b.get("postal") or "").strip().upper() prov = _POSTAL_PROV.get(postal[:1], "ON") city = _CITY_BY_ID.get(cid, "") 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"From ${int(price)}/month" if price else "", availability_date=avail_date, area_sqft=sqft, pets=pets, furnished=furnished, description=description, details=details, images=images, lat=lat, lng=lng, )