# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/parcscanada.py : Parcs Canada (reservation.pc.gc.ca) — # hébergements « prêt-à-camper » des parcs nationaux et lieux historiques # fédéraux AU QUÉBEC : oTENTik, Ôasis, MicrOcube, tentes de prospecteur… # (Forillon, La Mauricie, Archipel-de-Mingan, canaux de Lachine et # de Saint-Ours). ~60 unités. # # Méthode : le système de réservation (GoingToCamp/Aspira, derrière un WAF # Azure qui laisse passer les requêtes au User-Agent de navigateur) expose # une API JSON publique : # 1. GET /api/resourcecategory → id de catégorie → nom fr (oTENTik, Ôasis, # Yourte, Chalet rustique, Tente de prospecteur, MicrOcube…) ; # 2. GET /api/resourcelocation/resources?resourceLocationId= → TOUTES # les ressources d'un terrain (emplacements + hébergements) : nom, # description fr, capacité, photos, catégorie. On ne garde que les # catégories « hébergement » (table KEEP ci-dessous) ; # 3. prix (cache self.detail, clé mensuelle) : POST /api/resource/feeDetails # ?resourceId=…&startDate= → feeTotal = tarif d'une nuit. Les unités # fermées à J+35 (saisonnier) sont réessayées à J+95/185/275 ; # 4. commodités : chaque ressource porte des definedAttributes # (attributeDefinitionId + valeur/énums) que GET /api/attribute/filterable # permet de traduire en libellés français (« Foyer sur l'emplacement », # « Chiens permis », « Éclairage fourni : solaire »…) — on ne retient que # les attributs pertinents (table AMEN_DEFS), les dimensions techniques # d'emplacement sont ignorées. # # Les ids sont des entiers négatifs (int32 min + n). Aucune géoloc par unité # dans l'API : lat/lng et région touristique viennent de la table statique # des terrains québécois (QC_LOCATIONS) — fait géographique stable. # Pas de page publique par unité : l'URL pointe la recherche du terrain. # Réglage env : LOUKA_PARCSCANADA_LIMIT (nb max d'unités, 0 = tout). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import html as _html import os import re import sys from ..schema import StListing from .base import StConnector BASE = "https://reservation.pc.gc.ca" HEADERS = {"Accept": "application/json, text/plain, */*", "Accept-Language": "fr", "Referer": BASE + "/"} # Terrains de Parcs Canada au Québec : id → (nom court, région touristique, # lat, lng approximatifs du terrain). Les autres provinces sont ignorées. QC_LOCATIONS = { -2147483625: ("Forillon – Des-Rosiers", "Gaspésie", 48.879, -64.295), -2147483626: ("Forillon – Petit-Gaspé", "Gaspésie", 48.812, -64.279), -2147483627: ("Forillon – Cap-Bon-Ami", "Gaspésie", 48.868, -64.235), -2147483534: ("Forillon – Arrière-pays", "Gaspésie", 48.850, -64.350), -2147483575: ("La Mauricie – Mistagance", "Mauricie", 46.790, -72.940), -2147483573: ("La Mauricie – Rivière-à-la-Pêche", "Mauricie", 46.672, -72.920), -2147483524: ("La Mauricie – Saint-Jean-des-Piles", "Mauricie", 46.669, -72.752), -2147483578: ("La Mauricie – Wapizagonke", "Mauricie", 46.757, -73.005), -2147483570: ("Archipel-de-Mingan", "Côte-Nord", 50.222, -63.600), -2147483571: ("Canal-de-Lachine", "Montréal", 45.430, -73.670), -2147483548: ("Canal-de-Saint-Ours", "Montérégie", 45.869, -73.150), -2147483513: ("Canal-de-Chambly", "Montérégie", 45.441, -73.283), } # Catégories « hébergement » (les emplacements de camping nus sont exclus) KEEP = { -2147483647: "Yourte", # Yourte -2147483646: "Chalet", # Chalet -2147483645: "Chalet", # Chalet rustique -2147483644: "Prêt-à-camper", # Ôasis (cocon suspendu) -2147483643: "Prêt-à-camper", # oTENTik -2147483642: "Mini-maison", # MicrOcube -2147483635: "Prêt-à-camper", # Camping tout équipé -2147483634: "Chalet", # Chalet en arrière-pays -2147483633: "Yourte", # Yourte en arrière-pays -2147483631: "Prêt-à-camper", # Tipi -2147483630: "Prêt-à-camper", # Tente de prospecteur -2147483629: "Refuge", # Abri en zone d'arrière-pays } _TAG_RE = re.compile(r"<[^>]+>") # Attributs « commodités » pertinents (id de définition → retenu). Les autres # (dimensions, pentes, obstructions, ampérage…) sont du bruit technique. AMEN_DEFS = { -32758, # Foyer sur l'emplacement -32571, # Type de foyer -32717, # Feux de camp permis -32721, # Admissible au permis de feu -32753, # Wi-fi -32754, # Couverture cellulaire -32760, # Barbecue fourni -32570, # Fourneau fourni -32762, # Source de chaleur interne disponible -32763, # Éclairage fourni -32764, # Électricité disponible à l'intérieur -32761, # Électricité disponible à l'extérieur -32582, # Électricité -32736, # Service d'eau -32756, # Accessible -32723, # Secteur riverain -32724, # Accès au rivage -32725, # Conditions de baignade -32757, # Tables de pique-nique -32748, # Ombrage à l'emplacement -32759, # Casiers à provisions fournis -32713, # Stationnement -32709, # Permis de pêche inclus -32715, # Vélos permis } PETS_DEFS = {-32718, -32572} # Chiens permis / Animaux de compagnie permis MIN_STAY_DEF = -32697 # Séjour minimum (nuits) # valeurs d'énum « négatives » : l'attribut est alors omis des commodités _VAL_NEGATIVES = {"non", "no", "aucun", "aucune", "none", "n/a"} def _fr(localized: list[dict], *keys: str) -> dict: by_culture = {v.get("cultureName"): v for v in (localized or [])} return by_culture.get("fr-CA") or by_culture.get("en-CA") or {} def _pets(description: str) -> str | None: low = description.lower() if re.search(r"accueille les animaux|animaux .{0,20}(admis|accept|bienvenu)", low): return "oui" if re.search(r"n'accueille pas les animaux|animaux .{0,20}(interdits|" r"pas admis|non admis)|aucun animal", low): return "non" return None class ParcsCanada(StConnector): source_id = "parcscanada" request_delay = 0.5 def _api(self, path: str, **params) -> object: resp = self.get(BASE + path, params=params or None, headers=HEADERS) return resp.json() # -- prix d'une nuit (cache BD, clé mensuelle) ------------------------------- def _fetch_fee(self, resource_id: int) -> dict: # unités saisonnières : pas de tarif hors saison → on sonde plusieurs # dates réparties sur l'année jusqu'à trouver une nuit tarifée for jours in (35, 95, 185, 275): start = (datetime.date.today() + datetime.timedelta(days=jours)).isoformat() try: resp = self.post( BASE + "/api/resource/feeDetails", params={"resourceId": resource_id, "startDate": start, "boatLength": 0, "bookingCategoryId": 0, "entryPointResourceId": 0, "exitPointResourceId": 0}, json=[], headers=HEADERS) except Exception: # noqa: BLE001 — on tente la date suivante continue fees = (resp.json() or {}).get("resourceFeeDetails") or [] total = sum(f.get("feeTotal") or 0 for f in fees) if total > 0: return {"fee": round(total, 2), "date": start} return {} # -- définitions d'attributs (libellés fr des commodités) -------------------- def _fetch_attr_defs(self) -> dict: data = self._api("/api/attribute/filterable") or {} defs: dict = {} for key, v in data.items(): fr = _fr(v.get("localizedValues") or []) name = fr.get("displayName") or "" if not name: continue values = {} for ev in v.get("values") or []: lab = _fr(ev.get("localizedValues") or []).get("displayName") if lab is not None and ev.get("enumValue") is not None: values[str(ev["enumValue"])] = lab defs[str(key)] = {"name": name, "values": values} return defs @staticmethod def _amenities(res: dict, defs: dict) -> tuple[list[str], str | None, float | None]: """(commodités, animaux oui/non, séjour minimum) d'une ressource.""" amen: list[str] = [] pets: str | None = None min_stay: float | None = None for da in res.get("definedAttributes") or []: did = da.get("attributeDefinitionId") d = defs.get(str(did)) if did == MIN_STAY_DEF and da.get("value") is not None: min_stay = float(da["value"]) continue if d is None: continue labels = [d["values"].get(str(v)) for v in (da.get("values") or [])] labels = [l for l in labels if l] if did in PETS_DEFS and labels: pets = "non" if all( l.lower() in _VAL_NEGATIVES for l in labels) else "oui" continue if did not in AMEN_DEFS: continue positifs = [l for l in labels if l.lower() not in _VAL_NEGATIVES] if not positifs: continue if all(l.lower() in ("oui", "yes") for l in positifs): item = d["name"] # booléen : le nom suffit else: item = f"{d['name']} : {', '.join(positifs)}" if item not in amen: amen.append(item) # l'API renvoie les attributs dans un ordre variable : trier pour # un diff stable dans la base return sorted(amen), pets, min_stay # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_PARCSCANADA_LIMIT", "0") or 0) cat_names: dict[int, str] = {} for c in self._api("/api/resourcecategory") or []: name = _fr(c.get("localizedValues")).get("name") or "" if name: cat_names[c.get("resourceCategoryId")] = name month = datetime.date.today().strftime("%Y-%m") # prix revus au mois # libellés fr des attributs (une requête, cache BD mensuel) try: attr_defs = self.detail("_attrdefs", month, self._fetch_attr_defs) except Exception as exc: # noqa: BLE001 — les commodités sont optionnelles print(f"[parcscanada] définitions d'attributs : {exc}", file=sys.stderr) attr_defs = {} listings: list[StListing] = [] for loc_id, (loc_name, region, lat, lng) in QC_LOCATIONS.items(): try: resources = self._api("/api/resourcelocation/resources", resourceLocationId=loc_id) or {} except Exception as exc: # noqa: BLE001 — un terrain HS ≠ tout perdu print(f"[parcscanada] terrain {loc_name} : {exc}", file=sys.stderr) continue for res in resources.values(): cat_id = res.get("resourceCategoryId") if cat_id not in KEEP: continue rid = res.get("resourceId") fr = _fr(res.get("localizedValues")) name = (fr.get("name") or "").strip() if rid is None or not name: continue desc = _html.unescape(_TAG_RE.sub( " ", (fr.get("description") or "").replace("
", "\n"))) desc = re.sub(r"[ \t]+", " ", desc).strip() images = [] for ph in (res.get("photos") or [])[:12]: u = ((ph or {}).get("photoUrlResult") or {}).get("url") or "" if u.startswith("https://") and u not in images: images.append(u) cat_fr = cat_names.get(cat_id, KEEP[cat_id]) title = f"{cat_fr} {name} – {loc_name}" try: # « v2 » : sondage multi-dates (unités saisonnières) fee = self.detail(str(rid), month + ":v2", lambda r=rid: self._fetch_fee(r)) except Exception as exc: # noqa: BLE001 — le prix est optionnel print(f"[parcscanada] tarif {rid} : {exc}", file=sys.stderr) fee = {} price = fee.get("fee") amenities, pets_attr, min_stay = self._amenities( res, attr_defs) details = {"parc": loc_name, "categorie": cat_fr} if min_stay: details["sejour_minimum"] = min_stay cap = res.get("maxCapacity") listings.append(StListing( source=self.source_id, external_id=str(rid), url=(f"{BASE}/create-booking/results?resourceLocationId=" f"{loc_id}&searchTabGroupId=0&bookingCategoryId=0"), title=title, property_type=KEEP[cat_id], address=loc_name, region=region, price_night=float(price) if price else None, price_label=(f"{price:.2f} $ / nuit" if price else ""), capacity=float(cap) if cap else None, pets=pets_attr or _pets(desc), description=desc[:4000], amenities=amenities, details=details, images=images, lat=lat, lng=lng, ).finalize()) if limit and len(listings) >= limit: return listings return listings