# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/louer_ca.py : ABSTRACT BASE for the Rentals.ca GraphQL network # (kept under its historical name; the louer.ca QC connector itself is NOT # registered — source_id is empty). Reverse-engineered internal GraphQL # API: public `rentalsGqlKey` read from window.appconf on the home page, # `acquireAuthInfo` mutation -> JWT (JSON scalar with ~1 h accessToken), # `Authorization: Bearer ` header. Search goes through the city: # typeahead -> City.id, then `node(id){... on City{ rentalListings(first, # after) }}` (Relay cursor). The `node(id)` detail provides the address, # description, gallery (images.rentals.ca) and floorPlans (one type/price # per plan) -> one listing per floor plan. # ⚠️ Rentals.ca network ToS restrict extraction without written agreement. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import datetime import json import os import re import time from ..schema import Listing from .base import BaseConnector from . import _detailutil as du HOME = "https://louer.ca/" GQL = "https://louer.ca/graphql" FALLBACK_KEY = "ME8N-J3IX-At86-2yIi" PAGE_SIZE = int(os.environ.get("RENTKA_LOUERCA_PAGE_SIZE", "50")) DETAIL_LIMIT = int(os.environ.get("RENTKA_LOUERCA_DETAIL_LIMIT", "600")) TTL_DAYS = float(os.environ.get("RENTKA_LOUERCA_TTL_DAYS", "5")) MAX_PER_CITY = int(os.environ.get("RENTKA_LOUERCA_MAX_PER_CITY", "0")) # 0=all # amenity (category, value) -> display label (normalized by finalize()) _AMENITY_FR = { "laundry-facilities": "Laundry facilities", "in-suite-laundry": "In-suite laundry", "security-on-site": "On-site security", "storage-lockers": "Storage locker", "swimming-pool": "Pool", "gym": "Gym", "elevator": "Elevator", "sauna": "Sauna", "on-site-staff": "On-site staff", "stove": "Stove", "fridge": "Fridge", "balcony": "Balcony", "microwave": "Microwave", "dishwasher": "Dishwasher", "air-conditioning": "Air conditioning", "individual-thermostats": "Individual thermostats", "heating": "Heat included", "water": "Water included", "hydro-electricity": "Electricity included", "public-transit": "Public transit nearby", "parking": "Parking", "no-smoking-allowed": "No smoking", } # regionCode -> province code (identity for Canadian provinces) _EXCLUDED_REGIONS = {"QC"} # Québec is Rent-Ka's territory class LouerCaConnector(BaseConnector): # Abstract base: empty source_id keeps it OUT of the connector registry. # Subclasses (rentals_ca) set their own source_id/transport/cities. source_id = "" request_delay = 0.5 home_url = HOME gql_url = GQL fallback_key = FALLBACK_KEY page_size = PAGE_SIZE max_per_city = MAX_PER_CITY cities: list[str] = [] def __init__(self) -> None: super().__init__() self.session.headers.update({ "Content-Type": "application/json", "Origin": self.home_url.rstrip("/"), "Referer": self.home_url, }) self._token = "" self._token_time = 0.0 self._api_key = self.fallback_key # -- auth ------------------------------------------------------------------ def _ensure_token(self) -> None: if self._token and time.time() - self._token_time < 2700: # ~45 min return try: home = self.get(self.home_url).text m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home) if m: self._api_key = m.group(1) except Exception: pass data = self._gql( "mutation($k:String!){acquireAuthInfo(credentials:{apiKey:$k}){jwt status}}", {"k": self._api_key}, auth=False) auth = (data or {}).get("acquireAuthInfo") or {} jwt = auth.get("jwt") if isinstance(jwt, str) and jwt.startswith("{"): jwt = json.loads(jwt) token = jwt.get("accessToken") if isinstance(jwt, dict) else jwt if not token: raise RuntimeError("Rentals.ca network: JWT handshake failed") self._token = token self._token_time = time.time() def _gql(self, query: str, variables: dict, auth: bool = True) -> dict: headers = {} if auth: self._ensure_token() headers["Authorization"] = f"Bearer {self._token}" resp = self.post(self.gql_url, data=json.dumps({"query": query, "variables": variables}), headers=headers) payload = resp.json() if payload.get("errors"): msg = payload["errors"][0].get("message", "") if "AUTH" in msg.upper() and auth: # expired token: retry once self._token = "" self._ensure_token() resp = self.post(self.gql_url, data=json.dumps({"query": query, "variables": variables}), headers={"Authorization": f"Bearer {self._token}"}) payload = resp.json() return payload.get("data") or {} # -- discovery --------------------------------------------------------------- def _city_id(self, slug: str) -> str | None: """Resolve a city slug to its City.id — any Canadian region except the excluded ones (Québec).""" d = self._gql( "query($v:String!){typeahead(value:$v){nodeType node{id ... on City" "{name path regionCode listingCount}}}}", {"v": slug.replace("-", " ")}) for it in d.get("typeahead") or []: node = it.get("node") or {} if node.get("path") == slug \ and node.get("regionCode") not in _EXCLUDED_REGIONS: return node.get("id") # otherwise the first proposed city outside the excluded regions for it in d.get("typeahead") or []: node = it.get("node") or {} if node.get("id") and node.get("regionCode") \ and node.get("regionCode") not in _EXCLUDED_REGIONS: return node.get("id") return None _LIST_FRAG = ("id name path location rentRange bedsRange bathsRange " "sizeRange type furnished petOptions amenities verified " "created modified") def _city_listings(self, city_id: str) -> list[dict]: query = ("query($id:ID!,$first:PositiveInt!,$after:String){node(id:$id)" "{... on City{rentalListings(first:$first,after:$after){" "meta{totalCount} pageInfo{hasNextPage endCursor} " "edges{node{" + self._LIST_FRAG + "}}}}}}") out, after = [], None while True: d = self._gql(query, {"id": city_id, "first": self.page_size, "after": after}) rl = ((d.get("node") or {}).get("rentalListings")) or {} edges = rl.get("edges") or [] out.extend(e["node"] for e in edges if e.get("node")) info = rl.get("pageInfo") or {} if not info.get("hasNextPage") or not edges: break if self.max_per_city and len(out) >= self.max_per_city: break after = info.get("endCursor") return out _DETAIL_FRAG = ("id name path location " "address{city{name regionCode} neighbourhood{name} " "postalCode street} description{plain} " "imagesCount images{scales} " "floorPlans{beds baths rent size availability furnished}") def _detail(self, gid: str) -> dict: d = self._gql("query($id:ID!){node(id:$id){... on RentalListing{" + self._DETAIL_FRAG + "}}}", {"id": gid}) return d.get("node") or {} # -- construction ---------------------------------------------------------- @staticmethod def _unit_type(beds) -> str: try: b = float(beds) except (TypeError, ValueError): return "" if b <= 0: return "Studio" n = int(b) if n >= 5: return "5+ bedrooms" return f"{n} bedroom" + ("s" if n > 1 else "") @staticmethod def _amenities(pairs) -> list[str]: out = [] for pair in pairs or []: val = pair[1] if isinstance(pair, list) and len(pair) > 1 else None label = _AMENITY_FR.get(val) if label and label not in out: out.append(label) return out def _images(self, node: dict) -> list[str]: imgs = [] for im in node.get("images") or []: scales = im.get("scales") if isinstance(scales, str): try: scales = json.loads(scales) except ValueError: scales = [] best = "" for sc in scales or []: if sc.get("name") in ("large", "medium") and sc.get("url"): best = sc["url"] if sc["name"] == "large": break if not best and scales: best = scales[0].get("url", "") if best and best not in imgs: imgs.append(best) return imgs @staticmethod def _numeric_id(gid: str) -> str: """« cmVudGFsbGlzdGluZzoxMTMyMTE2 » -> « 1132116 » (rentallisting:1132116).""" try: decoded = base64.b64decode(gid + "==").decode("utf-8", "ignore") m = re.search(r"(\d+)", decoded) if m: return m.group(1) except Exception: pass m = re.search(r"(\d+)", gid) return m.group(1) if m else gid def _card_listings(self, card: dict, node: dict, today: str, default_city: str = "", province: str = "ON") -> list[Listing]: """One list card + its detail node -> one listing per floor plan (or a single «from $» listing when there are no plans).""" gid = card.get("id") if not gid: return [] addr = node.get("address") or {} city = (addr.get("city") or {}).get("name") or default_city sector = (addr.get("neighbourhood") or {}).get("name") or "" loc = card.get("location") or [] lng, lat = (loc + [None, None])[:2] base_desc = ((node.get("description") or {}).get("plain") or "")[:6000] images = self._images(node) amenities = self._amenities(card.get("amenities")) common = dict( source=self.source_id, url=f"{self.home_url.rstrip('/')}/{card.get('path', '')}", address=addr.get("street") or "", sector=sector, city=city, province=province, description=base_desc, amenities=amenities, images=images, lat=lat, lng=lng, ) if addr.get("postalCode"): common["details"] = {"Postal code": addr["postalCode"]} out: list[Listing] = [] plans = node.get("floorPlans") or [] if not plans: # no detailed plan: one «from $» listing rng = card.get("rentRange") or [] price = rng[0] if rng else None beds = (card.get("bedsRange") or [None])[0] out.append(self._mk(common, gid, "", price, self._unit_type(beds), None, today, price_from=bool(rng))) return out for i, fp in enumerate(plans): avail = fp.get("availability") or {} adate = "now" if avail.get("now") else ( avail.get("date") or "")[:10] or None if adate and adate != "now" and adate <= today: adate = "now" lst = self._mk(common, gid, f"-{i}", fp.get("rent"), self._unit_type(fp.get("beds")), fp.get("size"), today, adate=adate) if fp.get("furnished") == "yes": lst.furnished = True out.append(lst) return out def fetch(self) -> list[Listing]: # the "detail" is a GraphQL request, not an HTML page: the cache's # fetch_html receives the gid and returns it as-is, parse_fn runs # the GraphQL query. cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS, key="v1", fetch_html=lambda gid: gid) out: list[Listing] = [] today = datetime.date.today().isoformat() try: for slug in self.cities: cid = self._city_id(slug) if not cid: continue for card in self._city_listings(cid): gid = card.get("id") if not gid: continue node = cache.get(gid, gid, lambda g: self._detail(g)) or {} region = (((node.get("address") or {}).get("city") or {}) .get("regionCode")) or "" if region in _EXCLUDED_REGIONS: continue out.extend(self._card_listings( card, node, today, default_city=slug.replace("-", " ").title(), province=region or "ON")) finally: cache.close() return out def _mk(self, common: dict, gid: str, suffix: str, price, unit_type, size, today, adate=None, price_from=False) -> Listing: num = self._numeric_id(gid) p = None try: p = float(price) if price is not None else None except (TypeError, ValueError): p = None details = dict(common.get("details") or {}) if price_from: details["price_from"] = True lst = Listing( **{k: v for k, v in common.items() if k != "details"}, external_id=f"{num}{suffix}", title=common.get("address") or "Rental unit", unit_type=unit_type, price=p, price_label=(("From " if price_from else "") + (f"${p:,.0f}/month" if p else "")), area_sqft=float(size) if size else None, availability_date=adate, availability=("Available now" if adate == "now" else f"Available {adate}" if adate else ""), details=details, ) return lst