SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
8.1 KB · 175 lines python
Raw Blame History
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