# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/minto.py : connecteur Minto Apartments (mintoapartments.com)
# Page « projects » Montréal rendue serveur : Rockhill, Haddon Hall, Le 4300,
# Le Hill-Park. Chaque fiche propriété (main.html) liste ses types de suites
# dans des cartes .projects-apartamets-unit-card : nom (h4), disponibilité
# (« Available now » / « Available September 17 » / « Not available »),
# prix ($X - $Y), pi² (span icon-svg-column), sdb, galerie photo par type
# (tableaux JS `lightboxImages…`). La section Contact donne l'adresse
# complète, le quartier (« Neighbourhood: ») et le téléphone/courriel ;
# les sections Features listent les commodités (items à coche).
# Une annonce par type de suite disponible.
# -----------------------------------------------------------------------------
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from ..schema import Listing, normalize_unit_type, parse_price, strip_accents
from .base import BaseConnector
BASE = "https://www.mintoapartments.com"
LIST_URL = f"{BASE}/montreal/apartment-rentals/projects.html"
PROJECT_RE = re.compile(
r'https?://www\.mintoapartments\.com/montreal/apartment-rentals/'
r'([A-Za-z0-9\-]+)/main\.html')
LIGHTBOX_RE = re.compile(r'var\s+lightboxImages(\d+)\s*=\s*\[(.*?)\];', re.S)
IMG_SRC_RE = re.compile(r"src:\s*'([^']+)'")
_PHONE_RE = re.compile(r"tel:([\d\-() .]{7,20})")
_EMAIL_RE = re.compile(r"mailto:([\w.+-]+@[\w-]+\.[\w.]+)")
# Secteurs connus recherchés dans le
(repli si pas de
# « Neighbourhood: ») ; certains sont des villes distinctes de l'île.
_KNOWN_SECTORS = [
"Côte-des-Neiges", "Westmount", "Côte-Saint-Luc", "Mont-Royal",
"Notre-Dame-de-Grâce", "NDG", "Griffintown", "Plateau", "Ville-Marie",
"Downtown",
]
_CITY_SECTORS = {"westmount": "Westmount", "mont-royal": "Mont-Royal",
"cote-saint-luc": "Côte-Saint-Luc"}
class MintoConnector(BaseConnector):
source_id = "minto"
request_delay = 0.6
max_projects = 12 # garde-fou
max_images = 25
def fetch(self) -> list[Listing]:
html = self.get(LIST_URL).text
slugs = list(dict.fromkeys(PROJECT_RE.findall(html)))
listings: list[Listing] = []
for slug in slugs[: self.max_projects]:
try:
listings.extend(self._project_listings(slug))
except Exception:
continue
return listings
def _project_listings(self, slug: str) -> list[Listing]:
url = f"{BASE}/montreal/apartment-rentals/{slug}/main.html"
html = self.get(url).text
soup = BeautifulSoup(html, "html.parser")
name = slug.replace("-", " ").strip()
h1 = soup.find("h1")
if h1 and h1.get_text(strip=True):
name = h1.get_text(" ", strip=True)
t = soup.find("title")
title_text = t.get_text(strip=True) if t else ""
# Quartier : « Neighbourhood: Shaughnessy Village » (section Contact),
# sinon premier quartier connu mentionné dans le
sector = ""
nm = re.search(r"Neighbourhood:\s*([^<\n]{2,50})", html)
if nm:
sector = nm.group(1).strip()
else:
for s in _KNOWN_SECTORS:
if s.lower() in title_text.lower():
sector = "Centre-ville" if s == "Downtown" else s
break
city = _CITY_SECTORS.get(strip_accents(sector.lower()), "Montréal")
if sector == city:
sector = ""
# Adresse complète (section Contact : rue + ville + code postal)
address = ""
am = re.search(
r'font-weight:\s*normal">\s*([^<]{8,140})', html)
if am:
address = re.sub(r"\s*\n\s*", ", ", am.group(1).strip())
address = re.sub(r"\s+", " ", address)
if not address:
am = re.search(
r'\d{2,5},?\s+(?:chemin|chem\.|avenue|rue|boulevard|c[ôo]te)'
r'[^<>"{}]{3,60}', html, re.I)
if am:
address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",")
# Contact location (téléphone / courriel) -> details.contact
details: dict = {}
contact: dict = {}
pm = _PHONE_RE.search(html)
if pm:
contact["phone"] = pm.group(1).strip()
em = _EMAIL_RE.search(html)
if em:
contact["email"] = em.group(1)
if contact:
details["contact"] = contact
# Photos de la propriété (carrousel d'entête, repli des galeries)
hero = [u for u in re.findall(
r'https://media\.minto\.com/(?:dev/)?slideshows/[^"\'\s]+'
r'\.(?:jpg|jpeg|png|webp)', html)]
hero = list(dict.fromkeys(hero))[:8]
# Commodités : items à coche des sections « Building features » /
# « Suite features » (l'astérisque « * » = dans certaines suites)
amenities: list[str] = []
for span in soup.select('li span[class*="icon-svg-check-mark"]'):
li = span.find_parent("li")
txt = li.get_text(" ", strip=True) if li else ""
if txt and txt not in amenities and len(txt) < 60:
amenities.append(txt)
amenities = amenities[:30]
og = soup.find("meta", attrs={"name": "description"})
desc = (og.get("content", "").strip()[:600] if og else "")
listings: list[Listing] = []
for card in soup.select(".projects-apartamets-unit-card"):
try:
h4 = card.select_one("h4.h-h3-minto")
if not h4:
continue
suite_name = h4.get_text(" ", strip=True)
if not suite_name or len(suite_name) > 70:
continue
# Disponibilité par type de suite (« Available now »,
# « Available September 17 », « Not available »)
av_el = card.select_one('[class*="btn-availa"]')
availability = (av_el.get_text(" ", strip=True)
if av_el else "")
if re.match(r"^not\s+available$", availability, re.I):
continue # type de suite non offert actuellement
# Prix « $1,215 - $1,305 »
ph5 = card.select_one("h5.h-h3-minto")
price_txt = ph5.get_text(" ", strip=True) if ph5 else ""
pmatch = re.search(r'\$[\d,]+(?:\s*-\s*\$[\d,]+)?', price_txt)
if not pmatch:
continue # carte non tarifée
price_label = re.sub(r"\s+", " ", pmatch.group(0))
price = parse_price(
price_label.split("-")[0].replace("$", "")
.replace(",", "") + " $")
# Superficie structurée « 425 - 560 » SQ FT (min de la plage)
area_sqft = None
sq_icon = card.select_one("span.icon-svg-column")
if sq_icon:
sib = sq_icon.find_next_sibling("span")
if sib:
nums = [float(n.replace(",", "")) for n in
re.findall(r"[\d,]+", sib.get_text())]
nums = [n for n in nums if 80 <= n <= 20000]
if nums:
area_sqft = min(nums)
bm = re.search(r'([\d.]+)\s*Bathroom',
card.get_text(" ", strip=True))
# Galerie du type de suite (tableau lightbox de la carte)
imgs: list[str] = []
gm = LIGHTBOX_RE.search(str(card))
if gm:
imgs = IMG_SRC_RE.findall(gm.group(2))
imgs = list(dict.fromkeys(imgs))[: self.max_images] or hero
suite_slug = re.sub(r"[^a-z0-9]+", "-",
strip_accents(suite_name.lower())).strip("-")
bits = [f"{bm.group(1)} sdb" if bm else ""]
listings.append(Listing(
source=self.source_id,
external_id=f"{slug}-{suite_slug}",
url=url,
title=f"{name} — {suite_name}",
address=address,
sector=sector,
city=city,
unit_type=normalize_unit_type(suite_name),
price=price,
price_label=f"{price_label} /mois",
availability=availability,
area_sqft=area_sqft,
description=" — ".join(
x for x in [desc] + bits if x)[:600],
amenities=amenities,
details=dict(details),
images=imgs,
))
except Exception:
continue
return listings