# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_mj.py : connecteur Gestion Immobilière M.J (gestionmj.com) # Immeuble du 9155 rue Meilleur, Montréal (Ahuntsic / district Chabanel). # WordPress (thème Astra) rendu serveur avec CPT « a-louer » exposé en # REST (/wp-json/wp/v2/a-louer) : on énumère les annonces via l'API puis # on parse chaque page détail (h2.price, p.address, p.availability, # section Détails = commodités, Description, galerie). # ⚠️ Les blocs icônes (chambres / pi² / étage) et la date de disponibilité # sont des valeurs de gabarit IDENTIQUES sur toutes les pages (« 2 # chambre(s) », « 900 pi² », « 02/03/2025 ») → chambres/typologie dérivées # du TITRE (« Studio moderne », « Appartement 2/3 chambres »), icônes # reléguées en details. Granularité : une annonce par unité (post CPT). # 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, # en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient # (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA # (même pattern que boreal_abitibi/citiluxx/cromwell/deschenes_pepin/ # gestion_habitation/gimcote/appartements_rimouski/lbm, hébergeur # identique). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from urllib.parse import quote from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://gestionmj.com" API_URL = (f"{BASE}/wp-json/wp/v2/a-louer?per_page=100" "&_fields=id,slug,link,title,modified") # page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) _SG_MARKER = "/.well-known/sgcaptcha/" def _sg_challenge(resp) -> bool: """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): return True return _SG_MARKER in (resp.text or "")[:600] BED_TITLE_RE = re.compile(r"(\d)\s*chambres?", re.I) IMG_RE = re.compile(r"https://gestionmj\.com/wp-content/uploads/" r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I) class GestionMJConnector(BaseConnector): source_id = "gestion_mj" request_delay = 0.7 def _enable_residential_proxy(self) -> bool: """Route toute la session via Oxylabs résidentiel CA (session collante).""" endpoint = os.environ.get("OXYLABS_PROXY") user = os.environ.get("OXYLABS_PROXY_USER") pwd = os.environ.get("OXYLABS_PROXY_PASS") if not (endpoint and user and pwd): return False puser = f"{user}-cc-CA-sessid-gestionmj{int(time.time())}-sesstime-10" proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" self.session.proxies = {"http": proxy, "https": proxy} self.session.verify = False # CA MITM du proxy Oxylabs return True def _get_html(self, url: str) -> str: """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" resp = self.get(url) if not _sg_challenge(resp): return resp.text if not self.session.proxies and self._enable_residential_proxy(): resp = self.get(url) if not _sg_challenge(resp): return resp.text raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") def fetch(self) -> list[Listing]: listings: list[Listing] = [] # échec franc sur challenge/panne (fini le « 0 trouvé ok » silencieux) posts = json.loads(self._get_html(API_URL)) if not isinstance(posts, list): raise RuntimeError("réponse wp-json inattendue (pas une liste)") for post in posts: try: slug = post.get("slug", "") url = (post.get("link") or "").split("?")[0] if not slug or "/a-louer/" not in url: continue title = BeautifulSoup( (post.get("title") or {}).get("rendered", ""), "html.parser").get_text(" ", strip=True) or slug d = self.detail(slug, str(post.get("modified", "")), lambda url=url: self._fetch_detail(url)) # typologie/chambres depuis le TITRE (icônes non fiables) bedrooms = None if re.search(r"\bstudio\b", title, re.I): bedrooms = 0.0 else: m = BED_TITLE_RE.search(title) if m: bedrooms = float(m.group(1)) unit_type = normalize_unit_type( "Studio" if bedrooms == 0.0 else (f"{int(bedrooms)} chambres" if bedrooms else "")) listings.append(Listing( source=self.source_id, external_id=slug, url=url, title=title, address=d.get("address", ""), city="Montréal", sector="Ahuntsic-Cartierville", unit_type=unit_type, bedrooms=bedrooms, price=d.get("price"), availability=d.get("availability", ""), furnished="Meublé" in (d.get("amenities") or []), description=d.get("description", ""), amenities=d.get("amenities") or [], details=d.get("details") or {}, images=d.get("images") or [], )) except Exception: continue return listings def _fetch_detail(self, url: str) -> dict: out: dict = {} # _get_html lève sur challenge : pas de {} vide dans detail_cache # (clé figée jamais invalidée) html = self._get_html(url) soup = BeautifulSoup(html, "html.parser") el = soup.select_one("h2.price") if el: out["price"] = parse_price(el.get_text(" ", strip=True)) el = soup.select_one("p.address") if el: out["address"] = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() el = soup.select_one("p.availability") if el: out["availability"] = re.sub( r"\s+", " ", el.get_text(" ", strip=True)).strip() # section « Détails » : commodités (li) h = soup.find(string=re.compile(r"^\s*Détails\s*$")) amenities: list[str] = [] if h: sec = h.find_parent(["div", "section"]) for _ in range(3): if sec is None: break lis = sec.select("li") if lis: amenities = [li.get_text(" ", strip=True) for li in lis if 0 < len(li.get_text(strip=True)) <= 60] break sec = sec.parent out["amenities"] = amenities[:15] # description : paragraphes après le titre « Description » h = soup.find(string=re.compile(r"^\s*Description\s*$")) if h: par = h.find_parent(["div", "section"]) ps = [p.get_text(" ", strip=True) for p in (par.find_all_next("p", limit=3) if par else []) if len(p.get_text(strip=True)) > 60] if ps: out["description"] = re.sub(r"\s+", " ", " ".join(ps[:2]))[:900] # icônes gabarit (non fiables) → details, à titre indicatif details: dict = {} for sel, key in (("p.icon.square-footage", "gabarit_superficie"), ("p.icon.floor", "gabarit_etage")): el = soup.select_one(sel) if el: details[key] = el.get_text(" ", strip=True) out["details"] = details out["images"] = [ u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", u, re.I) ][:20] return out