SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
8.1 KB · 202 lines python
Raw Blame History
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