Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/rentals_ca.py : Rentals.ca — portail locatif national, filtré5# PROVINCE DE QUÉBEC. Même plateforme GraphQL que Louer.ca (mêmes City.id,6# mutation acquireAuthInfo, clé publique `rentalsGqlKey` de window.appconf),7# mais l'inventaire de marque est un peu plus large (~+15 % vérifié sur8# Québec : 78 annonces contre 67 côté Louer.ca) — les doublons inter-marques9# sont résorbés par la dédup (rentals_ca est dans la liste PORTALS).10# ⚠️ rentals.ca est derrière Cloudflare (« Just a moment… ») : chaque appel11# GraphQL est REJOUÉ via Scrapfly ASP SANS rendu JS (POST JSON, coût minime12# par rapport à un render_js). Pour contenir le budget Scrapfly :13# - villes limitées (LOUKA_RENTALSCA_CITIES) et plafond par ville ;14# - pages de 200 annonces (1 à 4 requêtes liste par ville) ;15# - détails PAR LOTS de 40 via nodes(ids:[…]) — 1 requête pour 40 fiches —16# avec cache TTL : seuls les lots nouveaux/périmés sont re-téléchargés.17# Régime de croisière : ~25-40 appels Scrapfly par synchronisation.18# ⚠️ CGU Rentals.ca : extraction interdite sans accord écrit (même réserve19# que louer_ca).20# -----------------------------------------------------------------------------21from __future__ import annotations2223import datetime24import json25import os26import re2728from ..schema import Listing29from . import _detailutil as du30from .louer_ca import LouerCaConnector3132# villes QC interrogées (slugs rentals.ca) — volontairement bornées : chaque33# page liste coûte un appel Scrapfly ASP34CITIES = [c.strip() for c in os.environ.get(35 "LOUKA_RENTALSCA_CITIES",36 "montreal,quebec-city,laval,gatineau,longueuil,sherbrooke,levis,"37 "trois-rivieres,saguenay,drummondville").split(",") if c.strip()]3839PAGE_SIZE = int(os.environ.get("LOUKA_RENTALSCA_PAGE_SIZE", "200"))40MAX_PER_CITY = int(os.environ.get("LOUKA_RENTALSCA_MAX_PER_CITY", "800"))41DETAIL_LIMIT = int(os.environ.get("LOUKA_RENTALSCA_DETAIL_LIMIT", "600"))42DETAIL_BATCH = int(os.environ.get("LOUKA_RENTALSCA_DETAIL_BATCH", "40"))43TTL_DAYS = float(os.environ.get("LOUKA_RENTALSCA_TTL_DAYS", "7"))444546class RentalsCaConnector(LouerCaConnector):47 source_id = "rentals_ca"48 request_delay = 0.3 # entre appels Scrapfly (API, pas le site)49 home_url = "https://rentals.ca/"50 gql_url = "https://rentals.ca/graphql"51 site = "https://rentals.ca"52 fallback_key = "kJFM-mm4c-xg6B-qiwy" # rentalsGqlKey publique (2026-08)53 page_size = PAGE_SIZE54 max_per_city = MAX_PER_CITY5556 # -- transport : tout passe par Scrapfly ASP (POST JSON, sans rendu JS) ----57 def _scrapfly_gql(self, query: str, variables: dict,58 token: str | None = None) -> dict:59 headers = {"Content-Type": "application/json",60 "Origin": self.site, "Referer": self.home_url}61 if token:62 headers["Authorization"] = f"Bearer {token}"63 res = self.scrapfly(self.gql_url, render_js=False, asp=True,64 method="POST",65 body=json.dumps({"query": query,66 "variables": variables}),67 headers=headers)68 try:69 return json.loads(res.get("content") or "{}")70 except ValueError:71 return {}7273 def _ensure_token(self) -> None:74 import time as _time75 if self._token and _time.time() - self._token_time < 2700:76 return77 payload = self._scrapfly_gql(78 "mutation($k:String!){acquireAuthInfo(credentials:{apiKey:$k})"79 "{jwt status}}", {"k": self._api_key})80 auth = ((payload.get("data") or {}).get("acquireAuthInfo")) or {}81 jwt = auth.get("jwt")82 if isinstance(jwt, str) and jwt.startswith("{"):83 jwt = json.loads(jwt)84 token = jwt.get("accessToken") if isinstance(jwt, dict) else jwt85 if not token:86 # clé périmée ? la relire dans window.appconf de la page d'accueil87 home = self.get_scrapfly(self.home_url, render_js=False, asp=True)88 m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home or "")89 if m:90 self._api_key = m.group(1)91 payload = self._scrapfly_gql(92 "mutation($k:String!){acquireAuthInfo(credentials:"93 "{apiKey:$k}){jwt status}}", {"k": self._api_key})94 auth = ((payload.get("data") or {})95 .get("acquireAuthInfo")) or {}96 jwt = auth.get("jwt")97 if isinstance(jwt, str) and jwt.startswith("{"):98 jwt = json.loads(jwt)99 token = (jwt.get("accessToken")100 if isinstance(jwt, dict) else jwt)101 if not token:102 raise RuntimeError("Rentals.ca: handshake JWT échoué")103 self._token = token104 self._token_time = _time.time()105106 def _gql(self, query: str, variables: dict, auth: bool = True) -> dict:107 if auth:108 self._ensure_token()109 payload = self._scrapfly_gql(query, variables,110 token=self._token if auth else None)111 if payload.get("errors") and auth:112 msg = payload["errors"][0].get("message", "")113 if "AUTH" in msg.upper(): # jeton expiré : on réessaie114 self._token = ""115 self._ensure_token()116 payload = self._scrapfly_gql(query, variables,117 token=self._token)118 return payload.get("data") or {}119120 # -- détail par LOTS (1 requête Scrapfly pour DETAIL_BATCH fiches) ---------121 def _details_batch(self, gids: list[str]) -> dict[str, dict]:122 q = ("query($ids:[ID!]!){nodes(ids:$ids){... on RentalListing{"123 + self._DETAIL_FRAG + "}}}")124 out: dict[str, dict] = {}125 for node in self._gql(q, {"ids": gids}).get("nodes") or []:126 if isinstance(node, dict) and node.get("id"):127 out[node["id"]] = node128 return out129130 def fetch(self) -> list[Listing]:131 if not os.environ.get("SCRAPFLY_KEY"):132 raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")133 today = datetime.date.today().isoformat()134 cards: dict[str, tuple[dict, str]] = {}135 for slug in CITIES:136 cid = self._city_id(slug)137 if not cid:138 continue139 for card in self._city_listings(cid):140 gid = card.get("id")141 if gid:142 cards.setdefault(gid, (card, slug))143144 # cache TTL : détails frais réutilisés sans trafic, le reste par lots145 cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,146 key="v1", fetch_html=lambda gid: gid)147 details: dict[str, dict] = {}148 todo: list[str] = []149 out: list[Listing] = []150 try:151 for gid in cards:152 payload, fresh = cache.peek(gid)153 if payload is not None:154 details[gid] = payload155 if not fresh and len(todo) < DETAIL_LIMIT:156 todo.append(gid)157 for i in range(0, len(todo), DETAIL_BATCH):158 got = self._details_batch(todo[i:i + DETAIL_BATCH])159 for gid, node in got.items():160 cache.put(gid, node)161 details[gid] = node162163 for gid, (card, slug) in cards.items():164 node = details.get(gid) or {}165 region = (((node.get("address") or {}).get("city") or {})166 .get("regionCode"))167 if region and region != "QC":168 continue # sécurité : Québec uniquement169 out.extend(self._card_listings(170 card, node, today,171 default_city=slug.replace("-", " ").title()))172 finally:173 cache.close()174 return out175