# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/monsieurchalets.py : MonsieurChalets (monsieurchalets.com) # # Plateforme québécoise de location de chalets (~650 propriétés, 17 régions). # Méthode (aucun anti-bot, tout en JSON) : # 1. LISTE : POST https://api.monsieurchalets.com/api/v1/public/search/properties # {"limit": 200, "page": N} → {properties: […], total, current_page}. # Chaque item contient déjà prix (basic_pricing), capacité, chambres, # salles de bain, lits, adresse complète, lat/lng, photos CloudFront, # note/avis et breadcrumbs (région/ville touristique). # 2. DÉTAIL (cache self.detail) : le site est un Next.js « pages router » — # GET /_next/data//chalets-a-louer/<...chemin>/.json # → pageProps.property : description_fr, amenities, registrations (CITQ), # rules (pets_allowed…). Le buildId est lu dans le __NEXT_DATA__ de # l'accueil et rafraîchi si l'endpoint répond 404 (déploiement du site). # ⚠️ PIÈGE : le chemin de la fiche = breadcrumbs s'ils sont présents, # sinon / slugifiés (logique reproduite du # bundle _app du site) — mais pour Charlevoix le site utilise la MRC # (« charlevoix ») plutôt que la région administrative # (« capitale-nationale »). On essaie donc plusieurs candidats (un 404 # coûte ~0,3 s) et on mémorise le chemin résolu dans le cache détail, # qui sert aussi d'URL publique de l'annonce. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import unicodedata import requests from ..schema import StListing from .base import StConnector API = "https://api.monsieurchalets.com/api/v1" SITE = "https://www.monsieurchalets.com" # breadcrumb[0] (slug région du site) → région touristique canonique _REGION_SLUGS = { "abitibi-temiscamingue": "Abitibi-Témiscamingue", "bas-saint-laurent": "Bas-Saint-Laurent", "capitale-nationale": "Québec", "centre-du-quebec": "Centre-du-Québec", "charlevoix": "Charlevoix", "chaudiere-appalaches": "Chaudière-Appalaches", "cote-nord": "Côte-Nord", "estrie": "Cantons-de-l'Est", "gaspesie": "Gaspésie", "lanaudiere": "Lanaudière", "laurentides": "Laurentides", "laval": "Laval", "mauricie": "Mauricie", "monteregie": "Montérégie", "montreal": "Montréal", "outaouais": "Outaouais", "quebec": "Québec", "saguenay": "Saguenay–Lac-Saint-Jean", "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean", } # type d'hébergement déduit du nom/sous-titre (le site est ~100 % chalets, # mais quelques fiches sont des dômes, mini-chalets, condos…) _TYPE_HINTS = [ ("dome", "Dôme"), ("dôme", "Dôme"), ("yourte", "Yourte"), ("mini-chalet", "Mini-maison"), ("mini chalet", "Mini-maison"), ("micro-chalet", "Mini-maison"), ("mini-maison", "Mini-maison"), ("pret-a-camper", "Prêt-à-camper"), ("prêt-à-camper", "Prêt-à-camper"), ("condo", "Condo"), ("appartement", "Appartement"), ("loft", "Loft"), ("refuge", "Refuge"), ("gite", "Gîte"), ("gîte", "Gîte"), ("auberge", "Auberge"), ("maison", "Maison"), ("chalet", "Chalet"), ("cottage", "Chalet"), ] def _property_type(*texts: str) -> str: hay = " ".join(t or "" for t in texts).lower() for needle, canon in _TYPE_HINTS: if needle in hay: return canon return "" def _num(v) -> float | None: try: return float(v) if v is not None else None except (TypeError, ValueError): return None def _slug(s: str) -> str: """Slugification équivalente à celle du site (accents → ascii, '-').""" s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode() return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") def _city_variants(city_slug: str) -> list[str]: """Variantes du slug de ville observées sur le site : « lanse-saint-jean » → « anse-saint-jean » (article élidé), « stoneham-et-tewkesbury » → « stoneham » (nom composé tronqué).""" out = [city_slug] if city_slug else [] if city_slug.startswith(("l-", "le-", "la-", "les-")): out.append(city_slug.split("-", 1)[1]) elif city_slug.startswith("l") and not city_slug.startswith("l-"): pass if city_slug.startswith("lanse-"): out.append(city_slug[1:]) # lanse-… → anse-… if "-et-" in city_slug: out.append(city_slug.split("-et-")[0]) if "-de-" in city_slug: out.append(city_slug.split("-de-")[0]) seen, uniq = set(), [] for v in out: if v and v not in seen: seen.add(v) uniq.append(v) return uniq def _candidate_paths(it: dict) -> list[str]: """Chemins possibles de la fiche (sous /chalets-a-louer), ordre de vraisemblance.""" handle = (it.get("handle") or "").strip() addr = (it.get("addresses") or [{}])[0] or {} admin = _slug(addr.get("administrative_area") or "") mrc = _slug(addr.get("mrc") or "") mrc2 = _slug(addr.get("mrc_other") or "") # ex. « Charlevoix » county = _slug(addr.get("county") or "") cities = _city_variants(_slug(addr.get("city") or "")) crumbs = [_slug(c) for c in (it.get("breadcrumbs") or []) if isinstance(c, str) and c] cands: list[list[str]] = [] if crumbs: cands.append(crumbs) combos: list[list[str]] = [] for region in (admin, mrc2, mrc, county): for city in cities: combos.append([region, city]) # ville absente (ou slug divergent) : le site retombe sur / combos += [[admin, mrc], [admin, mrc2], [mrc2, mrc], [admin], [mrc2], [mrc], *[[c] for c in cities], []] for parts in combos: parts = [p for p in parts if p] if parts not in cands: cands.append(parts) return ["/".join(["chalets-a-louer", *parts, handle]) for parts in cands] class MonsieurChalets(StConnector): source_id = "monsieurchalets" request_delay = 0.35 # API + pages _next/data légères — reste poli def __init__(self) -> None: super().__init__() self._build_id: str | None = None # -- liste ------------------------------------------------------------ def _search_page(self, page: int) -> dict: resp = self.post( f"{API}/public/search/properties", json={"limit": 200, "page": page}, headers={"Accept": "application/json", "Origin": SITE, "Referer": SITE + "/"}) return resp.json() def _all_items(self) -> list[dict]: items: list[dict] = [] page = 1 while True: data = self._search_page(page) batch = data.get("properties") or [] if not batch: break items.extend(batch) total = data.get("total") or 0 if len(items) >= total or page > 30: # garde-fou break page += 1 return items # -- détail (Next.js _next/data, via cache BD) ------------------------- def _fetch_build_id(self) -> str: html = self.get(SITE + "/").text m = re.search(r'"buildId"\s*:\s*"([^"]+)"', html) if not m: raise RuntimeError("buildId Next.js introuvable sur l'accueil") return m.group(1) def _next_data(self, path: str) -> dict | None: """pageProps de la page détail via l'endpoint JSON de Next.js (None si la page n'existe pas — 404).""" if not self._build_id: self._build_id = self._fetch_build_id() try: resp = self.get(f"{SITE}/_next/data/{self._build_id}/{path}.json", headers={"Accept": "application/json"}) return resp.json().get("pageProps") or {} except requests.HTTPError as exc: r = getattr(exc, "response", None) if r is not None and r.status_code == 404: return None raise except ValueError: return {} def _fetch_detail(self, paths: list[str]) -> dict: """Essaie les chemins candidats ; ne conserve que les champs utiles (le payload complet fait ~150 Ko). Mémorise le chemin résolu.""" prop, found = {}, "" for attempt in (1, 2): for path in paths: pp = self._next_data(path) if pp is None: # 404 → candidat suivant continue prop = pp.get("property") or {} found = path break if found or attempt == 2: break # tous 404 : le site a peut-être été redéployé (buildId périmé) self._build_id = self._fetch_build_id() if not prop: return {"path": found} if found else {} citq = "" for reg in prop.get("registrations") or []: if (reg or {}).get("type") == "citq" and reg.get("unique_number"): citq = str(reg["unique_number"]) if reg.get("is_current"): break rules = [r for r in (prop.get("rules") or []) if isinstance(r, str)] return { "path": found, "description": prop.get("description_fr") or prop.get("description_en") or "", "amenities": [a.replace("_", " ").replace("/", " ").strip() for a in (prop.get("amenities") or []) if isinstance(a, str)], "citq": citq, "pets": "oui" if "pets_allowed" in rules else None, "rules": rules, } # -- contrat ------------------------------------------------------------ def fetch(self) -> list[StListing]: listings: list[StListing] = [] for it in self._all_items(): uid = str(it.get("uid") or it.get("id") or "") handle = (it.get("handle") or "").strip() if not uid or not handle: continue crumbs = [c for c in (it.get("breadcrumbs") or []) if isinstance(c, str) and c] paths = _candidate_paths(it) addr = (it.get("addresses") or [{}])[0] or {} pricing = it.get("basic_pricing") or {} fee = it.get("basic_fee") or {} rental = it.get("rental_parameter") or {} low = _num(pricing.get("calculated_lowest_rate")) \ or _num(pricing.get("base_rate")) high = _num(pricing.get("calculated_highest_rate")) title = (it.get("name_fr") or it.get("name_en") or handle).strip() sub = (it.get("sub_name_fr") or it.get("sub_name_en") or "").strip() images = [] for ph in sorted(it.get("photos") or [], key=lambda p: (p or {}).get("order") or 0): u = ((ph or {}).get("image") or {}).get("url") if u and u not in images: images.append(u) if len(images) >= 15: break # clé de cache détail : sous-ensemble STABLE du contenu liste # (le prix bouge tous les jours — l'exclure évite de revisiter # les 650 fiches à chaque synchronisation) key = json.dumps([uid, handle, title, sub, it.get("photo_count"), it.get("number_of_rooms"), it.get("maximum_guests")], ensure_ascii=False) try: det = self.detail(uid, key, lambda p=paths: self._fetch_detail(p)) except Exception: # une fiche détail cassée ≠ annonce perdue det = {} url = f"{SITE}/{det.get('path') or paths[0]}" # région touristique : 1er segment du chemin résolu (la vraie # région du site), sinon breadcrumb, sinon région administrative seg = (det.get("path") or "").split("/") region = (_REGION_SLUGS.get(seg[1]) if len(seg) > 2 else None) \ or (_REGION_SLUGS.get(crumbs and _slug(crumbs[0]) or "")) \ or _REGION_SLUGS.get(_slug(addr.get("administrative_area") or "")) \ or addr.get("administrative_area") or "" pets = det.get("pets") if pets is None and _num(fee.get("animal_fee")): pets = "oui" # frais « animaux » affiché ⇒ admis details = {k: v for k, v in { "sub_name": sub, "mrc": addr.get("mrc"), "min_nights": rental.get("min_nights_to_rent"), "cleaning_fee": _num(fee.get("cleaning_fee")), "animal_fee": _num(fee.get("animal_fee")), "quality": it.get("quality"), }.items() if v not in (None, "", 0)} rating = _num(it.get("average_rating")) listings.append(StListing( source=self.source_id, external_id=uid, url=url, title=title, property_type=_property_type(title, sub), address=addr.get("full_address") or "", city=addr.get("city") or "", region=region, price_night=low, price_label=(f"à partir de {low:g} $ / nuit" if low and high and high > low else (f"{low:g} $ / nuit" if low else "")), capacity=_num(it.get("maximum_guests")), bedrooms=_num(it.get("number_of_rooms")), beds=_num(it.get("number_of_beds")), bathrooms=_num(it.get("number_of_rooms_bath")), pets=pets, citq=det.get("citq") or "", rating=rating if rating else None, reviews=int(it["review_count"]) if it.get("review_count") else None, description=det.get("description") or "", amenities=det.get("amenities") or [], details=details, images=images, lat=_num(it.get("latitude")), lng=_num(it.get("longitude")), )) return listings