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/louer_ca.py : Louer.ca — portail locatif 100 % Québec du réseau5# Rentals.ca. Accès par l'API GraphQL interne (https://louer.ca/graphql),6# rétro-conçue : clé publique `rentalsGqlKey` lue dans window.appconf de la7# page d'accueil, mutation `acquireAuthInfo` -> JWT (scalaire JSON contenant8# accessToken ~1 h + refreshToken), en-tête `Authorization: Bearer <access>`.9# La recherche passe par la ville : typeahead -> City.id, puis10# `node(id){... on City{ rentalListings(first,after) }}` (curseur Relay ;11# le champ rentalListings top-level renvoie 0). Le détail `node(id)` fournit12# adresse, description, galerie (images.rentals.ca) et les floorPlans13# (un type/prix par plan) -> une annonce Lou-Ka par plan d'étage.14# ⚠️ Réseau Rentals.ca : CGU interdisant l'extraction sans accord écrit.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import base6419import datetime20import json21import os22import re23import time2425from ..schema import Listing26from .base import BaseConnector27from . import _detailutil as du2829HOME = "https://louer.ca/"30GQL = "https://louer.ca/graphql"31FALLBACK_KEY = "ME8N-J3IX-At86-2yIi"3233# villes québécoises visées (slug interne Louer.ca) ; résolues via typeahead34CITIES = ["montreal", "quebec", "laval", "gatineau", "longueuil", "sherbrooke",35 "trois-rivieres", "levis", "terrebonne", "brossard", "saguenay",36 "drummondville", "granby", "saint-jean-sur-richelieu", "repentigny"]3738PAGE_SIZE = int(os.environ.get("LOUKA_LOUERCA_PAGE_SIZE", "50"))39DETAIL_LIMIT = int(os.environ.get("LOUKA_LOUERCA_DETAIL_LIMIT", "600"))40TTL_DAYS = float(os.environ.get("LOUKA_LOUERCA_TTL_DAYS", "5"))41MAX_PER_CITY = int(os.environ.get("LOUKA_LOUERCA_MAX_PER_CITY", "0")) # 0 = tout4243# amenity (catégorie, valeur) -> libellé FR affichable44_AMENITY_FR = {45 "laundry-facilities": "Buanderie", "in-suite-laundry": "Laveuse/sécheuse dans l'unité",46 "security-on-site": "Sécurité sur place", "storage-lockers": "Espace de rangement",47 "swimming-pool": "Piscine", "gym": "Salle d'entraînement", "elevator": "Ascenseur",48 "sauna": "Sauna", "on-site-staff": "Personnel sur place", "stove": "Cuisinière",49 "fridge": "Réfrigérateur", "balcony": "Balcon", "microwave": "Micro-ondes",50 "dishwasher": "Lave-vaisselle", "air-conditioning": "Air climatisé",51 "individual-thermostats": "Thermostats individuels", "heating": "Chauffage inclus",52 "water": "Eau incluse", "hydro-electricity": "Électricité incluse",53 "public-transit": "Transport en commun à proximité", "parking": "Stationnement",54 "no-smoking-allowed": "Non-fumeur",55}565758class LouerCaConnector(BaseConnector):59 source_id = "louer_ca"60 request_delay = 0.561 # points d'entrée du réseau Rentals.ca — surchargés par rentals_ca.py62 home_url = HOME63 gql_url = GQL64 site = "https://louer.ca"65 fallback_key = FALLBACK_KEY66 page_size = PAGE_SIZE67 max_per_city = MAX_PER_CITY6869 def __init__(self) -> None:70 super().__init__()71 self.session.headers.update({72 "Content-Type": "application/json",73 "Origin": self.site,74 "Referer": self.home_url,75 })76 self._token = ""77 self._token_time = 0.078 self._api_key = self.fallback_key7980 # -- auth ------------------------------------------------------------------81 def _ensure_token(self) -> None:82 if self._token and time.time() - self._token_time < 2700: # ~45 min83 return84 try:85 home = self.get(self.home_url).text86 m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home)87 if m:88 self._api_key = m.group(1)89 except Exception:90 pass91 data = self._gql(92 "mutation($k:String!){acquireAuthInfo(credentials:{apiKey:$k}){jwt status}}",93 {"k": self._api_key}, auth=False)94 auth = (data or {}).get("acquireAuthInfo") or {}95 jwt = auth.get("jwt")96 if isinstance(jwt, str) and jwt.startswith("{"):97 jwt = json.loads(jwt)98 token = jwt.get("accessToken") if isinstance(jwt, dict) else jwt99 if not token:100 raise RuntimeError("Louer.ca: handshake JWT échoué")101 self._token = token102 self._token_time = time.time()103104 def _gql(self, query: str, variables: dict, auth: bool = True) -> dict:105 headers = {}106 if auth:107 self._ensure_token()108 headers["Authorization"] = f"Bearer {self._token}"109 resp = self.post(self.gql_url, data=json.dumps({"query": query,110 "variables": variables}),111 headers=headers)112 payload = resp.json()113 if payload.get("errors"):114 msg = payload["errors"][0].get("message", "")115 if "AUTH" in msg.upper() and auth: # jeton expiré : on réessaie116 self._token = ""117 self._ensure_token()118 resp = self.post(self.gql_url,119 data=json.dumps({"query": query,120 "variables": variables}),121 headers={"Authorization": f"Bearer {self._token}"})122 payload = resp.json()123 return payload.get("data") or {}124125 # -- découverte ------------------------------------------------------------126 def _city_id(self, slug: str) -> str | None:127 d = self._gql(128 "query($v:String!){typeahead(value:$v){nodeType node{id ... on City"129 "{name path regionCode listingCount}}}}", {"v": slug.replace("-", " ")})130 for it in d.get("typeahead") or []:131 node = it.get("node") or {}132 if node.get("path") == slug and node.get("regionCode") == "QC":133 return node.get("id")134 # à défaut, première ville québécoise proposée135 for it in d.get("typeahead") or []:136 node = it.get("node") or {}137 if node.get("id") and node.get("regionCode") == "QC":138 return node.get("id")139 return None140141 _LIST_FRAG = ("id name path location rentRange bedsRange bathsRange "142 "sizeRange type furnished petOptions amenities verified "143 "created modified")144145 def _city_listings(self, city_id: str) -> list[dict]:146 query = ("query($id:ID!,$first:PositiveInt!,$after:String){node(id:$id)"147 "{... on City{rentalListings(first:$first,after:$after){"148 "meta{totalCount} pageInfo{hasNextPage endCursor} "149 "edges{node{" + self._LIST_FRAG + "}}}}}}")150 out, after = [], None151 while True:152 d = self._gql(query, {"id": city_id, "first": self.page_size,153 "after": after})154 rl = ((d.get("node") or {}).get("rentalListings")) or {}155 edges = rl.get("edges") or []156 out.extend(e["node"] for e in edges if e.get("node"))157 info = rl.get("pageInfo") or {}158 if not info.get("hasNextPage") or not edges:159 break160 if self.max_per_city and len(out) >= self.max_per_city:161 break162 after = info.get("endCursor")163 return out164165 _DETAIL_FRAG = ("id name path location "166 "address{city{name regionCode} neighbourhood{name} "167 "postalCode street} description{plain} "168 "imagesCount images{scales} tours{name type refId} "169 "floorPlans{beds baths rent size availability furnished "170 "tours{name type refId}}")171172 def _detail(self, gid: str) -> dict:173 d = self._gql("query($id:ID!){node(id:$id){... on RentalListing{"174 + self._DETAIL_FRAG + "}}}", {"id": gid})175 return d.get("node") or {}176177 # -- construction ----------------------------------------------------------178 @staticmethod179 def _unit_type(beds) -> str:180 try:181 b = float(beds)182 except (TypeError, ValueError):183 return ""184 if b <= 0:185 return "Studio"186 n = int(b) + 2 # chambres -> pièces et demie187 return "6½+" if n >= 6 else f"{n}½"188189 @staticmethod190 def _amenities(pairs) -> list[str]:191 out = []192 for pair in pairs or []:193 val = pair[1] if isinstance(pair, list) and len(pair) > 1 else None194 label = _AMENITY_FR.get(val)195 if label and label not in out:196 out.append(label)197 return out198199 def _images(self, node: dict) -> list[str]:200 imgs = []201 for im in node.get("images") or []:202 scales = im.get("scales")203 if isinstance(scales, str):204 try:205 scales = json.loads(scales)206 except ValueError:207 scales = []208 best = ""209 for sc in scales or []:210 if sc.get("name") in ("large", "medium") and sc.get("url"):211 best = sc["url"]212 if sc["name"] == "large":213 break214 if not best and scales:215 best = scales[0].get("url", "")216 if best and best not in imgs:217 imgs.append(best)218 return imgs219220 @staticmethod221 def _count(v) -> float | None:222 """beds/baths GraphQL -> float (0 = studio) ; None si inconnu."""223 try:224 f = float(v)225 except (TypeError, ValueError):226 return None227 return f if 0 <= f <= 20 else None228229 @staticmethod230 def _tour_url(tours) -> str | None:231 """URL de visite virtuelle depuis les `tours` GraphQL du réseau232 Rentals.ca : refId Matterport/YouTube (ou URL complète). La visite233 interactive prime sur la simple vidéo."""234 video = None235 for t in tours or []:236 ref = str(t.get("refId") or "").strip()237 typ = t.get("type") or ""238 if not ref:239 continue240 if ref.startswith("http"):241 url = ref242 elif "matterport" in typ:243 url = f"https://my.matterport.com/show/?m={ref}"244 elif "youtube" in typ:245 url = f"https://www.youtube.com/watch?v={ref}"246 else:247 continue248 if "interactive" in typ or "matterport" in typ:249 return url250 video = video or url251 return video252253 @staticmethod254 def _numeric_id(gid: str) -> str:255 """« cmVudGFsbGlzdGluZzoxMTMyMTE2 » -> « 1132116 » (rentallisting:1132116)."""256 try:257 decoded = base64.b64decode(gid + "==").decode("utf-8", "ignore")258 m = re.search(r"(\d+)", decoded)259 if m:260 return m.group(1)261 except Exception:262 pass263 m = re.search(r"(\d+)", gid)264 return m.group(1) if m else gid265266 def _card_listings(self, card: dict, node: dict, today: str,267 default_city: str = "") -> list[Listing]:268 """Annonces Lou-Ka d'une carte liste + son nœud détail GraphQL269 (une par plan d'étage). Partagé avec rentals_ca.py."""270 out: list[Listing] = []271 gid = card.get("id")272 addr = node.get("address") or {}273 city = (addr.get("city") or {}).get("name") or default_city274 sector = (addr.get("neighbourhood") or {}).get("name") or ""275 loc = card.get("location") or []276 lng, lat = (loc + [None, None])[:2]277 base_desc = ((node.get("description") or {}).get("plain")278 or "")[:6000]279 images = self._images(node)280 amenities = self._amenities(card.get("amenities"))281 common = dict(282 source=self.source_id, url=f"{self.site}/{card.get('path','')}",283 address=addr.get("street") or "", sector=sector, city=city,284 description=base_desc, amenities=amenities, images=images,285 lat=lat, lng=lng,286 )287 base_details: dict = {}288 if addr.get("postalCode"):289 base_details["Code postal"] = addr["postalCode"]290 tour = self._tour_url(node.get("tours"))291 if tour:292 base_details["virtual_tour"] = tour293 if base_details:294 common["details"] = base_details295296 plans = node.get("floorPlans") or []297 if not plans:298 # pas de plan détaillé : une annonce « à partir de »299 rng = card.get("rentRange") or []300 price = rng[0] if rng else None301 beds = (card.get("bedsRange") or [None])[0]302 lst = self._mk(common, gid, "", price,303 self._unit_type(beds), None, today,304 price_from=bool(rng))305 lst.bedrooms = self._count(beds)306 lst.bathrooms = self._count(307 (card.get("bathsRange") or [None])[0])308 out.append(lst)309 return out310 for i, fp in enumerate(plans):311 avail = fp.get("availability") or {}312 adate = "now" if avail.get("now") else (313 avail.get("date") or "")[:10] or None314 if adate and adate != "now" and adate <= today:315 adate = "now"316 lst = self._mk(common, gid, f"-{i}", fp.get("rent"),317 self._unit_type(fp.get("beds")),318 fp.get("size"), today, adate=adate)319 lst.bedrooms = self._count(fp.get("beds"))320 lst.bathrooms = self._count(fp.get("baths"))321 fp_tour = self._tour_url(fp.get("tours"))322 if fp_tour: # la visite du plan précis bat celle de l'immeuble323 lst.details["virtual_tour"] = fp_tour324 if fp.get("furnished") == "yes":325 lst.furnished = True326 out.append(lst)327 return out328329 def fetch(self) -> list[Listing]:330 # le « détail » est une requête GraphQL, pas une page HTML : le331 # fetch_html du cache reçoit le gid et le renvoie tel quel, parse_fn332 # exécute la requête GraphQL.333 # clé v2 : force le re-téléchargement progressif des détails pour334 # capter tours{} (visites virtuelles) et floorPlans.baths ajoutés en335 # vague 2 — les payloads v1 périmés restent utilisés en attendant336 cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,337 key="v2", fetch_html=lambda gid: gid)338 out: list[Listing] = []339 today = datetime.date.today().isoformat()340 try:341 for slug in CITIES:342 cid = self._city_id(slug)343 if not cid:344 continue345 for card in self._city_listings(cid):346 gid = card.get("id")347 if not gid:348 continue349 node = cache.get(gid, gid,350 lambda g: self._detail(g)) or {}351 out.extend(self._card_listings(card, node, today,352 default_city=slug.title()))353 finally:354 cache.close()355 return out356357 def _mk(self, common: dict, gid: str, suffix: str, price, unit_type,358 size, today, adate=None, price_from=False) -> Listing:359 num = self._numeric_id(gid)360 p = None361 try:362 p = float(price) if price is not None else None363 except (TypeError, ValueError):364 p = None365 details = dict(common.get("details") or {})366 if price_from:367 details["price_from"] = True368 lst = Listing(369 **{k: v for k, v in common.items() if k != "details"},370 external_id=f"{num}{suffix}",371 title=common.get("address") or "Logement à louer",372 unit_type=unit_type,373 price=p,374 price_label=(("À partir de " if price_from else "")375 + (f"{p:,.0f} $/mois".replace(",", " ") if p else "")),376 area_sqft=float(size) if size else None,377 availability_date=adate,378 availability=("Libre immédiatement" if adate == "now"379 else f"Libre le {adate}" if adate else ""),380 details=details,381 )382 return lst383