# -----------------------------------------------------------------------------
# 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>//, 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"
) 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