# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/rentfaster.py : RentFaster.ca — national rental portal (Alberta- # dominant, listings in every province) with a fully OPEN JSON API (no auth, # no Cloudflare — verified live 2026-08-27): # GET /api/search.json?city_id=&cur_page=

(48 listings/page, total) # Numeric city_ids are not published anywhere; scripts/probe_rentfaster_cities.py # probes them once and stores data/rentfaster_cities.json ({id, city, # province, total}) — this connector iterates every non-QC city with # inventory, paginating politely. One record per property (ref_id); price # ranges ("1523 - 2170") keep the min with a "From $X" label, like the # LiftSystem connector. Private landlords AND property managers both publish # here — dedup.run() masks cross-source duplicates downstream. # Env knobs: RENTKA_RENTFASTER_MAX_PAGES (per city, default 200), # RENTKA_RENTFASTER_CITIES (comma list of city names to restrict). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re from pathlib import Path from ..schema import Listing from .base import BaseConnector CITIES_PATH = Path(__file__).resolve().parents[2] / "data" / \ "rentfaster_cities.json" BASE = "https://www.rentfaster.ca" PAGE_SIZE = 48 _NON_RESIDENTIAL = {"parking", "storage", "office", "retail", "commercial"} def _load_cities() -> list[dict]: try: data = json.loads(CITIES_PATH.read_text("utf-8")) except (OSError, ValueError): return [] keep = (os.environ.get("RENTKA_RENTFASTER_CITIES") or "").strip() keep_set = {c.strip().lower() for c in keep.split(",") if c.strip()} out = [] for c in data.get("cities") or []: if not c.get("total") or not c.get("city"): continue if (c.get("province") or "").upper() in ("QC", ""): continue # Rent-Ka scope: Canada outside Québec if keep_set and c["city"].lower() not in keep_set: continue out.append(c) # biggest markets first so a capped run still covers the bulk return sorted(out, key=lambda c: -int(c.get("total") or 0)) def _price(raw) -> tuple[float | None, str]: """'1523 - 2170' -> (1523.0, 'From $1,523') ; '2580' -> (2580.0, '$2,580/mo').""" s = str(raw or "").replace(",", "").strip() nums = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", s)] nums = [n for n in nums if 100 <= n <= 50000] if not nums: return None, "" lo = min(nums) if len(nums) > 1 and max(nums) > lo: return lo, f"From ${lo:,.0f}" return lo, f"${lo:,.0f}/mo" def _count(raw) -> float | None: """'1', '1 - 3', 'Studio' -> smallest count (None when absent).""" s = str(raw or "").strip().lower() if not s: return None if "studio" in s or "bachelor" in s: return 0.0 m = re.search(r"\d+(?:\.\d+)?", s) return float(m.group()) if m else None def _area(raw) -> float | None: """'615, 661, 761' / '885' -> smallest plausible square footage.""" nums = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", str(raw or "").replace(",", " "))] nums = [n for n in nums if 80 <= n <= 20000] return min(nums) if nums else None class RentFasterConnector(BaseConnector): """RentFaster.ca — open JSON API, every province, private + managed.""" source_id = "rentfaster" request_delay = 0.9 timeout = 30 use_detail_cache = False # the list payload is self-sufficient max_images = 3 def fetch(self) -> list[Listing]: max_pages = int(os.environ.get("RENTKA_RENTFASTER_MAX_PAGES") or 200) listings: list[Listing] = [] seen: set[str] = set() for c in _load_cities(): pages = min(max_pages, -(-int(c.get("total") or 0) // PAGE_SIZE)) for page in range(pages): try: d = self.get(f"{BASE}/api/search.json", params={ "city_id": str(c["id"]), "cur_page": str(page), }, headers={"Accept": "application/json"}).json() except Exception: break rows = d.get("listings") or [] for r in rows: try: lst = self._listing(r, c) except Exception: continue if lst and lst.external_id not in seen: seen.add(lst.external_id) listings.append(lst) if len(rows) < PAGE_SIZE: break return listings def _listing(self, r: dict, c: dict) -> Listing | None: rid = str(r.get("ref_id") or r.get("id") or "").strip() if not rid: return None ptype = str(r.get("type") or "").strip() if ptype.lower() in _NON_RESIDENTIAL: return None price, price_label = _price(r.get("price")) _b1, _b2 = _count(r.get("beds")), _count(r.get("beds2")) beds = min((b for b in (_b1, _b2) if b is not None), default=None) unit_type = "" if ptype.lower() in ("apartment", "condo") or not ptype: if beds == 0: unit_type = "Studio" elif beds is not None and r.get("beds") == r.get("beds2", r.get("beds")): unit_type = f"{int(beds)} bedroom" + ("s" if beds > 1 else "") elif ptype.lower() in ("house", "townhouse", "condo", "loft", "basement", "duplex"): unit_type = {"house": "House", "townhouse": "Townhouse", "condo": "Condo", "loft": "Loft", "basement": "Studio", "duplex": "House"}.get( ptype.lower(), "") if ptype.lower() == "basement": unit_type = "" # basement suites: bedrooms say more city = (r.get("city") or c.get("city") or "").strip() prov = (c.get("province") or "").upper() street = (r.get("address") or r.get("intro") or "").strip() address = ", ".join(x for x in (street, city) if x) if address: address += f", {prov}" try: lat = float(r.get("latitude")) if r.get("latitude") else None lng = float(r.get("longitude")) if r.get("longitude") else None except (TypeError, ValueError): lat = lng = None images = [] for k in ("slide", "thumb2", "thumb"): u = (r.get(k) or "").strip() if u and u not in images: images.append(u) avail = str(r.get("availability") or r.get("avdate") or "").strip() avail_date = None m = re.fullmatch(r"20\d{2}-\d{2}-\d{2}", str(r.get("a") or "").strip()[:10]) if m and not m.group().startswith("2000-"): avail_date = m.group() details: dict = {} if ptype: details["Property type"] = ptype if r.get("community"): details["Neighbourhood"] = str(r["community"]).strip() if r.get("units") and int(r.get("units") or 0) > 1: details["Units available"] = int(r["units"]) baths = _count(r.get("baths")) return Listing( source=self.source_id, external_id=rid, url=BASE + str(r.get("link") or ""), title=(r.get("title") or street or f"Rental in {city}").strip(), address=address, sector=(r.get("community") or r.get("location") or "").strip(), city=city, province=prov, unit_type=unit_type, price=price, price_label=price_label, availability=avail, availability_date=avail_date, area_sqft=_area(r.get("sq_feet")), bedrooms=beds, bathrooms=baths, description="", amenities=[], details=details, images=images[: self.max_images], lat=lat, lng=lng, )