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/monsieurchalets.py : MonsieurChalets (monsieurchalets.com)4#5# Plateforme québécoise de location de chalets (~650 propriétés, 17 régions).6# Méthode (aucun anti-bot, tout en JSON) :7# 1. LISTE : POST https://api.monsieurchalets.com/api/v1/public/search/properties8# {"limit": 200, "page": N} → {properties: […], total, current_page}.9# Chaque item contient déjà prix (basic_pricing), capacité, chambres,10# salles de bain, lits, adresse complète, lat/lng, photos CloudFront,11# note/avis et breadcrumbs (région/ville touristique).12# 2. DÉTAIL (cache self.detail) : le site est un Next.js « pages router » —13# GET /_next/data/<buildId>/chalets-a-louer/<...chemin>/<handle>.json14# → pageProps.property : description_fr, amenities, registrations (CITQ),15# rules (pets_allowed…). Le buildId est lu dans le __NEXT_DATA__ de16# l'accueil et rafraîchi si l'endpoint répond 404 (déploiement du site).17# ⚠️ PIÈGE : le chemin de la fiche = breadcrumbs s'ils sont présents,18# sinon <administrative_area>/<city> slugifiés (logique reproduite du19# bundle _app du site) — mais pour Charlevoix le site utilise la MRC20# (« charlevoix ») plutôt que la région administrative21# (« capitale-nationale »). On essaie donc plusieurs candidats (un 40422# coûte ~0,3 s) et on mémorise le chemin résolu dans le cache détail,23# qui sert aussi d'URL publique de l'annonce.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import json28import re29import unicodedata3031import requests3233from ..schema import StListing34from .base import StConnector3536API = "https://api.monsieurchalets.com/api/v1"37SITE = "https://www.monsieurchalets.com"3839# breadcrumb[0] (slug région du site) → région touristique canonique40_REGION_SLUGS = {41 "abitibi-temiscamingue": "Abitibi-Témiscamingue",42 "bas-saint-laurent": "Bas-Saint-Laurent",43 "capitale-nationale": "Québec",44 "centre-du-quebec": "Centre-du-Québec",45 "charlevoix": "Charlevoix",46 "chaudiere-appalaches": "Chaudière-Appalaches",47 "cote-nord": "Côte-Nord",48 "estrie": "Cantons-de-l'Est",49 "gaspesie": "Gaspésie",50 "lanaudiere": "Lanaudière",51 "laurentides": "Laurentides",52 "laval": "Laval",53 "mauricie": "Mauricie",54 "monteregie": "Montérégie",55 "montreal": "Montréal",56 "outaouais": "Outaouais",57 "quebec": "Québec",58 "saguenay": "Saguenay–Lac-Saint-Jean",59 "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean",60}6162# type d'hébergement déduit du nom/sous-titre (le site est ~100 % chalets,63# mais quelques fiches sont des dômes, mini-chalets, condos…)64_TYPE_HINTS = [65 ("dome", "Dôme"), ("dôme", "Dôme"),66 ("yourte", "Yourte"),67 ("mini-chalet", "Mini-maison"), ("mini chalet", "Mini-maison"),68 ("micro-chalet", "Mini-maison"), ("mini-maison", "Mini-maison"),69 ("pret-a-camper", "Prêt-à-camper"), ("prêt-à-camper", "Prêt-à-camper"),70 ("condo", "Condo"),71 ("appartement", "Appartement"),72 ("loft", "Loft"),73 ("refuge", "Refuge"),74 ("gite", "Gîte"), ("gîte", "Gîte"),75 ("auberge", "Auberge"),76 ("maison", "Maison"),77 ("chalet", "Chalet"), ("cottage", "Chalet"),78]798081def _property_type(*texts: str) -> str:82 hay = " ".join(t or "" for t in texts).lower()83 for needle, canon in _TYPE_HINTS:84 if needle in hay:85 return canon86 return ""878889def _num(v) -> float | None:90 try:91 return float(v) if v is not None else None92 except (TypeError, ValueError):93 return None949596def _slug(s: str) -> str:97 """Slugification équivalente à celle du site (accents → ascii, '-')."""98 s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()99 return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")100101102def _city_variants(city_slug: str) -> list[str]:103 """Variantes du slug de ville observées sur le site :104 « lanse-saint-jean » → « anse-saint-jean » (article élidé),105 « stoneham-et-tewkesbury » → « stoneham » (nom composé tronqué)."""106 out = [city_slug] if city_slug else []107 if city_slug.startswith(("l-", "le-", "la-", "les-")):108 out.append(city_slug.split("-", 1)[1])109 elif city_slug.startswith("l") and not city_slug.startswith("l-"):110 pass111 if city_slug.startswith("lanse-"):112 out.append(city_slug[1:]) # lanse-… → anse-…113 if "-et-" in city_slug:114 out.append(city_slug.split("-et-")[0])115 if "-de-" in city_slug:116 out.append(city_slug.split("-de-")[0])117 seen, uniq = set(), []118 for v in out:119 if v and v not in seen:120 seen.add(v)121 uniq.append(v)122 return uniq123124125def _candidate_paths(it: dict) -> list[str]:126 """Chemins possibles de la fiche (sous /chalets-a-louer), ordre de vraisemblance."""127 handle = (it.get("handle") or "").strip()128 addr = (it.get("addresses") or [{}])[0] or {}129 admin = _slug(addr.get("administrative_area") or "")130 mrc = _slug(addr.get("mrc") or "")131 mrc2 = _slug(addr.get("mrc_other") or "") # ex. « Charlevoix »132 county = _slug(addr.get("county") or "")133 cities = _city_variants(_slug(addr.get("city") or ""))134 crumbs = [_slug(c) for c in (it.get("breadcrumbs") or [])135 if isinstance(c, str) and c]136 cands: list[list[str]] = []137 if crumbs:138 cands.append(crumbs)139 combos: list[list[str]] = []140 for region in (admin, mrc2, mrc, county):141 for city in cities:142 combos.append([region, city])143 # ville absente (ou slug divergent) : le site retombe sur <admin>/<mrc>144 combos += [[admin, mrc], [admin, mrc2], [mrc2, mrc],145 [admin], [mrc2], [mrc], *[[c] for c in cities], []]146 for parts in combos:147 parts = [p for p in parts if p]148 if parts not in cands:149 cands.append(parts)150 return ["/".join(["chalets-a-louer", *parts, handle]) for parts in cands]151152153class MonsieurChalets(StConnector):154 source_id = "monsieurchalets"155 request_delay = 0.35 # API + pages _next/data légères — reste poli156157 def __init__(self) -> None:158 super().__init__()159 self._build_id: str | None = None160161 # -- liste ------------------------------------------------------------162 def _search_page(self, page: int) -> dict:163 resp = self.post(164 f"{API}/public/search/properties",165 json={"limit": 200, "page": page},166 headers={"Accept": "application/json",167 "Origin": SITE, "Referer": SITE + "/"})168 return resp.json()169170 def _all_items(self) -> list[dict]:171 items: list[dict] = []172 page = 1173 while True:174 data = self._search_page(page)175 batch = data.get("properties") or []176 if not batch:177 break178 items.extend(batch)179 total = data.get("total") or 0180 if len(items) >= total or page > 30: # garde-fou181 break182 page += 1183 return items184185 # -- détail (Next.js _next/data, via cache BD) -------------------------186 def _fetch_build_id(self) -> str:187 html = self.get(SITE + "/").text188 m = re.search(r'"buildId"\s*:\s*"([^"]+)"', html)189 if not m:190 raise RuntimeError("buildId Next.js introuvable sur l'accueil")191 return m.group(1)192193 def _next_data(self, path: str) -> dict | None:194 """pageProps de la page détail via l'endpoint JSON de Next.js195 (None si la page n'existe pas — 404)."""196 if not self._build_id:197 self._build_id = self._fetch_build_id()198 try:199 resp = self.get(f"{SITE}/_next/data/{self._build_id}/{path}.json",200 headers={"Accept": "application/json"})201 return resp.json().get("pageProps") or {}202 except requests.HTTPError as exc:203 r = getattr(exc, "response", None)204 if r is not None and r.status_code == 404:205 return None206 raise207 except ValueError:208 return {}209210 def _fetch_detail(self, paths: list[str]) -> dict:211 """Essaie les chemins candidats ; ne conserve que les champs utiles212 (le payload complet fait ~150 Ko). Mémorise le chemin résolu."""213 prop, found = {}, ""214 for attempt in (1, 2):215 for path in paths:216 pp = self._next_data(path)217 if pp is None: # 404 → candidat suivant218 continue219 prop = pp.get("property") or {}220 found = path221 break222 if found or attempt == 2:223 break224 # tous 404 : le site a peut-être été redéployé (buildId périmé)225 self._build_id = self._fetch_build_id()226 if not prop:227 return {"path": found} if found else {}228 citq = ""229 for reg in prop.get("registrations") or []:230 if (reg or {}).get("type") == "citq" and reg.get("unique_number"):231 citq = str(reg["unique_number"])232 if reg.get("is_current"):233 break234 rules = [r for r in (prop.get("rules") or []) if isinstance(r, str)]235 return {236 "path": found,237 "description": prop.get("description_fr")238 or prop.get("description_en") or "",239 "amenities": [a.replace("_", " ").replace("/", " ").strip()240 for a in (prop.get("amenities") or [])241 if isinstance(a, str)],242 "citq": citq,243 "pets": "oui" if "pets_allowed" in rules else None,244 "rules": rules,245 }246247 # -- contrat ------------------------------------------------------------248 def fetch(self) -> list[StListing]:249 listings: list[StListing] = []250 for it in self._all_items():251 uid = str(it.get("uid") or it.get("id") or "")252 handle = (it.get("handle") or "").strip()253 if not uid or not handle:254 continue255 crumbs = [c for c in (it.get("breadcrumbs") or [])256 if isinstance(c, str) and c]257 paths = _candidate_paths(it)258 addr = (it.get("addresses") or [{}])[0] or {}259 pricing = it.get("basic_pricing") or {}260 fee = it.get("basic_fee") or {}261 rental = it.get("rental_parameter") or {}262 low = _num(pricing.get("calculated_lowest_rate")) \263 or _num(pricing.get("base_rate"))264 high = _num(pricing.get("calculated_highest_rate"))265266 title = (it.get("name_fr") or it.get("name_en") or handle).strip()267 sub = (it.get("sub_name_fr") or it.get("sub_name_en") or "").strip()268269 images = []270 for ph in sorted(it.get("photos") or [],271 key=lambda p: (p or {}).get("order") or 0):272 u = ((ph or {}).get("image") or {}).get("url")273 if u and u not in images:274 images.append(u)275 if len(images) >= 15:276 break277278 # clé de cache détail : sous-ensemble STABLE du contenu liste279 # (le prix bouge tous les jours — l'exclure évite de revisiter280 # les 650 fiches à chaque synchronisation)281 key = json.dumps([uid, handle, title, sub, it.get("photo_count"),282 it.get("number_of_rooms"),283 it.get("maximum_guests")], ensure_ascii=False)284 try:285 det = self.detail(uid, key,286 lambda p=paths: self._fetch_detail(p))287 except Exception: # une fiche détail cassée ≠ annonce perdue288 det = {}289 url = f"{SITE}/{det.get('path') or paths[0]}"290291 # région touristique : 1er segment du chemin résolu (la vraie292 # région du site), sinon breadcrumb, sinon région administrative293 seg = (det.get("path") or "").split("/")294 region = (_REGION_SLUGS.get(seg[1]) if len(seg) > 2 else None) \295 or (_REGION_SLUGS.get(crumbs and _slug(crumbs[0]) or "")) \296 or _REGION_SLUGS.get(_slug(addr.get("administrative_area") or "")) \297 or addr.get("administrative_area") or ""298299 pets = det.get("pets")300 if pets is None and _num(fee.get("animal_fee")):301 pets = "oui" # frais « animaux » affiché ⇒ admis302303 details = {k: v for k, v in {304 "sub_name": sub,305 "mrc": addr.get("mrc"),306 "min_nights": rental.get("min_nights_to_rent"),307 "cleaning_fee": _num(fee.get("cleaning_fee")),308 "animal_fee": _num(fee.get("animal_fee")),309 "quality": it.get("quality"),310 }.items() if v not in (None, "", 0)}311312 rating = _num(it.get("average_rating"))313 listings.append(StListing(314 source=self.source_id,315 external_id=uid,316 url=url,317 title=title,318 property_type=_property_type(title, sub),319 address=addr.get("full_address") or "",320 city=addr.get("city") or "",321 region=region,322 price_night=low,323 price_label=(f"à partir de {low:g} $ / nuit"324 if low and high and high > low325 else (f"{low:g} $ / nuit" if low else "")),326 capacity=_num(it.get("maximum_guests")),327 bedrooms=_num(it.get("number_of_rooms")),328 beds=_num(it.get("number_of_beds")),329 bathrooms=_num(it.get("number_of_rooms_bath")),330 pets=pets,331 citq=det.get("citq") or "",332 rating=rating if rating else None,333 reviews=int(it["review_count"]) if it.get("review_count") else None,334 description=det.get("description") or "",335 amenities=det.get("amenities") or [],336 details=details,337 images=images,338 lat=_num(it.get("latitude")),339 lng=_num(it.get("longitude")),340 ))341 return listings342