# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sotramont.py : connecteur Sotramont (sotramont.com) # Promoteur montréalais : condos locatifs Liveo (Pointe-Claire/Greenwich # 1-2-3, Mascouche 1-2, Repentigny 1-2, Bois-Franc) et Curtiss Charlie # (Saint-Laurent). WordPress rendu serveur : le portfolio # ?category=locatifs liste les cartes article.c--article-project # (badge occupation, nom, « quartier – ville », « N condos locatifs », # li typologies « 1-2 chambres sur 10 étages » + « à partir de N $ par # mois avec inclusions »). AUCUN inventaire par unité publié → une # annonce par PHASE/immeuble ; les cartes « Vendu » ou « En construction » # sont exclues. Détail (description, photos) tiré de la page phase quand # elle est hébergée sur sotramont.com (Repentigny pointe vers liveo.ca). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import time from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://sotramont.com" LIST_URL = f"{BASE}/projets-maison-ville-condo-vendre-louer/?category=locatifs" SKIP_TAG_RE = re.compile(r"vendu|en\s+construction", re.I) PRICE_RE = re.compile(r"[àa]\s*partir\s*de\s*([\d\s ,]+)\s*\$", re.I) BEDS_RE = re.compile(r"(\d)(?:\s*-\s*(\d))?\s*chambres?", re.I) UNITS_RE = re.compile(r"(\d{2,4})\s*condos\s*locatifs", re.I) IMG_RE = re.compile(r"https://sotramont\.com/wp-content/uploads/" r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I) def _slugify(name: str) -> str: s = name.lower() s = re.sub(r"[àâä]", "a", s) s = re.sub(r"[éèêë]", "e", s) s = re.sub(r"[îï]", "i", s) s = re.sub(r"[ôö]", "o", s) s = re.sub(r"[ûüù]", "u", s) s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s class SotramontConnector(BaseConnector): source_id = "sotramont" request_delay = 0.7 def fetch(self) -> list[Listing]: listings: list[Listing] = [] # Hoquets réseau transitoires (vagues DNS du nœud 09-10/09-18, resets # du VPS) : retry court, puis échec FRANC — un `return []` silencieux # devenait « 0 trouvé ok » et déclenchait la dérive/stale à tort. html = "" last_err: Exception | None = None for attempt in range(3): try: html = self.get(LIST_URL).text break except Exception as e: # noqa: BLE001 — retry puis erreur franche last_err = e if attempt < 2: time.sleep(3 * (attempt + 1)) else: raise RuntimeError( f"sotramont : portfolio inaccessible après 3 tentatives " f"({last_err!r}) — {LIST_URL}") soup = BeautifulSoup(html, "html.parser") arts = soup.select("article.c--article-project") if not arts: # Vrai zéro = cartes présentes mais aucune locative active ; # AUCUNE carte = gabarit remanié ou page vide → erreur franche. raise RuntimeError( "sotramont : aucune carte projet dans le portfolio — " f"site remanié ? — {LIST_URL}") seen: set[str] = set() for art in arts: try: if "project-category-locatifs" not in (art.get("class") or []): continue tag_el = art.select_one(".c--article-project__tag") tag = tag_el.get_text(" ", strip=True) if tag_el else "" if not tag or SKIP_TAG_RE.search(tag): continue # vendu / en construction / sans badge name_el = art.select_one(".c--article-project__text-lg") name = (name_el.get_text(" ", strip=True) if name_el else "") if not name: continue ext_id = _slugify(name) if ext_id in seen: continue seen.add(ext_id) # « Greenwich – Pointe-Claire » / « Mascouche » loc_el = art.select_one(".c--article-project__text-md") loc = re.sub(r"\s+", " ", loc_el.get_text(" ", strip=True) if loc_el else "") sector, city = "", loc if "–" in loc or "-" in loc and "," not in loc: parts = re.split(r"\s*[–—]\s*", loc) if len(parts) == 2: sector, city = parts[0].strip(), parts[1].strip() # doublon d'accessibilité (« Bois-FrancBois-Franc ») half = len(sector) // 2 if half and sector[:half] == sector[half:]: sector = sector[:half] lis = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in art.select("li")] price = None price_label = "" typologie = "" bedrooms = None for t in lis: m = PRICE_RE.search(t) if m and price is None: try: price = float(m.group(1).replace(" ", "") .replace(" ", "").replace(" ", "") .replace(",", "")) price_label = t except ValueError: pass m = BEDS_RE.search(t) if m and not typologie: typologie = t # fourchette (« 1-2 chambres… ») if not m.group(2): # nombre précis seulement bedrooms = float(m.group(1)) details: dict = {} if typologie: details["typologies"] = typologie h5 = art.select_one(".content-text h5") if h5: m = UNITS_RE.search(h5.get_text(" ", strip=True)) if m: details["nb_unites"] = m.group(1) a = art.select_one("a.c--article-project__inner") url = (a.get("href") or "").split("?")[0] if a else "" images: list[str] = [] fig = art.find("figure") if fig: m = re.search(r"url\(([^)]+)\)", fig.get("style", "")) if m: images.append(m.group(1).strip("'\"")) d: dict = {} if url.startswith(BASE): key = hashlib.sha1( "|".join([tag, name] + lis).encode("utf-8") ).hexdigest() d = self.detail(ext_id, key, lambda url=url: self._fetch_detail(url)) elif not url: url = LIST_URL listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{name} — condos locatifs ({city})", sector=sector, city=city, bedrooms=bedrooms, price=price, price_label=price_label, availability=tag, description=d.get("description", ""), details=details, images=(d.get("images") or images)[:20], )) except Exception: continue return listings def _fetch_detail(self, url: str) -> dict: """Page phase : description marketing + galerie photos.""" out: dict = {} try: html = self.get(url).text except Exception: return out soup = BeautifulSoup(html, "html.parser") main = soup.find("main") or soup paras = [p.get_text(" ", strip=True) for p in main.find_all("p") if len(p.get_text(strip=True)) > 80] # sauter les descriptions génériques du méga-menu (répétées partout) paras = [p for p in paras if "portfolio" not in p.lower() and "équilibre sain" not in p.lower() and "quel que soit le budget" not in p.lower()] if paras: out["description"] = re.sub(r"\s+", " ", " ".join(paras[:3])).strip()[:1200] out["images"] = [ u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|favicon|cropped-|plan|" r"-\d{2,3}x\d{2,3}\.", u, re.I)][:20] return out