# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/m_immobilier.py : M Immobilier (mimmobilier.com) — LOCATIONS
# Agence indépendante de prestige (Grand Montréal), inscriptions Centris.
# La liste /properties est rendue serveur et paginée ; le formulaire expose
# un filtre statut « à louer » (radio name="CODE_STATUT" value="AL") qu'on
# rejoue en query string : /properties?CODE_STATUT=AL&page=N. Chaque carte
# « cardProperty » porte l'URL (avec no Centris), le badge « à louer », la
# ville, l'adresse, chambres, salles de bains, superficie (PC) et le loyer
# « X $ / M ». Adapté du connecteur « à vendre » d'Immo-Ka
# (agent-courtage/immoka), qui excluait justement ces cartes-là.
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as _html
import os
import re
from ..schema import Listing
from .base import BaseConnector
from . import _detailutil as du
BASE = "https://www.mimmobilier.com"
LISTING_URL = f"{BASE}/properties?CODE_STATUT=AL" # AL = à louer (serveur)
CARD_SPLIT = "cardProperty col-span-12"
HREF_RE = re.compile(r'href="(/properties/[^"]+/(\d+))"')
IMG_RE = re.compile(r'(/images/centris-slideshow/\d+-\d+-\d+\.(?:jpg|jpeg|png|webp))', re.I)
# loyer « 1 500.0 $ / M » (M = mois) — le montant précède le $
_RENT_RE = re.compile(r"([\d\s ,]+(?:\.\d+)?)\s*\$\s*/\s*M", re.I)
_H1_RE = re.compile(r"
]*>(.*?)
", re.S)
# l'agence loue aussi des locaux commerciaux au mois (bureaux, entrepôts…) sans
# ligne « chambres » : on ne garde ces cartes-là que si l'extrait de description
# contient un marqueur clairement résidentiel (studio, bachelor, logement…)
_RESIDENTIAL_RE = re.compile(
r"\b(studios?|bachelor|logements?|appartements?|condos?|chambres?|"
r"r[ée]sidentiel(?:le)?s?|unit[ée]s?|laveuse|maison|complexe)\b", re.I)
MAX_PAGES = int(os.environ.get("LOUKA_MIMMO_MAX_PAGES", "10"))
DETAIL_LIMIT = int(os.environ.get("LOUKA_MIMMO_DETAIL_LIMIT", "100"))
class MImmobilierConnector(BaseConnector):
source_id = "m_immobilier"
request_delay = 0.6
def fetch(self) -> list[Listing]:
out: dict[str, Listing] = {}
for page in range(1, MAX_PAGES + 1):
url = LISTING_URL if page == 1 else f"{LISTING_URL}&page={page}"
try:
html = self.get(url).text
except Exception:
break
cards = html.split(CARD_SPLIT)[1:]
fresh = 0
for card in cards:
lst = self._parse_card(CARD_SPLIT + card[:6000])
if lst and lst.uid not in out:
out[lst.uid] = lst
fresh += 1
# dernière page atteinte (vide ou uniquement des doublons)
if not cards or fresh == 0:
break
listings = list(out.values())
# fiche détail : galerie Centris complète + description + adresse pleine
du.enrich(self, listings, DETAIL_LIMIT, parse_m_detail, key="v1")
return listings
def _parse_card(self, card: str) -> Listing | None:
m = HREF_RE.search(card)
if not m:
return None
url = BASE + m.group(1)
external_id = m.group(2)
images = []
for im in IMG_RE.findall(card):
full = BASE + im
if full not in images:
images.append(full)
# texte du carton, ligne par ligne : badge statut, ville, adresse,
# extrait de description, puis paires libellé/valeur
text = _html.unescape(re.sub(r"<[^>]+>", "\n", card))
lines = [l.strip() for l in text.splitlines() if l.strip()]
try:
k = next(i for i, l in enumerate(lines)
if l.lower().startswith(("à louer", "a louer")))
except StopIteration:
return None # vente/vendu/loué : pas une location active
city_raw = lines[k + 1] if k + 1 < len(lines) else ""
address = lines[k + 2] if k + 2 < len(lines) else ""
snippet = lines[k + 3] if k + 3 < len(lines) else ""
price_label = _after(lines, "prix")
if "/ m" not in price_label.lower():
return None # garde-fou : loyer mensuel attendu
price = None
pm = _RENT_RE.search(price_label)
if pm:
try:
price = float(pm.group(1).replace(" ", "").replace(" ", "")
.replace(",", ""))
except ValueError:
price = None
beds = _int(_after(lines, "chambres")) # « 3 + 1 » -> 4
baths = _after(lines, "salles de bains")
sqft = _int(_after(lines, "pc")) # PC = pieds carrés
# sans chambres, c'est souvent un local commercial loué au mois :
# on exige un marqueur résidentiel dans l'extrait (studio, bachelor…)
unit_type = f"{beds} chambres" if beds else ""
if beds is None:
if not _RESIDENTIAL_RE.search(snippet):
return None
if re.search(r"\b(studios?|bachelor)\b", snippet, re.I):
unit_type = "Studio"
# ville « Montréal (Le Plateau-Mont-Royal) » -> ville + secteur ;
# les cartes tronquent à ~30 caractères (« Notr... ») -> on nettoie
city, sector = city_raw, ""
cm = re.match(r"^(.*?)\s*\(([^)]*)\)?\s*$", city_raw)
if cm and cm.group(2):
city, sector = cm.group(1).strip(), cm.group(2).strip()
city = city.rstrip(".").rstrip()
sector = re.sub(r"\.{2,}$", "", sector).rstrip("/ -")
# adresse parfois tronquée (« 9017 Rue Jean-Baptiste-Gauthie... ») :
# on la laisse vide et le H1 de la fiche détail la complète
truncated = address.endswith("...")
title = f"{re.sub(r'[.]{3,}$', '', address)}, {city}".strip(", ")
lst = Listing(
source=self.source_id,
external_id=external_id,
url=url,
title=title,
address="" if truncated else address,
sector=sector,
city=city,
unit_type=unit_type,
price=price,
price_label=price_label,
description=re.sub(r"[.]{3,}$", "", snippet),
details={"Agence": "M Immobilier", "No Centris": external_id},
images=images,
)
if baths:
lst.details["Salles de bain"] = baths.replace(" + ", "+")
if sqft:
lst.area_sqft = float(sqft)
return lst
def parse_m_detail(html: str) -> dict:
"""Galerie Centris complète + description (JSON-LD) + adresse pleine (H1)."""
out: dict = {}
imgs = []
seen = set()
for im in IMG_RE.findall(html):
full = BASE + im
if full not in seen:
seen.add(full)
imgs.append(full)
if imgs:
out["images"] = imgs
desc = du.ld_description(html)
if desc:
out["description"] = desc
m = _H1_RE.search(html)
if m:
h1 = _html.unescape(re.sub(r"<[^>]+>", " ", m.group(1))).strip()
if h1 and re.match(r"\d", h1):
out["address"] = h1 # complète les adresses tronquées
# pas de table de caractéristiques exploitable sur la fiche (seule la table
# des pièces existe, qui produirait du bruit) : galerie + description suffisent
return out
def _after(lines: list[str], label: str) -> str:
lab = label.lower()
for i, l in enumerate(lines):
if l.lower() == lab and i + 1 < len(lines):
return lines[i + 1]
return ""
def _int(s: str):
if not s:
return None
# « 3 + 1 » -> 4 (chambres principales + sous-sol)
nums = [int(x) for x in re.findall(r"\d+", s)]
return sum(nums) if nums else None