# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/san_leon.py : connecteur San Leon (sanleon.ca) # Appartements locatifs neufs au Faubourg Boisbriand — 3230, avenue des # Grandes Tourelles, Boisbriand (phases 4 et 5, ~118 unités/phase). # Le site embarque Planpoint : l'API Next.js répond en JSON à # POST https://app.planpoint.io/api/groups/find # body {"namespace":"san-leon","hostName":"san-leon"} → projects[] → floors[] # → units[] (name, bedrooms, bathrooms, squareFeet, price — None en phase 5, # availability Available/Sold/Leased/Reserved, inclusions, images, delivery). # On ne garde que les unités « Available ». Granularité : unité. # ----------------------------------------------------------------------------- from __future__ import annotations import re from datetime import date as _date from ..schema import Listing from .base import BaseConnector API_URL = "https://app.planpoint.io/api/groups/find" API_BODY = {"namespace": "san-leon", "hostName": "san-leon"} SITE_URL = "https://sanleon.ca/" ADDRESS = "3230, avenue des Grandes Tourelles, Boisbriand" CITY = "Boisbriand" SLUG_RE = re.compile(r"[^a-z0-9]+") # « 11/1/26 » ou « 2025-01-01 » US_DATE_RE = re.compile(r"^(\d{1,2})/(\d{1,2})/(\d{2})$") class SanLeonConnector(BaseConnector): source_id = "san_leon" request_delay = 0.6 max_images = 10 @staticmethod def _unit_type(bedrooms: str | int | None) -> str: try: n = int(str(bedrooms).strip()) except (TypeError, ValueError): return "" return f"{n + 2}½" if 0 < n < 5 else ("Studio" if n == 0 else "") def fetch(self) -> list[Listing]: data = self.post(API_URL, json=API_BODY).json() listings: list[Listing] = [] seen: set[str] = set() for proj in data.get("projects") or []: pname = (proj.get("name") or "San Leon").strip() phase = pname.rsplit(" ", 1)[-1] if "Phase" in pname else "" pslug = SLUG_RE.sub("-", pname.lower()).strip("-") addr = (proj.get("address") or "").strip() or ADDRESS for floor in proj.get("floors") or []: fname = str(floor.get("name") or "").strip() for u in floor.get("units") or []: if (u.get("availability") or "").strip() != "Available": continue num = str(u.get("name") or "").strip() if not num: continue ext_id = f"{pslug}-{num}" if ext_id in seen: continue seen.add(ext_id) price = None try: v = float(u.get("price") or 0) if 300 <= v <= 20000: price = v except (TypeError, ValueError): pass area = None try: v = float(u.get("squareFeet") or 0) if 100 <= v <= 10000: area = v except (TypeError, ValueError): pass beds = None try: beds = float(int(str(u.get("bedrooms")).strip())) except (TypeError, ValueError): pass baths = None try: baths = float(u.get("bathrooms") or 0) or None except (TypeError, ValueError): pass availability = "Disponible" delivery = str(u.get("deliveryDate") or "").strip() m = US_DATE_RE.match(delivery) if m: delivery = f"20{m.group(3)}-{int(m.group(1)):02d}" \ f"-{int(m.group(2)):02d}" if delivery and delivery > _date.today().isoformat(): availability = f"Disponible ({delivery})" amenities = [a.strip() for a in re.split(r"[,;]", u.get("inclusions") or "") if a.strip() and a.strip() != "*"] imgs = [i for i in dict.fromkeys(u.get("images") or []) if isinstance(i, str) and i.startswith("http") ][: self.max_images] desc_bits = [ f"Unité {num}" + (f", étage {fname}" if fname else ""), pname if phase else "", f"Orientation {u['orientation']}" if u.get("orientation") else "", "Meublé" if u.get("furnished") else "", ] listings.append(Listing( source=self.source_id, external_id=ext_id, url=SITE_URL, title=f"{pname} — Unité {num}", address=addr, city=CITY, unit_type=self._unit_type(u.get("bedrooms")), bedrooms=beds, bathrooms=baths, price=price, price_label="" if price else "Prix sur demande", availability=availability, area_sqft=area, description=" — ".join(b for b in desc_bits if b), amenities=amenities, images=imgs, )) return listings