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/chaletsdanslenord.py : Les Chalets dans le Nord4# (leschaletsdanslenord.com — Laurentides)5#6# Petite agence familiale (Sainte-Lucie-des-Laurentides / lac Sarrazin,7# ~6 chalets) — vitrine WordPress + moteur de réservation HOSTAWAY8# (reservation.leschaletsdanslenord.com, compte 96792).9#10# Méthode :11# 1. IDS : la racine du moteur Hostaway (Next.js rendu serveur) référence12# tous les chalets via des liens "/listings/<id>" ; la homepage WP donne13# en plus prix (« dès N $ / nuit ») et lien de la fiche vitrine (l'id14# Hostaway est dans l'URL des photos S3 `96792-<id>-…`).15# 2. DÉTAIL (cache self.detail) : /listings/<id> du moteur embarque le JSON16# complet dans le payload React Flight (`self.__next_f`) : prix de base17# par nuit, lat/lng, ville, capacité, chambres, sdb, lits, type, note18# (sur 10 → /2 par finalize), nb d'avis, ~50 photos, ~70 commodités et19# description (référence Flight « $xx » résolue via les segments T<hex>).20# External_id = id de listing Hostaway (stable, dans l'URL du moteur).21# -----------------------------------------------------------------------------22from __future__ import annotations2324import json25import re2627from ..schema import StListing28from .base import StConnector2930SITE = "https://leschaletsdanslenord.com"31ENGINE = "https://reservation.leschaletsdanslenord.com"323334def _num(v) -> float | None:35 try:36 return float(v) if v not in (None, "") else None37 except (TypeError, ValueError):38 return None394041def _flight_blob(html: str) -> str:42 parts = []43 for c in re.findall(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)',44 html):45 try:46 parts.append(json.loads(f'"{c}"'))47 except ValueError:48 continue49 return "".join(parts)505152def _flight_text(blob: str, ref: str) -> str:53 """Résout une référence texte Flight « $xx » (segment `xx:T<len hex>,`,54 longueur en OCTETS utf-8)."""55 rid = ref.lstrip("$")56 m = re.search(rf"(?:^|\n){re.escape(rid)}:T([0-9a-f]+),", blob)57 if not m:58 return ""59 n = int(m.group(1), 16)60 raw = blob[m.end():].encode("utf-8")[:n]61 return raw.decode("utf-8", errors="ignore")626364class ChaletsDansLeNord(StConnector):65 source_id = "chaletsdanslenord"6667 # -- ids + carte prix/urls vitrine --------------------------------------68 def _engine_ids(self) -> list[str]:69 h = self.get(f"{ENGINE}/").text70 return sorted(set(re.findall(r'"/listings/(\d+)"', h)))7172 def _wp_cards(self) -> dict[str, dict]:73 """id Hostaway → {url fiche vitrine, prix « dès N $ »} (homepage WP)."""74 try:75 h = self.get(f"{SITE}/").text76 except Exception:77 return {}78 cards: dict[str, dict] = {}79 for block in re.split(r'<li class="lcdln-hsg__card"', h)[1:]:80 m = re.search(r"hostaway-platform[^\"]*/listing/96792-(\d+)-",81 block)82 if not m:83 continue84 hid = m.group(1)85 card: dict = {}86 m = re.search(r'class="lcdln-hsg__card-btn" href="([^"]+)"', block)87 if m:88 card["url"] = m.group(1)89 m = re.search(r"dès\s*([\d ,]+)\s*\$\s*/\s*nuit", block)90 if m:91 card["price"] = float(m.group(1).replace(" ", "")92 .replace(",", "."))93 cards[hid] = card94 return cards9596 def _wp_fiche(self, url: str) -> dict:97 """Titre + description EN FRANÇAIS depuis la fiche vitrine WP."""98 h = self.get(url).text99 d: dict = {}100 m = re.search(r"(?s)<h1[^>]*>(.*?)</h1>", h)101 if m:102 d["title"] = re.sub(r"\s+", " ",103 re.sub(r"<[^>]+>", " ", m.group(1))).strip()104 m = re.search(r"(?s)<main[^>]*>(.*?)</main>", h)105 if m:106 import html as _h107 paras, seen = [], set()108 for p in re.findall(r"(?s)<p[^>]*>(.*?)</p>", m.group(1)):109 t = _h.unescape(re.sub(r"\s+", " ",110 re.sub(r"<[^>]+>", " ", p))).strip()111 if len(t) < 60 or t in seen or "Voir les" in t[:30]:112 continue113 seen.add(t)114 paras.append(t)115 if len(paras) >= 10:116 break117 if paras:118 d["description"] = " ".join(paras)[:4000]119 return d120121 # -- détail (moteur Hostaway + fiche vitrine FR) --------------------------122 def _detail(self, hid: str, wp_url: str = "") -> dict:123 h = self.get(f"{ENGINE}/listings/{hid}").text124 blob = _flight_blob(h)125 i = blob.find(f'"listing":{{"id":{hid}')126 if i < 0:127 return {}128 obj, _ = json.JSONDecoder().raw_decode(blob[i + len('"listing":'):])129 inner = obj.get("listing") or {}130131 desc = str(inner.get("description") or "")132 if desc.startswith("$"):133 desc = _flight_text(blob, desc)134 desc = re.sub(r"\s+", " ", desc).strip()135136 images = []137 for ph in obj.get("listingImage") or []:138 u = (ph or {}).get("url")139 if u and u not in images:140 images.append(u)141 if len(images) >= 20:142 break143144 # fiche vitrine WP : titre + description en français (prioritaires)145 wp: dict = {}146 if wp_url:147 try:148 wp = self._wp_fiche(wp_url)149 except Exception:150 wp = {}151 if wp.get("description"):152 desc = wp["description"]153154 pt = ((inner.get("propertyType") or {}).get("name") or "").strip()155 return {156 "title": wp.get("title") or (inner.get("name") or "").strip(),157 "price": _num(inner.get("price")),158 "lat": _num(inner.get("lat")),159 "lng": _num(inner.get("lng")),160 "city": (inner.get("city") or "").strip(),161 "capacity": _num(inner.get("personCapacity")),162 "bedrooms": _num(inner.get("bedroomsNumber")),163 "beds": _num(inner.get("bedsNumber")),164 "bathrooms": _num(inner.get("bathroomsNumber")),165 "property_type": pt,166 "rating": _num(obj.get("averageReviewRating")),167 "reviews": obj.get("reviewsCount"),168 "description": desc[:4000],169 "amenities": [n for n in170 (((a.get("amenity") or {}).get("name")171 or a.get("name") or "").strip()172 for a in obj.get("listingAmenity") or []173 if isinstance(a, dict)) if n][:80],174 "images": images,175 }176177 # -- contrat ----------------------------------------------------------178 def fetch(self) -> list[StListing]:179 cards = self._wp_cards()180 listings: list[StListing] = []181 for hid in self._engine_ids():182 card = cards.get(hid) or {}183 key = json.dumps([hid, card.get("price"), card.get("url")])184 try:185 det = self.detail(186 hid, key,187 lambda i=hid, u=card.get("url") or "": self._detail(i, u))188 except Exception:189 det = {}190 if not det.get("title"):191 continue192193 price = det.get("price") or card.get("price")194 reviews = det.get("reviews")195 listings.append(StListing(196 source=self.source_id,197 external_id=hid,198 url=card.get("url") or f"{ENGINE}/listings/{hid}",199 title=det["title"],200 property_type=det.get("property_type") or "Chalet",201 city=det.get("city") or "",202 region="Laurentides",203 price_night=price,204 price_label=(f"à partir de {price:.0f} $ / nuit"205 if price else ""),206 capacity=det.get("capacity"),207 bedrooms=det.get("bedrooms"),208 beds=det.get("beds"),209 bathrooms=det.get("bathrooms"),210 rating=det.get("rating"),211 reviews=int(reviews) if reviews else None,212 description=det.get("description") or "",213 amenities=det.get("amenities") or [],214 details={"booking_url": f"{ENGINE}/listings/{hid}"},215 images=det.get("images") or [],216 lat=det.get("lat"),217 lng=det.get("lng"),218 ))219 return listings220