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/morguard.py : Morguard (morguardliving.ca)5# Pan-Canadian REIT (~126 buildings) on the Rentsync platform: the site is6# an SPA but its official backend (website-gateway.rentsync.com, no auth)7# is queried directly — /properties for buildings, /units for available8# units, /photos for galleries. Rent-Ka keeps every building whose postal9# code is OUTSIDE Québec (first letter not G/H/J); the province is derived10# from the postal-code first letter (Canada Post scheme) since the gateway11# exposes no city/province names. Known Rentsync cityIds map to display12# city names (validated by postal FSA 2026-08-26); unknown cityIds keep an13# empty city — the address + coordinates still locate the listing and the14# geocoding/enrichment passes fill the rest. One listing per available15# unit (available == 1).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import html as htmllib20import os21import re2223from ..schema import Listing24from .base import BaseConnector2526SITE = "https://www.morguardliving.ca"27API = "https://website-gateway.rentsync.com/v1/morguard_invest"28IMG_BASE = "https://s3.amazonaws.com/lws_lift/morguard_invest/images/gallery/1152"2930# Postal-code first letter -> province (Canada Post; G/H/J = Québec excluded)31_POSTAL_PROV = {32 "A": "NL", "B": "NS", "C": "PE", "E": "NB",33 "K": "ON", "L": "ON", "M": "ON", "N": "ON", "P": "ON",34 "R": "MB", "S": "SK", "T": "AB", "V": "BC",35 "X": "NT", "Y": "YT",36}3738# Known Rentsync cityIds -> display city (the gateway has no city names)39_CITY_BY_ID = {40 3133: "Toronto", # M6K (Parkdale…)41 2015: "Toronto", # M3A (North York)42 1837: "Mississauga", # L4X43 329: "Brampton", # L6W44 2042: "Oakville", # L6K45 387: "Burlington", # L7T46 1174: "Hamilton", # L8S47 2084: "Ottawa", # K1V…48 1425: "Kitchener", # N2C49 3284: "Waterloo", # N2L50 1607: "London", # N6A51 3370: "Windsor", # N8Y52}5354# Bedroom count -> unit type55_BED_TYPE = {0: "Studio", 1: "1 bedroom", 2: "2 bedrooms", 3: "3 bedrooms",56 4: "4 bedrooms"}5758_TAG_RE = re.compile(r"<[^>]+>")59_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")606162def _clean(txt: str) -> str:63 txt = htmllib.unescape(htmllib.unescape(txt or ""))64 txt = _TAG_RE.sub(" ", txt)65 return re.sub(r"\s+", " ", txt).strip()666768class MorguardConnector(BaseConnector):69 source_id = "morguard"70 request_delay = 0.671 max_units = 1200 # safety cap (whole portfolio outside QC)7273 def fetch(self) -> list[Listing]:74 props = (self.get(f"{API}/properties", params={"limit": "300"})75 .json().get("data") or [])76 # keep every enabled building outside Québec (postal not G/H/J)77 buildings: dict[int, dict] = {}78 for p in props:79 postal = (p.get("postal") or "").strip().upper()80 if postal[:1] not in _POSTAL_PROV:81 continue # Québec (G/H/J) or unknown scheme82 if (p.get("status") or "") != "enabled":83 continue84 buildings[int(p["id"])] = p85 if not buildings:86 return []8788 ids = "|".join(str(i) for i in sorted(buildings))89 units = (self.get(f"{API}/units", params={90 "where": f"buildingId~in:{ids},status~in:enabled",91 "limit": "1500",92 }).json().get("data") or [])9394 listings: list[Listing] = []95 photo_cache: dict[int, list[str]] = {}96 count = 097 for u in units:98 try:99 if int(u.get("available") or 0) != 1:100 continue101 b = buildings.get(int(u.get("buildingId") or 0))102 if b is None:103 continue104 if count >= self.max_units:105 break106 count += 1107 listings.append(self._unit_listing(u, b, photo_cache))108 except Exception:109 continue110 return listings111112 # -- galerie photos de l'immeuble (endpoint /photos, mise en cache BD) -----113 def _building_photos(self, bid: int, cache: dict[int, list[str]],114 key: str) -> list[str]:115 if bid in cache:116 return cache[bid]117118 def _fetch() -> dict:119 data = (self.get(f"{API}/photos", params={120 "relations": "buildingsHasPhotos:bhp",121 "where": f"bhp.buildingId~in:{bid}",122 "limit": "40",123 }).json().get("data") or [])124 imgs = [f"{IMG_BASE}/{ph['image']}" for ph in data125 if ph.get("image") and ph.get("active")]126 return {"images": imgs[:25]}127128 payload = self.detail(f"photos-{bid}", key, _fetch)129 cache[bid] = payload.get("images") or []130 return cache[bid]131132 # -- annonce par unité disponible -------------------------------------------133 def _unit_listing(self, u: dict, b: dict,134 photo_cache: dict[int, list[str]]) -> Listing:135 bid = int(b["id"])136 name = _clean(b.get("buildingName") or "")137 street = _clean(f"{b.get('streetNumber') or ''} "138 f"{b.get('streetName') or ''}")139 cid = int(b.get("cityId") or 0)140 postal = (b.get("postal") or "").strip().upper()141 prov = _POSTAL_PROV.get(postal[:1], "ON")142 city = _CITY_BY_ID.get(cid, "")143 address = f"{street}, {city}, {prov} {postal}".strip(" ,")144 sector = _clean(b.get("neighbourhood") or "")145146 try:147 lat, lng = float(b["latitude"]), float(b["longitude"])148 except (TypeError, KeyError, ValueError):149 lat = lng = None150151 type_name = _clean(u.get("typeName") or "")152 bed = u.get("bed")153 unit_type = _BED_TYPE.get(int(bed), "") if bed is not None else ""154155 rate = u.get("rateMin") or u.get("rate")156 try:157 price = float(rate) if rate else None158 except (TypeError, ValueError):159 price = None160 if price is not None and not (100 <= price <= 20000):161 price = None162163 try:164 bath = float(u.get("bath")) if u.get("bath") is not None else None165 except (TypeError, ValueError):166 bath = None167 try:168 sqft = float(u.get("sqFtMin") or u.get("sqFt") or 0) or None169 except (TypeError, ValueError):170 sqft = None171172 # date de disponibilité ISO (ex. "2026-09-01T00:00:00.000Z")173 avail_date = None174 m = _DATE_RE.match(str(u.get("availabilityDate") or ""))175 if m:176 avail_date = m.group(1)177178 desc = _clean(u.get("description") or "")179 overview = _clean(b.get("buildingOverview") or "")180 description = (f"{desc} {overview}".strip())[:600]181182 furnished = True if int(u.get("furnished") or 0) == 1 else None183 pets = None184 if b.get("petFriendly") is not None:185 pets = "oui" if int(b.get("petFriendly") or 0) == 1 else "non"186187 details: dict = {}188 contact = {}189 if _clean(b.get("phone") or ""):190 contact["phone"] = _clean(b["phone"])191 if _clean(b.get("email") or ""):192 contact["email"] = _clean(b["email"])193 if contact:194 details["contact"] = contact195 parking = _clean(b.get("parking") or "")196 if parking:197 details["parking"] = {"available": True, "notes": parking[:120]}198199 # galerie : revisitée seulement quand l'immeuble est modifié200 images = self._building_photos(201 bid, photo_cache, key=str(b.get("modified") or ""))202203 perma = (b.get("permaLink") or "").strip("/")204 url = f"{SITE}/residential/{perma}" if perma else SITE205206 return Listing(207 source=self.source_id,208 external_id=str(u["id"]),209 url=url,210 title=f"{name} — {type_name}" if type_name else name,211 address=address,212 sector=sector,213 city=city,214 province=prov,215 unit_type=unit_type,216 bedrooms=float(bed) if bed is not None else None,217 bathrooms=bath,218 price=price,219 price_label=f"From ${int(price)}/month" if price else "",220 availability_date=avail_date,221 area_sqft=sqft,222 pets=pets,223 furnished=furnished,224 description=description,225 details=details,226 images=images,227 lat=lat,228 lng=lng,229 )230