SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
14.2 KB · 342 lines python
Raw Blame History
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