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/glampinghub.py : Glamping Hub (glampinghub.com) — hébergements4# insolites (dômes, yourtes, pods, cabanes…), ~200 fiches au Québec.5#6# Méthode : l'endpoint AJAX de la page de recherche est ouvert (aucun anti-bot) :7# GET https://glampinghub.com/search-accommodations/?lang=en&page=N8# &q=Quebec, Canada&location={"state": "Quebec", "country": "Canada"}9# &adults=2&…&numberOfResultsPerPage=2410# → search_results[…] + total_results. Chaque fiche est TRÈS riche : nom,11# catégorie, ville + coordonnées, prix (estimated_rate.daily_rate en devise12# originale, CAD au Québec), chambres/lits, capacité par unité13# (units_distribution), note sur 5, commodités (nested_features), photos.14# Une seule requête paginée suffit pour la liste ; seule la DESCRIPTION15# n'y figure pas → elle est prise sur la page détail (aucun anti-bot non16# plus), dans le bloc SSR <noscript id="description-content">, avec repli17# sur la <meta name="description">. Cache permanent dans louka_ct.db.18#19# URL publique : https://glampinghub.com<absolute_url_en>. Région touristique20# déduite des coordonnées (centroïdes partagés avec airbnb.py).21# Réglages env : LOUKA_GLAMPINGHUB_LIMIT (nb max de fiches, 0 = tout),22# LOUKA_GLAMPINGHUB_DETAIL_LIMIT (fetchs détail/sync, défaut 250).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import html as _html27import os28import re29import sys3031from ..schema import StListing32from .base import StConnector33from .airbnb import _region_from_latlng343536class _DetailSkip(Exception):37 """Fiche détail sautée (budget épuisé) — pas de mise en cache."""383940_DESC_RE = re.compile(r'<noscript id="description-content">(.*?)</noscript>',41 re.S)42_META_RE = re.compile(r'<meta name="description" content="([^"]+)"')4344SITE = "https://glampinghub.com"45API = f"{SITE}/search-accommodations/"46PAGE_SIZE = 244748# Catégorie Glamping Hub → type canonique Lou-Ka49_CATEGORIES = {50 "cabins": "Chalet", "cottages": "Chalet", "log cabins": "Chalet",51 "vacation rentals": "Maison", "villas": "Maison",52 "designer rentals": "Maison", "unique stays": "Autre",53 "tiny houses": "Mini-maison", "hobbit houses": "Mini-maison",54 "domes": "Dôme", "yurts": "Yourte", "tipis": "Prêt-à-camper",55 "pods": "Prêt-à-camper", "bell tents": "Prêt-à-camper",56 "safari tents": "Prêt-à-camper", "tented cabins": "Prêt-à-camper",57 "canvas tents": "Prêt-à-camper", "airstreams": "Prêt-à-camper",58 "caravans": "Prêt-à-camper", "campervans": "Prêt-à-camper",59 "tree houses": "Autre", "barns": "Autre", "boats": "Autre",60 "islands": "Autre", "castles": "Autre", "condos": "Condo",61 "apartments": "Appartement", "lodges": "Auberge",62 "bed and breakfasts": "Gîte",63}646566def _num(v) -> float | None:67 try:68 return float(v) if v not in (None, "") else None69 except (TypeError, ValueError):70 return None717273class GlampingHub(StConnector):74 source_id = "glampinghub"75 request_delay = 0.87677 # -- liste --------------------------------------------------------------78 def _search_page(self, page: int) -> dict:79 resp = self.get(API, params={80 "lang": "en",81 "page": page,82 "q": "Quebec, Canada",83 "location": '{"state": "Quebec", "country": "Canada"}',84 "adults": 2, "children": 0, "infants": 0,85 "sort": "-ranking_engine_boost",86 "source": "rentalsearch",87 "numberOfResultsPerPage": PAGE_SIZE,88 }, headers={"Accept": "application/json",89 "X-Requested-With": "XMLHttpRequest",90 "Referer": f"{SITE}/rentalsearch/"})91 return resp.json()9293 def _all_items(self) -> list[dict]:94 items: list[dict] = []95 page, total = 0, 196 while len(items) < min(total, 2000) and page < 100:97 data = self._search_page(page)98 batch = data.get("search_results") or []99 total = data.get("total_results") or 0100 if not batch:101 break102 items.extend(batch)103 page += 1104 return items105106 # -- description (page détail, SSR ouvert) --------------------------------107 @staticmethod108 def _parse_description(html: str) -> dict:109 """{description} depuis le bloc <noscript> SSR (repli : meta)."""110 desc = ""111 m = _DESC_RE.search(html or "")112 if m:113 txt = re.sub(r"<br\s*/?>|</p>|</h2>", "\n", m.group(1))114 txt = _html.unescape(re.sub(r"<[^>]+>", " ", txt))115 lines = [re.sub(r"\s+", " ", ln).strip() for ln in txt.split("\n")]116 # écarter le bruit : lignes « … », slogan SEO de bas de bloc et117 # liste de commodités hors-plateforme (pas une description)118 lines = [ln for ln in lines if ln and ln not in ("...", "…")119 and not re.match(r"^Book your dream .*!$", ln)120 and not ln.startswith("Amenities not shown on")]121 desc = "\n".join(lines).strip()122 if len(desc) < 40: # fiche laconique (« … ») : repli meta123 mm = _META_RE.search(html or "")124 meta = _html.unescape(mm.group(1)).strip() if mm else ""125 if len(meta) > len(desc):126 desc = meta127 return {"description": desc[:6000]} if desc else {}128129 def _enrich_details(self, listings: list[StListing]) -> None:130 """Complète la description via la page détail, sous budget (les hits131 de cache sont gratuits, seuls les fetchs réseau comptent)."""132 limit = max(0, int(os.environ.get("LOUKA_GLAMPINGHUB_DETAIL_LIMIT",133 "250") or 250))134 used = enriched = 0135 for lst in listings:136 if not lst.url.startswith(SITE + "/"):137 continue138 def fetch_fn(url=lst.url):139 nonlocal used140 if used >= limit:141 raise _DetailSkip142 used += 1143 return self._parse_description(self.get(url).text)144145 try:146 d = self.detail(lst.external_id, "v1", fetch_fn)147 except _DetailSkip:148 continue149 except Exception: # noqa: BLE001 — une fiche ne bloque pas le run150 continue151 if d.get("description") and len(d["description"]) > \152 len(lst.description or ""):153 lst.description = d["description"]154 enriched += 1155 print(f"[glampinghub] détail : {enriched} descriptions"156 f" ({used}/{limit} fetchs réseau)", file=sys.stderr)157158 # -- contrat --------------------------------------------------------------159 def fetch(self) -> list[StListing]:160 limit = int(os.environ.get("LOUKA_GLAMPINGHUB_LIMIT", "0") or 0)161 listings: list[StListing] = []162 seen: set[str] = set()163 for it in self._all_items():164 gid = str(it.get("id") or "").strip()165 title = (it.get("name_en") or "").strip()166 loc = it.get("location") or {}167 if not gid or gid in seen or not title:168 continue169 if (loc.get("state_en") or "").strip().lower() != "quebec":170 continue171 seen.add(gid)172173 lat = lng = None174 coords = (loc.get("coords") or "").split(",")175 if len(coords) == 2:176 lat, lng = _num(coords[0]), _num(coords[1])177178 path = it.get("absolute_url_en") or ""179 if not path:180 for u in it.get("absolute_url") or []:181 if (u or {}).get("lang") == "en":182 path = u.get("url") or ""183 break184185 images = []186 for ph in (it.get("images") or [])[:15]:187 u = (ph or {}).get("url") or ""188 if u.startswith("//"):189 u = "https:" + u190 if u.startswith("https://") and u not in images:191 images.append(u)192193 # prix : tarif nuit de base en devise originale (CAD au Québec)194 price_night, price_label = None, ""195 rate = it.get("estimated_rate") or {}196 daily = _num(rate.get("daily_rate"))197 if daily and (it.get("original_currency") or "") == "CAD":198 price_night = daily199 price_label = f"à partir de {daily:.0f} $ CAD / nuit"200201 units = it.get("units_distribution") or []202 capacity = max((_num(u.get("capacity")) or 0 for u in units),203 default=0) or None204205 amenities = []206 for f in it.get("nested_features") or []:207 name = (f or {}).get("name_en") or ""208 if f.get("active") and name and name not in amenities:209 amenities.append(name)210211 category = (it.get("category_en") or "").strip()212 details = {k: v for k, v in {213 "category": category,214 "units": len(units) or None,215 "min_stay": rate.get("min_stay"),216 "verified": bool(it.get("verified_accommodation")) or None,217 "remarkable": [f.get("name_en") for f in218 (it.get("remarkable_features") or [])219 if (f or {}).get("name_en")] or None,220 }.items() if v}221222 rating = _num(it.get("average_rate"))223 listings.append(StListing(224 source=self.source_id,225 external_id=gid,226 url=SITE + path if path else SITE,227 title=title,228 property_type=_CATEGORIES.get(category.lower(), "Autre"),229 city=loc.get("city_en") or "",230 region=_region_from_latlng(lat, lng),231 price_night=price_night,232 price_label=price_label,233 capacity=capacity,234 bedrooms=_num(it.get("bedrooms_number")),235 beds=_num(it.get("beds_number")),236 rating=rating if rating else None,237 amenities=amenities,238 details=details,239 images=images,240 lat=lat,241 lng=lng,242 ))243 if limit and len(listings) >= limit:244 break245 self._enrich_details(listings)246 return listings247