# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/summum.py : connecteur Gestion immobilière Summum # (location.summumpm.com) — portail hébergé Building Stack (même famille # que le portail edifialocation.com du connecteur edifia.py) : # Saint-Jérôme, Blainville, Montréal, Laval, Longueuil, Granby… # - /Listing/Listings embarque `var units = [...]` : JSON complet des # unités affichées (ApartmentId stable, prix, pi², chambres, salles de # bain, adresse complète avec GPS, contact de location, photo) ; # - les pages immeuble /b/ donnent la date de disponibilité par unité # et les commodités de l'immeuble (une requête par immeuble, en cache # via self.detail, plafonnée). # Portail SaaS standard, pas de robots.txt (tout permis). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://location.summumpm.com" # graphies sans accent vues dans le JSON du portail -> toponyme officiel _CITY_FIX = {"Montreal": "Montréal"} def _extract_json(html: str, marker: str): """Décode la structure JSON qui suit `marker` dans un script inline.""" i = html.find(marker) if i < 0: return None try: data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):].lstrip()) except Exception: return None return data class _CapAtteint(Exception): """Plafond de requêtes détail atteint pour cette synchronisation.""" class SummumConnector(BaseConnector): source_id = "summum" request_delay = 0.7 max_real_details = 45 # pages immeuble par sync (cache exclu) # -- pages immeuble (dates de disponibilité + commodités) ---------------------- def _building_page(self, pub_id: str, key: str) -> dict: """Page /b/ : { 'dates': {unité: dispo}, 'amenities': [...] }.""" def _fetch() -> dict: if self._real_details >= self.max_real_details: raise _CapAtteint() self._real_details += 1 html = self.get(f"{BASE}/b/{pub_id}").text soup = BeautifulSoup(html, "html.parser") dates: dict[str, str] = {} for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"): spans = [s.get_text(" ", strip=True) for s in a.find_all("span")] if len(spans) >= 6 and spans[0]: dates[spans[0]] = spans[5] # Unité -> Disponible amenities: list[str] = [] for li in soup.select(".facilities ul li label"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if 3 <= len(t) <= 90 and t not in amenities: amenities.append(t) imgs = [u for u in dict.fromkeys(re.findall( r"https://wfiles\.buildingstack\.com/resources/image/[\w]+", html))] return {"dates": dates, "amenities": amenities[:20], "images": imgs[:10]} try: return self.detail(pub_id, key, _fetch) except Exception: return {} # -- fetch ---------------------------------------------------------------------- def fetch(self) -> list[Listing]: self._real_details = 0 html = self.get(f"{BASE}/Listing/Listings").text units = _extract_json(html, "var units =") or [] # une page immeuble par immeuble (clé de cache = contenu des unités) by_building: dict[str, list[dict]] = {} for u in units: pub = str((u.get("Building") or {}) .get("PublicListBuildingName") or "") if pub: by_building.setdefault(pub, []).append(u) bldg_info: dict[str, dict] = {} for pub, us in by_building.items(): key = hashlib.sha1(json.dumps( sorted((str(x.get("ApartmentId")), str((x.get("Apartment") or {}).get("Price"))) for x in us)).encode("utf-8")).hexdigest()[:16] bldg_info[pub] = self._building_page(pub, key) listings: list[Listing] = [] for u in units: apt = u.get("Apartment") or {} adr = u.get("Address") or {} bld = u.get("Building") or {} ext_id = str(u.get("ApartmentId") or "") if not ext_id: continue num = str(apt.get("UnitName") or "").strip() building_name = str(u.get("BuildingName") or "").strip() pub = str(bld.get("PublicListBuildingName") or "") info = bldg_info.get(pub) or {} # typologie dérivée des chambres structurées (patron edifia.py) beds = apt.get("NumberOfBedrooms") or 0 baths = apt.get("NumberOfBathrooms") or 0 unit_type = "Studio" if beds == 0 else f"{beds + 2}½" dispo = (info.get("dates") or {}).get(num, "") if not dispo: availability = "" elif re.search(r"\d", dispo): # « déc. 01, 2026 » availability = f"Libre {dispo}" else: # « Disponible dès maintenant! » availability = dispo area = apt.get("Area") try: area = float(area) if not 80 <= area <= 20000: area = None except (TypeError, ValueError): area = None desc = " — ".join(x for x in [ f"{int(area)} pi²" if area else "", f"{beds} chambre(s)" if beds else "", f"{baths} salle(s) de bain" if baths else ""] if x) details: dict = {"bedrooms": int(beds), "bathrooms": int(baths)} contacts = bld.get("ListingEmployeesContacts") or [] if contacts: c = contacts[0] contact = {k: v for k, v in [ ("name", c.get("FullName")), ("phone", c.get("FormattedPhoneNumber")), ("email", c.get("Email"))] if v} if contact: details["contact"] = contact imgs = list(info.get("images") or []) prev = str(u.get("PreviewUrl") or "") if prev.startswith("http") and prev not in imgs: imgs.insert(0, prev) try: lat, lng = float(adr.get("Latitude")), float(adr.get("Longitude")) except (TypeError, ValueError): lat = lng = None city = str(adr.get("City") or "").strip() city = _CITY_FIX.get(city, city) listings.append(Listing( source=self.source_id, external_id=ext_id, url=f"{BASE}{u.get('BuildingUrl') or f'/b/{pub}'}", title=(f"{building_name} — Unité {num}" if num else building_name), address=str(adr.get("Full") or ""), sector="", city=city, unit_type=unit_type, price=float(apt.get("Price") or 0) or None, price_label=str(apt.get("PriceFormatted") or ""), availability=availability, area_sqft=area, description=desc[:600], amenities=list(info.get("amenities") or []), details=details, images=imgs[:12], lat=lat, lng=lng, )) return listings