# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/devimco.py : connecteur Devimco Appartements # (devimco.com/appartements — St Ann & Hexagone à Griffintown, Alexander & # Maestria au centre-ville de Montréal, Éolia / Luméo / Nobel à Brossard, # Ostral à Longueuil). # Chaque page projet embarque un iframe Planpoint (app.planpoint.io) ; # l'inventaire complet s'obtient via l'API JSON de Planpoint : # POST https://app.planpoint.io/api/projects/find {namespace, hostName} # -> floors[] -> units[] (prix, chambres, photos, disponibilité...). # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import re import time import urllib.parse from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://devimco.com" HUB_URL = f"{BASE}/appartements" PLANPOINT_FIND = "https://app.planpoint.io/api/projects/find" PLANPOINT_GROUP_FIND = "https://app.planpoint.io/api/groups/find" PROJECT_RE = re.compile( r"https://devimco\.com/appartements/a-louer/" r"(montreal|rive-sud-de-montreal)/([a-z\-]+)/([a-z0-9\-]+)") # secteur d'URL -> (ville, secteur affiché) SECTORS = { "centre-ville": ("Montréal", "Centre-ville"), "griffintown": ("Montréal", "Griffintown"), "brossard": ("Brossard", "Quartier DIX30"), "longueuil": ("Longueuil", "Vieux-Longueuil"), } def _unit_type(bedrooms: str) -> str: s = (bedrooms or "").lower() if "studio" in s: return "Studio" m = re.search(r"(\d+)", s) if not m: return bedrooms or "" n = int(m.group(1)) return "Studio" if n == 0 else f"{n + 2}½" # Jetons de la liste `inclusions` de l'API Planpoint -> details canoniques. # Champ structuré côté source (liste séparée par des virgules), donc mappé # explicitement ; un jeton absent = inconnu (jamais False). _INCLUSION_TOKENS = { "heating": ("inclusions", "heating"), "electricity": ("inclusions", "electricity"), "hot water": ("inclusions", "hot_water"), "internet": ("inclusions", "internet"), "cable": ("inclusions", "cable"), "stove": ("appliances", "stove"), "refrigerator": ("appliances", "fridge"), "fridge": ("appliances", "fridge"), "dishwasher": ("appliances", "dishwasher"), "air conditioning": (None, "ac"), "balcony": (None, "balcony"), } def _details_from_inclusions(tokens: list[str]) -> dict: """« internet, electricity, heating, stove, refrigerator… » -> details.""" details: dict = {} lows = [t.lower() for t in tokens] for tok in lows: for key, (group, name) in _INCLUSION_TOKENS.items(): if key in tok: if group: details.setdefault(group, {})[name] = True else: details[name] = True if "washer" in " ".join(lows) and "dryer" in " ".join(lows): details.setdefault("appliances", {})["washer_dryer"] = True return details class DevimcoConnector(BaseConnector): source_id = "devimco" request_delay = 0.6 max_projects = 15 # garde-fou def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: hub = self.get(HUB_URL).text except Exception: return listings # 1) Pages projets (a-louer///) projects: dict[str, tuple[str, str]] = {} for m in PROJECT_RE.finditer(hub): url = m.group(0) sector_slug, proj_slug = m.group(2), m.group(3) # ignorer les pages de secteur (sans slug projet "…-appartements") if proj_slug.endswith("-appartements"): projects[url] = (sector_slug, proj_slug) for i, (proj_url, (sector_slug, proj_slug)) in \ enumerate(projects.items()): if i >= self.max_projects: break try: listings.extend( self._fetch_project(proj_url, sector_slug)) except Exception: continue return listings # -- un projet --------------------------------------------------------------- def _fetch_project(self, proj_url: str, sector_slug: str) -> list[Listing]: out: list[Listing] = [] html = self.get(proj_url).text # iframe Planpoint : soit un projet (//), soit un groupe # de phases (/g/) projects: list[dict] = [] m = re.search( r"https://app\.planpoint\.io/g/([a-z0-9\-]+)", html) if m: group = self._planpoint(PLANPOINT_GROUP_FIND, {"namespace": m.group(1)}) projects = group.get("projects") or [] else: m = re.search( r"https://app\.planpoint\.io/([a-z0-9\-]+)/([a-z0-9\-]+)\?", html) if not m: return out projects = [self._planpoint( PLANPOINT_FIND, {"namespace": m.group(1), "hostName": m.group(2)})] # Adresse affichée sur la page Devimco (lien Google Maps) : repli # pour les phases dont l'API Planpoint n'a pas d'adresse # (ex. Maestria Tour B, Hexagone 2). page_address = "" m = re.search(r"google\.[a-z.]+/maps/search/\?[^\"']*query=([^\"'&]+)", html) if m: page_address = htmllib.unescape( urllib.parse.unquote(m.group(1))).strip() city, sector = SECTORS.get(sector_slug, ("Montréal", sector_slug)) for project in projects: try: out.extend(self._parse_units(project, proj_url, city, sector, page_address)) except Exception: continue return out def _planpoint(self, endpoint: str, payload: dict) -> dict: wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.post(endpoint, json=payload, timeout=60) self._last_request = time.time() resp.raise_for_status() return resp.json() or {} def _parse_units(self, project: dict, proj_url: str, city: str, sector: str, page_address: str = "") -> list[Listing]: out: list[Listing] = [] namespace = project.get("namespace") or "" name = project.get("name") or namespace address = (project.get("address") or "").split(", Quebec")[0] if not address: address = page_address lat, lng = project.get("lat"), project.get("lng") or project.get("lon") for floor in project.get("floors") or []: floor_name = floor.get("name") or "" for u in floor.get("units") or []: try: if (u.get("availability") or "").lower() != "available": continue # loué / réservé / à venir price = u.get("price") if u.get("unitPriceTBD") or not price: continue # pas de prix affiché unit_name = str(u.get("name") or "") images = [img for img in (u.get("images") or []) + (u.get("layoutGallery") or []) if isinstance(img, str) and img.startswith("http")] inclusions = [s.strip() for s in (u.get("inclusions") or "").split(",") if s.strip()] sqft = u.get("squareFeet") amenities = list(inclusions) if sqft: amenities.append(f"{sqft} pi²") if u.get("bathrooms"): amenities.append(f"{u['bathrooms']} salle(s) de bain") if u.get("furnished"): amenities.append("Meublé") availability = "Disponible" if u.get("deliveryDate"): availability = f"Disponible : {u['deliveryDate']}" # Superficie structurée de l'API (sinon None) area = None try: if sqft and 80 <= float(sqft) <= 20000: area = float(sqft) except (TypeError, ValueError): pass # Meublé : booléen explicite de l'API furnished = (bool(u["furnished"]) if isinstance(u.get("furnished"), bool) else None) # Type exact « 3.5 » de l'API, sinon via nb de chambres unit_type = (normalize_unit_type(str(u.get("type") or "")) or _unit_type(u.get("bedrooms") or "")) out.append(Listing( source=self.source_id, external_id=u.get("_id") or f"{namespace}-{unit_name}", url=proj_url, title=f"{name} — unité {unit_name}", address=address, sector=sector, city=city, unit_type=unit_type, price=float(price) if 100 <= float(price) <= 20000 else None, price_label=f"{int(price)} $/mois", availability=availability, area_sqft=area, furnished=furnished, description=(f"Étage {floor_name} — " f"{u.get('orientation') or ''}").strip(" —"), amenities=list(dict.fromkeys(amenities)), details=_details_from_inclusions(inclusions), images=list(dict.fromkeys(images)), lat=float(lat) if lat else None, lng=float(lng) if lng else None, )) except Exception: continue return out