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