# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_montreal.py : connecteur Gestion Montréal # (gestion-montreal.com — Montréal, Longueuil, Repentigny, La Prairie). # Site immosquare rendu serveur : la page /fr/inscriptions embarque un # GeoJSON complet (window.properties_geojson) avec prix, adresse, photos, # description, chambres/salles de bain et coordonnées. Les pages détail # /fr/inscriptions/ ajoutent les sections structurées « Équipements / # Caractéristiques / Services / Animaux » (via self.detail, mises en cache). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as html_lib import json import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_area_sqft, strip_accents from .base import BaseConnector BASE = "https://gestion-montreal.com" LIST_URL = f"{BASE}/fr/inscriptions" # Garde-fou géographique : bounding box du Grand Montréal (CMM approx.) BBOX = (-74.30, 45.15, -73.10, 45.85) # lng_min, lat_min, lng_max, lat_max # Nombre de chambres -> type d'unité normalisé _BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"} def _fix_mojibake(s: str) -> str: """Répare 'Montrã©Al' -> 'Montréal' (UTF-8 lu en latin-1 chez la source, parfois re-capitalisé ensuite, d'où le 'ã©' minuscule).""" if not s: return "" for bad, good in (("é", "é"), ("ã©", "é"), ("è", "è"), ("ã¨", "è"), ("ô", "ô"), ("ã´", "ô"), ("î", "î"), ("ã®", "î"), ("É", "É"), ("à ", "à ")): s = s.replace(bad, good) # la source re-capitalise après le 'ã©' : « MontréAl » -> « Montréal » s = re.sub(r"([éèêôîà])([A-Z])(?=[a-z])", lambda m: m.group(1) + m.group(2).lower(), s) return s.strip() def _norm_city(raw: str) -> str: city = _fix_mojibake(raw) if strip_accents(city).lower().startswith("montreal"): return "Montréal" return city # Quartiers/arrondissements connus (repérés dans le titre de l'annonce) _SECTORS = [ "Centre-ville", "Vieux-Montréal", "Plateau-Mont-Royal", "Plateau", "Mile-End", "Mile End", "Griffintown", "Saint-Henri", "Petite-Italie", "Petite-Patrie", "Rosemont", "Hochelaga-Maisonneuve", "Hochelaga", "Mercier", "Tétreaultville", "Verdun", "Île-des-Sœurs", "LaSalle", "Lachine", "Villeray", "Parc-Extension", "Ahuntsic", "Cartierville", "Saint-Laurent", "Saint-Léonard", "Saint-Michel", "Anjou", "Montréal-Nord", "Rivière-des-Prairies", "Pointe-aux-Trembles", "Côte-des-Neiges", "Notre-Dame-de-Grâce", "NDG", "Outremont", "Westmount", "Ville-Marie", "Sud-Ouest", "Pointe-Saint-Charles", "Quartier latin", "Quartier des spectacles", "Vieux-Longueuil", ] _SECTOR_RE = re.compile( "|".join(re.escape(s) for s in _SECTORS), re.IGNORECASE) def _strip_html(s: str) -> str: s = html_lib.unescape(html_lib.unescape(s or "")) # &eacute; -> é s = re.sub(r"<[^>]+>", " ", s) return re.sub(r"\s+", " ", s).strip() def _sector_from_text(*texts: str) -> str: """Repère un quartier connu dans le titre (puis la description).""" for text in texts: m = _SECTOR_RE.search(_fix_mojibake(text or "")) if m: sector = m.group(0) # recapitalisation propre à partir de la liste de référence for ref in _SECTORS: if ref.lower() == sector.lower(): return ref return sector return "" class GestionMontrealConnector(BaseConnector): source_id = "gestion_montreal" request_delay = 0.6 max_detail_requests = 150 # vraies requêtes détail par sync (hors cache) def __init__(self) -> None: super().__init__() # gestion-montreal.com coupe la connexion dès que le User-Agent # contient un suffixe de type bot ("LouKaBot/1.0 (+courriel)") ; # on se présente donc avec un UA navigateur standard. self.session.headers["User-Agent"] = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36") def fetch(self) -> list[Listing]: listings: list[Listing] = [] self._detail_budget = 0 try: html = self.get(LIST_URL).text except Exception: return listings m = re.search(r"window\.properties_geojson\s*=\s*", html) if not m: return listings try: features, _ = json.JSONDecoder().raw_decode(html[m.end():]) except ValueError: return listings for feat in features: try: p = feat.get("properties") or {} ext_id = str(p.get("id") or "") slug = p.get("slug_link") or "" if not ext_id or not slug: continue # exclusions : déjà loué, non résidentiel flag = (p.get("property_flag") or {}) if isinstance(flag, dict) and flag.get("fr") == "Loué": continue if p.get("sold_rented"): continue classification = ((p.get("property_classification") or {}) .get("fr") or "") if re.search(r"stationnement|commercial|bureau|local|terrain|" r"industriel|garage|entrep[oô]t", classification, re.I): continue # garde-fou géographique (Grand Montréal seulement) coords = (feat.get("geometry") or {}).get("coordinates") or [] lng, lat = (coords + [None, None])[:2] if lng is not None and lat is not None and not ( BBOX[0] <= lng <= BBOX[2] and BBOX[1] <= lat <= BBOX[3]): continue city = _norm_city(p.get("locality") or "") title_i18n = p.get("title") or {} title = _fix_mojibake(title_i18n.get("fr") or title_i18n.get("en") or p.get("address_short") or "") desc_fr = ((p.get("description") or {}).get("fr") or "") sector = (p.get("sublocality") or "").strip() or \ _sector_from_text(title, desc_fr[:800]) # type d'unité : Studio/Chambre/Maison direct, sinon chambres if re.search(r"studio", classification, re.I): unit_type = "Studio" elif re.search(r"chambre", classification, re.I): unit_type = "Chambre" elif re.search(r"maison", classification, re.I): unit_type = "Maison" else: unit_type = _BEDROOMS_TO_TYPE.get( p.get("bedrooms"), normalize_unit_type(classification)) price = p.get("price") price = float(price) if isinstance(price, (int, float)) else None price_label = ((p.get("prices_formatted") or {}).get("fr") or p.get("price_formatted") or "") desc_i18n = p.get("description") or {} description = _strip_html(desc_i18n.get("fr") or desc_i18n.get("en") or "")[:600] amenities: list[str] = [] if re.search(r"meubl", classification, re.I): amenities.append("Meublé") bathrooms = p.get("bathrooms") if isinstance(bathrooms, (int, float)) and bathrooms > 0: amenities.append(f"{int(bathrooms)} salle(s) de bain") # superficie structurée (rare : « 800 pc ») area_sqft = None if p.get("area"): area_sqft = parse_area_sqft(str(p.get("area_formatted") or "")) # page détail : sections « Équipements / Caractéristiques / # Services / Animaux » (cache BD, plafonné par sync) detail_url = f"{BASE}/fr/inscriptions/{slug}" key = hashlib.sha1(json.dumps( [p.get("updated_at"), p.get("major_updated_at"), p.get("price"), p.get("availability_date"), title], ensure_ascii=False).encode()).hexdigest()[:16] payload = self.detail(ext_id, key, lambda u=detail_url: self._fetch_detail(u)) amenities += [a for a in (payload.get("amenities") or []) if a not in amenities] details: dict = {} if payload.get("phone"): details["contact"] = {"phone": payload["phone"]} images = [u for u in (p.get("assets") or []) if isinstance(u, str) and not re.search(r"placehold|logo|icon", u, re.I)] listings.append(Listing( source=self.source_id, external_id=ext_id, url=detail_url, title=title or _fix_mojibake(p.get("address_short") or ""), address=_fix_mojibake(p.get("address_short") or p.get("address") or ""), sector=sector, city=city, unit_type=unit_type, price=price, price_label=price_label, availability=(p.get("availability_date") or "")[:10], area_sqft=area_sqft, description=description, amenities=amenities, details=details, images=list(dict.fromkeys(images)), lat=lat, lng=lng, )) except Exception: continue return listings # -- page détail : commodités structurées ----------------------------------- def _fetch_detail(self, url: str) -> dict: """Sections « Autres » de la fiche : Équipements, Caractéristiques Intérieures/Extérieures, Services, Animaux, Transport (items ). Retourne {} au-delà du budget de requêtes (les hits de cache ne passent pas par ici).""" if self._detail_budget >= self.max_detail_requests: return {} self._detail_budget += 1 try: html = self.get(url).text except Exception: return {} soup = BeautifulSoup(html, "html.parser") amenities: list[str] = [] for small in soup.select("div.card-body ul li ul li small"): t = re.sub(r"\s+", " ", small.get_text(" ", strip=True)) if t and t not in amenities: amenities.append(t) payload: dict = {} if amenities: payload["amenities"] = amenities tel = soup.select_one('a[href^="tel:"]') if tel: m = re.search(r"\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})", tel.get("href", "")) if m: payload["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" return payload