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/hipcamp.py : Hipcamp (hipcamp.com) — camping et prêt-à-camper4# chez des hôtes privés (terres agricoles, boisés…), ~350 terrains au Québec.5#6# Méthode : le site (Next.js) parle à un GraphQL public, accessible sans7# session avec les en-têtes HIPCAMP-API-KEY (clé publique embarquée dans le8# bundle JS) et HIPCAMP-PLATFORM: Web.9# 1. LISTE : POST https://www.hipcamp.com/graphql/search — requête10# LandsSearch(landFilter: {boundingBox: <bbox Québec>}) paginée par11# offset/limit (50 par page). Chaque « land » (terrain, souvent12# multi-emplacements) : nom, ville, coordonnées, prix/nuit (« CA$54.00 »),13# types d'hébergement (tent/rv/house), photos, % de recommandations.14# Le bbox mord sur l'Ontario et le Maine : on garde stateAbbrvName == QC.15# 2. DÉTAIL (cache self.detail) : POST /graphql/camper — requête Land par16# maskedId : description (overview, souvent bilingue avec no CITQ),17# capacité max, nb d'emplacements par type, commodités et activités.18# 3. Photos : CDN Cloudinary (https://hipcamp-res.cloudinary.com/…).19#20# Pas de note sur 5 chez Hipcamp : % de recommandations → details. Région21# touristique déduite des coordonnées (centroïdes partagés avec airbnb.py).22# Réglage env : LOUKA_HIPCAMP_LIMIT (nb max de terrains, 0 = tout).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import os27import re28import sys2930from ..schema import StListing31from .base import StConnector32from .airbnb import _in_quebec, _region_from_latlng3334SITE = "https://www.hipcamp.com"35GRAPHQL_SEARCH = f"{SITE}/graphql/search"36GRAPHQL_CAMPER = f"{SITE}/graphql/camper"37CDN = "https://hipcamp-res.cloudinary.com"3839# Clé API publique (embarquée dans le bundle JS du site, module 79018)40HEADERS = {41 "Content-Type": "application/json",42 "Accept": "application/json",43 "HIPCAMP-API-KEY": "Dp7qfhE8y8cTx73qSYu8b6M2",44 "HIPCAMP-PLATFORM": "Web",45}4647# Zone habitée du Québec (sud, ouest, nord, est) — même bbox qu'airbnb.py48QC_BBOX = (44.95, -79.80, 52.20, -56.90)49PAGE_SIZE = 505051LIST_QUERY = """query LandsSearch($landFilter: LandFilterInput!, $privateOffset: Int, $privateLimit: Int) {52 lands(landFilter: $landFilter) {53 privateLands(offset: $privateOffset, limit: $privateLimit) {54 total55 edges {56 availableAccommodationKeys57 availableCampsitesCount58 node {59 allAccommodationKeys60 cityName61 coordinate { latitude longitude }62 countryCode63 id64 maskedId65 name66 stateAbbrvName67 locationSummary68 topPhotos { filename }69 url70 }71 pricePerNight { symbol format minorAmount }72 }73 }74 }75}"""7677DETAIL_QUERY = """query Land($landId: ID!, $landIdType: LandIdTypeEnum!) {78 land(landId: $landId, landIdType: $landIdType) {79 maskedId fullName cityName countyName overview subheader80 maxSiteCapacity campsiteCount structureCount rvCount tentCount81 recommendsPercentage recommendsCount bookingsCount82 coordinate { lat lng }83 minPricePerNight { amount format isoCode }84 coreAmenities: landCampFeatures(type: CORE_AMENITY) { name }85 basicAmenities: landCampFeatures(type: BASIC_AMENITY) { name }86 activities: landCampFeatures(type: ACTIVITY) { name }87 }88}"""8990_PRICE_FMT_RE = re.compile(r"CA\$([\d,]+(?:\.\d{2})?)")919293def _photo_url(filename: str) -> str:94 """URL CDN d'une photo — deux formats de filename coexistent."""95 if not filename:96 return ""97 if filename.startswith("images/"): # chemin complet : pas de transform98 return f"{CDN}/{filename}"99 return f"{CDN}/f_auto,c_limit,w_1120,q_auto/{filename}"100101102def _property_type(keys: list[str]) -> str:103 """tent/rv = emplacements nus, house = unités bâties (cabane, dôme…)."""104 ks = {str(k).lower() for k in (keys or [])}105 if "house" in ks:106 return "Chalet" if not (ks & {"tent", "rv"}) else "Prêt-à-camper"107 return "Camping"108109110def _price_cad(price: dict) -> tuple[float | None, str]:111 fmt = (price or {}).get("format") or ""112 m = _PRICE_FMT_RE.search(fmt)113 if not m:114 return None, fmt115 return float(m.group(1).replace(",", "")), f"{fmt} / nuit"116117118class Hipcamp(StConnector):119 source_id = "hipcamp"120 request_delay = 0.5121122 def _graphql(self, url: str, query: str, variables: dict) -> dict:123 resp = self.post(url, json={"query": query, "variables": variables},124 headers=HEADERS)125 data = resp.json()126 if data.get("errors"):127 raise RuntimeError(str(data["errors"])[:200])128 return data.get("data") or {}129130 # -- liste ------------------------------------------------------------------131 def _all_edges(self) -> list[dict]:132 s, w, n, e = QC_BBOX133 land_filter = {"boundingBox": {134 "northeastLatitude": n, "northeastLongitude": e,135 "southwestLatitude": s, "southwestLongitude": w}}136 edges: list[dict] = []137 offset, total = 0, 1138 while offset < min(total, 3000):139 data = self._graphql(GRAPHQL_SEARCH, LIST_QUERY, {140 "landFilter": land_filter,141 "privateOffset": offset, "privateLimit": PAGE_SIZE})142 page = ((data.get("lands") or {}).get("privateLands") or {})143 batch = page.get("edges") or []144 total = page.get("total") or 0145 if not batch:146 break147 edges.extend(batch)148 offset += PAGE_SIZE149 return edges150151 # -- détail (cache BD) --------------------------------------------------------152 def _fetch_detail(self, masked_id: str) -> dict:153 data = self._graphql(GRAPHQL_CAMPER, DETAIL_QUERY,154 {"landId": masked_id, "landIdType": "MASKED"})155 land = data.get("land") or {}156 if not land:157 return {}158 amenities = []159 for grp in ("coreAmenities", "basicAmenities"):160 for it in land.get(grp) or []:161 name = (it or {}).get("name") or ""162 if name and name not in amenities:163 amenities.append(name)164 activities = [a.get("name") for a in (land.get("activities") or [])165 if (a or {}).get("name")]166 overview = re.sub(r"\s+", " ", land.get("overview") or "").strip()167 price = land.get("minPricePerNight") or {}168 return {169 "description": overview[:4000],170 "subheader": land.get("subheader") or "",171 "county": land.get("countyName") or "",172 "capacity": land.get("maxSiteCapacity"),173 "campsites": land.get("campsiteCount"),174 "structures": land.get("structureCount"),175 "amenities": amenities,176 "activities": activities,177 "recommends_pct": land.get("recommendsPercentage"),178 "recommends_count": land.get("recommendsCount"),179 "min_price": (price.get("amount")180 if price.get("isoCode") == "CAD" else None),181 }182183 # -- contrat --------------------------------------------------------------184 def fetch(self) -> list[StListing]:185 limit = int(os.environ.get("LOUKA_HIPCAMP_LIMIT", "0") or 0)186 listings: list[StListing] = []187 seen: set[str] = set()188 for edge in self._all_edges():189 node = edge.get("node") or {}190 mid = str(node.get("maskedId") or "").strip()191 title = (node.get("name") or "").strip()192 if not mid or mid in seen or not title:193 continue194 if (node.get("stateAbbrvName") or "").upper() != "QC":195 continue196 seen.add(mid)197198 coord = node.get("coordinate") or {}199 lat, lng = coord.get("latitude"), coord.get("longitude")200 if lat is not None and lng is not None and not _in_quebec(lat, lng):201 continue202203 images = []204 for ph in (node.get("topPhotos") or [])[:15]:205 u = _photo_url((ph or {}).get("filename") or "")206 if u and u not in images:207 images.append(u)208209 keys = node.get("allAccommodationKeys") or []210 price_night, price_label = _price_cad(edge.get("pricePerNight"))211212 # clé de cache détail : sous-ensemble stable de la carte liste213 key = f"{title}|{','.join(sorted(keys))}|{len(images)}"214 try:215 det = self.detail(mid, key,216 lambda m=mid: self._fetch_detail(m))217 except Exception as exc: # noqa: BLE001 — détail cassé ≠ perdu218 print(f"[hipcamp] détail {mid} : {exc}", file=sys.stderr)219 det = {}220221 if price_night is None and det.get("min_price"):222 price_night = float(det["min_price"])223 price_label = f"à partir de {price_night:.0f} $ / nuit"224225 details = {k: v for k, v in {226 "accommodation_keys": ", ".join(keys) or None,227 "campsites": det.get("campsites"),228 "structures": det.get("structures"),229 "county": det.get("county"),230 "recommends_pct": det.get("recommends_pct"),231 "recommends_count": det.get("recommends_count"),232 "activities": det.get("activities") or None,233 }.items() if v}234235 cap = det.get("capacity")236 listings.append(StListing(237 source=self.source_id,238 external_id=mid,239 url=SITE + (node.get("url") or f"/en-CA/land/{mid}"),240 title=title,241 property_type=_property_type(keys),242 city=node.get("cityName") or "",243 region=_region_from_latlng(lat, lng),244 price_night=price_night,245 price_label=price_label,246 capacity=float(cap) if cap else None,247 description=det.get("description") or "",248 amenities=det.get("amenities") or [],249 details=details,250 images=images,251 lat=lat,252 lng=lng,253 ))254 if limit and len(listings) >= limit:255 break256 return listings257