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.5 KB · 186 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/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