# -----------------------------------------------------------------------------
# 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))