# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/forum_mtl.py : Le Forum / Forum Properties (forumproperties.com) — # promoteur avec immeubles locatifs au Québec : Berson Apartments (Plateau, # Montréal), Lustra Ph. 1 (Terrebonne), 100 Windsor (Westmount). # Techno : widget Planpoint — l'index résidentiel passe par l'endpoint # POST app.planpoint.io/api/enterprises/find {namespace, hostName} qui # renvoie {projects:[...]} avec floors[].units[] inline. On réutilise le # helper partagé _planpoint.unit_listing pour le mapping unité -> Listing. # Portefeuille pancanadien : on ne garde que les projets au Québec # (adresse « , QC » ou lat/lng dans la province). # Granularité : UNITÉ (« Available » ; prix publié sur Lustra seulement). # ----------------------------------------------------------------------------- from __future__ import annotations import time from .base import BaseConnector from ._planpoint import PLANPOINT_API, unit_listing from ..schema import Listing NAMESPACE = "forum-residential-enterprise" HOST_NAME = "forum-residential" FALLBACK_URL = "https://www.forumproperties.com/fr/residentiel/" # adresses manquantes chez Planpoint (clé = namespace du projet) _ADDRESS_FIX = { "100-1-windsor": "100 avenue Windsor, Westmount", } def _in_quebec(project: dict) -> bool: addr = (project.get("address") or "") if ", QC" in addr or ", Qc" in addr or "Québec" in addr: return True lat, lon = project.get("lat"), project.get("lon") if isinstance(lat, (int, float)) and isinstance(lon, (int, float)): return 44.5 <= lat <= 63.0 and -80.0 <= lon <= -56.0 return False class ForumMtlConnector(BaseConnector): """Forum Properties — unités Planpoint des immeubles québécois.""" source_id = "forum_mtl" request_delay = 1.0 use_detail_cache = False # tout est inline dans enterprises/find def _enterprise_projects(self) -> list[dict]: """POST /api/enterprises/find -> projets (floors/units inline).""" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.post(f"{PLANPOINT_API}/enterprises/find", json={"namespace": NAMESPACE, "hostName": HOST_NAME}, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return resp.json().get("projects") or [] def fetch(self) -> list[Listing]: out: list[Listing] = [] seen: set[str] = set() for project in self._enterprise_projects(): if not _in_quebec(project): continue # AB/BC : hors périmètre Lou-Ka ns = project.get("namespace") or "" if not (project.get("address") or "").strip() and ns in _ADDRESS_FIX: project["address"] = _ADDRESS_FIX[ns] urls = project.get("internalURLs") or {} page_url = (urls.get("fr") or urls.get("en") or project.get("websiteURL") or project.get("internalURL") or FALLBACK_URL).strip() for floor in project.get("floors") or []: for unit in floor.get("units") or []: lst = unit_listing(self.source_id, project, floor, unit, page_url=page_url, default_city="Montréal") if lst and lst.external_id not in seen: seen.add(lst.external_id) out.append(lst) return out