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/wechalet.py : WeChalet (wechalet.com)4#5# Plateforme québécoise d'écotourisme (chalets, dômes, mini-maisons…),6# ~3 300 fiches dont ~90 % au Québec. SPA React, mais l'API Laravel interne7# est ouverte (aucun anti-bot) :8# 1. LISTE : POST https://api.wechalet.com/v1/search?page=N&per_page=1009# (body JSON vide) → data[…] + meta.last_page/total. Chaque item :10# prix/nuit, type, capacité, chambres, lits, note/avis, TOUTES les11# photos et location {city, state, lat, lng}. On garde le Québec.12# 2. DÉTAIL (cache self.detail) : GET /v1/listings/<uuid> →13# description fr/en, amenities, space.washrooms_count, house_rules14# (allow-pets…), licence_number = numéro CITQ (souvent vérifié).15# 3. URL publique : https://wechalet.com/fr/proprietes/<uuid>16# (format confirmé par properties_sitemap_fr.xml).17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import re2223from ...normalize import strip_accents24from ..schema import StListing25from .base import StConnector2627API = "https://api.wechalet.com/v1"28SITE = "https://wechalet.com"2930# space_type WeChalet → type canonique Lou-Ka31_SPACE_TYPES = {32 "chalet": "Chalet", "cottage": "Chalet", "cabin": "Chalet",33 "home": "Maison", "house": "Maison", "townhouse": "Maison",34 "farm-stay": "Maison", "villa": "Maison",35 "condo": "Condo", "condominium": "Condo",36 "apartment": "Appartement", "loft": "Loft", "studio": "Studio",37 "guest-suite": "Chambre", "room": "Chambre", "guesthouse": "Gîte",38 "bed-and-breakfast": "Gîte",39 "dome": "Dôme", "dome-house": "Dôme", "yurt": "Yourte",40 "tiny-house": "Mini-maison", "micro-chalet": "Mini-maison",41 "camper-rv": "Prêt-à-camper", "tent": "Prêt-à-camper",42 "campsite": "Camping", "treehouse": "Autre", "boat": "Autre",43}444546def _num(v) -> float | None:47 try:48 return float(v) if v not in (None, "") else None49 except (TypeError, ValueError):50 return None515253def _is_quebec(loc: dict) -> bool:54 state = strip_accents((loc.get("state") or "")).strip().lower()55 country = strip_accents((loc.get("country") or "")).strip().lower()56 if state in ("quebec", "qc"):57 return True58 # état absent mais coordonnées fournies : finalize() borne déjà au Québec —59 # on ne garde ici que ce qui est explicitement québécois ou canadien sans60 # état contradictoire.61 return not state and country in ("canada", "ca")626364class WeChalet(StConnector):65 source_id = "wechalet"66 request_delay = 0.3 # API JSON légère (~3 000 fiches au 1er run)6768 # -- liste --------------------------------------------------------------69 def _search_page(self, page: int) -> dict:70 resp = self.post(71 f"{API}/search?page={page}&per_page=100",72 json={},73 headers={"Accept": "application/json",74 "Origin": SITE, "Referer": SITE + "/"})75 return resp.json()7677 def _all_items(self) -> list[dict]:78 items: list[dict] = []79 page, last = 1, 180 while page <= last:81 data = self._search_page(page)82 batch = data.get("data") or []83 if not batch:84 break85 items.extend(batch)86 last = min((data.get("meta") or {}).get("last_page") or page, 60)87 page += 188 return items8990 # -- détail (cache BD) ----------------------------------------------------91 def _fetch_detail(self, listing_id: str) -> dict:92 resp = self.get(f"{API}/listings/{listing_id}",93 headers={"Accept": "application/json"})94 try:95 d = resp.json().get("data") or {}96 except ValueError:97 return {}98 if not d:99 return {}100 desc = d.get("description") or {}101 txt = ""102 for lang in ("fr", "en"):103 body = (desc.get(lang) or {}).get("description") or ""104 if body:105 txt = re.sub(r"<[^>]+>", " ", body)106 txt = re.sub(r"\s+", " ", txt).strip()107 break108 space = d.get("space") or {}109 rules = [r for r in (d.get("house_rules") or []) if isinstance(r, str)]110 licence = str(d.get("licence_number") or "").strip()111 return {112 "description": txt,113 "amenities": [a.replace("-", " ").strip()114 for a in (d.get("amenities") or [])115 if isinstance(a, str)],116 "washrooms": _num(space.get("washrooms_count")),117 "citq": licence if re.fullmatch(r"\d{6}", licence) else "",118 "licence_status": d.get("licence_number_status") or "",119 "pets": ("oui" if "allow-pets" in rules120 else ("non" if rules else None)),121 "min_stay": (d.get("min_stay") if isinstance(d.get("min_stay"),122 (int, float)) else None),123 }124125 # -- contrat --------------------------------------------------------------126 def fetch(self) -> list[StListing]:127 listings: list[StListing] = []128 for it in self._all_items():129 lid = str(it.get("id") or "").strip()130 title = (it.get("name") or "").strip()131 loc = it.get("location") or {}132 if not lid or not title or not _is_quebec(loc):133 continue134135 images = []136 for ph in sorted(it.get("photos") or [],137 key=lambda p: (p or {}).get("order") or 0):138 u = (ph or {}).get("url")139 if u and u not in images:140 images.append(u)141 if len(images) >= 15:142 break143 if not images and it.get("main_picture"):144 images = [it["main_picture"]]145146 # clé de cache détail : sous-ensemble stable (sans avg_price,147 # recalculé en continu par la plateforme)148 key = json.dumps([lid, title, it.get("space_type"),149 it.get("guests_count"), it.get("bedrooms_count"),150 it.get("beds_count"), len(images)],151 ensure_ascii=False)152 try:153 det = self.detail(lid, key, lambda i=lid: self._fetch_detail(i))154 except Exception: # une fiche détail cassée ≠ annonce perdue155 det = {}156157 details = {k: v for k, v in {158 "space_type": it.get("space_type"),159 "rent_type": it.get("rent_type"),160 "instant_booking": bool(it.get("instant_booking")) or None,161 "licence_status": det.get("licence_status"),162 "min_stay": det.get("min_stay"),163 }.items() if v not in (None, "", 0)}164165 rating = _num(it.get("rating"))166 reviews = it.get("reviews")167 listings.append(StListing(168 source=self.source_id,169 external_id=lid,170 url=f"{SITE}/fr/proprietes/{lid}",171 title=title,172 property_type=_SPACE_TYPES.get(173 (it.get("space_type") or "").strip().lower(), "Autre")174 if it.get("space_type") else "",175 city=loc.get("city") or "",176 region="", # non exposée par l'API177 price_night=_num(it.get("price")),178 capacity=_num(it.get("guests_count")),179 bedrooms=_num(it.get("bedrooms_count")),180 beds=_num(it.get("beds_count")),181 bathrooms=det.get("washrooms"),182 pets=det.get("pets"),183 citq=det.get("citq") or "",184 rating=rating if rating else None,185 reviews=int(reviews) if reviews else None,186 description=det.get("description") or "",187 amenities=det.get("amenities") or [],188 details=details,189 images=images,190 lat=_num(loc.get("latitude")),191 lng=_num(loc.get("longitude")),192 ))193 return listings194