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/parcscanada.py : Parcs Canada (reservation.pc.gc.ca) —4# hébergements « prêt-à-camper » des parcs nationaux et lieux historiques5# fédéraux AU QUÉBEC : oTENTik, Ôasis, MicrOcube, tentes de prospecteur…6# (Forillon, La Mauricie, Archipel-de-Mingan, canaux de Lachine et7# de Saint-Ours). ~60 unités.8#9# Méthode : le système de réservation (GoingToCamp/Aspira, derrière un WAF10# Azure qui laisse passer les requêtes au User-Agent de navigateur) expose11# une API JSON publique :12# 1. GET /api/resourcecategory → id de catégorie → nom fr (oTENTik, Ôasis,13# Yourte, Chalet rustique, Tente de prospecteur, MicrOcube…) ;14# 2. GET /api/resourcelocation/resources?resourceLocationId=<id> → TOUTES15# les ressources d'un terrain (emplacements + hébergements) : nom,16# description fr, capacité, photos, catégorie. On ne garde que les17# catégories « hébergement » (table KEEP ci-dessous) ;18# 3. prix (cache self.detail, clé mensuelle) : POST /api/resource/feeDetails19# ?resourceId=…&startDate=<J+n> → feeTotal = tarif d'une nuit. Les unités20# fermées à J+35 (saisonnier) sont réessayées à J+95/185/275 ;21# 4. commodités : chaque ressource porte des definedAttributes22# (attributeDefinitionId + valeur/énums) que GET /api/attribute/filterable23# permet de traduire en libellés français (« Foyer sur l'emplacement »,24# « Chiens permis », « Éclairage fourni : solaire »…) — on ne retient que25# les attributs pertinents (table AMEN_DEFS), les dimensions techniques26# d'emplacement sont ignorées.27#28# Les ids sont des entiers négatifs (int32 min + n). Aucune géoloc par unité29# dans l'API : lat/lng et région touristique viennent de la table statique30# des terrains québécois (QC_LOCATIONS) — fait géographique stable.31# Pas de page publique par unité : l'URL pointe la recherche du terrain.32# Réglage env : LOUKA_PARCSCANADA_LIMIT (nb max d'unités, 0 = tout).33# -----------------------------------------------------------------------------34from __future__ import annotations3536import datetime37import html as _html38import os39import re40import sys4142from ..schema import StListing43from .base import StConnector4445BASE = "https://reservation.pc.gc.ca"4647HEADERS = {"Accept": "application/json, text/plain, */*",48 "Accept-Language": "fr",49 "Referer": BASE + "/"}5051# Terrains de Parcs Canada au Québec : id → (nom court, région touristique,52# lat, lng approximatifs du terrain). Les autres provinces sont ignorées.53QC_LOCATIONS = {54 -2147483625: ("Forillon – Des-Rosiers", "Gaspésie", 48.879, -64.295),55 -2147483626: ("Forillon – Petit-Gaspé", "Gaspésie", 48.812, -64.279),56 -2147483627: ("Forillon – Cap-Bon-Ami", "Gaspésie", 48.868, -64.235),57 -2147483534: ("Forillon – Arrière-pays", "Gaspésie", 48.850, -64.350),58 -2147483575: ("La Mauricie – Mistagance", "Mauricie", 46.790, -72.940),59 -2147483573: ("La Mauricie – Rivière-à-la-Pêche", "Mauricie",60 46.672, -72.920),61 -2147483524: ("La Mauricie – Saint-Jean-des-Piles", "Mauricie",62 46.669, -72.752),63 -2147483578: ("La Mauricie – Wapizagonke", "Mauricie", 46.757, -73.005),64 -2147483570: ("Archipel-de-Mingan", "Côte-Nord", 50.222, -63.600),65 -2147483571: ("Canal-de-Lachine", "Montréal", 45.430, -73.670),66 -2147483548: ("Canal-de-Saint-Ours", "Montérégie", 45.869, -73.150),67 -2147483513: ("Canal-de-Chambly", "Montérégie", 45.441, -73.283),68}6970# Catégories « hébergement » (les emplacements de camping nus sont exclus)71KEEP = {72 -2147483647: "Yourte", # Yourte73 -2147483646: "Chalet", # Chalet74 -2147483645: "Chalet", # Chalet rustique75 -2147483644: "Prêt-à-camper", # Ôasis (cocon suspendu)76 -2147483643: "Prêt-à-camper", # oTENTik77 -2147483642: "Mini-maison", # MicrOcube78 -2147483635: "Prêt-à-camper", # Camping tout équipé79 -2147483634: "Chalet", # Chalet en arrière-pays80 -2147483633: "Yourte", # Yourte en arrière-pays81 -2147483631: "Prêt-à-camper", # Tipi82 -2147483630: "Prêt-à-camper", # Tente de prospecteur83 -2147483629: "Refuge", # Abri en zone d'arrière-pays84}8586_TAG_RE = re.compile(r"<[^>]+>")8788# Attributs « commodités » pertinents (id de définition → retenu). Les autres89# (dimensions, pentes, obstructions, ampérage…) sont du bruit technique.90AMEN_DEFS = {91 -32758, # Foyer sur l'emplacement92 -32571, # Type de foyer93 -32717, # Feux de camp permis94 -32721, # Admissible au permis de feu95 -32753, # Wi-fi96 -32754, # Couverture cellulaire97 -32760, # Barbecue fourni98 -32570, # Fourneau fourni99 -32762, # Source de chaleur interne disponible100 -32763, # Éclairage fourni101 -32764, # Électricité disponible à l'intérieur102 -32761, # Électricité disponible à l'extérieur103 -32582, # Électricité104 -32736, # Service d'eau105 -32756, # Accessible106 -32723, # Secteur riverain107 -32724, # Accès au rivage108 -32725, # Conditions de baignade109 -32757, # Tables de pique-nique110 -32748, # Ombrage à l'emplacement111 -32759, # Casiers à provisions fournis112 -32713, # Stationnement113 -32709, # Permis de pêche inclus114 -32715, # Vélos permis115}116PETS_DEFS = {-32718, -32572} # Chiens permis / Animaux de compagnie permis117MIN_STAY_DEF = -32697 # Séjour minimum (nuits)118119# valeurs d'énum « négatives » : l'attribut est alors omis des commodités120_VAL_NEGATIVES = {"non", "no", "aucun", "aucune", "none", "n/a"}121122123def _fr(localized: list[dict], *keys: str) -> dict:124 by_culture = {v.get("cultureName"): v for v in (localized or [])}125 return by_culture.get("fr-CA") or by_culture.get("en-CA") or {}126127128def _pets(description: str) -> str | None:129 low = description.lower()130 if re.search(r"accueille les animaux|animaux .{0,20}(admis|accept|bienvenu)",131 low):132 return "oui"133 if re.search(r"n'accueille pas les animaux|animaux .{0,20}(interdits|"134 r"pas admis|non admis)|aucun animal", low):135 return "non"136 return None137138139class ParcsCanada(StConnector):140 source_id = "parcscanada"141 request_delay = 0.5142143 def _api(self, path: str, **params) -> object:144 resp = self.get(BASE + path, params=params or None, headers=HEADERS)145 return resp.json()146147 # -- prix d'une nuit (cache BD, clé mensuelle) -------------------------------148 def _fetch_fee(self, resource_id: int) -> dict:149 # unités saisonnières : pas de tarif hors saison → on sonde plusieurs150 # dates réparties sur l'année jusqu'à trouver une nuit tarifée151 for jours in (35, 95, 185, 275):152 start = (datetime.date.today()153 + datetime.timedelta(days=jours)).isoformat()154 try:155 resp = self.post(156 BASE + "/api/resource/feeDetails",157 params={"resourceId": resource_id, "startDate": start,158 "boatLength": 0, "bookingCategoryId": 0,159 "entryPointResourceId": 0,160 "exitPointResourceId": 0},161 json=[], headers=HEADERS)162 except Exception: # noqa: BLE001 — on tente la date suivante163 continue164 fees = (resp.json() or {}).get("resourceFeeDetails") or []165 total = sum(f.get("feeTotal") or 0 for f in fees)166 if total > 0:167 return {"fee": round(total, 2), "date": start}168 return {}169170 # -- définitions d'attributs (libellés fr des commodités) --------------------171 def _fetch_attr_defs(self) -> dict:172 data = self._api("/api/attribute/filterable") or {}173 defs: dict = {}174 for key, v in data.items():175 fr = _fr(v.get("localizedValues") or [])176 name = fr.get("displayName") or ""177 if not name:178 continue179 values = {}180 for ev in v.get("values") or []:181 lab = _fr(ev.get("localizedValues") or []).get("displayName")182 if lab is not None and ev.get("enumValue") is not None:183 values[str(ev["enumValue"])] = lab184 defs[str(key)] = {"name": name, "values": values}185 return defs186187 @staticmethod188 def _amenities(res: dict, defs: dict) -> tuple[list[str], str | None,189 float | None]:190 """(commodités, animaux oui/non, séjour minimum) d'une ressource."""191 amen: list[str] = []192 pets: str | None = None193 min_stay: float | None = None194 for da in res.get("definedAttributes") or []:195 did = da.get("attributeDefinitionId")196 d = defs.get(str(did))197 if did == MIN_STAY_DEF and da.get("value") is not None:198 min_stay = float(da["value"])199 continue200 if d is None:201 continue202 labels = [d["values"].get(str(v)) for v in (da.get("values") or [])]203 labels = [l for l in labels if l]204 if did in PETS_DEFS and labels:205 pets = "non" if all(206 l.lower() in _VAL_NEGATIVES for l in labels) else "oui"207 continue208 if did not in AMEN_DEFS:209 continue210 positifs = [l for l in labels211 if l.lower() not in _VAL_NEGATIVES]212 if not positifs:213 continue214 if all(l.lower() in ("oui", "yes") for l in positifs):215 item = d["name"] # booléen : le nom suffit216 else:217 item = f"{d['name']} : {', '.join(positifs)}"218 if item not in amen:219 amen.append(item)220 # l'API renvoie les attributs dans un ordre variable : trier pour221 # un diff stable dans la base222 return sorted(amen), pets, min_stay223224 # -- contrat --------------------------------------------------------------225 def fetch(self) -> list[StListing]:226 limit = int(os.environ.get("LOUKA_PARCSCANADA_LIMIT", "0") or 0)227228 cat_names: dict[int, str] = {}229 for c in self._api("/api/resourcecategory") or []:230 name = _fr(c.get("localizedValues")).get("name") or ""231 if name:232 cat_names[c.get("resourceCategoryId")] = name233234 month = datetime.date.today().strftime("%Y-%m") # prix revus au mois235236 # libellés fr des attributs (une requête, cache BD mensuel)237 try:238 attr_defs = self.detail("_attrdefs", month, self._fetch_attr_defs)239 except Exception as exc: # noqa: BLE001 — les commodités sont optionnelles240 print(f"[parcscanada] définitions d'attributs : {exc}",241 file=sys.stderr)242 attr_defs = {}243 listings: list[StListing] = []244 for loc_id, (loc_name, region, lat, lng) in QC_LOCATIONS.items():245 try:246 resources = self._api("/api/resourcelocation/resources",247 resourceLocationId=loc_id) or {}248 except Exception as exc: # noqa: BLE001 — un terrain HS ≠ tout perdu249 print(f"[parcscanada] terrain {loc_name} : {exc}",250 file=sys.stderr)251 continue252253 for res in resources.values():254 cat_id = res.get("resourceCategoryId")255 if cat_id not in KEEP:256 continue257 rid = res.get("resourceId")258 fr = _fr(res.get("localizedValues"))259 name = (fr.get("name") or "").strip()260 if rid is None or not name:261 continue262263 desc = _html.unescape(_TAG_RE.sub(264 " ", (fr.get("description") or "").replace("<br>", "\n")))265 desc = re.sub(r"[ \t]+", " ", desc).strip()266267 images = []268 for ph in (res.get("photos") or [])[:12]:269 u = ((ph or {}).get("photoUrlResult") or {}).get("url") or ""270 if u.startswith("https://") and u not in images:271 images.append(u)272273 cat_fr = cat_names.get(cat_id, KEEP[cat_id])274 title = f"{cat_fr} {name} – {loc_name}"275276 try:277 # « v2 » : sondage multi-dates (unités saisonnières)278 fee = self.detail(str(rid), month + ":v2",279 lambda r=rid: self._fetch_fee(r))280 except Exception as exc: # noqa: BLE001 — le prix est optionnel281 print(f"[parcscanada] tarif {rid} : {exc}", file=sys.stderr)282 fee = {}283 price = fee.get("fee")284285 amenities, pets_attr, min_stay = self._amenities(286 res, attr_defs)287 details = {"parc": loc_name, "categorie": cat_fr}288 if min_stay:289 details["sejour_minimum"] = min_stay290291 cap = res.get("maxCapacity")292 listings.append(StListing(293 source=self.source_id,294 external_id=str(rid),295 url=(f"{BASE}/create-booking/results?resourceLocationId="296 f"{loc_id}&searchTabGroupId=0&bookingCategoryId=0"),297 title=title,298 property_type=KEEP[cat_id],299 address=loc_name,300 region=region,301 price_night=float(price) if price else None,302 price_label=(f"{price:.2f} $ / nuit" if price else ""),303 capacity=float(cap) if cap else None,304 pets=pets_attr or _pets(desc),305 description=desc[:4000],306 amenities=amenities,307 details=details,308 images=images,309 lat=lat,310 lng=lng,311 ).finalize())312 if limit and len(listings) >= limit:313 return listings314 return listings315