# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/summit.py : connecteur Summit Property Management # (summitmanagement.ca — 3 000+ unités à Montréal : Vue/Triangle, LIV, # Allegra, IVY, Le V, Le Duke, Skyla, Le 400 Sherbrooke Ouest, RIVA…). # Plateforme Rentsync/LiftSystem : la page /apartments expose client_id et # city_ids (div.search-data) et son /scripts/main.js embarque le jeton # public du flux officiel https://api.theliftsystem.com/v2/search — aucun # rendu JavaScript nécessaire. Comme hazelview.py, on interroge le flux par # nombre de chambres (min_bed/max_bed + only_available_suites) pour obtenir, # par immeuble ET par type d'unité, les loyers réels (stats min/max), la # superficie et le nombre d'unités disponibles -> une annonce par immeuble # et par type d'unité (uid stables « -bed »). # ⚠️ Gestionnaire pancanadien (Ottawa aussi servie par le même flux) : # garde-fou province_code == QC — seules les propriétés montréalaises # passent. La fiche immeuble du site (rendu serveur) fournit la galerie # assets.rentsync.com, via le cache BD self.detail() (revisitée seulement # quand la ligne du flux change). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, strip_accents from .base import BaseConnector BASE = "https://www.summitmanagement.ca" SEARCH_PAGE = f"{BASE}/apartments" LIFT_API = "https://api.theliftsystem.com/v2/search" # Valeurs observées sur la page/main.js — repli si l'extraction dynamique casse DEFAULT_CLIENT_ID = "162" DEFAULT_AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (main.js, prod) MONTREAL_CITY_ID = "1863" # Montréal dans la base Lift SEARCH_PARAMS = ("only_available_suites=true&show_all_properties=false" "&min_bath=-1&max_bath=10&min_rate=0&max_rate=20000") # jeton de la branche production de main.js : # a="https://api.theliftsystem.com/v2/search?locale="+s+"&",o="&auth_token=…" _TOKEN_RE = re.compile( r'api\.theliftsystem\.com/v2/search\?locale="\+\w+\+"&",' r'\w+="&auth_token=([A-Za-z0-9]+)"') _MAINJS_RE = re.compile(r'src="(/scripts/main\.js[^"]*)"') # galerie de la fiche immeuble (assets Rentsync, rendu serveur) _IMG_RE = re.compile( r"https://assets\.rentsync\.com/[^\"'\\)\s]+\.(?:jpg|jpeg|png|webp)", re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|badge|theme-settings|/256/", re.I) _TAG_RE = re.compile(r"<[^>]+>") # (min_bed, max_bed, type d'unité Lou-Ka) _BED_QUERIES = [(0, 0, "Studio"), (1, 1, "3½"), (2, 2, "4½"), (3, 3, "5½"), (4, 5, "6½+")] # Villes QC admissibles (l'API renvoie aussi Ottawa pour ce client) _QC_CITIES = {"montreal": "Montréal"} class SummitConnector(BaseConnector): source_id = "summit" request_delay = 0.7 max_details = 30 # garde-fou pages immeuble (vraies requêtes/sync) max_images = 20 # -- paramètres du flux (page + main.js, avec replis) ---------------------- def _feed_params(self) -> tuple[str, str, str]: """(client_id, city_ids, auth_token) lus sur le site, replis constants.""" client_id, city_ids, token = (DEFAULT_CLIENT_ID, MONTREAL_CITY_ID, DEFAULT_AUTH_TOKEN) try: page = self.get(SEARCH_PAGE).text soup = BeautifulSoup(page, "html.parser") data = soup.find("div", class_="search-data") if data: client_id = (data.get("data-client-id") or client_id).strip() ids = (data.get("data-city-ids-string") or "").strip() if ids: # le filtre province élimine Ottawa ensuite city_ids = ids m = _MAINJS_RE.search(page) if m: js = self.get(BASE + m.group(1)).text mt = _TOKEN_RE.search(js) if mt: token = mt.group(1) except Exception: pass # replis : les constantes observées return client_id, city_ids, token def fetch(self) -> list[Listing]: client_id, city_ids, token = self._feed_params() listings: list[Listing] = [] seen: set[str] = set() for min_bed, max_bed, unit_type in _BED_QUERIES: try: props = self._search(client_id, token, city_ids, min_bed, max_bed) except Exception: continue if not isinstance(props, list): continue for p in props: try: lst = self._prop_listing(p, unit_type, min_bed) if lst and lst.external_id not in seen: seen.add(lst.external_id) listings.append(lst) except Exception: continue # galerie de la fiche immeuble (cache BD, 1 requête par immeuble) self._fetched = 0 memo: dict[str, dict] = {} for lst in listings: pid = lst.external_id.split("-")[0] if not lst.url: continue if pid not in memo: key = hashlib.sha1( f"{lst.availability}|{lst.price}|{lst.images[:1]}" .encode("utf-8")).hexdigest() try: memo[pid] = self.detail( pid, key, lambda u=lst.url: self._fetch_gallery(u)) except Exception: memo[pid] = {} extra = [u for u in (memo[pid].get("images") or []) if u not in lst.images] lst.images = (lst.images + extra)[: self.max_images] return listings def _search(self, client_id: str, token: str, city_ids: str, min_bed: int, max_bed: int) -> list: url = (f"{LIFT_API}?locale=en&client_id={client_id}" f"&auth_token={token}&city_ids={city_ids}" f"&min_bed={min_bed}&max_bed={max_bed}" f"&{SEARCH_PARAMS}&limit=100") return self.get(url, headers={"Accept": "application/json", "Referer": BASE + "/"}).json() # -- une annonce par immeuble et par type d'unité --------------------------- def _prop_listing(self, p: dict, unit_type: str, beds: int) -> Listing | None: if not p.get("availability_count"): return None addr = p.get("address") or {} if (addr.get("province_code") or "").upper() != "QC": return None # gestionnaire pancanadien : Ottawa exclue city = _QC_CITIES.get( strip_accents((addr.get("city") or "").strip().lower())) if not city: return None # ville QC inattendue : ne rien inventer stats = ((p.get("statistics") or {}).get("suites") or {}) rates = stats.get("rates") or {} sq = stats.get("square_feet") or {} def _num(v): try: v = float(v) except (TypeError, ValueError): return None return v rmin, rmax = _num(rates.get("min")), _num(rates.get("max")) # le flux publie parfois des sentinelles (0.01 $) — prix plausibles only price = rmin if rmin and 300 <= rmin <= 20000 else None if price and rmax and rmax != rmin: price_label = f"À partir de {int(price)} $ (max {int(rmax)} $)" elif price: price_label = f"{int(price)} $/mois" else: price_label = "" sqmin = _num(sq.get("min")) area = sqmin if sqmin and 80 <= sqmin <= 20000 else None details_src = p.get("details") or {} desc = _TAG_RE.sub(" ", details_src.get("overview") or "") desc = re.sub(r"\s+", " ", desc).strip()[:500] sbits = [f"{p['availability_count']} unité(s) disponible(s)"] amenities: list[str] = [] for a in p.get("amenities") or []: t = (a.get("name") if isinstance(a, dict) else str(a) or "").strip() if t and t not in amenities: amenities.append(t) pid = p.get("id") name = (p.get("name") or "").strip() geo = p.get("geocode") or {} try: lat, lng = float(geo.get("latitude")), float(geo.get("longitude")) except (TypeError, ValueError): lat = lng = None pets = None if isinstance(p.get("pet_friendly"), bool): pets = "oui" if p["pet_friendly"] else None # false ≠ « interdit » details: dict = {"building": name} if name else {} contact = p.get("contact") or {} cinfo: dict = {} if (contact.get("phone") or "").strip(): cinfo["phone"] = contact["phone"].strip() for em in (contact.get("email") or "").split(","): em = em.strip() if em and "leadmanaging" not in em: cinfo["email"] = em break if cinfo: details["contact"] = cinfo images = [] if p.get("photo_path"): images.append(p["photo_path"]) # le site marque certains immeubles « furnished » dans le permalink furnished = True if "/furnished/" in (p.get("permalink") or "") else None return Listing( source=self.source_id, external_id=f"{pid}-{beds}bed", url=(p.get("permalink") or SEARCH_PAGE).strip(), title=f"{name} — {unit_type}", address=", ".join(x for x in [ (addr.get("address") or "").strip(), city, (addr.get("postal_code") or "").strip()] if x), sector=(addr.get("neighbourhood") or "").strip(), city=city, unit_type=unit_type, price=price, price_label=price_label, availability=(p.get("min_availability_date") or p.get("availability_status_label") or ""), area_sqft=area, pets=pets, furnished=furnished, description=" — ".join([desc] + sbits if desc else sbits)[:600], amenities=amenities[:25], details=details, images=images, lat=lat, lng=lng, ) # -- galerie de la fiche immeuble (rendu serveur) --------------------------- def _fetch_gallery(self, url: str) -> dict: """Photos assets.rentsync.com de la fiche (variante /1152/ préférée).""" if self._fetched >= self.max_details: raise RuntimeError("budget de pages immeuble atteint") self._fetched += 1 out: dict = {"images": []} try: page = self.get(url).text except Exception: return out urls = _IMG_RE.findall(page) images: list[str] = [] seen: set[str] = set() for u in urls: if _SKIP_IMG.search(u): continue fname = u.rsplit("/", 1)[-1] if fname in seen: continue big = u.replace("/512/", "/1152/") seen.add(fname) images.append(big if big in urls else u) out["images"] = images[: self.max_images] return out