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/hebergia.py : Hébergia (hebergia.ca)4#5# Agence multi-régions (Cantons-de-l'Est, Laurentides, Lanaudière, Charlevoix,6# Centre-du-Québec) — WordPress + moteur Guesty, ~60 chalets.7#8# Méthode :9# 1. LISTE : la page /chalets/ embarque `window.chalets = [...]` (JSON10# complet : gid Guesty, prix de base/nuit, url, titre, RÉGION, capacité,11# chambres, lits, lat/lng, vignette). Une seule requête.12# 2. DÉTAIL (cache self.detail, clé = champs stables de la liste) : la page13# WP fr /chalets/<slug>/ fournit ville (« Austin, Cantons-de-l'Est »),14# salles de bain (bloc inshort), description, commodités (section15# « Commodités incluses »), photos (assets.guesty.com) et numéro CITQ16# (dans le règlement).17# External_id = gid (id de listing Guesty, stable).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import html as _html22import json23import re2425from ..schema import StListing26from .base import StConnector2728SITE = "https://hebergia.ca"2930_TAG_RE = re.compile(r"<[^>]+>")313233def _text(fragment: str) -> str:34 return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()353637def _num(v) -> float | None:38 try:39 return float(v) if v not in (None, "") else None40 except (TypeError, ValueError):41 return None424344class Hebergia(StConnector):45 source_id = "hebergia"4647 # -- liste ----------------------------------------------------------------48 def _list_items(self) -> list[dict]:49 h = self.get(f"{SITE}/chalets/").text50 m = re.search(r"window\.chalets\s*=\s*(\[.*?\]);", h, re.S)51 if not m:52 return []53 try:54 return json.loads(m.group(1))55 except ValueError:56 return []5758 # -- page détail ------------------------------------------------------59 def _detail(self, url: str) -> dict:60 h = self.get(url).text61 d: dict = {}6263 m = re.search(r'class="sous-titre-localisation">([^<]+)<', h)64 if m:65 loc = _text(m.group(1))66 d["city"] = loc.split(",")[0].strip()6768 # bloc « inshort » : 8 voyageurs / 3 chambres / lits / 2 salles de bain69 m = re.search(r"(\d+)\s*salles?\s*de\s*bain", h)70 if m:71 d["bathrooms"] = float(m.group(1))7273 # description : paragraphes de l'article avant les accordéons74 m = re.search(r"(?s)<article class=\"hbg-fiche-chalet-content\">"75 r".*?</h1>(.*?)<h2 class=\"toggler\"", h)76 if m:77 frag = re.sub(r'(?s)<p class="sous-titre-localisation">.*?</p>',78 " ", m.group(1))79 paras = re.findall(r"(?s)<p[^>]*>(.*?)</p>", frag)80 d["description"] = " ".join(_text(p) for p in paras81 if _text(p))[:4000]8283 # commodités : section « Commodités incluses » (lignes à puces)84 m = re.search(r'(?s)Commodités incluses</button></h2>\s*'85 r'<div class="toToggle"[^>]*>(.*?)</div>', h)86 if m:87 amens = []88 for line in re.split(r"<br\s*/?>|</p>", m.group(1)):89 t = _text(line).lstrip("•· ").strip()90 if 2 <= len(t) <= 80 and not t.isupper():91 amens.append(t)92 d["amenities"] = amens[:60]9394 # photos (CDN Guesty, dédupliquées)95 imgs: list[str] = []96 for u in re.findall(r'data-flickity-lazyload-src="'97 r'(https://assets\.guesty\.com/[^"]+)"', h):98 if u not in imgs:99 imgs.append(u)100 d["images"] = imgs[:20]101102 m = re.search(r"CITQ\D{0,12}(\d{6})", h, re.I)103 if m:104 d["citq"] = m.group(1)105106 m = re.search(r"animaux non admis|pas d.animaux|no pets", h, re.I)107 if m:108 d["pets"] = "non"109 return d110111 # -- contrat ----------------------------------------------------------112 def fetch(self) -> list[StListing]:113 listings: list[StListing] = []114 for it in self._list_items():115 gid = str(it.get("gid") or "").strip()116 url = it.get("url") or ""117 title = _text(str(it.get("title") or ""))118 if not gid or not url or not title or "/en/" in url:119 continue120121 key = json.dumps([gid, title, it.get("price"),122 it.get("accommodates"), it.get("bedrooms"),123 it.get("beds"), it.get("shortDesc")],124 ensure_ascii=False)125 try:126 det = self.detail(gid, key, lambda u=url: self._detail(u))127 except Exception: # une fiche détail cassée ≠ annonce perdue128 det = {}129130 price = _num(it.get("price"))131 starting = _num(it.get("startingPrice"))132 if starting and starting > 0:133 price = starting134 desc = det.get("description") or ""135 short = _text(str(it.get("shortDesc") or ""))136 if short and short not in desc:137 desc = f"{short} {desc}".strip()138139 listings.append(StListing(140 source=self.source_id,141 external_id=gid,142 url=url,143 title=title,144 property_type="Chalet",145 city=det.get("city") or "",146 region=it.get("region") or "",147 price_night=price,148 price_label=(f"à partir de {price:.0f} $ / nuit"149 if price else ""),150 capacity=_num(it.get("accommodates")),151 bedrooms=_num(it.get("bedrooms")),152 beds=_num(it.get("beds")),153 bathrooms=det.get("bathrooms"),154 pets=det.get("pets"),155 citq=det.get("citq") or "",156 description=desc[:4000],157 amenities=det.get("amenities") or [],158 details={"guesty_id": gid},159 images=det.get("images")160 or ([it["thumbnail"]] if it.get("thumbnail") else []),161 lat=_num(it.get("lat")),162 lng=_num(it.get("lng")),163 ))164 return listings165