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%
8.1 KB · 194 lines python
Raw Blame History
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