SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
8.6 KB · 230 lines python
Raw Blame History
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