Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/m_immobilier.py : M Immobilier (mimmobilier.com) — LOCATIONS5# Agence indépendante de prestige (Grand Montréal), inscriptions Centris.6# La liste /properties est rendue serveur et paginée ; le formulaire expose7# un filtre statut « à louer » (radio name="CODE_STATUT" value="AL") qu'on8# rejoue en query string : /properties?CODE_STATUT=AL&page=N. Chaque carte9# « cardProperty » porte l'URL (avec no Centris), le badge « à louer », la10# ville, l'adresse, chambres, salles de bains, superficie (PC) et le loyer11# « X $ / M ». Adapté du connecteur « à vendre » d'Immo-Ka12# (agent-courtage/immoka), qui excluait justement ces cartes-là.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import html as _html17import os18import re1920from ..schema import Listing21from .base import BaseConnector2223from . import _detailutil as du2425BASE = "https://www.mimmobilier.com"26LISTING_URL = f"{BASE}/properties?CODE_STATUT=AL" # AL = à louer (serveur)27CARD_SPLIT = "cardProperty col-span-12"28HREF_RE = re.compile(r'href="(/properties/[^"]+/(\d+))"')29IMG_RE = re.compile(r'(/images/centris-slideshow/\d+-\d+-\d+\.(?:jpg|jpeg|png|webp))', re.I)30# loyer « 1 500.0 $ / M » (M = mois) — le montant précède le $31_RENT_RE = re.compile(r"([\d\s ,]+(?:\.\d+)?)\s*\$\s*/\s*M", re.I)32_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)33# l'agence loue aussi des locaux commerciaux au mois (bureaux, entrepôts…) sans34# ligne « chambres » : on ne garde ces cartes-là que si l'extrait de description35# contient un marqueur clairement résidentiel (studio, bachelor, logement…)36_RESIDENTIAL_RE = re.compile(37 r"\b(studios?|bachelor|logements?|appartements?|condos?|chambres?|"38 r"r[ée]sidentiel(?:le)?s?|unit[ée]s?|laveuse|maison|complexe)\b", re.I)39MAX_PAGES = int(os.environ.get("LOUKA_MIMMO_MAX_PAGES", "10"))40DETAIL_LIMIT = int(os.environ.get("LOUKA_MIMMO_DETAIL_LIMIT", "100"))414243class MImmobilierConnector(BaseConnector):44 source_id = "m_immobilier"45 request_delay = 0.64647 def fetch(self) -> list[Listing]:48 out: dict[str, Listing] = {}49 for page in range(1, MAX_PAGES + 1):50 url = LISTING_URL if page == 1 else f"{LISTING_URL}&page={page}"51 try:52 html = self.get(url).text53 except Exception:54 break55 cards = html.split(CARD_SPLIT)[1:]56 fresh = 057 for card in cards:58 lst = self._parse_card(CARD_SPLIT + card[:6000])59 if lst and lst.uid not in out:60 out[lst.uid] = lst61 fresh += 162 # dernière page atteinte (vide ou uniquement des doublons)63 if not cards or fresh == 0:64 break65 listings = list(out.values())66 # fiche détail : galerie Centris complète + description + adresse pleine67 du.enrich(self, listings, DETAIL_LIMIT, parse_m_detail, key="v1")68 return listings6970 def _parse_card(self, card: str) -> Listing | None:71 m = HREF_RE.search(card)72 if not m:73 return None74 url = BASE + m.group(1)75 external_id = m.group(2)7677 images = []78 for im in IMG_RE.findall(card):79 full = BASE + im80 if full not in images:81 images.append(full)8283 # texte du carton, ligne par ligne : badge statut, ville, adresse,84 # extrait de description, puis paires libellé/valeur85 text = _html.unescape(re.sub(r"<[^>]+>", "\n", card))86 lines = [l.strip() for l in text.splitlines() if l.strip()]87 try:88 k = next(i for i, l in enumerate(lines)89 if l.lower().startswith(("à louer", "a louer")))90 except StopIteration:91 return None # vente/vendu/loué : pas une location active92 city_raw = lines[k + 1] if k + 1 < len(lines) else ""93 address = lines[k + 2] if k + 2 < len(lines) else ""94 snippet = lines[k + 3] if k + 3 < len(lines) else ""9596 price_label = _after(lines, "prix")97 if "/ m" not in price_label.lower():98 return None # garde-fou : loyer mensuel attendu99 price = None100 pm = _RENT_RE.search(price_label)101 if pm:102 try:103 price = float(pm.group(1).replace(" ", "").replace(" ", "")104 .replace(",", ""))105 except ValueError:106 price = None107108 beds = _int(_after(lines, "chambres")) # « 3 + 1 » -> 4109 baths = _after(lines, "salles de bains")110 sqft = _int(_after(lines, "pc")) # PC = pieds carrés111112 # sans chambres, c'est souvent un local commercial loué au mois :113 # on exige un marqueur résidentiel dans l'extrait (studio, bachelor…)114 unit_type = f"{beds} chambres" if beds else ""115 if beds is None:116 if not _RESIDENTIAL_RE.search(snippet):117 return None118 if re.search(r"\b(studios?|bachelor)\b", snippet, re.I):119 unit_type = "Studio"120121 # ville « Montréal (Le Plateau-Mont-Royal) » -> ville + secteur ;122 # les cartes tronquent à ~30 caractères (« Notr... ») -> on nettoie123 city, sector = city_raw, ""124 cm = re.match(r"^(.*?)\s*\(([^)]*)\)?\s*$", city_raw)125 if cm and cm.group(2):126 city, sector = cm.group(1).strip(), cm.group(2).strip()127 city = city.rstrip(".").rstrip()128 sector = re.sub(r"\.{2,}$", "", sector).rstrip("/ -")129130 # adresse parfois tronquée (« 9017 Rue Jean-Baptiste-Gauthie... ») :131 # on la laisse vide et le H1 de la fiche détail la complète132 truncated = address.endswith("...")133 title = f"{re.sub(r'[.]{3,}$', '', address)}, {city}".strip(", ")134135 lst = Listing(136 source=self.source_id,137 external_id=external_id,138 url=url,139 title=title,140 address="" if truncated else address,141 sector=sector,142 city=city,143 unit_type=unit_type,144 price=price,145 price_label=price_label,146 description=re.sub(r"[.]{3,}$", "", snippet),147 details={"Agence": "M Immobilier", "No Centris": external_id},148 images=images,149 )150 if baths:151 lst.details["Salles de bain"] = baths.replace(" + ", "+")152 if sqft:153 lst.area_sqft = float(sqft)154 return lst155156157def parse_m_detail(html: str) -> dict:158 """Galerie Centris complète + description (JSON-LD) + adresse pleine (H1)."""159 out: dict = {}160 imgs = []161 seen = set()162 for im in IMG_RE.findall(html):163 full = BASE + im164 if full not in seen:165 seen.add(full)166 imgs.append(full)167 if imgs:168 out["images"] = imgs169 desc = du.ld_description(html)170 if desc:171 out["description"] = desc172 m = _H1_RE.search(html)173 if m:174 h1 = _html.unescape(re.sub(r"<[^>]+>", " ", m.group(1))).strip()175 if h1 and re.match(r"\d", h1):176 out["address"] = h1 # complète les adresses tronquées177 # pas de table de caractéristiques exploitable sur la fiche (seule la table178 # des pièces existe, qui produirait du bruit) : galerie + description suffisent179 return out180181182def _after(lines: list[str], label: str) -> str:183 lab = label.lower()184 for i, l in enumerate(lines):185 if l.lower() == lab and i + 1 < len(lines):186 return lines[i + 1]187 return ""188189190def _int(s: str):191 if not s:192 return None193 # « 3 + 1 » -> 4 (chambres principales + sous-sol)194 nums = [int(x) for x in re.findall(r"\d+", s)]195 return sum(nums) if nums else None196