# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/capreit.py : CAPREIT (capreit.ca) # Official search-engine JSON feed (admin-ajax `property_json`) — a single # request returns every Canadian property (~278, all provinces); filtering # is purely client-side. Rent-Ka keeps EVERY province except QC (BC, AB, # SK, MB, ON, NB, NS, PE, NL…). Server-rendered property pages provide # unit types, prices, availability, amenities and the photo gallery # (French /fr/ URLs — labels are normalized centrally by finalize()). # One listing per available unit type. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import os import re from bs4 import BeautifulSoup from ..schema import (Listing, normalize_unit_type, parse_area_sqft, parse_availability_date, parse_price, strip_accents) from .base import BaseConnector BASE = "https://www.capreit.ca" FEED_URL = f"{BASE}/wp-admin/admin-ajax.php?action=property_json&language=fr" # Ontario display-name mapping: normalized feed key -> display name. The # CAPREIT feed distinguishes Toronto's former boroughs (Scarborough, North # York…): kept as cities, consistent with local usage. Cities absent from # this map are NOT rejected — the feed city is used as-is (whole-province # coverage); the map only normalizes names and regroups Ottawa's sectors. _ON_CITIES = { # Toronto et arrondissements (ancienne métropole) "toronto": "Toronto", "scarborough": "Scarborough", "north york": "North York", "etobicoke": "Etobicoke", "york": "York", "east york": "East York", # GTA — York Region "thornhill": "Thornhill", "vaughan": "Vaughan", "markham": "Markham", "richmond hill": "Richmond Hill", # GTA — Peel / Halton "mississauga": "Mississauga", "brampton": "Brampton", "oakville": "Oakville", "burlington": "Burlington", "milton": "Milton", # GTA — Durham "pickering": "Pickering", "ajax": "Ajax", "whitby": "Whitby", "oshawa": "Oshawa", # Ottawa (Orléans/Nepean/Kanata/Gloucester = secteurs d'Ottawa) "ottawa": "Ottawa", "orleans": "Ottawa", "nepean": "Ottawa", "kanata": "Ottawa", "gloucester": "Ottawa", # London "london": "London", # Hamilton / Kitchener-Waterloo "hamilton": "Hamilton", "kitchener": "Kitchener", "waterloo": "Waterloo", "cambridge": "Cambridge", } _IMG_RE = re.compile( r'https://www\.capreit\.ca/wp-content/uploads/[^"\'\s\\]+' r'\.(?:jpg|jpeg|png|webp)', re.I) _SKIP_IMG = re.compile( r"logo|icon|favicon|cropped|-\d{2,4}x\d{2,4}\.|BIL|Phone|badge", re.I) class CapreitConnector(BaseConnector): source_id = "capreit" request_delay = 0.6 max_properties = 260 # safety cap (whole Canada outside QC) max_images = 25 @staticmethod def _city_key(city: str) -> str: return strip_accents((city or "").strip().lower()) def fetch(self) -> list[Listing]: props = self.get(FEED_URL).json() listings: list[Listing] = [] count = 0 for p in props: try: prov = (p.get("province") or "").strip().upper() if not prov or prov == "QC": continue # Québec is Rent-Ka's territory if not p.get("has_vacancies"): continue if count >= self.max_properties: break count += 1 listings.extend(self._property_listings(p, province=prov)) except Exception: continue return listings def _property_listings(self, p: dict, province: str = "ON") -> list[Listing]: pid = str(p.get("id")) url = p.get("url") or "" title = (p.get("title") or "").strip() address = (p.get("address") or "").strip() feed_city = (p.get("city") or "").strip() # full address: street + city + province + postal code (from the feed) postal = (p.get("postal_code") or "").strip() if address and feed_city: address = f"{address}, {feed_city}" + (f", {province} {postal}" if postal else "") # coordonnées GPS du flux try: lat = float(p["latitude"]) if p.get("latitude") else None lng = float(p["longitude"]) if p.get("longitude") else None except (TypeError, ValueError): lat = lng = None incentive = (p.get("incentive") or "").strip() # city: feed city, normalized through the ON display map when it # regroups (e.g. Orléans -> Ottawa, old name becomes the sector); # sector defaults to the nearest intersection. city_key = self._city_key(feed_city) city = _ON_CITIES.get(city_key, feed_city) if province == "ON" \ else feed_city if self._city_key(city) == city_key: sector = (p.get("nearest_intersection") or "").strip() else: sector = feed_city # fiche propriété (rendu serveur) via le cache BD : revisitée # seulement quand la ligne du flux change feed_key = hashlib.sha1("|".join( str(p.get(k)) for k in ("id", "min_rent", "earliest_date", "vacancy_message", "price_range", "has_vacancies", "units_count", "incentive") ).encode("utf-8")).hexdigest() d = self.detail(pid, feed_key, lambda: self._fetch_property(url)) desc = d.get("desc", "") amenities = d.get("amenities", []) images = d.get("images", []) rows = d.get("rows", []) # promotion du flux (ex. « 1 mois de loyer gratuit ») if incentive: desc = f"Promotion : {incentive}. {desc}".strip() # date de disponibilité structurée du flux (ex. 20260201) avail_date = None ed = str(p.get("earliest_date") or "") if re.fullmatch(r"20\d{6}", ed): avail_date = f"{ed[:4]}-{ed[4:6]}-{ed[6:]}" out: list[Listing] = [] if rows: for r in rows: ut = normalize_unit_type(r["unit_raw"]) slug = re.sub(r"[^a-z0-9]+", "-", strip_accents(r["unit_raw"].lower())).strip("-") out.append(Listing( source=self.source_id, external_id=f"{pid}-{slug or 'u'}", url=url, title=f"{title} — {r['unit_raw']}" if r["unit_raw"] else title, address=address, sector=sector, city=city, province=province, unit_type=ut, price=parse_price(r["price"]), price_label=r["price"], availability=r["avail"], # date de la ligne (« Disponible 1 sept. ») sinon # earliest_date structuré du flux availability_date=(parse_availability_date(r["avail"]) or avail_date), # superficie structurée de la ligne (ex. « 875 pi² ») area_sqft=parse_area_sqft(r["sqft"]), description=desc[:600], amenities=amenities, images=images, lat=lat, lng=lng, )) else: # repli : annonce par propriété avec le prix plancher du flux min_rent = p.get("min_rent") out.append(Listing( source=self.source_id, external_id=pid, url=url, title=title, address=address, sector=sector, city=city, province=province, unit_type=normalize_unit_type( (p.get("bedroom_range") or "").split("-")[0]), price=float(min_rent) if min_rent else None, price_label=p.get("price_range") or "", availability=p.get("vacancy_message") or "", availability_date=avail_date, description=desc, amenities=amenities, images=images, lat=lat, lng=lng, )) return out def _fetch_property(self, url: str) -> dict: """Scrape la fiche propriété : galerie, commodités, description, et une ligne par type d'unité disponible (« Vos options »).""" out: dict = {"desc": "", "amenities": [], "images": [], "rows": []} try: page = self.get(url).text except Exception: return out soup = BeautifulSoup(page, "html.parser") # galerie photos (héro + blocs JSON de la page) images: list[str] = [] for u in _IMG_RE.findall(page): if _SKIP_IMG.search(u): continue if u not in images: images.append(u) out["images"] = images[: self.max_images] # commodités (listes à icônes) amenities: list[str] = [] seen = set() for li in soup.select("li"): if not li.find("div", class_="icon"): continue t = li.get_text(" ", strip=True) if t and len(t) < 60 and t not in seen: seen.add(t) amenities.append(t) out["amenities"] = amenities[:25] # description (« Caractéristiques de l'immeuble ») h = soup.find(["h2", "h3"], string=re.compile( "Caractéristiques de l['’]immeuble")) if h: nxt = h.find_next(["p", "div"]) if nxt: out["desc"] = nxt.get_text(" ", strip=True)[:600] # types d'unités disponibles for li in soup.select("li.property-options-list-item"): avail_el = li.select_one( ".property-options-list-item-availability") price_el = li.select_one( ".property-options-list-item-price") details = [d.get_text(" ", strip=True) for d in li.select(".property-options-item")] unit_raw = details[0] if details else "" sqft = details[1] if len(details) > 1 else "" if li.get("data-available") == "false": continue out["rows"].append({ "unit_raw": unit_raw, "sqft": sqft, "price": price_el.get_text(" ", strip=True) if price_el else "", "avail": avail_el.get_text(" ", strip=True) if avail_el else "", }) return out