spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/summum.py : connecteur Gestion immobilière Summum5# (location.summumpm.com) — portail hébergé Building Stack (même famille6# que le portail edifialocation.com du connecteur edifia.py) :7# Saint-Jérôme, Blainville, Montréal, Laval, Longueuil, Granby…8# - /Listing/Listings embarque `var units = [...]` : JSON complet des9# unités affichées (ApartmentId stable, prix, pi², chambres, salles de10# bain, adresse complète avec GPS, contact de location, photo) ;11# - les pages immeuble /b/<id> donnent la date de disponibilité par unité12# et les commodités de l'immeuble (une requête par immeuble, en cache13# via self.detail, plafonnée).14# Portail SaaS standard, pas de robots.txt (tout permis).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import json20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing25from .base import BaseConnector2627BASE = "https://location.summumpm.com"2829# graphies sans accent vues dans le JSON du portail -> toponyme officiel30_CITY_FIX = {"Montreal": "Montréal"}313233def _extract_json(html: str, marker: str):34 """Décode la structure JSON qui suit `marker` dans un script inline."""35 i = html.find(marker)36 if i < 0:37 return None38 try:39 data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):].lstrip())40 except Exception:41 return None42 return data434445class _CapAtteint(Exception):46 """Plafond de requêtes détail atteint pour cette synchronisation."""474849class SummumConnector(BaseConnector):50 source_id = "summum"51 request_delay = 0.752 max_real_details = 45 # pages immeuble par sync (cache exclu)5354 # -- pages immeuble (dates de disponibilité + commodités) ----------------------55 def _building_page(self, pub_id: str, key: str) -> dict:56 """Page /b/<id> : { 'dates': {unité: dispo}, 'amenities': [...] }."""57 def _fetch() -> dict:58 if self._real_details >= self.max_real_details:59 raise _CapAtteint()60 self._real_details += 161 html = self.get(f"{BASE}/b/{pub_id}").text62 soup = BeautifulSoup(html, "html.parser")63 dates: dict[str, str] = {}64 for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"):65 spans = [s.get_text(" ", strip=True) for s in a.find_all("span")]66 if len(spans) >= 6 and spans[0]:67 dates[spans[0]] = spans[5] # Unité -> Disponible68 amenities: list[str] = []69 for li in soup.select(".facilities ul li label"):70 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))71 if 3 <= len(t) <= 90 and t not in amenities:72 amenities.append(t)73 imgs = [u for u in dict.fromkeys(re.findall(74 r"https://wfiles\.buildingstack\.com/resources/image/[\w]+",75 html))]76 return {"dates": dates, "amenities": amenities[:20],77 "images": imgs[:10]}7879 try:80 return self.detail(pub_id, key, _fetch)81 except Exception:82 return {}8384 # -- fetch ----------------------------------------------------------------------85 def fetch(self) -> list[Listing]:86 self._real_details = 087 html = self.get(f"{BASE}/Listing/Listings").text88 units = _extract_json(html, "var units =") or []8990 # une page immeuble par immeuble (clé de cache = contenu des unités)91 by_building: dict[str, list[dict]] = {}92 for u in units:93 pub = str((u.get("Building") or {})94 .get("PublicListBuildingName") or "")95 if pub:96 by_building.setdefault(pub, []).append(u)97 bldg_info: dict[str, dict] = {}98 for pub, us in by_building.items():99 key = hashlib.sha1(json.dumps(100 sorted((str(x.get("ApartmentId")),101 str((x.get("Apartment") or {}).get("Price")))102 for x in us)).encode("utf-8")).hexdigest()[:16]103 bldg_info[pub] = self._building_page(pub, key)104105 listings: list[Listing] = []106 for u in units:107 apt = u.get("Apartment") or {}108 adr = u.get("Address") or {}109 bld = u.get("Building") or {}110 ext_id = str(u.get("ApartmentId") or "")111 if not ext_id:112 continue113 num = str(apt.get("UnitName") or "").strip()114 building_name = str(u.get("BuildingName") or "").strip()115 pub = str(bld.get("PublicListBuildingName") or "")116 info = bldg_info.get(pub) or {}117118 # typologie dérivée des chambres structurées (patron edifia.py)119 beds = apt.get("NumberOfBedrooms") or 0120 baths = apt.get("NumberOfBathrooms") or 0121 unit_type = "Studio" if beds == 0 else f"{beds + 2}½"122123 dispo = (info.get("dates") or {}).get(num, "")124 if not dispo:125 availability = ""126 elif re.search(r"\d", dispo): # « déc. 01, 2026 »127 availability = f"Libre {dispo}"128 else: # « Disponible dès maintenant! »129 availability = dispo130131 area = apt.get("Area")132 try:133 area = float(area)134 if not 80 <= area <= 20000:135 area = None136 except (TypeError, ValueError):137 area = None138139 desc = " — ".join(x for x in [140 f"{int(area)} pi²" if area else "",141 f"{beds} chambre(s)" if beds else "",142 f"{baths} salle(s) de bain" if baths else ""] if x)143144 details: dict = {"bedrooms": int(beds), "bathrooms": int(baths)}145 contacts = bld.get("ListingEmployeesContacts") or []146 if contacts:147 c = contacts[0]148 contact = {k: v for k, v in [149 ("name", c.get("FullName")),150 ("phone", c.get("FormattedPhoneNumber")),151 ("email", c.get("Email"))] if v}152 if contact:153 details["contact"] = contact154155 imgs = list(info.get("images") or [])156 prev = str(u.get("PreviewUrl") or "")157 if prev.startswith("http") and prev not in imgs:158 imgs.insert(0, prev)159160 try:161 lat, lng = float(adr.get("Latitude")), float(adr.get("Longitude"))162 except (TypeError, ValueError):163 lat = lng = None164165 city = str(adr.get("City") or "").strip()166 city = _CITY_FIX.get(city, city)167168 listings.append(Listing(169 source=self.source_id,170 external_id=ext_id,171 url=f"{BASE}{u.get('BuildingUrl') or f'/b/{pub}'}",172 title=(f"{building_name} — Unité {num}" if num173 else building_name),174 address=str(adr.get("Full") or ""),175 sector="",176 city=city,177 unit_type=unit_type,178 price=float(apt.get("Price") or 0) or None,179 price_label=str(apt.get("PriceFormatted") or ""),180 availability=availability,181 area_sqft=area,182 description=desc[:600],183 amenities=list(info.get("amenities") or []),184 details=details,185 images=imgs[:12],186 lat=lat,187 lng=lng,188 ))189 return listings190