Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/sotramont.py : connecteur Sotramont (sotramont.com)5# Promoteur montréalais : condos locatifs Liveo (Pointe-Claire/Greenwich6# 1-2-3, Mascouche 1-2, Repentigny 1-2, Bois-Franc) et Curtiss Charlie7# (Saint-Laurent). WordPress rendu serveur : le portfolio8# ?category=locatifs liste les cartes article.c--article-project9# (badge occupation, nom, « quartier – ville », « N condos locatifs »,10# li typologies « 1-2 chambres sur 10 étages » + « à partir de N $ par11# mois avec inclusions »). AUCUN inventaire par unité publié → une12# annonce par PHASE/immeuble ; les cartes « Vendu » ou « En construction »13# sont exclues. Détail (description, photos) tiré de la page phase quand14# elle est hébergée sur sotramont.com (Repentigny pointe vers liveo.ca).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re20import time2122from bs4 import BeautifulSoup2324from ..schema import Listing25from .base import BaseConnector2627BASE = "https://sotramont.com"28LIST_URL = f"{BASE}/projets-maison-ville-condo-vendre-louer/?category=locatifs"2930SKIP_TAG_RE = re.compile(r"vendu|en\s+construction", re.I)31PRICE_RE = re.compile(r"[àa]\s*partir\s*de\s*([\d\s ,]+)\s*\$", re.I)32BEDS_RE = re.compile(r"(\d)(?:\s*-\s*(\d))?\s*chambres?", re.I)33UNITS_RE = re.compile(r"(\d{2,4})\s*condos\s*locatifs", re.I)34IMG_RE = re.compile(r"https://sotramont\.com/wp-content/uploads/"35 r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I)363738def _slugify(name: str) -> str:39 s = name.lower()40 s = re.sub(r"[àâä]", "a", s)41 s = re.sub(r"[éèêë]", "e", s)42 s = re.sub(r"[îï]", "i", s)43 s = re.sub(r"[ôö]", "o", s)44 s = re.sub(r"[ûüù]", "u", s)45 s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")46 return s474849class SotramontConnector(BaseConnector):50 source_id = "sotramont"51 request_delay = 0.75253 def fetch(self) -> list[Listing]:54 listings: list[Listing] = []55 # Hoquets réseau transitoires (vagues DNS du nœud 09-10/09-18, resets56 # du VPS) : retry court, puis échec FRANC — un `return []` silencieux57 # devenait « 0 trouvé ok » et déclenchait la dérive/stale à tort.58 html = ""59 last_err: Exception | None = None60 for attempt in range(3):61 try:62 html = self.get(LIST_URL).text63 break64 except Exception as e: # noqa: BLE001 — retry puis erreur franche65 last_err = e66 if attempt < 2:67 time.sleep(3 * (attempt + 1))68 else:69 raise RuntimeError(70 f"sotramont : portfolio inaccessible après 3 tentatives "71 f"({last_err!r}) — {LIST_URL}")72 soup = BeautifulSoup(html, "html.parser")7374 arts = soup.select("article.c--article-project")75 if not arts:76 # Vrai zéro = cartes présentes mais aucune locative active ;77 # AUCUNE carte = gabarit remanié ou page vide → erreur franche.78 raise RuntimeError(79 "sotramont : aucune carte projet dans le portfolio — "80 f"site remanié ? — {LIST_URL}")8182 seen: set[str] = set()83 for art in arts:84 try:85 if "project-category-locatifs" not in (art.get("class") or []):86 continue87 tag_el = art.select_one(".c--article-project__tag")88 tag = tag_el.get_text(" ", strip=True) if tag_el else ""89 if not tag or SKIP_TAG_RE.search(tag):90 continue # vendu / en construction / sans badge91 name_el = art.select_one(".c--article-project__text-lg")92 name = (name_el.get_text(" ", strip=True)93 if name_el else "")94 if not name:95 continue96 ext_id = _slugify(name)97 if ext_id in seen:98 continue99 seen.add(ext_id)100101 # « Greenwich – Pointe-Claire » / « Mascouche »102 loc_el = art.select_one(".c--article-project__text-md")103 loc = re.sub(r"\s+", " ", loc_el.get_text(" ", strip=True)104 if loc_el else "")105 sector, city = "", loc106 if "–" in loc or "-" in loc and "," not in loc:107 parts = re.split(r"\s*[–—]\s*", loc)108 if len(parts) == 2:109 sector, city = parts[0].strip(), parts[1].strip()110 # doublon d'accessibilité (« Bois-FrancBois-Franc »)111 half = len(sector) // 2112 if half and sector[:half] == sector[half:]:113 sector = sector[:half]114115 lis = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))116 for li in art.select("li")]117 price = None118 price_label = ""119 typologie = ""120 bedrooms = None121 for t in lis:122 m = PRICE_RE.search(t)123 if m and price is None:124 try:125 price = float(m.group(1).replace(" ", "")126 .replace(" ", "").replace(" ", "")127 .replace(",", ""))128 price_label = t129 except ValueError:130 pass131 m = BEDS_RE.search(t)132 if m and not typologie:133 typologie = t # fourchette (« 1-2 chambres… »)134 if not m.group(2): # nombre précis seulement135 bedrooms = float(m.group(1))136137 details: dict = {}138 if typologie:139 details["typologies"] = typologie140 h5 = art.select_one(".content-text h5")141 if h5:142 m = UNITS_RE.search(h5.get_text(" ", strip=True))143 if m:144 details["nb_unites"] = m.group(1)145146 a = art.select_one("a.c--article-project__inner")147 url = (a.get("href") or "").split("?")[0] if a else ""148 images: list[str] = []149 fig = art.find("figure")150 if fig:151 m = re.search(r"url\(([^)]+)\)", fig.get("style", ""))152 if m:153 images.append(m.group(1).strip("'\""))154155 d: dict = {}156 if url.startswith(BASE):157 key = hashlib.sha1(158 "|".join([tag, name] + lis).encode("utf-8")159 ).hexdigest()160 d = self.detail(ext_id, key,161 lambda url=url: self._fetch_detail(url))162 elif not url:163 url = LIST_URL164165 listings.append(Listing(166 source=self.source_id,167 external_id=ext_id,168 url=url,169 title=f"{name} — condos locatifs ({city})",170 sector=sector,171 city=city,172 bedrooms=bedrooms,173 price=price,174 price_label=price_label,175 availability=tag,176 description=d.get("description", ""),177 details=details,178 images=(d.get("images") or images)[:20],179 ))180 except Exception:181 continue182 return listings183184 def _fetch_detail(self, url: str) -> dict:185 """Page phase : description marketing + galerie photos."""186 out: dict = {}187 try:188 html = self.get(url).text189 except Exception:190 return out191 soup = BeautifulSoup(html, "html.parser")192 main = soup.find("main") or soup193194 paras = [p.get_text(" ", strip=True) for p in main.find_all("p")195 if len(p.get_text(strip=True)) > 80]196 # sauter les descriptions génériques du méga-menu (répétées partout)197 paras = [p for p in paras198 if "portfolio" not in p.lower()199 and "équilibre sain" not in p.lower()200 and "quel que soit le budget" not in p.lower()]201 if paras:202 out["description"] = re.sub(r"\s+", " ",203 " ".join(paras[:3])).strip()[:1200]204 out["images"] = [205 u for u in dict.fromkeys(IMG_RE.findall(html))206 if not re.search(r"logo|icon|favicon|cropped-|plan|"207 r"-\d{2,3}x\d{2,3}\.", u, re.I)][:20]208 return out209