# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lbm.py : connecteur Groupe Rive-Nord / LBM Gestion Locative # (grouperivenord.ca — info@lbmgestionlocative.com). Condos locatifs neufs # « tout inclus » sur le chemin d'Oka : Le 2922 et Le 3215 à # Sainte-Marthe-sur-le-Lac, Le 3533 (page /le-3537/) à Saint-Joseph-du-Lac. # WordPress/Divi rendu serveur : les pages projet (liens « le-* » du menu) # sont des vitrines ; certaines publient des cartes « Modèle N » (type, # « À partir de X$/m », superficie, chambres, inclusions), les autres # seulement les typologies offertes (« 4 1/2 et 5 1/2 », 1100 pi²). # Granularité MODÈLE/TYPOLOGIE par immeuble, prix seulement où publié. # 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, hébergeur identique). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import time from urllib.parse import quote from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://grouperivenord.ca" # 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] PROJECT_RE = re.compile(r'href="(https://grouperivenord\.ca/(le-[\w-]+)/)"') HERO_RE = re.compile(r"Bienvenue au\s*\|?\s*(\d{2,5}[^|]{0,40}?)\|", re.I) CITY_RE = re.compile(r"\|\s*((?:Saint|Sainte)e?-[A-Za-zÀ-ü’'-]+(?:-[A-Za-zÀ-ü’'-]+)*)\s*\|") OCCUP_RE = re.compile(r"Occupation d[èe]s[^|]{0,50}", re.I) SQFT_RE = re.compile(r"(\d{3,4})\s*pieds?\s*carr[ée]s", re.I) TYPES_RE = re.compile(r"\d\s*1/2") PRICE_HERO_RE = re.compile(r"À partir de\s*([\d\s ]{3,7})\$", re.I) MODEL_RE = re.compile( r"Modèle\s*(\d+)\|\s*\|?Type de logement\s*:\s*([\d\s/½]+)\|" r".{0,80}?À partir(?:\s*de)?\|([\d\s ]{3,7})\$\|/\|m" r"\|Superficie:\s*([\d\s ]{2,6})\s*pi2" r"(?:\|(\d)\s*chambres?)?" r"((?:\|[^|]{3,70}){0,10})", re.I | re.S) IMG_RE = re.compile( r"https://grouperivenord\.ca/wp-content/uploads/[^\"'\s\\)]+?" r"\.(?:jpg|jpeg|png|webp)", re.I) def _flat(html: str) -> str: txt = re.sub(r"", "", html, flags=re.S) txt = re.sub(r"", "", txt, flags=re.S) txt = re.sub(r"<[^>]+>", "|", txt).replace(" ", " ") txt = txt.replace("'", "'").replace("’", "'") return re.sub(r"[ \t\n]+", " ", re.sub(r"\|+", "|", txt)) def _num(txt: str) -> float | None: digits = re.sub(r"[^\d]", "", txt or "") return float(digits) if digits else None class LbmConnector(BaseConnector): source_id = "lbm" request_delay = 0.6 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-lbm{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) home = self._get_html(BASE + "/") pages = list(dict.fromkeys(PROJECT_RE.findall(home))) for page_url, slug in pages: try: html = self._get_html(page_url) except Exception: continue flat = _flat(html) m = HERO_RE.search(flat) address = (m.group(1).strip() if m else "").replace("ch. Oka", "chemin d'Oka") address = address.replace("Chemin D'Oka", "chemin d'Oka") cm = CITY_RE.search(flat) city = cm.group(1).strip() if cm else "" om = OCCUP_RE.search(flat) availability = om.group(0).strip() if om else "" images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|favicon|icon|-\d+x\d+\.", u, re.I)][:20] models = list(MODEL_RE.finditer(flat)) seen: set[str] = set() if models: for mm in models: n, raw_type, price, sqft, beds, tail = mm.groups() ext_id = f"{slug}-modele-{n}" if ext_id in seen: # blocs répétés desktop/mobile continue seen.add(ext_id) unit_type = normalize_unit_type(raw_type.strip()) amenities = [t.strip(" .") for t in tail.split("|") if 3 < len(t.strip()) < 70 and "Modèle" not in t] listings.append(Listing( source=self.source_id, external_id=ext_id, url=page_url, title=(f"{unit_type} (modèle {n}) — " f"{address}, {city}"), address=address, city=city, unit_type=unit_type, price=_num(price), price_label=f"À partir de {_num(price):.0f} $/mois", availability=availability, area_sqft=_num(sqft), bedrooms=float(beds) if beds else None, description=("Condo locatif neuf en formule tout " f"inclus au {address}, {city} " "(Groupe Rive-Nord / LBM Gestion " "Locative)."), amenities=amenities[:12], details={"model": f"Modèle {n}"}, images=images, )) else: # vitrine sans modèles : une annonce par typologie citée sq = SQFT_RE.search(flat) hero_zone = flat[:2500] for raw_type in dict.fromkeys(TYPES_RE.findall(hero_zone)): unit_type = normalize_unit_type(raw_type) ext_id = f"{slug}-{unit_type.replace('½', '.5')}" if ext_id in seen: continue seen.add(ext_id) pm = PRICE_HERO_RE.search(hero_zone) price = _num(pm.group(1)) if pm else None listings.append(Listing( source=self.source_id, external_id=ext_id, url=page_url, title=f"{unit_type} — {address}, {city}", address=address, city=city, unit_type=unit_type, price=price, price_label=(f"À partir de {price:.0f} $/mois" if price else ""), availability=availability, area_sqft=_num(sq.group(1)) if sq else None, description=("Condo locatif clé en main, formule " f"tout inclus, au {address}, {city} " "(Groupe Rive-Nord / LBM Gestion " "Locative)."), amenities=["Formule tout inclus"], images=images, )) return listings