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/rezerve.py : Rëzerve / reserver.ca (Charlevoix, Laurentides,4# Estrie, Lanaudière, Mauricie, Outaouais…)5#6# Gestionnaire québécois (~85 chalets) sur moteur Guesty. Le site reserver.ca7# est un Next.js (App Router) : le catalogue /chalets est RENDU SERVEUR et le8# payload React Flight (`self.__next_f.push([1,"…"])`) contient l'objet9# {"listings":[…]} complet — id Guesty, slug, nom, description fr, photos,10# chambres, sdb, lits, capacité, prix « à partir de » CAD, ville, adresse,11# lat/lng, commodités, numéro CITQ, heures d'arrivée/départ. UNE SEULE requête12# suffit, aucune page détail à visiter.13# External_id = id Guesty (stable). URL publique : /chalets/<slug>.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import re1920from ..schema import StListing21from .base import StConnector2223SITE = "https://reserver.ca"242526def _num(v) -> float | None:27 try:28 return float(v) if v not in (None, "") else None29 except (TypeError, ValueError):30 return None313233def _flight_blob(html: str) -> str:34 """Reconstitue le payload React Flight (chunks __next_f concaténés)."""35 parts = []36 for c in re.findall(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)',37 html):38 try:39 parts.append(json.loads(f'"{c}"'))40 except ValueError:41 continue42 return "".join(parts)434445class Rezerve(StConnector):46 source_id = "rezerve"4748 def _catalog(self) -> list[dict]:49 html = self.get(f"{SITE}/chalets").text50 blob = _flight_blob(html)51 i = blob.find('{"listings":[')52 if i < 0:53 return []54 obj, _ = json.JSONDecoder().raw_decode(blob[i:])55 return obj.get("listings") or []5657 @staticmethod58 def _description(it: dict) -> str:59 desc = str(it.get("description") or "")60 if not desc or desc.startswith("$"): # référence Flight non résolue61 desc = str((it.get("descriptions") or {}).get("fr") or "")62 if desc.startswith("$"):63 desc = ""64 return re.sub(r"\s+", " ", desc).strip()[:4000]6566 # -- contrat ----------------------------------------------------------67 def fetch(self) -> list[StListing]:68 listings: list[StListing] = []69 for it in self._catalog():70 lid = str(it.get("id") or "").strip()71 slug = (it.get("slug") or "").strip()72 title = (it.get("name") or "").strip()73 if not lid or not slug or not title:74 continue7576 geo = it.get("geo") or {}77 citq = str((it.get("citq") or {}).get("number") or "").strip()78 street = (it.get("addressStreet") or "").strip()79 postal = (it.get("addressPostal") or "").strip()80 price = _num(it.get("priceFromCAD"))8182 details = {k: v for k, v in {83 "guesty_id": lid,84 "area_sqft": it.get("areaSquareFeet"),85 "check_in": it.get("checkInTime"),86 "check_out": it.get("checkOutTime"),87 "tags": it.get("tags") or None,88 }.items() if v}8990 listings.append(StListing(91 source=self.source_id,92 external_id=lid,93 url=f"{SITE}/chalets/{slug}",94 title=title,95 property_type="Chalet",96 address=" ".join(p for p in (street, postal) if p),97 city=(it.get("city") or "").strip(),98 region="", # ville + lat/lng font foi99 price_night=price,100 price_label=(f"à partir de {price:.0f} $ / nuit"101 if price else ""),102 capacity=_num(it.get("maxGuests")),103 bedrooms=_num(it.get("bedrooms")),104 beds=_num(it.get("beds")),105 bathrooms=_num(it.get("bathrooms")),106 citq=citq if re.fullmatch(r"\d{6}", citq) else "",107 description=self._description(it),108 amenities=[a for a in (it.get("amenities") or [])109 if isinstance(a, str)][:80],110 details=details,111 images=[u for u in (it.get("images") or [])112 if isinstance(u, str)][:20],113 lat=_num(geo.get("lat")),114 lng=_num(geo.get("lng")),115 ))116 return listings117