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/complexe_st_charles.py : connecteur Complexe St-Charles5# (complexestcharles.ca — condos locatifs neufs en 7 phases, 130 à 190 rue6# de la Pruche à Terrebonne, ~200 unités). Site Wix mais rendu SERVEUR7# (curl suffit) : l'accueil publie les prix « à partir de » par typologie8# (3½/4½/5½) et le menu la liste des phases (« 190 - PHASE 2 » -> adresse9# civique) ; chaque page /phase-N détaille les modèles par typologie10# (« Unité A-03 · 104, 204, 304 & 404 »). Granularité TYPOLOGIE par phase11# (aucune unité individuelle ni disponibilité publiée) ; external_id =12# « phase-N-typologie » (stable). Les phases annoncées « À VENIR » sur13# l'accueil (7 et 8 en 2026) sont exclues.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type22from .base import BaseConnector2324BASE = "https://www.complexestcharles.ca"25STREET = "rue de la Pruche"26CITY = "Terrebonne"2728# « 190 - PHASE 2 » (menu) -> numéro civique + numéro de phase29MENU_RE = re.compile(r"(\d{2,4})\s*-?\s*PHASE\s*(\d+)", re.I)30# « 3 1/2 ... A partir de 1 500$ / mois » (accueil)31PRICE_RE = re.compile(32 r"([1-6])\s*1/2\s*A partir de\s*([\d\s ]+)\$\s*/\s*mois", re.I)33# « PHASE 7 ET PHASE 8 À VENIR » (accueil) -> phases exclues34COMING_RE = re.compile(r"PHASE\s*(\d+)(?:\s*ET\s*PHASE\s*(\d+))?\s*"35 r"[ÀA]\s*VENIR", re.I)36TYPO_RE = re.compile(r"^([1-6])\s*1/2$")37UNIT_RE = re.compile(r"^Unité\s+([\w\-]+)\s*(.*)$", re.I)38WIX_IMG_RE = re.compile(39 r"https://static\.wixstatic\.com/media/([\w~%]+\.(?:jpe?g|png|webp))")404142class ComplexeStCharlesConnector(BaseConnector):43 source_id = "complexe_st_charles"44 request_delay = 0.84546 def fetch(self) -> list[Listing]:47 try:48 home = self.get(f"{BASE}/").text49 except Exception:50 return []51 soup = BeautifulSoup(home, "html.parser")52 text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))5354 # prix « à partir de » par typologie (affichés sur l'accueil seulement)55 prices: dict[str, float] = {}56 for m in PRICE_RE.finditer(text):57 try:58 prices[f"{m.group(1)}½"] = float(59 re.sub(r"[\s ]", "", m.group(2)))60 except ValueError:61 pass6263 # phases « à venir » (pas encore louables) -> exclues64 coming: set[str] = set()65 m = COMING_RE.search(text)66 if m:67 coming = {g for g in m.groups() if g}6869 # menu : « 190 - PHASE 2 » -> {phase: numéro civique}70 phases: dict[str, str] = {}71 for m in MENU_RE.finditer(text):72 phases.setdefault(m.group(2), m.group(1))7374 listings: list[Listing] = []75 for phase, civic in sorted(phases.items(), key=lambda kv: int(kv[0])):76 if phase in coming:77 continue78 listings.extend(self._parse_phase(phase, civic, prices))79 return listings8081 # -- page /phase-N : modèles groupés par typologie ---------------------------82 def _parse_phase(self, phase: str, civic: str,83 prices: dict[str, float]) -> list[Listing]:84 url = f"{BASE}/phase-{phase}"85 try:86 soup = BeautifulSoup(self.get(url).text, "html.parser")87 except Exception:88 return []89 images = []90 for m in WIX_IMG_RE.finditer(str(soup)):91 full = f"https://static.wixstatic.com/media/{m.group(1)}"92 if full not in images:93 images.append(full)94 # parcours en ordre : un titre « 3 1/2 » ouvre une typologie, les95 # blocs « Unité A-03 · 104, 204... » qui suivent s'y rattachent96 typo = ""97 models: dict[str, list[str]] = {}98 for el in soup.select("h1, h2, h3, h4, h5"):99 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))100 m = TYPO_RE.match(t)101 if m:102 typo = f"{m.group(1)}½"103 models.setdefault(typo, [])104 continue105 m = UNIT_RE.match(t)106 if m and typo:107 label = f"modèle {m.group(1)}"108 if m.group(2).strip():109 label += f" (unités {m.group(2).strip()})"110 models[typo].append(label)111112 # nb d'unités de la phase (« Totalisant 28 unités locatives »)113 text = soup.get_text(" ", strip=True)114 m = re.search(r"(\d+)\s*unités", text)115 total = m.group(1) if m else ""116117 res: list[Listing] = []118 for typo, mods in models.items():119 if not mods:120 continue121 unit_type = normalize_unit_type(typo)122 price = prices.get(typo)123 desc = (f"Phase {phase} du Complexe St-Charles, {civic} "124 f"{STREET} à {CITY}"125 + (f" ({total} unités locatives)" if total else "") + ". "126 f"Condos {typo} : " + " ; ".join(mods) + ".")127 res.append(Listing(128 source=self.source_id,129 external_id=f"phase-{phase}-{typo.replace('½', '.5')}",130 url=url,131 title=f"Complexe St-Charles — Phase {phase} — {typo}",132 address=f"{civic}, {STREET}",133 city=CITY,134 unit_type=unit_type,135 price=price,136 price_label=(f"à partir de {price:.0f} $ /mois"137 if price else ""),138 description=desc[:1200],139 details={"phase": phase, "models": mods},140 images=images[:20],141 ))142 return res143