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/lesversants.py : Les Versants Mont-Tremblant (lesversants.com) —4# agence immobilière de Mont-Tremblant dont la division location propose5# ~16 maisons et condos en location saisonnière (Mont-Tremblant,6# Lac-Supérieur, Mont-Blanc).7#8# Méthode : le sitemap.xml est un vestige statique (2 URLs), mais le thème9# WordPress REAL_HOMES expose tout via l'API REST :10# GET /fr/wp-json/wp/v2/property?property-status=79&per_page=10011# (79 = terme « À louer » de la taxonomie property-status). Chaque fiche12# embarque property_meta REAL_HOMES_* : adresse, lat/lng, chambres, salles13# de bain (formats « 3 + 1 »), superficie, galerie (sizes.large), prix.14# ⚠️ PRIX SAISONNIER : le prix affiché est « Hiver 2026-27 | 18 500 $ »15# (un forfait pour LA SAISON, pas à la nuit) → on le range dans16# details.season_price et on laisse price_night/price_label vides pour ne17# pas empoisonner parse_price_night().18# Taxonomies property-type (439 Condo à louer, 440/441 maisons), city et19# mont_tremblant_sector résolues par une passe chacune.20#21# Réglage env : LOUKA_LESVERSANTS_LIMIT (nb max d'annonces, 0 = tout).22# -----------------------------------------------------------------------------23from __future__ import annotations2425import html as _html26import os27import re2829from ..schema import StListing30from .base import StConnector3132API = "https://lesversants.com/fr/wp-json/wp/v2"33STATUS_A_LOUER = 79 # terme « À louer » (property-status)3435# id property-type → type canonique36_TYPES = {439: "Condo", 440: "Maison", 441: "Maison"}3738_TAG_RE = re.compile(r"<[^>]+>")394041def _strip_html(txt: str) -> str:42 return re.sub(r"\s+", " ", _TAG_RE.sub(" ", _html.unescape(txt or ""))).strip()434445def _rooms(v: str) -> float | None:46 """« 3 », « 3 + 1 », « 3.5 » → nombre (les « + N » sont additionnés)."""47 nums = re.findall(r"\d+(?:\.\d+)?", str(v or ""))48 return sum(float(n) for n in nums) if nums else None495051class LesVersants(StConnector):52 source_id = "lesversants"53 request_delay = 0.55455 def _get_json(self, url: str):56 return self.get(url, headers={"Accept": "application/json"}).json()5758 def _tax(self, name: str) -> dict[int, str]:59 try:60 terms = self._get_json(f"{API}/{name}?per_page=100"61 "&_fields=id,name")62 return {t["id"]: _html.unescape(t.get("name") or "").strip()63 for t in terms}64 except Exception: # noqa: BLE001 — libellés manquants ≠ blocage65 return {}6667 # -- contrat --------------------------------------------------------------68 def fetch(self) -> list[StListing]:69 limit = int(os.environ.get("LOUKA_LESVERSANTS_LIMIT", "0") or 0)70 items = self._get_json(f"{API}/property?property-status="71 f"{STATUS_A_LOUER}&per_page=100")72 if not isinstance(items, list):73 return []74 if limit:75 items = items[:limit]7677 types = self._tax("property-type")78 cities = self._tax("city")79 sectors = self._tax("mont_tremblant_sector")8081 listings: list[StListing] = []82 seen: set[str] = set()83 for it in items:84 pid = str(it.get("id") or "").strip()85 url = (it.get("link") or "").strip()86 title = _strip_html((it.get("title") or {}).get("rendered") or "")87 if not pid or pid in seen or not url or not title:88 continue89 seen.add(pid)9091 meta = it.get("property_meta") or {}92 loc = meta.get("REAL_HOMES_property_location") or {}93 lat = float(loc["latitude"]) if loc.get("latitude") else None94 lng = float(loc["longitude"]) if loc.get("longitude") else None9596 type_ids = it.get("property-type") or []97 ptype = next((_TYPES[t] for t in type_ids if t in _TYPES), "")98 if not ptype:99 ptype = next((types[t] for t in type_ids if t in types), "")100101 city_ids = it.get("city") or []102 city = next((cities[c] for c in city_ids if c in cities),103 "Mont-Tremblant")104 sector_ids = it.get("mont_tremblant_sector") or []105 sector = next((sectors[s] for s in sector_ids if s in sectors), "")106107 # prix SAISONNIER (« Hiver 2026-27 | 18 500 $ ») → details108 prefix = (meta.get("REAL_HOMES_property_price_prefix") or "").strip()109 raw_price = re.sub(r"[^\d.]", "",110 str(meta.get("REAL_HOMES_property_price") or ""))111 season_price = ""112 if raw_price:113 season = prefix.rstrip("|").strip()114 season_price = (f"{season} : {raw_price} $" if season115 else f"{raw_price} $")116117 size = (meta.get("REAL_HOMES_property_size") or "").strip()118 size_post = (meta.get("REAL_HOMES_property_size_postfix")119 or "").strip()120121 images: list[str] = []122 for ph in meta.get("REAL_HOMES_property_images") or []:123 sizes = (ph or {}).get("sizes") or {}124 u = ((sizes.get("1536x1536") or {}).get("url")125 or (sizes.get("large") or {}).get("url")126 or (sizes.get("medium_large") or {}).get("url") or "")127 if u.startswith("https://") and u not in images:128 images.append(u)129 if len(images) >= 15:130 break131132 details = {k: v for k, v in {133 "season_price": season_price,134 "sector": sector,135 "size": f"{size} {size_post}".strip() if size else "",136 }.items() if v}137138 listings.append(StListing(139 source=self.source_id,140 external_id=pid,141 url=url,142 title=title,143 property_type=ptype,144 address=(meta.get("REAL_HOMES_property_address") or "").strip(),145 city=city,146 region="Laurentides",147 bedrooms=_rooms(meta.get("REAL_HOMES_property_bedrooms")),148 bathrooms=_rooms(meta.get("REAL_HOMES_property_bathrooms")),149 description=_strip_html((it.get("content") or {})150 .get("rendered") or "")[:3000],151 details=details,152 images=images,153 lat=lat,154 lng=lng,155 ))156 return listings157