# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/brivia_1sp.py : connecteur 1 Square Phillips (Groupe Brivia) # (1squarephillips.ca/locatif — tour locative au centre-ville de Montréal, # 1205 rue du Square-Phillips, Ville-Marie). La page /locatif présente les # trois typologies offertes (Studio / 1 Chambre / 2 Chambres) avec loyer # "à partir de" -> une annonce par typologie (uid stables). L'inventaire # unité par unité est exposé par l'API AJAX des plans (2022/php/ # ajax_load_unit_selector/_floor/_unit.php, phase=rental) : on y lit les # unités réellement disponibles (« onsale »), leur étage, leur superficie # (pi²) et le plan — utilisés pour enrichir chaque typologie. # ----------------------------------------------------------------------------- from __future__ import annotations import re import time from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import BaseConnector BASE = "https://www.1squarephillips.ca" LOCATIF_URL = f"{BASE}/locatif" GALERIE_URL = f"{BASE}/galerie" PLANS_AJAX = f"{BASE}/2022/php/" # Adresse du pied de page (avec code postal) ADDRESS = "1205, rue du Square-Phillips, Montréal, QC H3B 3C9" _TYPE_MAP = {"studio": "Studio", "1 chambre": "3½", "2 chambres": "4½", "3 chambres": "5½"} # Typologie -> data-type de l'API des plans (phase locative) _PLAN_TYPE = {"studio": 11, "1 chambre": 12, "2 chambres": 13} DESCRIPTION = ("Condos locatifs de luxe au centre-ville de Montréal, formule " "tout inclus : électroménagers, climatisation, chauffage, " "électricité, eau chaude et Wi-Fi.") # Repli si la section « Caractéristiques » du site devenait illisible AMENITIES = ["Tout inclus (électricité, chauffage, climatisation, eau chaude, " "Wi-Fi)", "Électroménagers inclus", "Piscine, sauna et bain " "vapeur", "Salles d'entraînement", "Espace de cotravail", "Salle de cinéma", "Terrasse", "Gardien 24 h", "Lounge du 21e " "étage", "Stationnement souterrain"] _ONSALE_RE = re.compile(r'id="unit(\d+)" class="unit onsale"') _FLOOR_RE = re.compile(r'data-floor="(\d+)"') _AREA_RE = re.compile(r"Superficie\s*([\d\s ,]+)\s*pi", re.I) _BALCONY_RE = re.compile(r"Balcon\s*([\d\s ,]+)\s*pi", re.I) def _unit_type(label: str) -> str: key = re.sub(r"\s+", " ", (label or "").strip().lower()) return _TYPE_MAP.get(key, label.strip()) def _num(txt: str) -> float | None: try: return float(re.sub(r"[\s ,]", "", txt)) except (TypeError, ValueError): return None class Brivia1SPConnector(BaseConnector): source_id = "brivia_1sp" request_delay = 0.6 max_unit_details = 150 # plafond de fiches unité par sync # -- helpers --------------------------------------------------------------- def _post_json(self, path: str, data: dict) -> dict: """POST throttlé vers l'API AJAX des plans (réponses JSON).""" wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.post(PLANS_AJAX + path, data=data, timeout=self.timeout) self._last_request = time.time() resp.raise_for_status() return resp.json() def _fetch_unit(self, unit: str) -> dict: """Fiche d'une unité (type, étage, superficie, plan) via l'API.""" d = self._post_json("ajax_load_plans_unit.php", {"lang": "fr", "phase": "rental", "unit": unit}) html = d.get("unit_details") or "" out: dict = {"unit": unit, "type": int(d.get("type") or 0), "floor": int(d.get("floor") or 0)} m = _AREA_RE.search(html) if m: out["area_sqft"] = _num(m.group(1)) m = _BALCONY_RE.search(html) if m: out["balcony_sqft"] = _num(m.group(1)) # « 1 chambre / 1 salle de bain » m = re.search(r'

([^<]+)

', html) if m: out["rooms"] = m.group(1).strip() m = re.search(r"

\s*Plan\s*([^<]+)

", html) if m: out["plan"] = m.group(1).strip() m = re.search(r' dict[int, list[dict]]: """Unités disponibles (« onsale ») par type (11/12/13), via l'API.""" sel = self._post_json("ajax_load_unit_selector.php", {"lang": "fr", "phase": "rental"}) floors = sorted({int(f) for f in _FLOOR_RE.findall(sel.get("unit_selector") or "")}) onsale: list[str] = [] for f in floors[:40]: d = self._post_json("ajax_load_plans_floor.php", {"lang": "fr", "phase": "rental", "type": 11, "floor": f}) onsale.extend(_ONSALE_RE.findall(d.get("floor") or "")) by_type: dict[int, list[dict]] = {} for u in onsale[: self.max_unit_details]: # la fiche d'une unité (plan) est immuable -> clé de cache fixe info = self.detail(f"unit-{u}", "plan-v1", lambda u=u: self._fetch_unit(u)) if info.get("unit"): by_type.setdefault(int(info.get("type") or 0), []).append(info) return by_type # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LOCATIF_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") # Photos : perspectives de la page locatif + galerie du site images = self._collect_images(html) try: images += self._collect_images(self.get(GALERIE_URL).text) except Exception: pass images = list(dict.fromkeys(images))[:30] # Caractéristiques réelles de l'immeuble (section .features) amenities = self._collect_features(soup) or list(AMENITIES) # Contact structuré (liens tel:/mailto: du pied de page) contact: dict = {} tel = soup.select_one('a[href^="tel:"]') if tel: digits = re.sub(r"\D", "", tel["href"])[-10:] if len(digits) == 10: contact["phone"] = f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" mail = soup.select_one('a[href^="mailto:"]') if mail: contact["email"] = mail["href"].removeprefix("mailto:").strip() # Inventaire unité par unité (API AJAX des plans, phase locative) inventory: dict[int, list[dict]] = {} try: inventory = self._rental_units() except Exception: pass # Typologies (ul.grid3cols : h4 = type, p = "à partir de X $/mois") for li in soup.select("ul.grid3cols li"): try: h4 = li.select_one("h4") p = li.select_one("p") if not h4 or not p: continue typology = h4.get_text(" ", strip=True).replace("\xa0", " ") price_label = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if "$" not in price_label: continue type_slug = re.sub(r"[^a-z0-9]+", "-", typology.lower()).strip("-") # Enrichissement avec les unités disponibles de la typologie tkey = re.sub(r"\s+", " ", typology.strip().lower()) units = sorted(inventory.get(_PLAN_TYPE.get(tkey, -1), []), key=lambda u: u["unit"]) availability = "Disponible (tour locative en location)" description = DESCRIPTION area = None unit_images: list[str] = [] if units: n = len(units) availability = (f"{n} unité{'s' if n > 1 else ''} " f"disponible{'s' if n > 1 else ''}") areas = [u["area_sqft"] for u in units if u.get("area_sqft")] area = min(areas) if areas else None # nota : pas de mention « étage N » ici, sinon la # normalisation centrale déduirait un faux details.floor dispo = ", ".join( f"unité {u['unit']}" + (f" ({u['area_sqft']:.0f} pi²" + (f" + balcon {u['balcony_sqft']:.0f} pi²" if u.get("balcony_sqft") else "") + ")" if u.get("area_sqft") else "") for u in units) description = f"{DESCRIPTION} Unités disponibles : {dispo}." unit_images = [u["plan_img"] for u in units if u.get("plan_img")] details: dict = {} if contact: details["contact"] = dict(contact) listings.append(Listing( source=self.source_id, external_id=f"1sp-{type_slug}", url=LOCATIF_URL, title=f"1 Square Phillips — {typology} locatif", address=ADDRESS, sector="Centre-ville (Ville-Marie)", city="Montréal", unit_type=_unit_type(typology), price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=area, description=description, amenities=amenities, details=details, images=list(dict.fromkeys(unit_images + images))[:40], )) except Exception: continue return listings @staticmethod def _collect_features(soup: BeautifulSoup) -> list[str]: """Caractéristiques de l'immeuble (section .features, div.back).""" out: list[str] = [] for el in soup.select("section.features li div.back"): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if t and t not in out: out.append(t) if out: # inclusions énoncées dans l'intro de la page locatif out.insert(0, "Tout inclus (électricité, chauffage, climatisation, " "eau chaude, Wi-Fi)") out.insert(1, "Électroménagers inclus") return out[:40] @staticmethod def _collect_images(html: str) -> list[str]: """Images pleine taille du site (perspectives + galerie).""" urls = re.findall( r'(?:https://www\.1squarephillips\.ca)?/?2022/images/' r'[^"\'\s\)]+\.(?:jpg|jpeg|png|webp)', html) out = [] for u in urls: if not u.startswith("http"): u = f"{BASE}/{u.lstrip('/')}" # exclure variantes portrait (doublons) et visuels non pertinents if re.search(r"-portrait\.|ico-|logo|favicon|bckg-contact|" r"bckg-project-(1|4)\b", u, re.I): continue if re.search(r"gallery|rental|persp|condo", u, re.I): out.append(u) return list(dict.fromkeys(out))