# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/cara.py : connecteur CARA — condos locatifs à Vaudreuil-Dorion # (condoscara.com — 1400, rue Émile-Bouchard). ⚠️ Compagnie distincte du # connecteur `carat_immobilier` (Carat gestion immobilière). # WordPress + Elementor, mais la grille des disponibilités est une app # externe Livya (plan interactif « Plan A » — app.livya.com), injectée en # iframe par le script realvuu : la page /disponibilites/ (rendue serveur) # porte un div .livya-module-container-plans avec data-entity (UUID du # plan) et data-project. On reconstruit l'URL de l'app Livya, on la rend # via Scrapfly (Next.js, unités chargées côté client) puis on lit les # cartes d'unités : « #116 | Disponible | 1 810 $ | /mois | 3½ | ch | sdb # | pi² ». Seules les unités au statut « Disponible » sont ingérées # (les « Loué »/« Réservé » sont exclues). # ----------------------------------------------------------------------------- from __future__ import annotations import re import time from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector BASE = "https://www.condoscara.com" LIST_URL = f"{BASE}/disponibilites/" LIVYA_FMT = ("https://app.livya.com/fr/plan-a/projects/{project}" "/plans/{entity}?noLayout=1") # plan observé le 2026-08-25 (repli si le data-entity disparaît de la page) FALLBACK_ENTITY = "6f0dcc75-0b82-4e00-ad67-0f6472ed0a94" ADDRESS = "1400, rue Émile-Bouchard, Vaudreuil-Dorion" CITY = "Vaudreuil-Dorion" UNIT_RE = re.compile(r"^#(\d{2,4})$") PRICE_RE = re.compile(r"^([\d\s  ]{3,9})\$$") TYPE_RE = re.compile(r"^\d½$") class CaraConnector(BaseConnector): source_id = "cara" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] # 1) page WordPress : UUID du plan Livya (data-entity) project, entity = "cara", FALLBACK_ENTITY try: wp = self.get(LIST_URL).text m = re.search(r'class="livya-module-container-plans"[^>]*' r'data-project="([^"]+)"[^>]*data-entity="([^"]+)"', wp) if m: project, entity = m.group(1), m.group(2) except Exception: pass # 2) app Livya rendue (Next.js : unités chargées côté client). Un raté # transitoire Scrapfly (contenu vide ou rendu partiel sans cartes, # vu 2026-08-29) renvoyait 0 annonce « ok » : retry court, puis # erreur franche plutôt qu'un faux zéro. url = LIVYA_FMT.format(project=project, entity=entity) soup = None for attempt in range(3): html = self.get_scrapfly(url, render_js=True, asp=True, rendering_wait=6000) if html: soup = BeautifulSoup(html, "html.parser") if soup.select("div.Card"): break if attempt < 2: time.sleep(5 * (attempt + 1)) else: raise RuntimeError( "rendu Livya sans cartes d'unités après 3 essais Scrapfly") # 3) cartes d'unités : div.Card -> « #116 Disponible 1 810 $ /mois # 3½ » seen: set[str] = set() for card in soup.select("div.Card"): try: bits = [t for t in (el.strip() for el in card.stripped_strings) if t] if not bits: continue m = UNIT_RE.match(bits[0]) if not m: continue unit_no = m.group(1) if unit_no in seen: continue status = bits[1] if len(bits) > 1 else "" if not re.match(r"Disponible", status, re.I): continue # Loué / Réservé / Vendu : exclu seen.add(unit_no) price = None price_label = "" unit_type = "" area = None counts: list[float] = [] for i, b in enumerate(bits[2:]): b_clean = b.replace(" ", " ").replace(" ", " ") mm = PRICE_RE.match(b_clean.replace(" ", "")) if mm and price is None: try: price = float(mm.group(1).replace(" ", "")) except ValueError: price = None nxt = bits[2 + i + 1] if 2 + i + 1 < len(bits) else "" price_label = f"{b_clean} {nxt}".strip() \ if "/mois" in nxt else b_clean continue if TYPE_RE.match(b_clean) and not unit_type: unit_type = normalize_unit_type(b_clean) continue if b_clean.endswith("pi²") and area is None: try: area = float(re.sub(r"[^\d]", "", b_clean[:-3])) except ValueError: area = None continue if re.fullmatch(r"\d", b_clean) and len(counts) < 2: counts.append(float(b_clean)) bedrooms = counts[0] if counts else None bathrooms = counts[1] if len(counts) > 1 else None images: list[str] = [] for im in card.find_all("img"): src = im.get("src") or "" # dérouler le proxy Next.js (/_next/image?url=…) mm = re.search(r"[?&]url=([^&]+)", src) if mm: from urllib.parse import unquote src = unquote(mm.group(1)) if src.startswith("http") and src not in images: images.append(src) amenities = [] if bathrooms: amenities.append(f"{int(bathrooms)} salle(s) de bain") listings.append(Listing( source=self.source_id, external_id=unit_no, url=LIST_URL, title=f"Condo #{unit_no} — CARA", address=ADDRESS, city=CITY, unit_type=unit_type, bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=price_label, availability=status, area_sqft=area, amenities=amenities, images=images[:10], )) except Exception: continue return listings