Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: 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# connecteur est `disabled` et exclu du registre (zéro impact prod QC).31# -----------------------------------------------------------------------------32from __future__ import annotations3334import os35import re36from urllib.parse import urljoin3738from bs4 import BeautifulSoup3940from ..schema import Listing, normalize_unit_type, parse_price41from .base import BaseConnector4243BASE = "https://riocanliving.com"44FEED_URL = f"{BASE}/wp-json/wp/v2/property?per_page=100"4546# Gate expansion Ontario : le connecteur reste hors registre tant que la47_ONTARIO = True # Rent-Ka: always on (ROC scope)4849# localisations ontariennes du CPT (class_list property-location-<slug>)50_ON_LOCATIONS = {"toronto-gta": "Toronto", "ottawa": "Ottawa"}5152# adresse civique sur la fiche (« 15 Roehampton Ave, Toronto, ON, M4P 1P9 »)53_ADDR_RE = re.compile(54 r"\d+[^,<>{}\n]{2,60},\s*[A-Za-z .'’-]+,\s*ON,?\s*"55 r"[A-Z]\d[A-Z]\s?\d[A-Z]\d")56# blob RentCafe embarqué (eCentral) : affectations JS, PAS du JSON —57# floorPlanData[0] = {name:'The Centric II', BedroomNum: '1', …};58_FPDATA_RE = re.compile(r"floorPlanData\[\d+\]\s*=\s*\{([\s\S]*?)\};")59# cartes par catégorie (Bridge) : « Studio Starting from $2,095* » —60# la catégorie doit être ADJACENTE au prix (pas de texte d'intro entre deux)61_CAT_RE = re.compile(62 r"(Studio|Bachelor|(\d+)[\s-]*Bed(?:room)?s?)\s*(?:\|\s*)?"63 r"Starting\s+from\s+\$\s*([\d,]+)", re.I)64_BED_RE = re.compile(r"(\d+)\s*Bed", re.I)65_BATH_RE = re.compile(r"([\d.]+)\s*Bath", re.I)66_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)67# lien « suites/plans » du microsite (découverte sur la page d'accueil)68_SUITES_HREF_RE = re.compile(69 r"(floor-?plans?|rental-suites|/suites?/?$|availability)", re.I)707172class RioCanConnector(BaseConnector):73 source_id = "riocan"74 request_delay = 1.2 # microsites variés, dont un Cloudflare (Pivot)75 disabled = False76 max_properties = 10 # garde-fou (4 tours ON aujourd'hui)77 max_images = 157879 def fetch(self) -> list[Listing]:80 props = self.get(FEED_URL,81 headers={"Accept": "application/json"}).json()8283 listings: list[Listing] = []84 count = 085 for p in props:86 try:87 link = p.get("link") or ""88 # billets EN seulement (les FR doublonnent /fr/propriete/)89 if "/property/" not in link:90 continue91 cls = set(p.get("class_list") or [])92 if "property-type-rental" not in cls:93 continue94 # Ontario seulement (fourth-street-lofts = Calgary -> exclu)95 city = ""96 for slug, label in _ON_LOCATIONS.items():97 if f"property-location-{slug}" in cls:98 city = label99 break100 if not city:101 continue102 if count >= self.max_properties:103 break104 count += 1105 listings.extend(self._property_listings(p, city))106 except Exception:107 continue108 return listings109110 # -- annonces d'une tour (une par plan/catégorie tarifé, repli) -------------111 def _property_listings(self, p: dict, city: str) -> list[Listing]:112 pid = str(p.get("id"))113 link = p.get("link") or ""114 title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",115 "html.parser").get_text(" ", strip=True)116117 # fiche riocanliving + découverte du microsite via le cache BD :118 # revisitée seulement quand le billet WordPress est modifié119 feed_key = str(p.get("modified") or p.get("modified_gmt") or "")120 d = self.detail(pid, feed_key, lambda: self._fetch_detail(link))121122 url = d.get("microsite") or link123 common = dict(124 source=self.source_id, url=url,125 address=d.get("address") or "", city=city, province="ON",126 description=d.get("description") or "",127 images=(d.get("images") or [])[: self.max_images],128 )129130 # page « suites » du microsite EN DIRECT (prix/dispo = donnée vivante)131 page = ""132 suites_url = d.get("suites_url") or ""133 if suites_url:134 try:135 page = self.get(suites_url).text136 except Exception:137 page = ""138139 out = self._parse_floorplandata(page, pid, title, common)140 if not out:141 out = self._parse_fp_containers(page, pid, title, common)142 if not out:143 out = self._parse_categories(page, pid, title, common)144 if out:145 return out146147 # repli : une annonce par tour — aucun prix inventé (Rhythm…)148 return [Listing(149 external_id=pid,150 title=title,151 unit_type="",152 **common,153 )]154155 # -- parseur 1 : blob JS floorPlanData (eCentral) ----------------------------156 @staticmethod157 def _js_field(body: str, *keys: str) -> str:158 """Valeur d'un champ d'objet littéral JS (`name:'The Centric II'`)."""159 for k in keys:160 m = re.search(rf"\b{k}\s*:\s*'((?:[^'\\]|\\.)*)'", body)161 if m:162 return m.group(1).replace("\\'", "'").strip()163 return ""164165 def _parse_floorplandata(self, page: str, pid: str, title: str,166 common: dict) -> list[Listing]:167 out: list[Listing] = []168 seen: set[str] = set()169 for m in _FPDATA_RE.finditer(page or ""):170 body = m.group(1)171 name = self._js_field(body, "name", "Name")172 fpid = self._js_field(body, "FloorplanId", "floorplanId", "id")173 key = fpid or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")174 if not key or key in seen:175 continue176 seen.add(key)177 # loyer plancher affiché seulement (0/absent = pas de prix)178 price = None179 try:180 v = float(self._js_field(body, "MinimumRent")181 .replace(",", "") or 0)182 if v > 0:183 price = v184 except ValueError:185 pass186 if price is None:187 continue188 beds = baths = None189 try:190 beds = float(self._js_field(body, "BedroomNum"))191 except ValueError:192 pass193 try:194 baths = float(self._js_field(body, "BathNum", "Bath"))195 except ValueError:196 pass197 sqft = None198 msq = re.search(r"[\d,]+", self._js_field(body, "Size",199 "DisplaySize"))200 if msq:201 try:202 v = float(msq.group(0).replace(",", ""))203 if 80 <= v <= 20000:204 sqft = v205 except ValueError:206 pass207 avail = self._js_field(body, "avaliableDate", "AvailableDate")208 out.append(Listing(209 external_id=f"{pid}-{key}",210 title=f"{title} — {name}" if name else title,211 unit_type=("Studio" if beds == 0 else normalize_unit_type(212 f"{int(beds)} chambres") if beds is not None else ""),213 bedrooms=beds,214 bathrooms=baths,215 price=price,216 price_label=f"À partir de {price:.0f} $ /mois",217 availability=avail,218 area_sqft=sqft,219 **common,220 ))221 return out222223 # -- parseur 2 : cartes RentCafe .fp-container (Pivot) -----------------------224 def _parse_fp_containers(self, page: str, pid: str, title: str,225 common: dict) -> list[Listing]:226 if not page or "fp-container" not in page:227 return []228 soup = BeautifulSoup(page, "html.parser")229 out: list[Listing] = []230 seen: set[str] = set()231 for card in soup.select("[id^=fp-container-]"):232 key = (card.get("id") or "").replace("fp-container-", "").strip()233 h = card.select_one(".card-title") or card.find(["h2", "h3"])234 name = h.get_text(" ", strip=True) if h else ""235 if not key:236 key = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")237 if not key or key in seen:238 continue239 seen.add(key)240 txt = card.get_text(" ", strip=True)241 # prix plancher affiché seulement (« Starting at $3,204.00 »)242 mprice = re.search(r"Starting\s+at\s+(\$[\d,.]+)", txt, re.I)243 price = parse_price(mprice.group(1)) if mprice else None244 if price is None:245 continue246 beds = None247 if re.search(r"\bStudio|\bBachelor", txt, re.I):248 beds = 0.0249 else:250 mb = _BED_RE.search(txt)251 if mb:252 beds = float(mb.group(1))253 mba = _BATH_RE.search(txt)254 msq = _SQFT_RE.search(txt)255 sqft = float(msq.group(1).replace(",", "")) if msq else None256 mav = re.search(r"Available\s+from\s+([\d/]+)", txt, re.I)257 out.append(Listing(258 external_id=f"{pid}-{key}",259 title=f"{title} — {name}" if name else title,260 unit_type=("Studio" if beds == 0 else normalize_unit_type(261 f"{int(beds)} chambres") if beds is not None else ""),262 bedrooms=beds,263 bathrooms=float(mba.group(1)) if mba else None,264 price=price,265 price_label=f"À partir de {price:.0f} $ /mois",266 availability=f"Disponible le {mav.group(1)}" if mav else "",267 area_sqft=sqft if sqft and 80 <= sqft <= 20000 else None,268 **common,269 ))270 return out271272 # -- parseur 3 : cartes par catégorie (Bridge) --------------------------------273 def _parse_categories(self, page: str, pid: str, title: str,274 common: dict) -> list[Listing]:275 if not page:276 return []277 txt = BeautifulSoup(page, "html.parser").get_text(" ", strip=True)278 out: list[Listing] = []279 seen: set[str] = set()280 for m in _CAT_RE.finditer(txt):281 label = m.group(1).strip()282 beds = 0.0 if m.group(2) is None else float(m.group(2))283 key = "studio" if beds == 0 else f"{int(beds)}-bed"284 if key in seen:285 continue286 seen.add(key)287 try:288 price = float(m.group(3).replace(",", ""))289 except ValueError:290 continue291 if price <= 0:292 continue293 out.append(Listing(294 external_id=f"{pid}-cat-{key}",295 title=f"{title} — {label}",296 unit_type=("Studio" if beds == 0297 else normalize_unit_type(f"{int(beds)} chambres")),298 bedrooms=beds,299 price=price,300 price_label=f"À partir de {price:.0f} $ /mois",301 **common,302 ))303 return out304305 # -- fiche riocanliving : adresse, description, visuels, microsite -----------306 def _fetch_detail(self, link: str) -> dict:307 out: dict = {"address": "", "description": "", "images": [],308 "microsite": "", "suites_url": ""}309 if not link:310 return out311 try:312 page = self.get(link).text313 except Exception:314 return out315 soup = BeautifulSoup(page, "html.parser")316317 m = _ADDR_RE.search(soup.get_text(" ", strip=True))318 if m:319 out["address"] = re.sub(r"\s+", " ", m.group(0)).strip()320321 # description : premiers paragraphes substantiels de la fiche322 paras = [q.get_text(" ", strip=True) for q in soup.find_all("p")]323 out["description"] = " ".join(324 t for t in paras if len(t) > 60)[:600]325326 # visuels du site corporatif (app/uploads) — hors logos/icônes327 images: list[str] = []328 for img in soup.find_all("img"):329 src = (img.get("src") or "").strip()330 if "/app/uploads/" not in src or src.lower().endswith(".svg"):331 continue332 if re.search(r"logo|icon", src, re.I):333 continue334 if src not in images:335 images.append(src)336 out["images"] = images[: self.max_images]337338 # CTA « Website » -> microsite de la tour339 micro = ""340 for a in soup.find_all("a", href=True):341 if a.get_text(" ", strip=True).lower() in (342 "website", "visit website"):343 micro = a["href"].strip()344 break345 out["microsite"] = micro346 if not micro:347 return out348349 # découverte de la page « suites/plans » sur l'accueil du microsite350 # (la chaîne résiliente escalade seule le Cloudflare de Pivot)351 try:352 home = self.get(micro).text353 except Exception:354 return out355 hsoup = BeautifulSoup(home, "html.parser")356 for a in hsoup.find_all("a", href=True):357 href = a["href"].strip()358 if _SUITES_HREF_RE.search(href.split("?")[0]):359 out["suites_url"] = urljoin(micro, href)360 break361 # certains microsites affichent les plans sur l'accueil même362 if not out["suites_url"] and (363 "floorPlanData" in home or "fp-container" in home):364 out["suites_url"] = micro365 return out366