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 (Québec + expansion Ontario)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/riocan.py : connecteur RioCan Living (riocanliving.com)5# Bras résidentiel du REIT RioCan — tours locatives neuves au-dessus de ses6# centres commerciaux (eCentral/ePlace et Pivot à Toronto, Rhythm à Ottawa,7# Bridge à Leaside…). Le site corporatif est un WordPress SANS anti-bot :8# CPT `property` exposé par l'API REST (`/wp-json/wp/v2/property?per_page=9# 100`) — on garde les billets EN (« /property/ » dans le lien, les billets10# FR doublonnent sous « /fr/propriete/ »), de type « rental » et localisés11# en Ontario (property-location-toronto-gta / -ottawa ; fourth-street-lofts12# est à CALGARY -> exclu). La fiche riocanliving fournit adresse civique,13# description, visuels app/uploads et le CTA « Website » vers le MICROSITE14# de la tour — chaque tour a le sien, d'architecture différente :15# - eCentral : blob JS `floorPlanData` (RentCafe embarqué : nom,16# BedroomNum, BathNum, Size, MinimumRent, FloorplanId, avaliableDate)17# - Pivot (livingatpivot.com, Cloudflare 403 -> la chaîne résiliente de18# self.get escalade d'elle-même vers Scrapfly ASP) : cartes RentCafe19# `.fp-container` (« N Bed / N Bath / N Sq. Ft. », « Starting at $X »,20# « Available from JJ/MM/AAAA »)21# - Bridge : cartes par CATÉGORIE (« Studio … Starting from $2,095* ») —22# une annonce par catégorie de chambres, prix plancher affiché23# - Rhythm : aucun prix publié -> repli « une annonce par propriété »24# Fiche riocanliving + découverte du microsite via le cache BD25# self.detail() (clé = date `modified` du flux WP) ; la page « suites » du26# microsite est relue EN DIRECT à chaque synchronisation (donnée vivante).27# Une annonce par plan d'étage TARIFÉ ; sinon par catégorie tarifée ; sinon28# repli par propriété SANS prix (rien d'inventé).29#30# Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le31# connecteur est `disabled` et exclu du registre (zéro impact prod QC).32# -----------------------------------------------------------------------------33from __future__ import annotations3435import os36import re37from urllib.parse import urljoin3839from bs4 import BeautifulSoup4041from ..schema import Listing, normalize_unit_type, parse_price42from .base import BaseConnector4344BASE = "https://riocanliving.com"45FEED_URL = f"{BASE}/wp-json/wp/v2/property?per_page=100"4647# Gate expansion Ontario : le connecteur reste hors registre tant que la48# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.49_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"5051# localisations ontariennes du CPT (class_list property-location-<slug>)52_ON_LOCATIONS = {"toronto-gta": "Toronto", "ottawa": "Ottawa"}5354# adresse civique sur la fiche (« 15 Roehampton Ave, Toronto, ON, M4P 1P9 »)55_ADDR_RE = re.compile(56 r"\d+[^,<>{}\n]{2,60},\s*[A-Za-z .'’-]+,\s*ON,?\s*"57 r"[A-Z]\d[A-Z]\s?\d[A-Z]\d")58# blob RentCafe embarqué (eCentral) : affectations JS, PAS du JSON —59# floorPlanData[0] = {name:'The Centric II', BedroomNum: '1', …};60_FPDATA_RE = re.compile(r"floorPlanData\[\d+\]\s*=\s*\{([\s\S]*?)\};")61# cartes par catégorie (Bridge) : « Studio Starting from $2,095* » —62# la catégorie doit être ADJACENTE au prix (pas de texte d'intro entre deux)63_CAT_RE = re.compile(64 r"(Studio|Bachelor|(\d+)[\s-]*Bed(?:room)?s?)\s*(?:\|\s*)?"65 r"Starting\s+from\s+\$\s*([\d,]+)", re.I)66_BED_RE = re.compile(r"(\d+)\s*Bed", re.I)67_BATH_RE = re.compile(r"([\d.]+)\s*Bath", re.I)68_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)69# lien « suites/plans » du microsite (découverte sur la page d'accueil)70_SUITES_HREF_RE = re.compile(71 r"(floor-?plans?|rental-suites|/suites?/?$|availability)", re.I)727374class RioCanConnector(BaseConnector):75 source_id = "riocan"76 request_delay = 1.2 # microsites variés, dont un Cloudflare (Pivot)77 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)78 max_properties = 10 # garde-fou (4 tours ON aujourd'hui)79 max_images = 158081 def fetch(self) -> list[Listing]:82 props = self.get(FEED_URL,83 headers={"Accept": "application/json"}).json()8485 listings: list[Listing] = []86 count = 087 for p in props:88 try:89 link = p.get("link") or ""90 # billets EN seulement (les FR doublonnent /fr/propriete/)91 if "/property/" not in link:92 continue93 cls = set(p.get("class_list") or [])94 if "property-type-rental" not in cls:95 continue96 # Ontario seulement (fourth-street-lofts = Calgary -> exclu)97 city = ""98 for slug, label in _ON_LOCATIONS.items():99 if f"property-location-{slug}" in cls:100 city = label101 break102 if not city:103 continue104 if count >= self.max_properties:105 break106 count += 1107 listings.extend(self._property_listings(p, city))108 except Exception:109 continue110 return listings111112 # -- annonces d'une tour (une par plan/catégorie tarifé, repli) -------------113 def _property_listings(self, p: dict, city: str) -> list[Listing]:114 pid = str(p.get("id"))115 link = p.get("link") or ""116 title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",117 "html.parser").get_text(" ", strip=True)118119 # fiche riocanliving + découverte du microsite via le cache BD :120 # revisitée seulement quand le billet WordPress est modifié121 feed_key = str(p.get("modified") or p.get("modified_gmt") or "")122 d = self.detail(pid, feed_key, lambda: self._fetch_detail(link))123124 url = d.get("microsite") or link125 common = dict(126 source=self.source_id, url=url,127 address=d.get("address") or "", city=city, province="ON",128 description=d.get("description") or "",129 images=(d.get("images") or [])[: self.max_images],130 )131132 # page « suites » du microsite EN DIRECT (prix/dispo = donnée vivante)133 page = ""134 suites_url = d.get("suites_url") or ""135 if suites_url:136 try:137 page = self.get(suites_url).text138 except Exception:139 page = ""140141 out = self._parse_floorplandata(page, pid, title, common)142 if not out:143 out = self._parse_fp_containers(page, pid, title, common)144 if not out:145 out = self._parse_categories(page, pid, title, common)146 if out:147 return out148149 # repli : une annonce par tour — aucun prix inventé (Rhythm…)150 return [Listing(151 external_id=pid,152 title=title,153 unit_type="",154 **common,155 )]156157 # -- parseur 1 : blob JS floorPlanData (eCentral) ----------------------------158 @staticmethod159 def _js_field(body: str, *keys: str) -> str:160 """Valeur d'un champ d'objet littéral JS (`name:'The Centric II'`)."""161 for k in keys:162 m = re.search(rf"\b{k}\s*:\s*'((?:[^'\\]|\\.)*)'", body)163 if m:164 return m.group(1).replace("\\'", "'").strip()165 return ""166167 def _parse_floorplandata(self, page: str, pid: str, title: str,168 common: dict) -> list[Listing]:169 out: list[Listing] = []170 seen: set[str] = set()171 for m in _FPDATA_RE.finditer(page or ""):172 body = m.group(1)173 name = self._js_field(body, "name", "Name")174 fpid = self._js_field(body, "FloorplanId", "floorplanId", "id")175 key = fpid or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")176 if not key or key in seen:177 continue178 seen.add(key)179 # loyer plancher affiché seulement (0/absent = pas de prix)180 price = None181 try:182 v = float(self._js_field(body, "MinimumRent")183 .replace(",", "") or 0)184 if v > 0:185 price = v186 except ValueError:187 pass188 if price is None:189 continue190 beds = baths = None191 try:192 beds = float(self._js_field(body, "BedroomNum"))193 except ValueError:194 pass195 try:196 baths = float(self._js_field(body, "BathNum", "Bath"))197 except ValueError:198 pass199 sqft = None200 msq = re.search(r"[\d,]+", self._js_field(body, "Size",201 "DisplaySize"))202 if msq:203 try:204 v = float(msq.group(0).replace(",", ""))205 if 80 <= v <= 20000:206 sqft = v207 except ValueError:208 pass209 avail = self._js_field(body, "avaliableDate", "AvailableDate")210 out.append(Listing(211 external_id=f"{pid}-{key}",212 title=f"{title} — {name}" if name else title,213 unit_type=("Studio" if beds == 0 else normalize_unit_type(214 f"{int(beds)} chambres") if beds is not None else ""),215 bedrooms=beds,216 bathrooms=baths,217 price=price,218 price_label=f"À partir de {price:.0f} $ /mois",219 availability=avail,220 area_sqft=sqft,221 **common,222 ))223 return out224225 # -- parseur 2 : cartes RentCafe .fp-container (Pivot) -----------------------226 def _parse_fp_containers(self, page: str, pid: str, title: str,227 common: dict) -> list[Listing]:228 if not page or "fp-container" not in page:229 return []230 soup = BeautifulSoup(page, "html.parser")231 out: list[Listing] = []232 seen: set[str] = set()233 for card in soup.select("[id^=fp-container-]"):234 key = (card.get("id") or "").replace("fp-container-", "").strip()235 h = card.select_one(".card-title") or card.find(["h2", "h3"])236 name = h.get_text(" ", strip=True) if h else ""237 if not key:238 key = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")239 if not key or key in seen:240 continue241 seen.add(key)242 txt = card.get_text(" ", strip=True)243 # prix plancher affiché seulement (« Starting at $3,204.00 »)244 mprice = re.search(r"Starting\s+at\s+(\$[\d,.]+)", txt, re.I)245 price = parse_price(mprice.group(1)) if mprice else None246 if price is None:247 continue248 beds = None249 if re.search(r"\bStudio|\bBachelor", txt, re.I):250 beds = 0.0251 else:252 mb = _BED_RE.search(txt)253 if mb:254 beds = float(mb.group(1))255 mba = _BATH_RE.search(txt)256 msq = _SQFT_RE.search(txt)257 sqft = float(msq.group(1).replace(",", "")) if msq else None258 mav = re.search(r"Available\s+from\s+([\d/]+)", txt, re.I)259 out.append(Listing(260 external_id=f"{pid}-{key}",261 title=f"{title} — {name}" if name else title,262 unit_type=("Studio" if beds == 0 else normalize_unit_type(263 f"{int(beds)} chambres") if beds is not None else ""),264 bedrooms=beds,265 bathrooms=float(mba.group(1)) if mba else None,266 price=price,267 price_label=f"À partir de {price:.0f} $ /mois",268 availability=f"Disponible le {mav.group(1)}" if mav else "",269 area_sqft=sqft if sqft and 80 <= sqft <= 20000 else None,270 **common,271 ))272 return out273274 # -- parseur 3 : cartes par catégorie (Bridge) --------------------------------275 def _parse_categories(self, page: str, pid: str, title: str,276 common: dict) -> list[Listing]:277 if not page:278 return []279 txt = BeautifulSoup(page, "html.parser").get_text(" ", strip=True)280 out: list[Listing] = []281 seen: set[str] = set()282 for m in _CAT_RE.finditer(txt):283 label = m.group(1).strip()284 beds = 0.0 if m.group(2) is None else float(m.group(2))285 key = "studio" if beds == 0 else f"{int(beds)}-bed"286 if key in seen:287 continue288 seen.add(key)289 try:290 price = float(m.group(3).replace(",", ""))291 except ValueError:292 continue293 if price <= 0:294 continue295 out.append(Listing(296 external_id=f"{pid}-cat-{key}",297 title=f"{title} — {label}",298 unit_type=("Studio" if beds == 0299 else normalize_unit_type(f"{int(beds)} chambres")),300 bedrooms=beds,301 price=price,302 price_label=f"À partir de {price:.0f} $ /mois",303 **common,304 ))305 return out306307 # -- fiche riocanliving : adresse, description, visuels, microsite -----------308 def _fetch_detail(self, link: str) -> dict:309 out: dict = {"address": "", "description": "", "images": [],310 "microsite": "", "suites_url": ""}311 if not link:312 return out313 try:314 page = self.get(link).text315 except Exception:316 return out317 soup = BeautifulSoup(page, "html.parser")318319 m = _ADDR_RE.search(soup.get_text(" ", strip=True))320 if m:321 out["address"] = re.sub(r"\s+", " ", m.group(0)).strip()322323 # description : premiers paragraphes substantiels de la fiche324 paras = [q.get_text(" ", strip=True) for q in soup.find_all("p")]325 out["description"] = " ".join(326 t for t in paras if len(t) > 60)[:600]327328 # visuels du site corporatif (app/uploads) — hors logos/icônes329 images: list[str] = []330 for img in soup.find_all("img"):331 src = (img.get("src") or "").strip()332 if "/app/uploads/" not in src or src.lower().endswith(".svg"):333 continue334 if re.search(r"logo|icon", src, re.I):335 continue336 if src not in images:337 images.append(src)338 out["images"] = images[: self.max_images]339340 # CTA « Website » -> microsite de la tour341 micro = ""342 for a in soup.find_all("a", href=True):343 if a.get_text(" ", strip=True).lower() in (344 "website", "visit website"):345 micro = a["href"].strip()346 break347 out["microsite"] = micro348 if not micro:349 return out350351 # découverte de la page « suites/plans » sur l'accueil du microsite352 # (la chaîne résiliente escalade seule le Cloudflare de Pivot)353 try:354 home = self.get(micro).text355 except Exception:356 return out357 hsoup = BeautifulSoup(home, "html.parser")358 for a in hsoup.find_all("a", href=True):359 href = a["href"].strip()360 if _SUITES_HREF_RE.search(href.split("?")[0]):361 out["suites_url"] = urljoin(micro, href)362 break363 # certains microsites affichent les plans sur l'accueil même364 if not out["suites_url"] and (365 "floorPlanData" in home or "fp-container" in home):366 out["suites_url"] = micro367 return out368