# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/hipcamp.py : Hipcamp (hipcamp.com) — camping et prêt-à-camper # chez des hôtes privés (terres agricoles, boisés…), ~350 terrains au Québec. # # Méthode : le site (Next.js) parle à un GraphQL public, accessible sans # session avec les en-têtes HIPCAMP-API-KEY (clé publique embarquée dans le # bundle JS) et HIPCAMP-PLATFORM: Web. # 1. LISTE : POST https://www.hipcamp.com/graphql/search — requête # LandsSearch(landFilter: {boundingBox: }) paginée par # offset/limit (50 par page). Chaque « land » (terrain, souvent # multi-emplacements) : nom, ville, coordonnées, prix/nuit (« CA$54.00 »), # types d'hébergement (tent/rv/house), photos, % de recommandations. # Le bbox mord sur l'Ontario et le Maine : on garde stateAbbrvName == QC. # 2. DÉTAIL (cache self.detail) : POST /graphql/camper — requête Land par # maskedId : description (overview, souvent bilingue avec no CITQ), # capacité max, nb d'emplacements par type, commodités et activités. # 3. Photos : CDN Cloudinary (https://hipcamp-res.cloudinary.com/…). # # Pas de note sur 5 chez Hipcamp : % de recommandations → details. Région # touristique déduite des coordonnées (centroïdes partagés avec airbnb.py). # Réglage env : LOUKA_HIPCAMP_LIMIT (nb max de terrains, 0 = tout). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import sys from ..schema import StListing from .base import StConnector from .airbnb import _in_quebec, _region_from_latlng SITE = "https://www.hipcamp.com" GRAPHQL_SEARCH = f"{SITE}/graphql/search" GRAPHQL_CAMPER = f"{SITE}/graphql/camper" CDN = "https://hipcamp-res.cloudinary.com" # Clé API publique (embarquée dans le bundle JS du site, module 79018) HEADERS = { "Content-Type": "application/json", "Accept": "application/json", "HIPCAMP-API-KEY": "Dp7qfhE8y8cTx73qSYu8b6M2", "HIPCAMP-PLATFORM": "Web", } # Zone habitée du Québec (sud, ouest, nord, est) — même bbox qu'airbnb.py QC_BBOX = (44.95, -79.80, 52.20, -56.90) PAGE_SIZE = 50 LIST_QUERY = """query LandsSearch($landFilter: LandFilterInput!, $privateOffset: Int, $privateLimit: Int) { lands(landFilter: $landFilter) { privateLands(offset: $privateOffset, limit: $privateLimit) { total edges { availableAccommodationKeys availableCampsitesCount node { allAccommodationKeys cityName coordinate { latitude longitude } countryCode id maskedId name stateAbbrvName locationSummary topPhotos { filename } url } pricePerNight { symbol format minorAmount } } } } }""" DETAIL_QUERY = """query Land($landId: ID!, $landIdType: LandIdTypeEnum!) { land(landId: $landId, landIdType: $landIdType) { maskedId fullName cityName countyName overview subheader maxSiteCapacity campsiteCount structureCount rvCount tentCount recommendsPercentage recommendsCount bookingsCount coordinate { lat lng } minPricePerNight { amount format isoCode } coreAmenities: landCampFeatures(type: CORE_AMENITY) { name } basicAmenities: landCampFeatures(type: BASIC_AMENITY) { name } activities: landCampFeatures(type: ACTIVITY) { name } } }""" _PRICE_FMT_RE = re.compile(r"CA\$([\d,]+(?:\.\d{2})?)") def _photo_url(filename: str) -> str: """URL CDN d'une photo — deux formats de filename coexistent.""" if not filename: return "" if filename.startswith("images/"): # chemin complet : pas de transform return f"{CDN}/{filename}" return f"{CDN}/f_auto,c_limit,w_1120,q_auto/{filename}" def _property_type(keys: list[str]) -> str: """tent/rv = emplacements nus, house = unités bâties (cabane, dôme…).""" ks = {str(k).lower() for k in (keys or [])} if "house" in ks: return "Chalet" if not (ks & {"tent", "rv"}) else "Prêt-à-camper" return "Camping" def _price_cad(price: dict) -> tuple[float | None, str]: fmt = (price or {}).get("format") or "" m = _PRICE_FMT_RE.search(fmt) if not m: return None, fmt return float(m.group(1).replace(",", "")), f"{fmt} / nuit" class Hipcamp(StConnector): source_id = "hipcamp" request_delay = 0.5 def _graphql(self, url: str, query: str, variables: dict) -> dict: resp = self.post(url, json={"query": query, "variables": variables}, headers=HEADERS) data = resp.json() if data.get("errors"): raise RuntimeError(str(data["errors"])[:200]) return data.get("data") or {} # -- liste ------------------------------------------------------------------ def _all_edges(self) -> list[dict]: s, w, n, e = QC_BBOX land_filter = {"boundingBox": { "northeastLatitude": n, "northeastLongitude": e, "southwestLatitude": s, "southwestLongitude": w}} edges: list[dict] = [] offset, total = 0, 1 while offset < min(total, 3000): data = self._graphql(GRAPHQL_SEARCH, LIST_QUERY, { "landFilter": land_filter, "privateOffset": offset, "privateLimit": PAGE_SIZE}) page = ((data.get("lands") or {}).get("privateLands") or {}) batch = page.get("edges") or [] total = page.get("total") or 0 if not batch: break edges.extend(batch) offset += PAGE_SIZE return edges # -- détail (cache BD) -------------------------------------------------------- def _fetch_detail(self, masked_id: str) -> dict: data = self._graphql(GRAPHQL_CAMPER, DETAIL_QUERY, {"landId": masked_id, "landIdType": "MASKED"}) land = data.get("land") or {} if not land: return {} amenities = [] for grp in ("coreAmenities", "basicAmenities"): for it in land.get(grp) or []: name = (it or {}).get("name") or "" if name and name not in amenities: amenities.append(name) activities = [a.get("name") for a in (land.get("activities") or []) if (a or {}).get("name")] overview = re.sub(r"\s+", " ", land.get("overview") or "").strip() price = land.get("minPricePerNight") or {} return { "description": overview[:4000], "subheader": land.get("subheader") or "", "county": land.get("countyName") or "", "capacity": land.get("maxSiteCapacity"), "campsites": land.get("campsiteCount"), "structures": land.get("structureCount"), "amenities": amenities, "activities": activities, "recommends_pct": land.get("recommendsPercentage"), "recommends_count": land.get("recommendsCount"), "min_price": (price.get("amount") if price.get("isoCode") == "CAD" else None), } # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_HIPCAMP_LIMIT", "0") or 0) listings: list[StListing] = [] seen: set[str] = set() for edge in self._all_edges(): node = edge.get("node") or {} mid = str(node.get("maskedId") or "").strip() title = (node.get("name") or "").strip() if not mid or mid in seen or not title: continue if (node.get("stateAbbrvName") or "").upper() != "QC": continue seen.add(mid) coord = node.get("coordinate") or {} lat, lng = coord.get("latitude"), coord.get("longitude") if lat is not None and lng is not None and not _in_quebec(lat, lng): continue images = [] for ph in (node.get("topPhotos") or [])[:15]: u = _photo_url((ph or {}).get("filename") or "") if u and u not in images: images.append(u) keys = node.get("allAccommodationKeys") or [] price_night, price_label = _price_cad(edge.get("pricePerNight")) # clé de cache détail : sous-ensemble stable de la carte liste key = f"{title}|{','.join(sorted(keys))}|{len(images)}" try: det = self.detail(mid, key, lambda m=mid: self._fetch_detail(m)) except Exception as exc: # noqa: BLE001 — détail cassé ≠ perdu print(f"[hipcamp] détail {mid} : {exc}", file=sys.stderr) det = {} if price_night is None and det.get("min_price"): price_night = float(det["min_price"]) price_label = f"à partir de {price_night:.0f} $ / nuit" details = {k: v for k, v in { "accommodation_keys": ", ".join(keys) or None, "campsites": det.get("campsites"), "structures": det.get("structures"), "county": det.get("county"), "recommends_pct": det.get("recommends_pct"), "recommends_count": det.get("recommends_count"), "activities": det.get("activities") or None, }.items() if v} cap = det.get("capacity") listings.append(StListing( source=self.source_id, external_id=mid, url=SITE + (node.get("url") or f"/en-CA/land/{mid}"), title=title, property_type=_property_type(keys), city=node.get("cityName") or "", region=_region_from_latlng(lat, lng), price_night=price_night, price_label=price_label, capacity=float(cap) if cap else None, description=det.get("description") or "", amenities=det.get("amenities") or [], details=details, images=images, lat=lat, lng=lng, )) if limit and len(listings) >= limit: break return listings