# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/wechalet.py : WeChalet (wechalet.com) # # Plateforme québécoise d'écotourisme (chalets, dômes, mini-maisons…), # ~3 300 fiches dont ~90 % au Québec. SPA React, mais l'API Laravel interne # est ouverte (aucun anti-bot) : # 1. LISTE : POST https://api.wechalet.com/v1/search?page=N&per_page=100 # (body JSON vide) → data[…] + meta.last_page/total. Chaque item : # prix/nuit, type, capacité, chambres, lits, note/avis, TOUTES les # photos et location {city, state, lat, lng}. On garde le Québec. # 2. DÉTAIL (cache self.detail) : GET /v1/listings/ → # description fr/en, amenities, space.washrooms_count, house_rules # (allow-pets…), licence_number = numéro CITQ (souvent vérifié). # 3. URL publique : https://wechalet.com/fr/proprietes/ # (format confirmé par properties_sitemap_fr.xml). # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from ...normalize import strip_accents from ..schema import StListing from .base import StConnector API = "https://api.wechalet.com/v1" SITE = "https://wechalet.com" # space_type WeChalet → type canonique Lou-Ka _SPACE_TYPES = { "chalet": "Chalet", "cottage": "Chalet", "cabin": "Chalet", "home": "Maison", "house": "Maison", "townhouse": "Maison", "farm-stay": "Maison", "villa": "Maison", "condo": "Condo", "condominium": "Condo", "apartment": "Appartement", "loft": "Loft", "studio": "Studio", "guest-suite": "Chambre", "room": "Chambre", "guesthouse": "Gîte", "bed-and-breakfast": "Gîte", "dome": "Dôme", "dome-house": "Dôme", "yurt": "Yourte", "tiny-house": "Mini-maison", "micro-chalet": "Mini-maison", "camper-rv": "Prêt-à-camper", "tent": "Prêt-à-camper", "campsite": "Camping", "treehouse": "Autre", "boat": "Autre", } def _num(v) -> float | None: try: return float(v) if v not in (None, "") else None except (TypeError, ValueError): return None def _is_quebec(loc: dict) -> bool: state = strip_accents((loc.get("state") or "")).strip().lower() country = strip_accents((loc.get("country") or "")).strip().lower() if state in ("quebec", "qc"): return True # état absent mais coordonnées fournies : finalize() borne déjà au Québec — # on ne garde ici que ce qui est explicitement québécois ou canadien sans # état contradictoire. return not state and country in ("canada", "ca") class WeChalet(StConnector): source_id = "wechalet" request_delay = 0.3 # API JSON légère (~3 000 fiches au 1er run) # -- liste -------------------------------------------------------------- def _search_page(self, page: int) -> dict: resp = self.post( f"{API}/search?page={page}&per_page=100", json={}, headers={"Accept": "application/json", "Origin": SITE, "Referer": SITE + "/"}) return resp.json() def _all_items(self) -> list[dict]: items: list[dict] = [] page, last = 1, 1 while page <= last: data = self._search_page(page) batch = data.get("data") or [] if not batch: break items.extend(batch) last = min((data.get("meta") or {}).get("last_page") or page, 60) page += 1 return items # -- détail (cache BD) ---------------------------------------------------- def _fetch_detail(self, listing_id: str) -> dict: resp = self.get(f"{API}/listings/{listing_id}", headers={"Accept": "application/json"}) try: d = resp.json().get("data") or {} except ValueError: return {} if not d: return {} desc = d.get("description") or {} txt = "" for lang in ("fr", "en"): body = (desc.get(lang) or {}).get("description") or "" if body: txt = re.sub(r"<[^>]+>", " ", body) txt = re.sub(r"\s+", " ", txt).strip() break space = d.get("space") or {} rules = [r for r in (d.get("house_rules") or []) if isinstance(r, str)] licence = str(d.get("licence_number") or "").strip() return { "description": txt, "amenities": [a.replace("-", " ").strip() for a in (d.get("amenities") or []) if isinstance(a, str)], "washrooms": _num(space.get("washrooms_count")), "citq": licence if re.fullmatch(r"\d{6}", licence) else "", "licence_status": d.get("licence_number_status") or "", "pets": ("oui" if "allow-pets" in rules else ("non" if rules else None)), "min_stay": (d.get("min_stay") if isinstance(d.get("min_stay"), (int, float)) else None), } # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: listings: list[StListing] = [] for it in self._all_items(): lid = str(it.get("id") or "").strip() title = (it.get("name") or "").strip() loc = it.get("location") or {} if not lid or not title or not _is_quebec(loc): continue images = [] for ph in sorted(it.get("photos") or [], key=lambda p: (p or {}).get("order") or 0): u = (ph or {}).get("url") if u and u not in images: images.append(u) if len(images) >= 15: break if not images and it.get("main_picture"): images = [it["main_picture"]] # clé de cache détail : sous-ensemble stable (sans avg_price, # recalculé en continu par la plateforme) key = json.dumps([lid, title, it.get("space_type"), it.get("guests_count"), it.get("bedrooms_count"), it.get("beds_count"), len(images)], ensure_ascii=False) try: det = self.detail(lid, key, lambda i=lid: self._fetch_detail(i)) except Exception: # une fiche détail cassée ≠ annonce perdue det = {} details = {k: v for k, v in { "space_type": it.get("space_type"), "rent_type": it.get("rent_type"), "instant_booking": bool(it.get("instant_booking")) or None, "licence_status": det.get("licence_status"), "min_stay": det.get("min_stay"), }.items() if v not in (None, "", 0)} rating = _num(it.get("rating")) reviews = it.get("reviews") listings.append(StListing( source=self.source_id, external_id=lid, url=f"{SITE}/fr/proprietes/{lid}", title=title, property_type=_SPACE_TYPES.get( (it.get("space_type") or "").strip().lower(), "Autre") if it.get("space_type") else "", city=loc.get("city") or "", region="", # non exposée par l'API price_night=_num(it.get("price")), capacity=_num(it.get("guests_count")), bedrooms=_num(it.get("bedrooms_count")), beds=_num(it.get("beds_count")), bathrooms=det.get("washrooms"), pets=det.get("pets"), citq=det.get("citq") or "", rating=rating if rating else None, reviews=int(reviews) if reviews else None, description=det.get("description") or "", amenities=det.get("amenities") or [], details=details, images=images, lat=_num(loc.get("latitude")), lng=_num(loc.get("longitude")), )) return listings