SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
5.8 KB · 143 lines python
Raw Blame History
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