Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/rentfaster.py : RentFaster.ca — national rental portal (Alberta-5# dominant, listings in every province) with a fully OPEN JSON API (no auth,6# no Cloudflare — verified live 2026-08-27):7# GET /api/search.json?city_id=<N>&cur_page=<P> (48 listings/page, total)8# Numeric city_ids are not published anywhere; scripts/probe_rentfaster_cities.py9# probes them once and stores data/rentfaster_cities.json ({id, city,10# province, total}) — this connector iterates every non-QC city with11# inventory, paginating politely. One record per property (ref_id); price12# ranges ("1523 - 2170") keep the min with a "From $X" label, like the13# LiftSystem connector. Private landlords AND property managers both publish14# here — dedup.run() masks cross-source duplicates downstream.15# Env knobs: RENTKA_RENTFASTER_MAX_PAGES (per city, default 200),16# RENTKA_RENTFASTER_CITIES (comma list of city names to restrict).17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import os22import re23from pathlib import Path2425from ..schema import Listing26from .base import BaseConnector2728CITIES_PATH = Path(__file__).resolve().parents[2] / "data" / \29 "rentfaster_cities.json"30BASE = "https://www.rentfaster.ca"31PAGE_SIZE = 483233_NON_RESIDENTIAL = {"parking", "storage", "office", "retail", "commercial"}343536def _load_cities() -> list[dict]:37 try:38 data = json.loads(CITIES_PATH.read_text("utf-8"))39 except (OSError, ValueError):40 return []41 keep = (os.environ.get("RENTKA_RENTFASTER_CITIES") or "").strip()42 keep_set = {c.strip().lower() for c in keep.split(",") if c.strip()}43 out = []44 for c in data.get("cities") or []:45 if not c.get("total") or not c.get("city"):46 continue47 if (c.get("province") or "").upper() in ("QC", ""):48 continue # Rent-Ka scope: Canada outside Québec49 if keep_set and c["city"].lower() not in keep_set:50 continue51 out.append(c)52 # biggest markets first so a capped run still covers the bulk53 return sorted(out, key=lambda c: -int(c.get("total") or 0))545556def _price(raw) -> tuple[float | None, str]:57 """'1523 - 2170' -> (1523.0, 'From $1,523') ; '2580' -> (2580.0, '$2,580/mo')."""58 s = str(raw or "").replace(",", "").strip()59 nums = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", s)]60 nums = [n for n in nums if 100 <= n <= 50000]61 if not nums:62 return None, ""63 lo = min(nums)64 if len(nums) > 1 and max(nums) > lo:65 return lo, f"From ${lo:,.0f}"66 return lo, f"${lo:,.0f}/mo"676869def _count(raw) -> float | None:70 """'1', '1 - 3', 'Studio' -> smallest count (None when absent)."""71 s = str(raw or "").strip().lower()72 if not s:73 return None74 if "studio" in s or "bachelor" in s:75 return 0.076 m = re.search(r"\d+(?:\.\d+)?", s)77 return float(m.group()) if m else None787980def _area(raw) -> float | None:81 """'615, 661, 761' / '885' -> smallest plausible square footage."""82 nums = [float(x) for x in83 re.findall(r"\d+(?:\.\d+)?", str(raw or "").replace(",", " "))]84 nums = [n for n in nums if 80 <= n <= 20000]85 return min(nums) if nums else None868788class RentFasterConnector(BaseConnector):89 """RentFaster.ca — open JSON API, every province, private + managed."""9091 source_id = "rentfaster"92 request_delay = 0.993 timeout = 3094 use_detail_cache = False # the list payload is self-sufficient95 max_images = 39697 def fetch(self) -> list[Listing]:98 max_pages = int(os.environ.get("RENTKA_RENTFASTER_MAX_PAGES") or 200)99 listings: list[Listing] = []100 seen: set[str] = set()101 for c in _load_cities():102 pages = min(max_pages,103 -(-int(c.get("total") or 0) // PAGE_SIZE))104 for page in range(pages):105 try:106 d = self.get(f"{BASE}/api/search.json", params={107 "city_id": str(c["id"]), "cur_page": str(page),108 }, headers={"Accept": "application/json"}).json()109 except Exception:110 break111 rows = d.get("listings") or []112 for r in rows:113 try:114 lst = self._listing(r, c)115 except Exception:116 continue117 if lst and lst.external_id not in seen:118 seen.add(lst.external_id)119 listings.append(lst)120 if len(rows) < PAGE_SIZE:121 break122 return listings123124 def _listing(self, r: dict, c: dict) -> Listing | None:125 rid = str(r.get("ref_id") or r.get("id") or "").strip()126 if not rid:127 return None128 ptype = str(r.get("type") or "").strip()129 if ptype.lower() in _NON_RESIDENTIAL:130 return None131 price, price_label = _price(r.get("price"))132 _b1, _b2 = _count(r.get("beds")), _count(r.get("beds2"))133 beds = min((b for b in (_b1, _b2) if b is not None), default=None)134 unit_type = ""135 if ptype.lower() in ("apartment", "condo") or not ptype:136 if beds == 0:137 unit_type = "Studio"138 elif beds is not None and r.get("beds") == r.get("beds2", r.get("beds")):139 unit_type = f"{int(beds)} bedroom" + ("s" if beds > 1 else "")140 elif ptype.lower() in ("house", "townhouse", "condo", "loft",141 "basement", "duplex"):142 unit_type = {"house": "House", "townhouse": "Townhouse",143 "condo": "Condo", "loft": "Loft",144 "basement": "Studio", "duplex": "House"}.get(145 ptype.lower(), "")146 if ptype.lower() == "basement":147 unit_type = "" # basement suites: bedrooms say more148 city = (r.get("city") or c.get("city") or "").strip()149 prov = (c.get("province") or "").upper()150 street = (r.get("address") or r.get("intro") or "").strip()151 address = ", ".join(x for x in (street, city) if x)152 if address:153 address += f", {prov}"154 try:155 lat = float(r.get("latitude")) if r.get("latitude") else None156 lng = float(r.get("longitude")) if r.get("longitude") else None157 except (TypeError, ValueError):158 lat = lng = None159 images = []160 for k in ("slide", "thumb2", "thumb"):161 u = (r.get(k) or "").strip()162 if u and u not in images:163 images.append(u)164 avail = str(r.get("availability") or r.get("avdate") or "").strip()165 avail_date = None166 m = re.fullmatch(r"20\d{2}-\d{2}-\d{2}",167 str(r.get("a") or "").strip()[:10])168 if m and not m.group().startswith("2000-"):169 avail_date = m.group()170 details: dict = {}171 if ptype:172 details["Property type"] = ptype173 if r.get("community"):174 details["Neighbourhood"] = str(r["community"]).strip()175 if r.get("units") and int(r.get("units") or 0) > 1:176 details["Units available"] = int(r["units"])177 baths = _count(r.get("baths"))178 return Listing(179 source=self.source_id,180 external_id=rid,181 url=BASE + str(r.get("link") or ""),182 title=(r.get("title") or street or f"Rental in {city}").strip(),183 address=address,184 sector=(r.get("community") or r.get("location") or "").strip(),185 city=city,186 province=prov,187 unit_type=unit_type,188 price=price,189 price_label=price_label,190 availability=avail,191 availability_date=avail_date,192 area_sqft=_area(r.get("sq_feet")),193 bedrooms=beds,194 bathrooms=baths,195 description="",196 amenities=[],197 details=details,198 images=images[: self.max_images],199 lat=lat,200 lng=lng,201 )202