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%
9.0 KB · 216 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/rvmt.py : Rendez-vous Mont-Tremblant (rvmt.com) — agence de4#   gestion locative de la station Mont-Tremblant, ~108 condos, maisons et5#   maisons de ville (Plateau, Algonquin, Étoile du Matin, etc.).6#7# Méthode : le site (Nuxt 3) renvoie systématiquement 429 en direct →8#   UN SEUL appel Scrapfly (sans render_js) sur une page de complexe9#   (/fr/algonquin — la page liste n'embarque PAS les taxonomies, la page10#   complexe si). Tout l'inventaire (les 108 unités) vit dans le11#   <script id="__NUXT_DATA__"> au format devalue : un tableau plat où les12#   valeurs des dicts/listes sont des INDEX entiers vers d'autres cases13#   (wrappers ["ShallowReactive", n] à déréférencer).14#   On y trouve :15#     - le nœud racine {units-rvmt: [108 unités], features-rvmt, beds-rvmt,16#       rooms-rvmt, areas-rvmt, amenities-rvmt} — chaque unité est complète :17#       code (external_id stable, ex. PLA214-03), adresse+lat/lng, chambres,18#       sdb, occupancy, pets_allowed, rental_license (CITQ), min/max_price19#       ($/nuit CAD), description fr, galerie Cloudinary, features/amenities20#       (ids → libellés fr via les taxonomies), lits par pièce ;21#     - le nœud site-config {pages, nav, slug} dont les pages22#       data == {"id": <mongo-id du complexe>} donnent l'URL du complexe →23#       URL détail = /fr/<complexe>/<slug(nom)> (2 unités non mappées24#       retombent sur la page liste).25#26# Réglage env : LOUKA_RVMT_LIMIT (nb max d'annonces, 0 = tout).27# -----------------------------------------------------------------------------28from __future__ import annotations2930import html as _html31import json32import os33import re34import unicodedata3536from ..schema import StListing37from .base import StConnector3839LIST_URL = "https://www.rvmt.com/fr/condos-mont-tremblant"40PAYLOAD_URL = "https://www.rvmt.com/fr/algonquin"  # payload complet (taxos)4142# tag pt-* → type canonique43_TYPES = {"pt-condo": "Condo", "pt-townhome": "Maison",44          "pt-private-home": "Maison", "pt-hotel": "Condo"}4546_WRAPPERS = ("ShallowReactive", "Reactive", "Ref", "ShallowRef", "EmptyRef",47             "EmptyShallowRef")484950def _slugify(name: str) -> str:51    s = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()52    return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", s.lower())).strip("-")535455def _fr(node) -> str:56    """Nœud {text: {fr, en}} ou {fr, en} → libellé français (repli anglais)."""57    if isinstance(node, dict):58        t = node.get("text") if isinstance(node.get("text"), dict) else node59        if isinstance(t, dict):60            return _html.unescape(str(t.get("fr") or t.get("en") or "")).strip()61    return ""626364class Rvmt(StConnector):65    source_id = "rvmt"6667    # -- devalue --------------------------------------------------------------68    def _resolve(self, arr: list, i, seen: frozenset = frozenset()):69        v = arr[i] if isinstance(i, int) and 0 <= i < len(arr) else i70        if isinstance(v, dict):71            if i in seen:72                return None73            return {k: self._resolve(arr, x, seen | {i}) for k, x in v.items()}74        if isinstance(v, list):75            if v and v[0] in _WRAPPERS:76                return self._resolve(arr, v[1], seen) if len(v) > 1 else None77            if i in seen:78                return None79            return [self._resolve(arr, x, seen | {i}) for x in v]80        return v8182    # -- contrat --------------------------------------------------------------83    def fetch(self) -> list[StListing]:84        limit = int(os.environ.get("LOUKA_RVMT_LIMIT", "0") or 0)85        h = self.get_scrapfly(PAYLOAD_URL, render_js=False)86        m = re.search(r'(?s)<script[^>]*id="__NUXT_DATA__"[^>]*>(.*?)</script>',87                      h)88        if not m:89            return []90        arr = json.loads(m.group(1))9192        # racine = le dict qui contient unités ET taxonomies (une page liste93        # n'aurait que units-rvmt : on garde le nœud le plus complet)94        root = None95        site_cfg = None96        fallback_i = None97        for i, v in enumerate(arr):98            if not isinstance(v, dict):99                continue100            if root is None and "units-rvmt" in v:101                if "features-rvmt" in v:102                    root = self._resolve(arr, i)103                elif fallback_i is None:104                    fallback_i = i105            elif site_cfg is None and {"pages", "nav", "slug"} <= set(v):106                site_cfg = self._resolve(arr, i)107            if root is not None and site_cfg is not None:108                break109        if root is None and fallback_i is not None:110            root = self._resolve(arr, fallback_i)111        if not root:112            return []113114        units = root.get("units-rvmt") or []115        tax = {name: {t.get("value"): _fr(t)116                      for t in (root.get(f"{name}-rvmt") or [])117                      if isinstance(t, dict)}118               for name in ("features", "beds", "rooms", "areas", "amenities")}119120        # id de complexe → slug d'URL (pages du site-config avec data={id})121        complexes: dict[str, str] = {}122        for p in (site_cfg or {}).get("pages") or []:123            data = p.get("data") if isinstance(p, dict) else None124            if isinstance(data, dict) and set(data) == {"id"} and p.get("url"):125                complexes[str(data["id"])] = str(p["url"]).strip("/")126127        listings: list[StListing] = []128        vus: set[str] = set()129        for u in units:130            if not isinstance(u, dict):131                continue132            code = str(u.get("code") or "").strip()133            name = _html.unescape(str(u.get("name") or "")).strip()134            if not code or code in vus or not name:135                continue136            vus.add(code)137138            comp = complexes.get(str(u.get("complex") or ""))139            url = (f"https://www.rvmt.com/fr/{comp}/{_slugify(name)}"140                   if comp else LIST_URL)141142            addr = u.get("address") or {}143            tags = u.get("tags") or []144            ptype = next((_TYPES[t] for t in tags if t in _TYPES), "Condo")145            area = tax["areas"].get(u.get("area")) or ""146147            # lits : somme des quantités des pièces déclarées148            beds = 0149            for room in u.get("rooms") or []:150                for b in (room or {}).get("beds") or []:151                    try:152                        beds += int(b.get("quantity") or 0)153                    except (TypeError, ValueError):154                        pass155156            amen: list[str] = []157            for f in u.get("features") or []:158                t = tax["features"].get(f)159                if t and t not in amen:160                    amen.append(t)161            for a in u.get("amenities") or []:162                t = tax["amenities"].get((a or {}).get("amenity"))163                if t and t not in amen:164                    amen.append(t)165166            desc = u.get("short_description") or {}167            description = _html.unescape(str(desc.get("fr")168                                             or desc.get("en") or "")).strip()169170            price = u.get("min_price")171            price = float(price) if isinstance(price, (int, float)) \172                and 20 <= price <= 20000 else None173            price_label = f"À partir de {price:g} $ / nuit" if price else ""174175            gallery = [g for g in (u.get("gallery") or [])176                       if isinstance(g, str) and g.startswith("https://")]177178            details = {k: v for k, v in {179                "area": area,180                "complex": comp or "",181                "max_price_night": u.get("max_price"),182                "wheelchair_accessible": bool(u.get("wheelchair_accessible")),183            }.items() if v}184185            line1 = str(addr.get("line_1") or "").strip()186            apt = str(addr.get("apt") or "").strip()187188            listings.append(StListing(189                source=self.source_id,190                external_id=code,191                url=url,192                title=name,193                property_type=ptype,194                address=f"{line1}, app. {apt}" if line1 and apt else line1,195                city=str(addr.get("city") or "Mont-Tremblant").strip(),196                region="Laurentides",197                price_night=price,198                price_label=price_label,199                capacity=float(u["occupancy"]) if u.get("occupancy") else None,200                bedrooms=float(u["bedrooms"]) if u.get("bedrooms") is not None201                         else None,202                beds=float(beds) if beds else None,203                bathrooms=float(u["bathrooms"]) if u.get("bathrooms") else None,204                pets="oui" if u.get("pets_allowed") else "non",205                citq=str(u.get("rental_license") or "").strip(),206                description=description[:5000],207                amenities=amen,208                details=details,209                images=gallery[:20],210                lat=addr.get("latitude"),211                lng=addr.get("longitude"),212            ))213            if limit and len(listings) >= limit:214                break215        return listings216