# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/tremblantliving.py : Tremblant Living (tremblantliving.com → # tremblantliving.ca) — agence de chalets et condos de luxe à Mont-Tremblant # (~35 propriétés, moteur Streamline VRS sur WordPress). # # Méthode : property-sitemap.xml (~37 fiches /property/ et /rental/, lastmod # = clé du cache détail). Chaque page détail embarque un JSON-LD schema.org # VacationRental complet : identifiant Streamline (unit_id), chambres, # salles de bain, capacité, note/avis, adresse, lat/lng, photos (galerie # streamlinevrs.com). La description longue vient du bloc #
, les commodités des
  • . # PRIX : pas de prix statique dans le HTML, mais l'API Streamline passe par # admin-ajax.php avec action=streamlinecore-api-request et le corps JSON # {methodName, params} DANS LA QUERY STRING (format du plugin Angular) — # contrairement au POST classique, ce format n'est pas bloqué par # Cloudflare. GetPropertyRatesRawData(unit_id) retourne la grille des # tarifs saisonniers ($/nuit) → price_night = minimum des périodes # courantes/futures (« à partir de »). Rafraîchi à chaque run (37 appels). # Les /monthly-rentals/ (units-sitemap.xml) sont du long terme : ignorés. # ----------------------------------------------------------------------------- from __future__ import annotations import datetime as _dt import html as _html import json import os import re import sys from urllib.parse import urlencode from ..schema import StListing from .base import StConnector SITE = "https://www.tremblantliving.ca" SITEMAP = SITE + "/property-sitemap.xml" AJAX = SITE + "/wp-admin/admin-ajax.php" # type déduit du nom de la fiche (agence ~100 % chalets et condos) _TYPE_HINTS = [ ("penthouse", "Condo"), ("condo", "Condo"), ("appartement", "Appartement"), ("apartment", "Appartement"), ("loft", "Loft"), ("studio", "Studio"), ("estate", "Maison"), ("maison", "Maison"), ("house", "Maison"), ("villa", "Maison"), ("chalet", "Chalet"), ("cottage", "Chalet"), ("cabin", "Chalet"), ("lodge", "Chalet"), ] _TAG_RE = re.compile(r"<[^>]+>") def _text(fragment: str) -> str: return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip() def _f(v) -> float | None: try: return float(v) if v is not None else None except (TypeError, ValueError): return None class TremblantLiving(StConnector): source_id = "tremblant_living" # -- API Streamline (via admin-ajax, JSON en query string) ---------------- def _api(self, method: str, params: dict) -> dict: req = json.dumps({"methodName": method, "params": params}, separators=(",", ":")) q = urlencode({"action": "streamlinecore-api-request", "params": req}) resp = self.post(f"{AJAX}?{q}", headers={"Content-Type": "application/json"}) return resp.json() def _price_from_rates(self, unit_id: str) -> tuple[float | None, str]: """Prix « à partir de » = minimum $/nuit des périodes tarifaires courantes et futures (GetPropertyRatesRawData). Jamais mis en cache : les tarifs saisonniers bougent sans que la page change.""" data = self._api("GetPropertyRatesRawData", {"unit_id": int(unit_id)}).get("data") or {} rates = data.get("rates") or [] today = _dt.date.today() prices: list[float] = [] for r in rates if isinstance(rates, list) else [rates]: try: end = _dt.datetime.strptime( str(r.get("period_end") or ""), "%m/%d/%Y").date() except ValueError: end = today # période sans date : on la garde if end < today: continue # saison passée for k in ("daily_first_interval_price", "daily_second_interval_price"): m = re.search(r"(\d[\d,]*(?:\.\d+)?)", str(r.get(k) or "")) if m: v = float(m.group(1).replace(",", "")) if 20 <= v <= 20000: prices.append(v) if not prices: return None, "" mn = min(prices) mn = int(mn) if mn == int(mn) else mn return float(mn), f"à partir de {mn} $ / nuit" # -- page détail -------------------------------------------------------- def _detail(self, url: str) -> dict: h = self.get(url).text d: dict = {} for block in re.findall(r'", h, re.S): try: ld = json.loads(block) except ValueError: continue if ld.get("@type") == "VacationRental": d["ld"] = ld break # description longue :
    … m = re.search(r'(?s)
    ]*>.*?' r"]*>(.*?)
    ", h) if m: texte = re.sub(r"", "\n", m.group(1)) texte = _html.unescape(_TAG_RE.sub(" ", texte)) texte = re.sub(r"[ \t]+", " ", texte) texte = re.sub(r"\n\s+", "\n", texte).strip() d["description"] = texte[:5000] # commodités :
  • avec coche (les entêtes de # catégorie sont des
  • en gras sans icône fa-check) amen: list[str] = [] for li in re.findall(r'(?s)
  • ]*>(.*?)
  • ', h): if "fa-check" not in li: continue t = _text(li) if t and t not in amen: amen.append(t) if amen: d["amenities"] = amen return d # -- contrat ------------------------------------------------------------ def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_TREMBLANT_LIMIT", "0") or 0) xml = self.get(SITEMAP).text entries = re.findall(r"(?s)\s*([^<]+)" r"(?:\s*([^<]*))?", xml) listings: list[StListing] = [] vus: set[str] = set() for url, lastmod in entries: parts = [p for p in url.split("/") if p] # …/property// ou …/rental// (les /monthly-rentals/ # sont dans units-sitemap.xml : long terme, hors mandat) if len(parts) < 4 or parts[-2] not in ("property", "rental"): continue slug = parts[-1] if slug in vus: continue vus.add(slug) det = self.detail(slug, lastmod or "v1", lambda u=url: self._detail(u)) ld = det.get("ld") or {} if not ld: continue place = ld.get("containsPlace") or {} addr = ld.get("address") or {} agg = ld.get("aggregateRating") or {} occupancy = (place.get("occupancy") or {}).get("value") title = _text(str(ld.get("name") or slug)) hay = f"{title} {slug}".lower() ptype = next((canon for needle, canon in _TYPE_HINTS if needle in hay), "Chalet") amen = det.get("amenities") or [] pets = "oui" if any("pet friendly" in a.lower() for a in amen) else None imgs = ld.get("image") or [] if isinstance(imgs, str): imgs = [imgs] reviews = agg.get("reviewCount") # tarif « à partir de » via l'API Streamline (hors cache détail) price_night, price_label = None, "" unit_id = str(ld.get("identifier") or "") if unit_id.isdigit(): try: price_night, price_label = self._price_from_rates(unit_id) except Exception as exc: # tarif manquant ≠ annonce perdue print(f"[tremblant_living] tarifs {unit_id} : {exc}", file=sys.stderr) listings.append(StListing( source=self.source_id, external_id=str(ld.get("identifier") or slug), url=url, title=title, property_type=ptype, address=_text(str(addr.get("streetAddress") or "")), city=_text(str(addr.get("addressLocality") or "Mont-Tremblant")), region="Laurentides", price_night=price_night, price_label=price_label, capacity=_f(occupancy), bedrooms=_f(place.get("numberOfBedrooms")), bathrooms=_f(place.get("numberOfBathroomsTotal")), pets=pets, rating=_f(agg.get("ratingValue")), reviews=int(reviews) if reviews else None, description=det.get("description") or _text(str(ld.get("description") or "")), amenities=amen, details={"postal_code": addr.get("postalCode") or ""}, images=[u for u in imgs if isinstance(u, str) and u.startswith("https://")][:20], lat=_f(ld.get("latitude")), lng=_f(ld.get("longitude")), )) if limit and len(listings) >= limit: break return listings