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/tremblantliving.py : Tremblant Living (tremblantliving.com →4# tremblantliving.ca) — agence de chalets et condos de luxe à Mont-Tremblant5# (~35 propriétés, moteur Streamline VRS sur WordPress).6#7# Méthode : property-sitemap.xml (~37 fiches /property/ et /rental/, lastmod8# = clé du cache détail). Chaque page détail embarque un JSON-LD schema.org9# VacationRental complet : identifiant Streamline (unit_id), chambres,10# salles de bain, capacité, note/avis, adresse, lat/lng, photos (galerie11# streamlinevrs.com). La description longue vient du bloc12# <div class="description block">, les commodités des <li class="amenity_item">.13# PRIX : pas de prix statique dans le HTML, mais l'API Streamline passe par14# admin-ajax.php avec action=streamlinecore-api-request et le corps JSON15# {methodName, params} DANS LA QUERY STRING (format du plugin Angular) —16# contrairement au POST classique, ce format n'est pas bloqué par17# Cloudflare. GetPropertyRatesRawData(unit_id) retourne la grille des18# tarifs saisonniers ($/nuit) → price_night = minimum des périodes19# courantes/futures (« à partir de »). Rafraîchi à chaque run (37 appels).20# Les /monthly-rentals/ (units-sitemap.xml) sont du long terme : ignorés.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import datetime as _dt25import html as _html26import json27import os28import re29import sys30from urllib.parse import urlencode3132from ..schema import StListing33from .base import StConnector3435SITE = "https://www.tremblantliving.ca"36SITEMAP = SITE + "/property-sitemap.xml"37AJAX = SITE + "/wp-admin/admin-ajax.php"3839# type déduit du nom de la fiche (agence ~100 % chalets et condos)40_TYPE_HINTS = [41 ("penthouse", "Condo"), ("condo", "Condo"), ("appartement", "Appartement"),42 ("apartment", "Appartement"), ("loft", "Loft"), ("studio", "Studio"),43 ("estate", "Maison"), ("maison", "Maison"), ("house", "Maison"),44 ("villa", "Maison"), ("chalet", "Chalet"), ("cottage", "Chalet"),45 ("cabin", "Chalet"), ("lodge", "Chalet"),46]4748_TAG_RE = re.compile(r"<[^>]+>")495051def _text(fragment: str) -> str:52 return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()535455def _f(v) -> float | None:56 try:57 return float(v) if v is not None else None58 except (TypeError, ValueError):59 return None606162class TremblantLiving(StConnector):63 source_id = "tremblant_living"6465 # -- API Streamline (via admin-ajax, JSON en query string) ----------------66 def _api(self, method: str, params: dict) -> dict:67 req = json.dumps({"methodName": method, "params": params},68 separators=(",", ":"))69 q = urlencode({"action": "streamlinecore-api-request", "params": req})70 resp = self.post(f"{AJAX}?{q}",71 headers={"Content-Type": "application/json"})72 return resp.json()7374 def _price_from_rates(self, unit_id: str) -> tuple[float | None, str]:75 """Prix « à partir de » = minimum $/nuit des périodes tarifaires76 courantes et futures (GetPropertyRatesRawData). Jamais mis en cache :77 les tarifs saisonniers bougent sans que la page change."""78 data = self._api("GetPropertyRatesRawData",79 {"unit_id": int(unit_id)}).get("data") or {}80 rates = data.get("rates") or []81 today = _dt.date.today()82 prices: list[float] = []83 for r in rates if isinstance(rates, list) else [rates]:84 try:85 end = _dt.datetime.strptime(86 str(r.get("period_end") or ""), "%m/%d/%Y").date()87 except ValueError:88 end = today # période sans date : on la garde89 if end < today:90 continue # saison passée91 for k in ("daily_first_interval_price",92 "daily_second_interval_price"):93 m = re.search(r"(\d[\d,]*(?:\.\d+)?)", str(r.get(k) or ""))94 if m:95 v = float(m.group(1).replace(",", ""))96 if 20 <= v <= 20000:97 prices.append(v)98 if not prices:99 return None, ""100 mn = min(prices)101 mn = int(mn) if mn == int(mn) else mn102 return float(mn), f"à partir de {mn} $ / nuit"103104 # -- page détail --------------------------------------------------------105 def _detail(self, url: str) -> dict:106 h = self.get(url).text107 d: dict = {}108109 for block in re.findall(r'<script type="application/ld\+json"[^>]*>'110 r"(.*?)</script>", h, re.S):111 try:112 ld = json.loads(block)113 except ValueError:114 continue115 if ld.get("@type") == "VacationRental":116 d["ld"] = ld117 break118119 # description longue : <div class="description block"><article>…120 m = re.search(r'(?s)<div class="description block[^"]*"[^>]*>.*?'121 r"<article[^>]*>(.*?)</article>", h)122 if m:123 texte = re.sub(r"<br\s*/?>", "\n", m.group(1))124 texte = _html.unescape(_TAG_RE.sub(" ", texte))125 texte = re.sub(r"[ \t]+", " ", texte)126 texte = re.sub(r"\n\s+", "\n", texte).strip()127 d["description"] = texte[:5000]128129 # commodités : <li class="amenity_item"> avec coche (les entêtes de130 # catégorie sont des <li> en gras sans icône fa-check)131 amen: list[str] = []132 for li in re.findall(r'(?s)<li class="amenity_item"[^>]*>(.*?)</li>', h):133 if "fa-check" not in li:134 continue135 t = _text(li)136 if t and t not in amen:137 amen.append(t)138 if amen:139 d["amenities"] = amen140 return d141142 # -- contrat ------------------------------------------------------------143 def fetch(self) -> list[StListing]:144 limit = int(os.environ.get("LOUKA_TREMBLANT_LIMIT", "0") or 0)145 xml = self.get(SITEMAP).text146 entries = re.findall(r"(?s)<url>\s*<loc>([^<]+)</loc>"147 r"(?:\s*<lastmod>([^<]*)</lastmod>)?", xml)148149 listings: list[StListing] = []150 vus: set[str] = set()151 for url, lastmod in entries:152 parts = [p for p in url.split("/") if p]153 # …/property/<slug>/ ou …/rental/<slug>/ (les /monthly-rentals/154 # sont dans units-sitemap.xml : long terme, hors mandat)155 if len(parts) < 4 or parts[-2] not in ("property", "rental"):156 continue157 slug = parts[-1]158 if slug in vus:159 continue160 vus.add(slug)161162 det = self.detail(slug, lastmod or "v1",163 lambda u=url: self._detail(u))164 ld = det.get("ld") or {}165 if not ld:166 continue167 place = ld.get("containsPlace") or {}168 addr = ld.get("address") or {}169 agg = ld.get("aggregateRating") or {}170 occupancy = (place.get("occupancy") or {}).get("value")171172 title = _text(str(ld.get("name") or slug))173 hay = f"{title} {slug}".lower()174 ptype = next((canon for needle, canon in _TYPE_HINTS175 if needle in hay), "Chalet")176177 amen = det.get("amenities") or []178 pets = "oui" if any("pet friendly" in a.lower()179 for a in amen) else None180181 imgs = ld.get("image") or []182 if isinstance(imgs, str):183 imgs = [imgs]184 reviews = agg.get("reviewCount")185186 # tarif « à partir de » via l'API Streamline (hors cache détail)187 price_night, price_label = None, ""188 unit_id = str(ld.get("identifier") or "")189 if unit_id.isdigit():190 try:191 price_night, price_label = self._price_from_rates(unit_id)192 except Exception as exc: # tarif manquant ≠ annonce perdue193 print(f"[tremblant_living] tarifs {unit_id} : {exc}",194 file=sys.stderr)195196 listings.append(StListing(197 source=self.source_id,198 external_id=str(ld.get("identifier") or slug),199 url=url,200 title=title,201 property_type=ptype,202 address=_text(str(addr.get("streetAddress") or "")),203 city=_text(str(addr.get("addressLocality") or "Mont-Tremblant")),204 region="Laurentides",205 price_night=price_night,206 price_label=price_label,207 capacity=_f(occupancy),208 bedrooms=_f(place.get("numberOfBedrooms")),209 bathrooms=_f(place.get("numberOfBathroomsTotal")),210 pets=pets,211 rating=_f(agg.get("ratingValue")),212 reviews=int(reviews) if reviews else None,213 description=det.get("description")214 or _text(str(ld.get("description") or "")),215 amenities=amen,216 details={"postal_code": addr.get("postalCode") or ""},217 images=[u for u in imgs if isinstance(u, str)218 and u.startswith("https://")][:20],219 lat=_f(ld.get("latitude")),220 lng=_f(ld.get("longitude")),221 ))222 if limit and len(listings) >= limit:223 break224 return listings225