spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/gestion_montreal.py : connecteur Gestion Montréal5# (gestion-montreal.com — Montréal, Longueuil, Repentigny, La Prairie).6# Site immosquare rendu serveur : la page /fr/inscriptions embarque un7# GeoJSON complet (window.properties_geojson) avec prix, adresse, photos,8# description, chambres/salles de bain et coordonnées. Les pages détail9# /fr/inscriptions/<slug> ajoutent les sections structurées « Équipements /10# Caractéristiques / Services / Animaux » (via self.detail, mises en cache).11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import html as html_lib16import json17import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_area_sqft, strip_accents22from .base import BaseConnector2324BASE = "https://gestion-montreal.com"25LIST_URL = f"{BASE}/fr/inscriptions"2627# Garde-fou géographique : bounding box du Grand Montréal (CMM approx.)28BBOX = (-74.30, 45.15, -73.10, 45.85) # lng_min, lat_min, lng_max, lat_max2930# Nombre de chambres -> type d'unité normalisé31_BEDROOMS_TO_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}323334def _fix_mojibake(s: str) -> str:35 """Répare 'Montrã©Al' -> 'Montréal' (UTF-8 lu en latin-1 chez la source,36 parfois re-capitalisé ensuite, d'où le 'ã©' minuscule)."""37 if not s:38 return ""39 for bad, good in (("é", "é"), ("ã©", "é"), ("è", "è"), ("ã¨", "è"),40 ("ô", "ô"), ("ã´", "ô"), ("î", "î"), ("ã®", "î"),41 ("É", "É"), ("à ", "à ")):42 s = s.replace(bad, good)43 # la source re-capitalise après le 'ã©' : « MontréAl » -> « Montréal »44 s = re.sub(r"([éèêôîà])([A-Z])(?=[a-z])",45 lambda m: m.group(1) + m.group(2).lower(), s)46 return s.strip()474849def _norm_city(raw: str) -> str:50 city = _fix_mojibake(raw)51 if strip_accents(city).lower().startswith("montreal"):52 return "Montréal"53 return city545556# Quartiers/arrondissements connus (repérés dans le titre de l'annonce)57_SECTORS = [58 "Centre-ville", "Vieux-Montréal", "Plateau-Mont-Royal", "Plateau",59 "Mile-End", "Mile End", "Griffintown", "Saint-Henri", "Petite-Italie",60 "Petite-Patrie", "Rosemont", "Hochelaga-Maisonneuve", "Hochelaga",61 "Mercier", "Tétreaultville", "Verdun", "Île-des-Sœurs", "LaSalle",62 "Lachine", "Villeray", "Parc-Extension", "Ahuntsic", "Cartierville",63 "Saint-Laurent", "Saint-Léonard", "Saint-Michel", "Anjou",64 "Montréal-Nord", "Rivière-des-Prairies", "Pointe-aux-Trembles",65 "Côte-des-Neiges", "Notre-Dame-de-Grâce", "NDG", "Outremont",66 "Westmount", "Ville-Marie", "Sud-Ouest", "Pointe-Saint-Charles",67 "Quartier latin", "Quartier des spectacles", "Vieux-Longueuil",68]69_SECTOR_RE = re.compile(70 "|".join(re.escape(s) for s in _SECTORS), re.IGNORECASE)717273def _strip_html(s: str) -> str:74 s = html_lib.unescape(html_lib.unescape(s or "")) # &eacute; -> é75 s = re.sub(r"<[^>]+>", " ", s)76 return re.sub(r"\s+", " ", s).strip()777879def _sector_from_text(*texts: str) -> str:80 """Repère un quartier connu dans le titre (puis la description)."""81 for text in texts:82 m = _SECTOR_RE.search(_fix_mojibake(text or ""))83 if m:84 sector = m.group(0)85 # recapitalisation propre à partir de la liste de référence86 for ref in _SECTORS:87 if ref.lower() == sector.lower():88 return ref89 return sector90 return ""919293class GestionMontrealConnector(BaseConnector):94 source_id = "gestion_montreal"95 request_delay = 0.696 max_detail_requests = 150 # vraies requêtes détail par sync (hors cache)9798 def __init__(self) -> None:99 super().__init__()100 # gestion-montreal.com coupe la connexion dès que le User-Agent101 # contient un suffixe de type bot ("LouKaBot/1.0 (+courriel)") ;102 # on se présente donc avec un UA navigateur standard.103 self.session.headers["User-Agent"] = (104 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "105 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36")106107 def fetch(self) -> list[Listing]:108 listings: list[Listing] = []109 self._detail_budget = 0110 try:111 html = self.get(LIST_URL).text112 except Exception:113 return listings114115 m = re.search(r"window\.properties_geojson\s*=\s*", html)116 if not m:117 return listings118 try:119 features, _ = json.JSONDecoder().raw_decode(html[m.end():])120 except ValueError:121 return listings122123 for feat in features:124 try:125 p = feat.get("properties") or {}126 ext_id = str(p.get("id") or "")127 slug = p.get("slug_link") or ""128 if not ext_id or not slug:129 continue130131 # exclusions : déjà loué, non résidentiel132 flag = (p.get("property_flag") or {})133 if isinstance(flag, dict) and flag.get("fr") == "Loué":134 continue135 if p.get("sold_rented"):136 continue137 classification = ((p.get("property_classification") or {})138 .get("fr") or "")139 if re.search(r"stationnement|commercial|bureau|local|terrain|"140 r"industriel|garage|entrep[oô]t", classification, re.I):141 continue142143 # garde-fou géographique (Grand Montréal seulement)144 coords = (feat.get("geometry") or {}).get("coordinates") or []145 lng, lat = (coords + [None, None])[:2]146 if lng is not None and lat is not None and not (147 BBOX[0] <= lng <= BBOX[2] and BBOX[1] <= lat <= BBOX[3]):148 continue149150 city = _norm_city(p.get("locality") or "")151 title_i18n = p.get("title") or {}152 title = _fix_mojibake(title_i18n.get("fr")153 or title_i18n.get("en")154 or p.get("address_short") or "")155 desc_fr = ((p.get("description") or {}).get("fr") or "")156 sector = (p.get("sublocality") or "").strip() or \157 _sector_from_text(title, desc_fr[:800])158159 # type d'unité : Studio/Chambre/Maison direct, sinon chambres160 if re.search(r"studio", classification, re.I):161 unit_type = "Studio"162 elif re.search(r"chambre", classification, re.I):163 unit_type = "Chambre"164 elif re.search(r"maison", classification, re.I):165 unit_type = "Maison"166 else:167 unit_type = _BEDROOMS_TO_TYPE.get(168 p.get("bedrooms"),169 normalize_unit_type(classification))170171 price = p.get("price")172 price = float(price) if isinstance(price, (int, float)) else None173 price_label = ((p.get("prices_formatted") or {}).get("fr")174 or p.get("price_formatted") or "")175176 desc_i18n = p.get("description") or {}177 description = _strip_html(desc_i18n.get("fr")178 or desc_i18n.get("en") or "")[:600]179180 amenities: list[str] = []181 if re.search(r"meubl", classification, re.I):182 amenities.append("Meublé")183 bathrooms = p.get("bathrooms")184 if isinstance(bathrooms, (int, float)) and bathrooms > 0:185 amenities.append(f"{int(bathrooms)} salle(s) de bain")186187 # superficie structurée (rare : « 800 pc »)188 area_sqft = None189 if p.get("area"):190 area_sqft = parse_area_sqft(str(p.get("area_formatted")191 or ""))192193 # page détail : sections « Équipements / Caractéristiques /194 # Services / Animaux » (cache BD, plafonné par sync)195 detail_url = f"{BASE}/fr/inscriptions/{slug}"196 key = hashlib.sha1(json.dumps(197 [p.get("updated_at"), p.get("major_updated_at"),198 p.get("price"), p.get("availability_date"),199 title], ensure_ascii=False).encode()).hexdigest()[:16]200 payload = self.detail(ext_id, key,201 lambda u=detail_url: self._fetch_detail(u))202 amenities += [a for a in (payload.get("amenities") or [])203 if a not in amenities]204 details: dict = {}205 if payload.get("phone"):206 details["contact"] = {"phone": payload["phone"]}207208 images = [u for u in (p.get("assets") or [])209 if isinstance(u, str)210 and not re.search(r"placehold|logo|icon", u, re.I)]211212 listings.append(Listing(213 source=self.source_id,214 external_id=ext_id,215 url=detail_url,216 title=title or _fix_mojibake(p.get("address_short") or ""),217 address=_fix_mojibake(p.get("address_short")218 or p.get("address") or ""),219 sector=sector,220 city=city,221 unit_type=unit_type,222 price=price,223 price_label=price_label,224 availability=(p.get("availability_date") or "")[:10],225 area_sqft=area_sqft,226 description=description,227 amenities=amenities,228 details=details,229 images=list(dict.fromkeys(images)),230 lat=lat,231 lng=lng,232 ))233 except Exception:234 continue235236 return listings237238 # -- page détail : commodités structurées -----------------------------------239 def _fetch_detail(self, url: str) -> dict:240 """Sections « Autres » de la fiche : Équipements, Caractéristiques241 Intérieures/Extérieures, Services, Animaux, Transport (items <small>).242 Retourne {} au-delà du budget de requêtes (les hits de cache ne243 passent pas par ici)."""244 if self._detail_budget >= self.max_detail_requests:245 return {}246 self._detail_budget += 1247 try:248 html = self.get(url).text249 except Exception:250 return {}251 soup = BeautifulSoup(html, "html.parser")252 amenities: list[str] = []253 for small in soup.select("div.card-body ul li ul li small"):254 t = re.sub(r"\s+", " ", small.get_text(" ", strip=True))255 if t and t not in amenities:256 amenities.append(t)257 payload: dict = {}258 if amenities:259 payload["amenities"] = amenities260 tel = soup.select_one('a[href^="tel:"]')261 if tel:262 m = re.search(r"\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})",263 tel.get("href", ""))264 if m:265 payload["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"266 return payload267