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/rentals_ca.py : Rentals.ca — national rental portal, covering5# CANADA OUTSIDE QUÉBEC (major metros in every province). Same GraphQL6# platform as Louer.ca (same City.id, acquireAuthInfo mutation, public7# `rentalsGqlKey` from window.appconf); cross-brand duplicates are folded8# by the dedup pass (rentals_ca is in the PORTALS list).9# ⚠️ rentals.ca sits behind Cloudflare («Just a moment…»): every GraphQL10# call is REPLAYED through Scrapfly ASP WITHOUT JS rendering (POST JSON,11# minimal cost vs a render_js). To contain the Scrapfly budget:12# - bounded city list (RENTKA_RENTALSCA_CITIES) and per-city cap;13# - 200-listing pages (1-4 list requests per city);14# - details in BATCHES of 40 via nodes(ids:[…]) — 1 request per 40 —15# with a TTL cache: only new/stale batches are re-downloaded.16# ⚠️ Rentals.ca ToS restrict extraction without written agreement.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import datetime21import json22import os23import re2425from ..schema import Listing26from . import _detailutil as du27from .louer_ca import LouerCaConnector2829# Cities queried (rentals.ca slugs) — deliberately bounded: each list page30# costs one Scrapfly ASP call. Major metros in every province outside QC.31CITIES = [c.strip() for c in os.environ.get(32 "RENTKA_RENTALSCA_CITIES",33 "toronto,ottawa,mississauga,hamilton,london,kitchener,windsor,"34 "vancouver,victoria,surrey,burnaby,calgary,edmonton,winnipeg,"35 "saskatoon,regina,halifax,moncton,fredericton,charlottetown,st-johns"36 ).split(",") if c.strip()]3738# default slug -> province (fallback when a detail node has no regionCode)39_SLUG_PROV = {40 "toronto": "ON", "ottawa": "ON", "mississauga": "ON", "hamilton": "ON",41 "london": "ON", "kitchener": "ON", "windsor": "ON",42 "vancouver": "BC", "victoria": "BC", "surrey": "BC", "burnaby": "BC",43 "calgary": "AB", "edmonton": "AB", "winnipeg": "MB",44 "saskatoon": "SK", "regina": "SK", "halifax": "NS", "moncton": "NB",45 "fredericton": "NB", "charlottetown": "PE", "st-johns": "NL",46}4748PAGE_SIZE = int(os.environ.get("RENTKA_RENTALSCA_PAGE_SIZE", "200"))49MAX_PER_CITY = int(os.environ.get("RENTKA_RENTALSCA_MAX_PER_CITY", "800"))50DETAIL_LIMIT = int(os.environ.get("RENTKA_RENTALSCA_DETAIL_LIMIT", "600"))51DETAIL_BATCH = int(os.environ.get("RENTKA_RENTALSCA_DETAIL_BATCH", "40"))52TTL_DAYS = float(os.environ.get("RENTKA_RENTALSCA_TTL_DAYS", "7"))535455class RentalsCaConnector(LouerCaConnector):56 source_id = "rentals_ca"57 request_delay = 0.3 # entre appels Scrapfly (API, pas le site)58 home_url = "https://rentals.ca/"59 gql_url = "https://rentals.ca/graphql"60 site = "https://rentals.ca"61 fallback_key = "kJFM-mm4c-xg6B-qiwy" # public rentalsGqlKey (2026-08)62 page_size = PAGE_SIZE63 max_per_city = MAX_PER_CITY64 cities = CITIES6566 # -- transport : tout passe par Scrapfly ASP (POST JSON, sans rendu JS) ----67 def _scrapfly_gql(self, query: str, variables: dict,68 token: str | None = None) -> dict:69 headers = {"Content-Type": "application/json",70 "Origin": self.site, "Referer": self.home_url}71 if token:72 headers["Authorization"] = f"Bearer {token}"73 res = self.scrapfly(self.gql_url, render_js=False, asp=True,74 method="POST",75 body=json.dumps({"query": query,76 "variables": variables}),77 headers=headers)78 try:79 return json.loads(res.get("content") or "{}")80 except ValueError:81 return {}8283 def _ensure_token(self) -> None:84 import time as _time85 if self._token and _time.time() - self._token_time < 2700:86 return87 payload = self._scrapfly_gql(88 "mutation($k:String!){acquireAuthInfo(credentials:{apiKey:$k})"89 "{jwt status}}", {"k": self._api_key})90 auth = ((payload.get("data") or {}).get("acquireAuthInfo")) or {}91 jwt = auth.get("jwt")92 if isinstance(jwt, str) and jwt.startswith("{"):93 jwt = json.loads(jwt)94 token = jwt.get("accessToken") if isinstance(jwt, dict) else jwt95 if not token:96 # clé périmée ? la relire dans window.appconf de la page d'accueil97 home = self.get_scrapfly(self.home_url, render_js=False, asp=True)98 m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home or "")99 if m:100 self._api_key = m.group(1)101 payload = self._scrapfly_gql(102 "mutation($k:String!){acquireAuthInfo(credentials:"103 "{apiKey:$k}){jwt status}}", {"k": self._api_key})104 auth = ((payload.get("data") or {})105 .get("acquireAuthInfo")) or {}106 jwt = auth.get("jwt")107 if isinstance(jwt, str) and jwt.startswith("{"):108 jwt = json.loads(jwt)109 token = (jwt.get("accessToken")110 if isinstance(jwt, dict) else jwt)111 if not token:112 raise RuntimeError("Rentals.ca: JWT handshake failed")113 self._token = token114 self._token_time = _time.time()115116 def _gql(self, query: str, variables: dict, auth: bool = True) -> dict:117 if auth:118 self._ensure_token()119 payload = self._scrapfly_gql(query, variables,120 token=self._token if auth else None)121 if payload.get("errors") and auth:122 msg = payload["errors"][0].get("message", "")123 if "AUTH" in msg.upper(): # jeton expiré : on réessaie124 self._token = ""125 self._ensure_token()126 payload = self._scrapfly_gql(query, variables,127 token=self._token)128 return payload.get("data") or {}129130 # -- détail par LOTS (1 requête Scrapfly pour DETAIL_BATCH fiches) ---------131 def _details_batch(self, gids: list[str]) -> dict[str, dict]:132 q = ("query($ids:[ID!]!){nodes(ids:$ids){... on RentalListing{"133 + self._DETAIL_FRAG + "}}}")134 out: dict[str, dict] = {}135 for node in self._gql(q, {"ids": gids}).get("nodes") or []:136 if isinstance(node, dict) and node.get("id"):137 out[node["id"]] = node138 return out139140 def fetch(self) -> list[Listing]:141 if not os.environ.get("SCRAPFLY_KEY"):142 raise RuntimeError("SCRAPFLY_KEY missing (see .env)")143 today = datetime.date.today().isoformat()144 cards: dict[str, tuple[dict, str]] = {}145 for slug in self.cities:146 cid = self._city_id(slug)147 if not cid:148 continue149 for card in self._city_listings(cid):150 gid = card.get("id")151 if gid:152 cards.setdefault(gid, (card, slug))153154 # cache TTL : détails frais réutilisés sans trafic, le reste par lots155 cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,156 key="v1", fetch_html=lambda gid: gid)157 details: dict[str, dict] = {}158 todo: list[str] = []159 out: list[Listing] = []160 try:161 for gid in cards:162 payload, fresh = cache.peek(gid)163 if payload is not None:164 details[gid] = payload165 if not fresh and len(todo) < DETAIL_LIMIT:166 todo.append(gid)167 for i in range(0, len(todo), DETAIL_BATCH):168 got = self._details_batch(todo[i:i + DETAIL_BATCH])169 for gid, node in got.items():170 cache.put(gid, node)171 details[gid] = node172173 for gid, (card, slug) in cards.items():174 node = details.get(gid) or {}175 region = (((node.get("address") or {}).get("city") or {})176 .get("regionCode")) or ""177 if region == "QC":178 continue # Québec is Rent-Ka's territory179 out.extend(self._card_listings(180 card, node, today,181 default_city=slug.replace("-", " ").title(),182 province=region or _SLUG_PROV.get(slug, "ON")))183 finally:184 cache.close()185 return out186