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/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