# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/riocan.py : connecteur RioCan Living (riocanliving.com) # Bras résidentiel du REIT RioCan — tours locatives neuves au-dessus de ses # centres commerciaux (eCentral/ePlace et Pivot à Toronto, Rhythm à Ottawa, # Bridge à Leaside…). Le site corporatif est un WordPress SANS anti-bot : # CPT `property` exposé par l'API REST (`/wp-json/wp/v2/property?per_page= # 100`) — on garde les billets EN (« /property/ » dans le lien, les billets # FR doublonnent sous « /fr/propriete/ »), de type « rental » et localisés # en Ontario (property-location-toronto-gta / -ottawa ; fourth-street-lofts # est à CALGARY -> exclu). La fiche riocanliving fournit adresse civique, # description, visuels app/uploads et le CTA « Website » vers le MICROSITE # de la tour — chaque tour a le sien, d'architecture différente : # - eCentral : blob JS `floorPlanData` (RentCafe embarqué : nom, # BedroomNum, BathNum, Size, MinimumRent, FloorplanId, avaliableDate) # - Pivot (livingatpivot.com, Cloudflare 403 -> la chaîne résiliente de # self.get escalade d'elle-même vers Scrapfly ASP) : cartes RentCafe # `.fp-container` (« N Bed / N Bath / N Sq. Ft. », « Starting at $X », # « Available from JJ/MM/AAAA ») # - Bridge : cartes par CATÉGORIE (« Studio … Starting from $2,095* ») — # une annonce par catégorie de chambres, prix plancher affiché # - Rhythm : aucun prix publié -> repli « une annonce par propriété » # Fiche riocanliving + découverte du microsite via le cache BD # self.detail() (clé = date `modified` du flux WP) ; la page « suites » du # microsite est relue EN DIRECT à chaque synchronisation (donnée vivante). # Une annonce par plan d'étage TARIFÉ ; sinon par catégorie tarifée ; sinon # repli par propriété SANS prix (rien d'inventé). # # Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le # connecteur est `disabled` et exclu du registre (zéro impact prod QC). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://riocanliving.com" FEED_URL = f"{BASE}/wp-json/wp/v2/property?per_page=100" # Gate expansion Ontario : le connecteur reste hors registre tant que la # variable d'environnement LOUKA_ONTARIO=1 n'est pas posée. _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" # localisations ontariennes du CPT (class_list property-location-) _ON_LOCATIONS = {"toronto-gta": "Toronto", "ottawa": "Ottawa"} # adresse civique sur la fiche (« 15 Roehampton Ave, Toronto, ON, M4P 1P9 ») _ADDR_RE = re.compile( r"\d+[^,<>{}\n]{2,60},\s*[A-Za-z .'’-]+,\s*ON,?\s*" r"[A-Z]\d[A-Z]\s?\d[A-Z]\d") # blob RentCafe embarqué (eCentral) : affectations JS, PAS du JSON — # floorPlanData[0] = {name:'The Centric II', BedroomNum: '1', …}; _FPDATA_RE = re.compile(r"floorPlanData\[\d+\]\s*=\s*\{([\s\S]*?)\};") # cartes par catégorie (Bridge) : « Studio Starting from $2,095* » — # la catégorie doit être ADJACENTE au prix (pas de texte d'intro entre deux) _CAT_RE = re.compile( r"(Studio|Bachelor|(\d+)[\s-]*Bed(?:room)?s?)\s*(?:\|\s*)?" r"Starting\s+from\s+\$\s*([\d,]+)", re.I) _BED_RE = re.compile(r"(\d+)\s*Bed", re.I) _BATH_RE = re.compile(r"([\d.]+)\s*Bath", re.I) _SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I) # lien « suites/plans » du microsite (découverte sur la page d'accueil) _SUITES_HREF_RE = re.compile( r"(floor-?plans?|rental-suites|/suites?/?$|availability)", re.I) class RioCanConnector(BaseConnector): source_id = "riocan" request_delay = 1.2 # microsites variés, dont un Cloudflare (Pivot) disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1) max_properties = 10 # garde-fou (4 tours ON aujourd'hui) max_images = 15 def fetch(self) -> list[Listing]: props = self.get(FEED_URL, headers={"Accept": "application/json"}).json() listings: list[Listing] = [] count = 0 for p in props: try: link = p.get("link") or "" # billets EN seulement (les FR doublonnent /fr/propriete/) if "/property/" not in link: continue cls = set(p.get("class_list") or []) if "property-type-rental" not in cls: continue # Ontario seulement (fourth-street-lofts = Calgary -> exclu) city = "" for slug, label in _ON_LOCATIONS.items(): if f"property-location-{slug}" in cls: city = label break if not city: continue if count >= self.max_properties: break count += 1 listings.extend(self._property_listings(p, city)) except Exception: continue return listings # -- annonces d'une tour (une par plan/catégorie tarifé, repli) ------------- def _property_listings(self, p: dict, city: str) -> list[Listing]: pid = str(p.get("id")) link = p.get("link") or "" title = BeautifulSoup((p.get("title") or {}).get("rendered") or "", "html.parser").get_text(" ", strip=True) # fiche riocanliving + découverte du microsite via le cache BD : # revisitée seulement quand le billet WordPress est modifié feed_key = str(p.get("modified") or p.get("modified_gmt") or "") d = self.detail(pid, feed_key, lambda: self._fetch_detail(link)) url = d.get("microsite") or link common = dict( source=self.source_id, url=url, address=d.get("address") or "", city=city, province="ON", description=d.get("description") or "", images=(d.get("images") or [])[: self.max_images], ) # page « suites » du microsite EN DIRECT (prix/dispo = donnée vivante) page = "" suites_url = d.get("suites_url") or "" if suites_url: try: page = self.get(suites_url).text except Exception: page = "" out = self._parse_floorplandata(page, pid, title, common) if not out: out = self._parse_fp_containers(page, pid, title, common) if not out: out = self._parse_categories(page, pid, title, common) if out: return out # repli : une annonce par tour — aucun prix inventé (Rhythm…) return [Listing( external_id=pid, title=title, unit_type="", **common, )] # -- parseur 1 : blob JS floorPlanData (eCentral) ---------------------------- @staticmethod def _js_field(body: str, *keys: str) -> str: """Valeur d'un champ d'objet littéral JS (`name:'The Centric II'`).""" for k in keys: m = re.search(rf"\b{k}\s*:\s*'((?:[^'\\]|\\.)*)'", body) if m: return m.group(1).replace("\\'", "'").strip() return "" def _parse_floorplandata(self, page: str, pid: str, title: str, common: dict) -> list[Listing]: out: list[Listing] = [] seen: set[str] = set() for m in _FPDATA_RE.finditer(page or ""): body = m.group(1) name = self._js_field(body, "name", "Name") fpid = self._js_field(body, "FloorplanId", "floorplanId", "id") key = fpid or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") if not key or key in seen: continue seen.add(key) # loyer plancher affiché seulement (0/absent = pas de prix) price = None try: v = float(self._js_field(body, "MinimumRent") .replace(",", "") or 0) if v > 0: price = v except ValueError: pass if price is None: continue beds = baths = None try: beds = float(self._js_field(body, "BedroomNum")) except ValueError: pass try: baths = float(self._js_field(body, "BathNum", "Bath")) except ValueError: pass sqft = None msq = re.search(r"[\d,]+", self._js_field(body, "Size", "DisplaySize")) if msq: try: v = float(msq.group(0).replace(",", "")) if 80 <= v <= 20000: sqft = v except ValueError: pass avail = self._js_field(body, "avaliableDate", "AvailableDate") out.append(Listing( external_id=f"{pid}-{key}", title=f"{title} — {name}" if name else title, unit_type=("Studio" if beds == 0 else normalize_unit_type( f"{int(beds)} chambres") if beds is not None else ""), bedrooms=beds, bathrooms=baths, price=price, price_label=f"À partir de {price:.0f} $ /mois", availability=avail, area_sqft=sqft, **common, )) return out # -- parseur 2 : cartes RentCafe .fp-container (Pivot) ----------------------- def _parse_fp_containers(self, page: str, pid: str, title: str, common: dict) -> list[Listing]: if not page or "fp-container" not in page: return [] soup = BeautifulSoup(page, "html.parser") out: list[Listing] = [] seen: set[str] = set() for card in soup.select("[id^=fp-container-]"): key = (card.get("id") or "").replace("fp-container-", "").strip() h = card.select_one(".card-title") or card.find(["h2", "h3"]) name = h.get_text(" ", strip=True) if h else "" if not key: key = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") if not key or key in seen: continue seen.add(key) txt = card.get_text(" ", strip=True) # prix plancher affiché seulement (« Starting at $3,204.00 ») mprice = re.search(r"Starting\s+at\s+(\$[\d,.]+)", txt, re.I) price = parse_price(mprice.group(1)) if mprice else None if price is None: continue beds = None if re.search(r"\bStudio|\bBachelor", txt, re.I): beds = 0.0 else: mb = _BED_RE.search(txt) if mb: beds = float(mb.group(1)) mba = _BATH_RE.search(txt) msq = _SQFT_RE.search(txt) sqft = float(msq.group(1).replace(",", "")) if msq else None mav = re.search(r"Available\s+from\s+([\d/]+)", txt, re.I) out.append(Listing( external_id=f"{pid}-{key}", title=f"{title} — {name}" if name else title, unit_type=("Studio" if beds == 0 else normalize_unit_type( f"{int(beds)} chambres") if beds is not None else ""), bedrooms=beds, bathrooms=float(mba.group(1)) if mba else None, price=price, price_label=f"À partir de {price:.0f} $ /mois", availability=f"Disponible le {mav.group(1)}" if mav else "", area_sqft=sqft if sqft and 80 <= sqft <= 20000 else None, **common, )) return out # -- parseur 3 : cartes par catégorie (Bridge) -------------------------------- def _parse_categories(self, page: str, pid: str, title: str, common: dict) -> list[Listing]: if not page: return [] txt = BeautifulSoup(page, "html.parser").get_text(" ", strip=True) out: list[Listing] = [] seen: set[str] = set() for m in _CAT_RE.finditer(txt): label = m.group(1).strip() beds = 0.0 if m.group(2) is None else float(m.group(2)) key = "studio" if beds == 0 else f"{int(beds)}-bed" if key in seen: continue seen.add(key) try: price = float(m.group(3).replace(",", "")) except ValueError: continue if price <= 0: continue out.append(Listing( external_id=f"{pid}-cat-{key}", title=f"{title} — {label}", unit_type=("Studio" if beds == 0 else normalize_unit_type(f"{int(beds)} chambres")), bedrooms=beds, price=price, price_label=f"À partir de {price:.0f} $ /mois", **common, )) return out # -- fiche riocanliving : adresse, description, visuels, microsite ----------- def _fetch_detail(self, link: str) -> dict: out: dict = {"address": "", "description": "", "images": [], "microsite": "", "suites_url": ""} if not link: return out try: page = self.get(link).text except Exception: return out soup = BeautifulSoup(page, "html.parser") m = _ADDR_RE.search(soup.get_text(" ", strip=True)) if m: out["address"] = re.sub(r"\s+", " ", m.group(0)).strip() # description : premiers paragraphes substantiels de la fiche paras = [q.get_text(" ", strip=True) for q in soup.find_all("p")] out["description"] = " ".join( t for t in paras if len(t) > 60)[:600] # visuels du site corporatif (app/uploads) — hors logos/icônes images: list[str] = [] for img in soup.find_all("img"): src = (img.get("src") or "").strip() if "/app/uploads/" not in src or src.lower().endswith(".svg"): continue if re.search(r"logo|icon", src, re.I): continue if src not in images: images.append(src) out["images"] = images[: self.max_images] # CTA « Website » -> microsite de la tour micro = "" for a in soup.find_all("a", href=True): if a.get_text(" ", strip=True).lower() in ( "website", "visit website"): micro = a["href"].strip() break out["microsite"] = micro if not micro: return out # découverte de la page « suites/plans » sur l'accueil du microsite # (la chaîne résiliente escalade seule le Cloudflare de Pivot) try: home = self.get(micro).text except Exception: return out hsoup = BeautifulSoup(home, "html.parser") for a in hsoup.find_all("a", href=True): href = a["href"].strip() if _SUITES_HREF_RE.search(href.split("?")[0]): out["suites_url"] = urljoin(micro, href) break # certains microsites affichent les plans sur l'accueil même if not out["suites_url"] and ( "floorPlanData" in home or "fp-container" in home): out["suites_url"] = micro return out