SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
9.4 KB · 242 lines python
Raw Blame History
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