# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/m_immobilier.py : M Immobilier (mimmobilier.com) # Agence indépendante de prestige (Grand Montréal), inscriptions Centris. # La page /properties est rendue serveur : chaque carte « cardProperty » # porte l'URL (avec no Centris), la ville, l'adresse, chambres, salles de # bains, prix et les photos (/images/centris-slideshow/{id}-N-*.jpg). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from ..normalize import parse_price from ..schema import PropertyListing BASE = "https://www.mimmobilier.com" LISTING_URL = f"{BASE}/properties" 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) DETAIL_LIMIT = int(os.environ.get("IMMOKA_M_DETAIL_LIMIT", "300")) class MImmobilierConnector(BaseConnector): source_id = "m_immobilier" request_delay = 0.6 use_detail_cache = False def fetch(self) -> list[PropertyListing]: html = self.get(LISTING_URL).text cards = html.split(CARD_SPLIT)[1:] out: dict[str, PropertyListing] = {} for card in cards: card = CARD_SPLIT + card[:6000] lst = self._parse_card(card) if lst and lst.uid not in out: out[lst.uid] = lst listings = list(out.values()) # fiche détail : galerie complète (135 photos) + description + type du.enrich(self, listings, DETAIL_LIMIT, parse_m_detail, key="v2") return listings def _parse_card(self, card: str) -> PropertyListing | 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 text = _html.unescape(re.sub(r"<[^>]+>", "\n", card)) lines = [l.strip() for l in text.splitlines() if l.strip()] status_txt = lines[1] if len(lines) > 1 else "" # ville et adresse : les 2 lignes après le statut « À vendre » city = address = "" try: k = next(i for i, l in enumerate(lines) if l.lower().startswith(("à vendre", "a vendre", "vendu"))) status_txt = lines[k] city = lines[k + 1] if k + 1 < len(lines) else "" address = lines[k + 2] if k + 2 < len(lines) else "" except StopIteration: pass price_label = _after(lines, "prix") if price_label and "/ m" in price_label.lower(): return None # location, pas une vente price = parse_price(price_label or "") beds = _int(_after(lines, "chambres")) baths = _int(_after(lines, "salles de bains")) vendu = "vendu" in status_txt.lower() return PropertyListing( source=self.source_id, external_id=external_id, url=url, title=f"{address}, {city}".strip(", "), address=address, city=city, property_type="", # non exposé sur le carton price=price, price_label=price_label or "", bedrooms=beds, bathrooms=baths, mls=external_id, status="vendu" if vendu else "a-vendre", images=images, broker_name="M Immobilier", ) def parse_m_detail(html: str) -> dict: """Galerie Centris complète + description (JSON-LD) + caractéristiques.""" 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 _det = du.centris_details(du.flatten(html)) if _det: out.setdefault("details", {}).update(_det) 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 # « 4 + 1 » -> 5 (chambres principales + sous-sol) nums = [int(x) for x in re.findall(r"\d+", s)] return sum(nums) if nums else None