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%
16.2 KB · 383 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/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