SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
14.1 KB · 315 lines python
Raw Blame History
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