# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/lesversants.py : Les Versants Mont-Tremblant (lesversants.com) — # agence immobilière de Mont-Tremblant dont la division location propose # ~16 maisons et condos en location saisonnière (Mont-Tremblant, # Lac-Supérieur, Mont-Blanc). # # Méthode : le sitemap.xml est un vestige statique (2 URLs), mais le thème # WordPress REAL_HOMES expose tout via l'API REST : # GET /fr/wp-json/wp/v2/property?property-status=79&per_page=100 # (79 = terme « À louer » de la taxonomie property-status). Chaque fiche # embarque property_meta REAL_HOMES_* : adresse, lat/lng, chambres, salles # de bain (formats « 3 + 1 »), superficie, galerie (sizes.large), prix. # ⚠️ PRIX SAISONNIER : le prix affiché est « Hiver 2026-27 | 18 500 $ » # (un forfait pour LA SAISON, pas à la nuit) → on le range dans # details.season_price et on laisse price_night/price_label vides pour ne # pas empoisonner parse_price_night(). # Taxonomies property-type (439 Condo à louer, 440/441 maisons), city et # mont_tremblant_sector résolues par une passe chacune. # # Réglage env : LOUKA_LESVERSANTS_LIMIT (nb max d'annonces, 0 = tout). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from ..schema import StListing from .base import StConnector API = "https://lesversants.com/fr/wp-json/wp/v2" STATUS_A_LOUER = 79 # terme « À louer » (property-status) # id property-type → type canonique _TYPES = {439: "Condo", 440: "Maison", 441: "Maison"} _TAG_RE = re.compile(r"<[^>]+>") def _strip_html(txt: str) -> str: return re.sub(r"\s+", " ", _TAG_RE.sub(" ", _html.unescape(txt or ""))).strip() def _rooms(v: str) -> float | None: """« 3 », « 3 + 1 », « 3.5 » → nombre (les « + N » sont additionnés).""" nums = re.findall(r"\d+(?:\.\d+)?", str(v or "")) return sum(float(n) for n in nums) if nums else None class LesVersants(StConnector): source_id = "lesversants" request_delay = 0.5 def _get_json(self, url: str): return self.get(url, headers={"Accept": "application/json"}).json() def _tax(self, name: str) -> dict[int, str]: try: terms = self._get_json(f"{API}/{name}?per_page=100" "&_fields=id,name") return {t["id"]: _html.unescape(t.get("name") or "").strip() for t in terms} except Exception: # noqa: BLE001 — libellés manquants ≠ blocage return {} # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_LESVERSANTS_LIMIT", "0") or 0) items = self._get_json(f"{API}/property?property-status=" f"{STATUS_A_LOUER}&per_page=100") if not isinstance(items, list): return [] if limit: items = items[:limit] types = self._tax("property-type") cities = self._tax("city") sectors = self._tax("mont_tremblant_sector") listings: list[StListing] = [] seen: set[str] = set() for it in items: pid = str(it.get("id") or "").strip() url = (it.get("link") or "").strip() title = _strip_html((it.get("title") or {}).get("rendered") or "") if not pid or pid in seen or not url or not title: continue seen.add(pid) meta = it.get("property_meta") or {} loc = meta.get("REAL_HOMES_property_location") or {} lat = float(loc["latitude"]) if loc.get("latitude") else None lng = float(loc["longitude"]) if loc.get("longitude") else None type_ids = it.get("property-type") or [] ptype = next((_TYPES[t] for t in type_ids if t in _TYPES), "") if not ptype: ptype = next((types[t] for t in type_ids if t in types), "") city_ids = it.get("city") or [] city = next((cities[c] for c in city_ids if c in cities), "Mont-Tremblant") sector_ids = it.get("mont_tremblant_sector") or [] sector = next((sectors[s] for s in sector_ids if s in sectors), "") # prix SAISONNIER (« Hiver 2026-27 | 18 500 $ ») → details prefix = (meta.get("REAL_HOMES_property_price_prefix") or "").strip() raw_price = re.sub(r"[^\d.]", "", str(meta.get("REAL_HOMES_property_price") or "")) season_price = "" if raw_price: season = prefix.rstrip("|").strip() season_price = (f"{season} : {raw_price} $" if season else f"{raw_price} $") size = (meta.get("REAL_HOMES_property_size") or "").strip() size_post = (meta.get("REAL_HOMES_property_size_postfix") or "").strip() images: list[str] = [] for ph in meta.get("REAL_HOMES_property_images") or []: sizes = (ph or {}).get("sizes") or {} u = ((sizes.get("1536x1536") or {}).get("url") or (sizes.get("large") or {}).get("url") or (sizes.get("medium_large") or {}).get("url") or "") if u.startswith("https://") and u not in images: images.append(u) if len(images) >= 15: break details = {k: v for k, v in { "season_price": season_price, "sector": sector, "size": f"{size} {size_post}".strip() if size else "", }.items() if v} listings.append(StListing( source=self.source_id, external_id=pid, url=url, title=title, property_type=ptype, address=(meta.get("REAL_HOMES_property_address") or "").strip(), city=city, region="Laurentides", bedrooms=_rooms(meta.get("REAL_HOMES_property_bedrooms")), bathrooms=_rooms(meta.get("REAL_HOMES_property_bathrooms")), description=_strip_html((it.get("content") or {}) .get("rendered") or "")[:3000], details=details, images=images, lat=lat, lng=lng, )) return listings