Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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