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%
7.0 KB · 169 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/cara.py : connecteur CARA — condos locatifs à Vaudreuil-Dorion5#   (condoscara.com — 1400, rue Émile-Bouchard). ⚠️ Compagnie distincte du6#   connecteur `carat_immobilier` (Carat gestion immobilière).7#   WordPress + Elementor, mais la grille des disponibilités est une app8#   externe Livya (plan interactif « Plan A » — app.livya.com), injectée en9#   iframe par le script realvuu : la page /disponibilites/ (rendue serveur)10#   porte un div .livya-module-container-plans avec data-entity (UUID du11#   plan) et data-project. On reconstruit l'URL de l'app Livya, on la rend12#   via Scrapfly (Next.js, unités chargées côté client) puis on lit les13#   cartes d'unités : « #116 | Disponible | 1 810 $ | /mois | 3½ | ch | sdb14#   | pi² ». Seules les unités au statut « Disponible » sont ingérées15#   (les « Loué »/« Réservé » sont exclues).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re20import time2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type25from .base import BaseConnector2627BASE = "https://www.condoscara.com"28LIST_URL = f"{BASE}/disponibilites/"29LIVYA_FMT = ("https://app.livya.com/fr/plan-a/projects/{project}"30             "/plans/{entity}?noLayout=1")31# plan observé le 2026-08-25 (repli si le data-entity disparaît de la page)32FALLBACK_ENTITY = "6f0dcc75-0b82-4e00-ad67-0f6472ed0a94"3334ADDRESS = "1400, rue Émile-Bouchard, Vaudreuil-Dorion"35CITY = "Vaudreuil-Dorion"3637UNIT_RE = re.compile(r"^#(\d{2,4})$")38PRICE_RE = re.compile(r"^([\d\s  ]{3,9})\$$")39TYPE_RE = re.compile(r"^\d½$")404142class CaraConnector(BaseConnector):43    source_id = "cara"44    request_delay = 0.84546    def fetch(self) -> list[Listing]:47        listings: list[Listing] = []4849        # 1) page WordPress : UUID du plan Livya (data-entity)50        project, entity = "cara", FALLBACK_ENTITY51        try:52            wp = self.get(LIST_URL).text53            m = re.search(r'class="livya-module-container-plans"[^>]*'54                          r'data-project="([^"]+)"[^>]*data-entity="([^"]+)"',55                          wp)56            if m:57                project, entity = m.group(1), m.group(2)58        except Exception:59            pass6061        # 2) app Livya rendue (Next.js : unités chargées côté client). Un raté62        #    transitoire Scrapfly (contenu vide ou rendu partiel sans cartes,63        #    vu 2026-08-29) renvoyait 0 annonce « ok » : retry court, puis64        #    erreur franche plutôt qu'un faux zéro.65        url = LIVYA_FMT.format(project=project, entity=entity)66        soup = None67        for attempt in range(3):68            html = self.get_scrapfly(url, render_js=True, asp=True,69                                     rendering_wait=6000)70            if html:71                soup = BeautifulSoup(html, "html.parser")72                if soup.select("div.Card"):73                    break74            if attempt < 2:75                time.sleep(5 * (attempt + 1))76        else:77            raise RuntimeError(78                "rendu Livya sans cartes d'unités après 3 essais Scrapfly")7980        # 3) cartes d'unités : div.Card -> « #116 Disponible 1 810 $ /mois81        #    3½ <ch> <sdb> <pi²> »82        seen: set[str] = set()83        for card in soup.select("div.Card"):84            try:85                bits = [t for t in86                        (el.strip() for el in card.stripped_strings) if t]87                if not bits:88                    continue89                m = UNIT_RE.match(bits[0])90                if not m:91                    continue92                unit_no = m.group(1)93                if unit_no in seen:94                    continue9596                status = bits[1] if len(bits) > 1 else ""97                if not re.match(r"Disponible", status, re.I):98                    continue                    # Loué / Réservé / Vendu : exclu99                seen.add(unit_no)100101                price = None102                price_label = ""103                unit_type = ""104                area = None105                counts: list[float] = []106                for i, b in enumerate(bits[2:]):107                    b_clean = b.replace(" ", " ").replace(" ", " ")108                    mm = PRICE_RE.match(b_clean.replace(" ", ""))109                    if mm and price is None:110                        try:111                            price = float(mm.group(1).replace(" ", ""))112                        except ValueError:113                            price = None114                        nxt = bits[2 + i + 1] if 2 + i + 1 < len(bits) else ""115                        price_label = f"{b_clean} {nxt}".strip() \116                            if "/mois" in nxt else b_clean117                        continue118                    if TYPE_RE.match(b_clean) and not unit_type:119                        unit_type = normalize_unit_type(b_clean)120                        continue121                    if b_clean.endswith("pi²") and area is None:122                        try:123                            area = float(re.sub(r"[^\d]", "",124                                                b_clean[:-3]))125                        except ValueError:126                            area = None127                        continue128                    if re.fullmatch(r"\d", b_clean) and len(counts) < 2:129                        counts.append(float(b_clean))130131                bedrooms = counts[0] if counts else None132                bathrooms = counts[1] if len(counts) > 1 else None133134                images: list[str] = []135                for im in card.find_all("img"):136                    src = im.get("src") or ""137                    # dérouler le proxy Next.js (/_next/image?url=…)138                    mm = re.search(r"[?&]url=([^&]+)", src)139                    if mm:140                        from urllib.parse import unquote141                        src = unquote(mm.group(1))142                    if src.startswith("http") and src not in images:143                        images.append(src)144145                amenities = []146                if bathrooms:147                    amenities.append(f"{int(bathrooms)} salle(s) de bain")148149                listings.append(Listing(150                    source=self.source_id,151                    external_id=unit_no,152                    url=LIST_URL,153                    title=f"Condo #{unit_no} — CARA",154                    address=ADDRESS,155                    city=CITY,156                    unit_type=unit_type,157                    bedrooms=bedrooms,158                    bathrooms=bathrooms,159                    price=price,160                    price_label=price_label,161                    availability=status,162                    area_sqft=area,163                    amenities=amenities,164                    images=images[:10],165                ))166            except Exception:167                continue168        return listings169