# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/sepaq.py : Sépaq (sepaq.com) — chalets, yourtes, camps rustiques # et refuges des parcs nationaux, réserves fauniques et centres touristiques # du Québec (~630 unités, 32 établissements). # # Méthode : # 1. sitemap officiel des réservations « chalet » # (/sitemaps/fr/reservations/chalet.xml) → inventaire COMPLET des unités # (les feuilles /fr/reservation/chalet/<établissement>//) ; # 2. par établissement : la page /fr/reservation/chalet/<étab> pose le # contexte de recherche côté serveur (cookie JSESSIONID_TRANSAC), puis # l'API JSON interne de la carte (/fr/reservation/carte/resultats, # rejouée avec ce cookie) donne lat/lng, sous-type (Chalet/Yourte/…), # surnom et secteur de chaque unité — cache self.detail invalidé quand # la liste d'unités de l'établissement change dans le sitemap ; # 3. page détail de l'unité (cache self.detail, clé mensuelle pour suivre # les prix) : « À partir de X $ /nuit », capacité, chambres/lits, # commodités, chiens, no d'enregistrement CITQ, photos (galerie S3). # # Non couvert : le prêt-à-camper (section /fr/reservation/camping/), dont les # unités sont noyées parmi ~7 500 emplacements de camping nus sans marqueur # distinctif dans le sitemap (il faudrait crawler chaque boucle de camping). # Région touristique déduite de l'établissement (table statique ci-dessous). # Le site est derrière Cloudflare : self.get() escalade automatiquement. # # Description : les pages unité n'ont AUCUN texte descriptif (que des listes # d'équipements) — la prose vit sur la page éditoriale de l'établissement # (sepaq.com//, ex. pq/mot) : on la récupère une fois par établissement # (cache self.detail) et on l'applique aux unités. Géolocalisation : l'API # carte échoue souvent (cookie de session perdu derrière l'anti-bot) — repli # sur les coordonnées statiques de l'établissement (fait géographique stable, # précision « parc » signalée dans details.geo_precision). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import sys import time from ..schema import StListing from .base import StConnector BASE = "https://www.sepaq.com" SITEMAP = f"{BASE}/sitemaps/fr/reservations/chalet.xml" PREFIX = f"{BASE}/fr/reservation/chalet/" CARTE = f"{BASE}/fr/reservation/carte/resultats" # Région touristique de chaque établissement (fait géographique stable). REGION_ETAB = { "centre-touristique-du-lac-kenogami": "Saguenay–Lac-Saint-Jean", "centre-touristique-du-lac-simon": "Outaouais", "parc-national-d-aiguebelle": "Abitibi-Témiscamingue", "parc-national-d-oka": "Laurentides", "parc-national-de-frontenac": "Chaudière-Appalaches", "parc-national-de-la-gaspesie": "Gaspésie", "parc-national-de-la-jacques-cartier": "Québec", "parc-national-de-la-pointe-taillon": "Saguenay–Lac-Saint-Jean", "parc-national-de-la-yamaska": "Cantons-de-l'Est", "parc-national-de-plaisance": "Outaouais", "parc-national-des-grands-jardins": "Charlevoix", "parc-national-des-hautes-gorges-de-la-riviere-malbaie": "Charlevoix", "parc-national-des-monts-valin": "Saguenay–Lac-Saint-Jean", "parc-national-du-bic": "Bas-Saint-Laurent", "parc-national-du-fjord-du-saguenay": "Saguenay–Lac-Saint-Jean", "parc-national-du-mont-megantic": "Cantons-de-l'Est", "parc-national-du-mont-orford": "Cantons-de-l'Est", "parc-national-du-mont-tremblant": "Laurentides", "reserve-faunique-ashuapmushuan": "Saguenay–Lac-Saint-Jean", "reserve-faunique-de-matane": "Gaspésie", "reserve-faunique-de-papineau-labelle": "Outaouais", "reserve-faunique-de-port-cartier-sept-iles": "Côte-Nord", "reserve-faunique-de-port-daniel": "Gaspésie", "reserve-faunique-de-portneuf": "Québec", "reserve-faunique-de-rimouski": "Bas-Saint-Laurent", "reserve-faunique-des-chic-chocs": "Gaspésie", "reserve-faunique-des-laurentides": "Québec", "reserve-faunique-du-saint-maurice": "Mauricie", "reserve-faunique-la-verendrye": "Outaouais", "reserve-faunique-mastigouche": "Mauricie", "reserve-faunique-rouge-matawin": "Lanaudière", "sepaq-anticosti": "Côte-Nord", "station-touristique-duchesnay": "Québec", "auberge-de-montagne-des-chic-chocs": "Gaspésie", } # Coordonnées approximatives de chaque établissement (accueil/centre du # territoire — fait géographique stable). Repli quand l'API carte ne donne # pas les coordonnées précises de l'unité. ETAB_COORDS = { "centre-touristique-du-lac-kenogami": (48.336, -71.433), "centre-touristique-du-lac-simon": (46.008, -75.092), "parc-national-d-aiguebelle": (48.494, -78.712), "parc-national-d-oka": (45.472, -74.023), "parc-national-de-frontenac": (45.940, -71.150), "parc-national-de-la-gaspesie": (48.982, -66.242), "parc-national-de-la-jacques-cartier": (47.174, -71.374), "parc-national-de-la-pointe-taillon": (48.665, -72.080), "parc-national-de-la-yamaska": (45.462, -72.632), "parc-national-de-plaisance": (45.590, -75.070), "parc-national-des-grands-jardins": (47.667, -70.628), "parc-national-des-hautes-gorges-de-la-riviere-malbaie": (47.899, -70.412), "parc-national-des-monts-valin": (48.597, -70.828), "parc-national-du-bic": (48.336, -68.802), "parc-national-du-fjord-du-saguenay": (48.311, -70.324), "parc-national-du-mont-megantic": (45.455, -71.152), "parc-national-du-mont-orford": (45.357, -72.235), "parc-national-du-mont-tremblant": (46.356, -74.462), "reserve-faunique-ashuapmushuan": (48.890, -72.870), "reserve-faunique-de-matane": (48.632, -67.114), "reserve-faunique-de-papineau-labelle": (46.150, -75.300), "reserve-faunique-de-port-cartier-sept-iles": (50.318, -67.083), "reserve-faunique-de-port-daniel": (48.240, -64.970), "reserve-faunique-de-portneuf": (47.100, -72.250), "reserve-faunique-de-rimouski": (48.075, -68.375), "reserve-faunique-des-chic-chocs": (48.732, -66.463), "reserve-faunique-des-laurentides": (47.567, -71.233), "reserve-faunique-du-saint-maurice": (46.900, -73.100), "reserve-faunique-la-verendrye": (47.348, -76.868), "reserve-faunique-mastigouche": (46.600, -73.400), "reserve-faunique-rouge-matawin": (46.723, -74.500), "sepaq-anticosti": (49.500, -63.300), "station-touristique-duchesnay": (46.878, -71.635), "auberge-de-montagne-des-chic-chocs": (48.900, -66.489), } # Page éditoriale de chaque établissement (sepaq.com//) — codes vérifiés # via les titres des sitemaps etablissement/parc-national/reserve-faunique. ETAB_EDITO = { "centre-touristique-du-lac-kenogami": "ct/ken", "centre-touristique-du-lac-simon": "ct/sim", "parc-national-d-aiguebelle": "pq/aig", "parc-national-d-oka": "pq/oka", "parc-national-de-frontenac": "pq/fro", "parc-national-de-la-gaspesie": "pq/gas", "parc-national-de-la-jacques-cartier": "pq/jac", "parc-national-de-la-pointe-taillon": "pq/pta", "parc-national-de-la-yamaska": "pq/yam", "parc-national-de-plaisance": "pq/pla", "parc-national-des-grands-jardins": "pq/grj", "parc-national-des-hautes-gorges-de-la-riviere-malbaie": "pq/hgo", "parc-national-des-monts-valin": "pq/mva", "parc-national-du-bic": "pq/bic", "parc-national-du-fjord-du-saguenay": "pq/sag", "parc-national-du-mont-megantic": "pq/mme", "parc-national-du-mont-orford": "pq/mor", "parc-national-du-mont-tremblant": "pq/mot", "reserve-faunique-ashuapmushuan": "rf/ash", "reserve-faunique-de-matane": "rf/mat", "reserve-faunique-de-papineau-labelle": "rf/pal", "reserve-faunique-de-port-cartier-sept-iles": "rf/spc", "reserve-faunique-de-port-daniel": "rf/pod", "reserve-faunique-de-portneuf": "rf/por", "reserve-faunique-de-rimouski": "rf/rim", "reserve-faunique-des-chic-chocs": "rf/chc", "reserve-faunique-des-laurentides": "rf/lau", "reserve-faunique-du-saint-maurice": "rf/stm", "reserve-faunique-la-verendrye": "rf/lvy", "reserve-faunique-mastigouche": "rf/mas", "reserve-faunique-rouge-matawin": "rf/rom", "sepaq-anticosti": "sepaq-anticosti", "station-touristique-duchesnay": "ct/duc", "auberge-de-montagne-des-chic-chocs": "ct/amc", } _LOC_RE = re.compile(r"\s*(https?://[^<\s]+)") _COOKIE_RE = re.compile(r"(?:JSESSIONID_TRANSAC|__cf_bm)=[^;,\s]+") _JSON_ARRAY_RE = re.compile(r"\[.*\]", re.S) _PRICE_RE = re.compile( r'class="fiche-section-price"[^>]*>(.*?)

', re.S) _DL_RE = { "item_id": re.compile(r'item_id\s*:\s*"([^"]*)"'), "cat3": re.compile(r'item_category3\s*:\s*"([^"]*)"'), "dl_price": re.compile(r'\bprice\s*:\s*"([\d.,]+)"'), } _BED_RE = re.compile(r"(\d+)\s+lit", re.I) _CAP_RE = re.compile(r"(\d+)\s*personne", re.I) _CITQ_RE = re.compile(r"enregistrement\s*:?\s*(\d{5,7})") def _property_type(*labels: str) -> str: blob = " ".join(l or "" for l in labels).lower() if "yourte" in blob: return "Yourte" if "refuge" in blob or "camp" in blob: # camp rustique → Refuge return "Refuge" if "dôme" in blob or "dome" in blob: return "Dôme" if "tente" in blob or "prêt-à-camper" in blob or "pret-a-camper" in blob: return "Prêt-à-camper" if "auberge" in blob: return "Auberge" return "Chalet" class Sepaq(StConnector): source_id = "sepaq" request_delay = 0.4 # -- helpers ---------------------------------------------------------------- @staticmethod def _resp_json_array(resp): """L'API carte peut revenir enrobée de HTML (
) via l'escalade ASP."""
        import json
        m = _JSON_ARRAY_RE.search(resp.text or "")
        if not m:
            return []
        try:
            return json.loads(m.group(0))
        except ValueError:
            return []

    @staticmethod
    def _set_cookie(resp) -> str:
        hdrs = resp.headers or {}
        try:
            items = hdrs.items()
        except AttributeError:
            items = []
        raw = ""
        for k, v in items:
            if str(k).lower() == "set-cookie":
                raw += str(v) + ", "
        return "; ".join(_COOKIE_RE.findall(raw))

    # -- carte par établissement (coords + sous-type + secteur) -----------------
    def _fetch_carte(self, etab: str) -> dict:
        # le contexte est posé par la visite de la page établissement (cookie
        # JSESSIONID_TRANSAC) ; si la 1re visite est passée par l'escalade
        # anti-bot (pas de Set-Cookie exploitable), on retente une fois.
        raw = []
        for _ in range(2):
            r = self.get(PREFIX + etab)
            cookies = self._set_cookie(r)
            if not cookies:
                continue
            hdrs = {"X-Requested-With": "XMLHttpRequest",
                    "Accept": "application/json, text/javascript, */*",
                    "Referer": PREFIX + etab,
                    "Cookie": cookies}
            raw = self._resp_json_array(self.get(CARTE, headers=hdrs))
            if raw:
                break
        items = []
        for it in raw:
            if not isinstance(it, dict) or it.get("type") != "unite":
                continue
            coord = it.get("coordonnees") or {}
            parent = it.get("parent") or {}
            items.append({
                "url": it.get("url") or "",
                "lat": coord.get("lat"), "lng": coord.get("lng"),
                "sous_type": it.get("groupeSousType") or "",
                "surnom": it.get("surnom") or "",
                "nom": it.get("nom") or "",
                "secteur": parent.get("nom") or "",
            })
        return {"items": items}

    # -- page éditoriale d'un établissement (description riche) -----------------
    _EDITO_BRUIT = ("témoins", "cookies", "javascript", "modalités de réserv",
                    "navigateur", "abonnez-vous", "infolettre")

    def _fetch_edito(self, code: str) -> dict:
        from bs4 import BeautifulSoup
        html = self.get(f"{BASE}/{code}/").text
        soup = BeautifulSoup(html, "html.parser")
        if soup.title and "404" in soup.title.get_text():
            return {}
        parts: list[str] = []
        for p in soup.find_all("p"):
            txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))
            low = txt.lower()
            if len(txt) < 120 or any(b in low for b in self._EDITO_BRUIT):
                continue
            if txt not in parts:
                parts.append(txt)
            if sum(len(x) for x in parts) > 1200:
                break
        desc = "\n\n".join(parts)
        if not desc:      # repli : meta description de la page
            m = re.search(r' dict:
        from bs4 import BeautifulSoup
        html = self.get(PREFIX + slug).text
        soup = BeautifulSoup(html, "html.parser")
        d: dict = {}

        tt = soup.title.get_text(strip=True) if soup.title else ""
        parts = [p.strip() for p in tt.split(" - ") if p.strip()]
        if parts and parts[-1].lower() == "sépaq":
            parts.pop()
        if len(parts) >= 3:
            d["nom"], d["soustype"], d["parc"] = parts[0], parts[1], parts[2]
        elif len(parts) == 2:
            d["nom"], d["soustype"], d["parc"] = parts[0], "", parts[1]
        elif parts:
            d["nom"], d["soustype"], d["parc"] = parts[0], "", ""
        else:
            return {}

        fiche = soup.find("div", class_="fiche")
        if fiche is None:           # page générique (unité retirée) → ignorer
            return {}

        m = _PRICE_RE.search(html)
        if m:
            import html as _h
            label = _h.unescape(re.sub(r"<[^>]+>", " ", m.group(1)))
            label = re.sub(r"[\s ]+", " ", label).strip()
            d["price_label"] = label

        for key, rx in _DL_RE.items():
            mm = rx.search(html)
            if mm:
                d[key] = mm.group(1)

        # sections h4 → items
        sections: dict[str, list[str]] = {}
        for sub in fiche.find_all("div", class_="fiche-sous-section"):
            h4 = sub.find("h4")
            if not h4:
                continue
            name = h4.get_text(" ", strip=True)
            lis = [li.get_text(" ", strip=True)
                   for li in sub.find_all("li")]
            sections[name] = [x for x in lis if x]
            if "has-price" in (sub.get("class") or []):
                d["type_header"] = name          # ex. « Villégiature - chalet »
                if lis:
                    d["saison"] = lis[0]
        d["sections"] = sections

        for name, lis in sections.items():
            low = name.lower()
            if low.startswith("capacité") and lis:
                mm = _CAP_RE.search(lis[0])
                if mm:
                    d["capacity"] = int(mm.group(1))
            elif low.startswith("chambres et lits"):
                d["bedrooms"] = sum(1 for x in lis
                                    if x.lower().startswith("chambre"))
                beds = sum(int(n) for x in lis for n in _BED_RE.findall(x))
                if beds:
                    d["beds"] = beds
            elif "hébergement touristique" in low and lis:
                mm = _CITQ_RE.search(" ".join(lis))
                if mm:
                    d["citq"] = mm.group(1)

        if soup.select_one(".is-chiens-non"):
            d["pets"] = "non"
        elif soup.select_one(".is-chiens-oui"):
            d["pets"] = "oui"

        imgs = []
        for a in soup.select('[itemprop="contentUrl"]'):
            href = a.get("href") or ""
            if href.startswith("//"):
                href = "https:" + href
            if href and href not in imgs:
                imgs.append(href)
        d["images"] = imgs
        return d

    # -- inventaire ----------------------------------------------------------------
    def fetch(self) -> list[StListing]:
        xml = self.get(SITEMAP).text
        slugs = sorted({loc[len(PREFIX):].strip("/")
                        for loc in _LOC_RE.findall(xml)
                        if loc.startswith(PREFIX) and loc[len(PREFIX):].strip("/")})
        slugset = set(slugs)
        # feuilles à ≥ 3 niveaux (étab/secteur/unité) = unités réservables
        units = [s for s in slugs
                 if s.count("/") >= 2
                 and not any(o.startswith(s + "/") for o in slugset if o != s)]
        etabs = sorted({s.split("/", 1)[0] for s in units})

        # carte par établissement → géoloc / sous-type / secteur par unité
        geo: dict[str, dict] = {}
        for etab in etabs:
            sig = hashlib.sha1("|".join(
                u for u in units if u.startswith(etab + "/")).encode()
            ).hexdigest()[:16]
            try:
                payload = self.detail(f"etab:{etab}", sig,
                                      lambda e=etab: self._fetch_carte(e))
            except Exception as exc:  # noqa: BLE001 — la géoloc est optionnelle
                print(f"[sepaq] carte {etab} : {exc}", file=sys.stderr)
                continue
            for it in payload.get("items", []):
                path = it.get("url") or ""
                if "/fr/reservation/chalet/" in path:
                    path = path.split("/fr/reservation/chalet/", 1)[1].strip("/")
                if path:
                    geo[path] = it

        # description éditoriale par établissement (une visite, cache BD)
        editos: dict[str, str] = {}
        for etab in etabs:
            code = ETAB_EDITO.get(etab)
            if not code:
                continue
            try:
                payload = self.detail(f"edito:{etab}", "v1",
                                      lambda cd=code: self._fetch_edito(cd))
            except Exception as exc:  # noqa: BLE001 — la description est optionnelle
                print(f"[sepaq] édito {etab} : {exc}", file=sys.stderr)
                continue
            if payload.get("description"):
                editos[etab] = payload["description"]

        month = time.strftime("%Y-%m")      # re-visite mensuelle (prix/saison)
        listings: list[StListing] = []
        for slug in sorted(set(units) | set(geo)):
            try:
                d = self.detail(slug, month, lambda s=slug: self._fetch_unit(s))
            except Exception as exc:  # noqa: BLE001
                print(f"[sepaq] unité {slug} : {exc}", file=sys.stderr)
                continue
            if not d or not d.get("nom"):
                continue
            g = geo.get(slug, {})
            etab = slug.split("/", 1)[0]

            nom = d.get("nom", "")
            soustype = d.get("soustype") or g.get("sous_type") or ""
            parc = d.get("parc", "")
            if soustype and not nom.lower().startswith(soustype.lower()):
                base_title = f"{soustype} {nom}"
            else:
                base_title = nom
            title = f"{base_title} – {parc}" if parc else base_title

            secteur = g.get("secteur", "")
            details = {k: v for k, v in {
                "parc": parc,
                "secteur": secteur,
                "sous_type": soustype,
                "type_offre": d.get("type_header", ""),
                "saison": d.get("saison", ""),
                "item_id": d.get("item_id", ""),
                "acces": (d.get("sections") or {}).get("Accès") or None,
            }.items() if v}

            amenities = (d.get("sections") or {}).get("Description") or []

            # géo : coordonnées précises de l'unité (API carte) sinon repli
            # sur celles de l'établissement (précision « parc »)
            lat, lng = g.get("lat"), g.get("lng")
            if lat is None or lng is None:
                lat, lng = ETAB_COORDS.get(etab, (None, None))
                if lat is not None:
                    details["geo_precision"] = "etablissement"

            lst = StListing(
                source=self.source_id,
                external_id=slug,
                url=PREFIX + slug,
                title=title,
                property_type=_property_type(soustype, d.get("cat3", ""),
                                             d.get("type_header", "")),
                address=", ".join(x for x in (secteur, parc) if x),
                city="",
                region=REGION_ETAB.get(etab, ""),
                price_label=d.get("price_label", ""),
                capacity=float(d["capacity"]) if d.get("capacity") else None,
                bedrooms=(float(d["bedrooms"])
                          if d.get("bedrooms") is not None else None),
                beds=float(d["beds"]) if d.get("beds") else None,
                pets=d.get("pets"),
                citq=d.get("citq", ""),
                description=editos.get(etab, ""),
                amenities=amenities,
                details=details,
                images=d.get("images") or [],
                lat=lat,
                lng=lng,
            )
            listings.append(lst)
        return listings