# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/complexe_st_charles.py : connecteur Complexe St-Charles # (complexestcharles.ca — condos locatifs neufs en 7 phases, 130 à 190 rue # de la Pruche à Terrebonne, ~200 unités). Site Wix mais rendu SERVEUR # (curl suffit) : l'accueil publie les prix « à partir de » par typologie # (3½/4½/5½) et le menu la liste des phases (« 190 - PHASE 2 » -> adresse # civique) ; chaque page /phase-N détaille les modèles par typologie # (« Unité A-03 · 104, 204, 304 & 404 »). Granularité TYPOLOGIE par phase # (aucune unité individuelle ni disponibilité publiée) ; external_id = # « phase-N-typologie » (stable). Les phases annoncées « À VENIR » sur # l'accueil (7 et 8 en 2026) sont exclues. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.complexestcharles.ca" STREET = "rue de la Pruche" CITY = "Terrebonne" # « 190 - PHASE 2 » (menu) -> numéro civique + numéro de phase MENU_RE = re.compile(r"(\d{2,4})\s*-?\s*PHASE\s*(\d+)", re.I) # « 3 1/2 ... A partir de 1 500$ / mois » (accueil) PRICE_RE = re.compile( r"([1-6])\s*1/2\s*A partir de\s*([\d\s ]+)\$\s*/\s*mois", re.I) # « PHASE 7 ET PHASE 8 À VENIR » (accueil) -> phases exclues COMING_RE = re.compile(r"PHASE\s*(\d+)(?:\s*ET\s*PHASE\s*(\d+))?\s*" r"[ÀA]\s*VENIR", re.I) TYPO_RE = re.compile(r"^([1-6])\s*1/2$") UNIT_RE = re.compile(r"^Unité\s+([\w\-]+)\s*(.*)$", re.I) WIX_IMG_RE = re.compile( r"https://static\.wixstatic\.com/media/([\w~%]+\.(?:jpe?g|png|webp))") class ComplexeStCharlesConnector(BaseConnector): source_id = "complexe_st_charles" request_delay = 0.8 def fetch(self) -> list[Listing]: try: home = self.get(f"{BASE}/").text except Exception: return [] soup = BeautifulSoup(home, "html.parser") text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) # prix « à partir de » par typologie (affichés sur l'accueil seulement) prices: dict[str, float] = {} for m in PRICE_RE.finditer(text): try: prices[f"{m.group(1)}½"] = float( re.sub(r"[\s ]", "", m.group(2))) except ValueError: pass # phases « à venir » (pas encore louables) -> exclues coming: set[str] = set() m = COMING_RE.search(text) if m: coming = {g for g in m.groups() if g} # menu : « 190 - PHASE 2 » -> {phase: numéro civique} phases: dict[str, str] = {} for m in MENU_RE.finditer(text): phases.setdefault(m.group(2), m.group(1)) listings: list[Listing] = [] for phase, civic in sorted(phases.items(), key=lambda kv: int(kv[0])): if phase in coming: continue listings.extend(self._parse_phase(phase, civic, prices)) return listings # -- page /phase-N : modèles groupés par typologie --------------------------- def _parse_phase(self, phase: str, civic: str, prices: dict[str, float]) -> list[Listing]: url = f"{BASE}/phase-{phase}" try: soup = BeautifulSoup(self.get(url).text, "html.parser") except Exception: return [] images = [] for m in WIX_IMG_RE.finditer(str(soup)): full = f"https://static.wixstatic.com/media/{m.group(1)}" if full not in images: images.append(full) # parcours en ordre : un titre « 3 1/2 » ouvre une typologie, les # blocs « Unité A-03 · 104, 204... » qui suivent s'y rattachent typo = "" models: dict[str, list[str]] = {} for el in soup.select("h1, h2, h3, h4, h5"): t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) m = TYPO_RE.match(t) if m: typo = f"{m.group(1)}½" models.setdefault(typo, []) continue m = UNIT_RE.match(t) if m and typo: label = f"modèle {m.group(1)}" if m.group(2).strip(): label += f" (unités {m.group(2).strip()})" models[typo].append(label) # nb d'unités de la phase (« Totalisant 28 unités locatives ») text = soup.get_text(" ", strip=True) m = re.search(r"(\d+)\s*unités", text) total = m.group(1) if m else "" res: list[Listing] = [] for typo, mods in models.items(): if not mods: continue unit_type = normalize_unit_type(typo) price = prices.get(typo) desc = (f"Phase {phase} du Complexe St-Charles, {civic} " f"{STREET} à {CITY}" + (f" ({total} unités locatives)" if total else "") + ". " f"Condos {typo} : " + " ; ".join(mods) + ".") res.append(Listing( source=self.source_id, external_id=f"phase-{phase}-{typo.replace('½', '.5')}", url=url, title=f"Complexe St-Charles — Phase {phase} — {typo}", address=f"{civic}, {STREET}", city=CITY, unit_type=unit_type, price=price, price_label=(f"à partir de {price:.0f} $ /mois" if price else ""), description=desc[:1200], details={"phase": phase, "models": mods}, images=images[:20], )) return res