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/sepaq.py : Sépaq (sepaq.com) — chalets, yourtes, camps rustiques4# et refuges des parcs nationaux, réserves fauniques et centres touristiques5# du Québec (~630 unités, 32 établissements).6#7# Méthode :8# 1. sitemap officiel des réservations « chalet »9# (/sitemaps/fr/reservations/chalet.xml) → inventaire COMPLET des unités10# (les feuilles /fr/reservation/chalet/<établissement>/<secteur>/<unité>) ;11# 2. par établissement : la page /fr/reservation/chalet/<étab> pose le12# contexte de recherche côté serveur (cookie JSESSIONID_TRANSAC), puis13# l'API JSON interne de la carte (/fr/reservation/carte/resultats,14# rejouée avec ce cookie) donne lat/lng, sous-type (Chalet/Yourte/…),15# surnom et secteur de chaque unité — cache self.detail invalidé quand16# la liste d'unités de l'établissement change dans le sitemap ;17# 3. page détail de l'unité (cache self.detail, clé mensuelle pour suivre18# les prix) : « À partir de X $ /nuit », capacité, chambres/lits,19# commodités, chiens, no d'enregistrement CITQ, photos (galerie S3).20#21# Non couvert : le prêt-à-camper (section /fr/reservation/camping/), dont les22# unités sont noyées parmi ~7 500 emplacements de camping nus sans marqueur23# distinctif dans le sitemap (il faudrait crawler chaque boucle de camping).24# Région touristique déduite de l'établissement (table statique ci-dessous).25# Le site est derrière Cloudflare : self.get() escalade automatiquement.26#27# Description : les pages unité n'ont AUCUN texte descriptif (que des listes28# d'équipements) — la prose vit sur la page éditoriale de l'établissement29# (sepaq.com/<code>/, ex. pq/mot) : on la récupère une fois par établissement30# (cache self.detail) et on l'applique aux unités. Géolocalisation : l'API31# carte échoue souvent (cookie de session perdu derrière l'anti-bot) — repli32# sur les coordonnées statiques de l'établissement (fait géographique stable,33# précision « parc » signalée dans details.geo_precision).34# -----------------------------------------------------------------------------35from __future__ import annotations3637import hashlib38import re39import sys40import time4142from ..schema import StListing43from .base import StConnector4445BASE = "https://www.sepaq.com"46SITEMAP = f"{BASE}/sitemaps/fr/reservations/chalet.xml"47PREFIX = f"{BASE}/fr/reservation/chalet/"48CARTE = f"{BASE}/fr/reservation/carte/resultats"4950# Région touristique de chaque établissement (fait géographique stable).51REGION_ETAB = {52 "centre-touristique-du-lac-kenogami": "Saguenay–Lac-Saint-Jean",53 "centre-touristique-du-lac-simon": "Outaouais",54 "parc-national-d-aiguebelle": "Abitibi-Témiscamingue",55 "parc-national-d-oka": "Laurentides",56 "parc-national-de-frontenac": "Chaudière-Appalaches",57 "parc-national-de-la-gaspesie": "Gaspésie",58 "parc-national-de-la-jacques-cartier": "Québec",59 "parc-national-de-la-pointe-taillon": "Saguenay–Lac-Saint-Jean",60 "parc-national-de-la-yamaska": "Cantons-de-l'Est",61 "parc-national-de-plaisance": "Outaouais",62 "parc-national-des-grands-jardins": "Charlevoix",63 "parc-national-des-hautes-gorges-de-la-riviere-malbaie": "Charlevoix",64 "parc-national-des-monts-valin": "Saguenay–Lac-Saint-Jean",65 "parc-national-du-bic": "Bas-Saint-Laurent",66 "parc-national-du-fjord-du-saguenay": "Saguenay–Lac-Saint-Jean",67 "parc-national-du-mont-megantic": "Cantons-de-l'Est",68 "parc-national-du-mont-orford": "Cantons-de-l'Est",69 "parc-national-du-mont-tremblant": "Laurentides",70 "reserve-faunique-ashuapmushuan": "Saguenay–Lac-Saint-Jean",71 "reserve-faunique-de-matane": "Gaspésie",72 "reserve-faunique-de-papineau-labelle": "Outaouais",73 "reserve-faunique-de-port-cartier-sept-iles": "Côte-Nord",74 "reserve-faunique-de-port-daniel": "Gaspésie",75 "reserve-faunique-de-portneuf": "Québec",76 "reserve-faunique-de-rimouski": "Bas-Saint-Laurent",77 "reserve-faunique-des-chic-chocs": "Gaspésie",78 "reserve-faunique-des-laurentides": "Québec",79 "reserve-faunique-du-saint-maurice": "Mauricie",80 "reserve-faunique-la-verendrye": "Outaouais",81 "reserve-faunique-mastigouche": "Mauricie",82 "reserve-faunique-rouge-matawin": "Lanaudière",83 "sepaq-anticosti": "Côte-Nord",84 "station-touristique-duchesnay": "Québec",85 "auberge-de-montagne-des-chic-chocs": "Gaspésie",86}8788# Coordonnées approximatives de chaque établissement (accueil/centre du89# territoire — fait géographique stable). Repli quand l'API carte ne donne90# pas les coordonnées précises de l'unité.91ETAB_COORDS = {92 "centre-touristique-du-lac-kenogami": (48.336, -71.433),93 "centre-touristique-du-lac-simon": (46.008, -75.092),94 "parc-national-d-aiguebelle": (48.494, -78.712),95 "parc-national-d-oka": (45.472, -74.023),96 "parc-national-de-frontenac": (45.940, -71.150),97 "parc-national-de-la-gaspesie": (48.982, -66.242),98 "parc-national-de-la-jacques-cartier": (47.174, -71.374),99 "parc-national-de-la-pointe-taillon": (48.665, -72.080),100 "parc-national-de-la-yamaska": (45.462, -72.632),101 "parc-national-de-plaisance": (45.590, -75.070),102 "parc-national-des-grands-jardins": (47.667, -70.628),103 "parc-national-des-hautes-gorges-de-la-riviere-malbaie": (47.899, -70.412),104 "parc-national-des-monts-valin": (48.597, -70.828),105 "parc-national-du-bic": (48.336, -68.802),106 "parc-national-du-fjord-du-saguenay": (48.311, -70.324),107 "parc-national-du-mont-megantic": (45.455, -71.152),108 "parc-national-du-mont-orford": (45.357, -72.235),109 "parc-national-du-mont-tremblant": (46.356, -74.462),110 "reserve-faunique-ashuapmushuan": (48.890, -72.870),111 "reserve-faunique-de-matane": (48.632, -67.114),112 "reserve-faunique-de-papineau-labelle": (46.150, -75.300),113 "reserve-faunique-de-port-cartier-sept-iles": (50.318, -67.083),114 "reserve-faunique-de-port-daniel": (48.240, -64.970),115 "reserve-faunique-de-portneuf": (47.100, -72.250),116 "reserve-faunique-de-rimouski": (48.075, -68.375),117 "reserve-faunique-des-chic-chocs": (48.732, -66.463),118 "reserve-faunique-des-laurentides": (47.567, -71.233),119 "reserve-faunique-du-saint-maurice": (46.900, -73.100),120 "reserve-faunique-la-verendrye": (47.348, -76.868),121 "reserve-faunique-mastigouche": (46.600, -73.400),122 "reserve-faunique-rouge-matawin": (46.723, -74.500),123 "sepaq-anticosti": (49.500, -63.300),124 "station-touristique-duchesnay": (46.878, -71.635),125 "auberge-de-montagne-des-chic-chocs": (48.900, -66.489),126}127128# Page éditoriale de chaque établissement (sepaq.com/<code>/) — codes vérifiés129# via les titres des sitemaps etablissement/parc-national/reserve-faunique.130ETAB_EDITO = {131 "centre-touristique-du-lac-kenogami": "ct/ken",132 "centre-touristique-du-lac-simon": "ct/sim",133 "parc-national-d-aiguebelle": "pq/aig",134 "parc-national-d-oka": "pq/oka",135 "parc-national-de-frontenac": "pq/fro",136 "parc-national-de-la-gaspesie": "pq/gas",137 "parc-national-de-la-jacques-cartier": "pq/jac",138 "parc-national-de-la-pointe-taillon": "pq/pta",139 "parc-national-de-la-yamaska": "pq/yam",140 "parc-national-de-plaisance": "pq/pla",141 "parc-national-des-grands-jardins": "pq/grj",142 "parc-national-des-hautes-gorges-de-la-riviere-malbaie": "pq/hgo",143 "parc-national-des-monts-valin": "pq/mva",144 "parc-national-du-bic": "pq/bic",145 "parc-national-du-fjord-du-saguenay": "pq/sag",146 "parc-national-du-mont-megantic": "pq/mme",147 "parc-national-du-mont-orford": "pq/mor",148 "parc-national-du-mont-tremblant": "pq/mot",149 "reserve-faunique-ashuapmushuan": "rf/ash",150 "reserve-faunique-de-matane": "rf/mat",151 "reserve-faunique-de-papineau-labelle": "rf/pal",152 "reserve-faunique-de-port-cartier-sept-iles": "rf/spc",153 "reserve-faunique-de-port-daniel": "rf/pod",154 "reserve-faunique-de-portneuf": "rf/por",155 "reserve-faunique-de-rimouski": "rf/rim",156 "reserve-faunique-des-chic-chocs": "rf/chc",157 "reserve-faunique-des-laurentides": "rf/lau",158 "reserve-faunique-du-saint-maurice": "rf/stm",159 "reserve-faunique-la-verendrye": "rf/lvy",160 "reserve-faunique-mastigouche": "rf/mas",161 "reserve-faunique-rouge-matawin": "rf/rom",162 "sepaq-anticosti": "sepaq-anticosti",163 "station-touristique-duchesnay": "ct/duc",164 "auberge-de-montagne-des-chic-chocs": "ct/amc",165}166167_LOC_RE = re.compile(r"<loc>\s*(https?://[^<\s]+)")168_COOKIE_RE = re.compile(r"(?:JSESSIONID_TRANSAC|__cf_bm)=[^;,\s]+")169_JSON_ARRAY_RE = re.compile(r"\[.*\]", re.S)170_PRICE_RE = re.compile(171 r'class="fiche-section-price"[^>]*>(.*?)</p>', re.S)172_DL_RE = {173 "item_id": re.compile(r'item_id\s*:\s*"([^"]*)"'),174 "cat3": re.compile(r'item_category3\s*:\s*"([^"]*)"'),175 "dl_price": re.compile(r'\bprice\s*:\s*"([\d.,]+)"'),176}177_BED_RE = re.compile(r"(\d+)\s+lit", re.I)178_CAP_RE = re.compile(r"(\d+)\s*personne", re.I)179_CITQ_RE = re.compile(r"enregistrement\s*:?\s*(\d{5,7})")180181182def _property_type(*labels: str) -> str:183 blob = " ".join(l or "" for l in labels).lower()184 if "yourte" in blob:185 return "Yourte"186 if "refuge" in blob or "camp" in blob: # camp rustique → Refuge187 return "Refuge"188 if "dôme" in blob or "dome" in blob:189 return "Dôme"190 if "tente" in blob or "prêt-à-camper" in blob or "pret-a-camper" in blob:191 return "Prêt-à-camper"192 if "auberge" in blob:193 return "Auberge"194 return "Chalet"195196197class Sepaq(StConnector):198 source_id = "sepaq"199 request_delay = 0.4200201 # -- helpers ----------------------------------------------------------------202 @staticmethod203 def _resp_json_array(resp):204 """L'API carte peut revenir enrobée de HTML (<pre>) via l'escalade ASP."""205 import json206 m = _JSON_ARRAY_RE.search(resp.text or "")207 if not m:208 return []209 try:210 return json.loads(m.group(0))211 except ValueError:212 return []213214 @staticmethod215 def _set_cookie(resp) -> str:216 hdrs = resp.headers or {}217 try:218 items = hdrs.items()219 except AttributeError:220 items = []221 raw = ""222 for k, v in items:223 if str(k).lower() == "set-cookie":224 raw += str(v) + ", "225 return "; ".join(_COOKIE_RE.findall(raw))226227 # -- carte par établissement (coords + sous-type + secteur) -----------------228 def _fetch_carte(self, etab: str) -> dict:229 # le contexte est posé par la visite de la page établissement (cookie230 # JSESSIONID_TRANSAC) ; si la 1re visite est passée par l'escalade231 # anti-bot (pas de Set-Cookie exploitable), on retente une fois.232 raw = []233 for _ in range(2):234 r = self.get(PREFIX + etab)235 cookies = self._set_cookie(r)236 if not cookies:237 continue238 hdrs = {"X-Requested-With": "XMLHttpRequest",239 "Accept": "application/json, text/javascript, */*",240 "Referer": PREFIX + etab,241 "Cookie": cookies}242 raw = self._resp_json_array(self.get(CARTE, headers=hdrs))243 if raw:244 break245 items = []246 for it in raw:247 if not isinstance(it, dict) or it.get("type") != "unite":248 continue249 coord = it.get("coordonnees") or {}250 parent = it.get("parent") or {}251 items.append({252 "url": it.get("url") or "",253 "lat": coord.get("lat"), "lng": coord.get("lng"),254 "sous_type": it.get("groupeSousType") or "",255 "surnom": it.get("surnom") or "",256 "nom": it.get("nom") or "",257 "secteur": parent.get("nom") or "",258 })259 return {"items": items}260261 # -- page éditoriale d'un établissement (description riche) -----------------262 _EDITO_BRUIT = ("témoins", "cookies", "javascript", "modalités de réserv",263 "navigateur", "abonnez-vous", "infolettre")264265 def _fetch_edito(self, code: str) -> dict:266 from bs4 import BeautifulSoup267 html = self.get(f"{BASE}/{code}/").text268 soup = BeautifulSoup(html, "html.parser")269 if soup.title and "404" in soup.title.get_text():270 return {}271 parts: list[str] = []272 for p in soup.find_all("p"):273 txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))274 low = txt.lower()275 if len(txt) < 120 or any(b in low for b in self._EDITO_BRUIT):276 continue277 if txt not in parts:278 parts.append(txt)279 if sum(len(x) for x in parts) > 1200:280 break281 desc = "\n\n".join(parts)282 if not desc: # repli : meta description de la page283 m = re.search(r'<meta name="description" content="([^"]+)"', html)284 if m:285 import html as _h286 desc = _h.unescape(m.group(1)).strip()287 return {"description": desc[:2000]} if desc else {}288289 # -- page détail d'une unité --------------------------------------------------290 def _fetch_unit(self, slug: str) -> dict:291 from bs4 import BeautifulSoup292 html = self.get(PREFIX + slug).text293 soup = BeautifulSoup(html, "html.parser")294 d: dict = {}295296 tt = soup.title.get_text(strip=True) if soup.title else ""297 parts = [p.strip() for p in tt.split(" - ") if p.strip()]298 if parts and parts[-1].lower() == "sépaq":299 parts.pop()300 if len(parts) >= 3:301 d["nom"], d["soustype"], d["parc"] = parts[0], parts[1], parts[2]302 elif len(parts) == 2:303 d["nom"], d["soustype"], d["parc"] = parts[0], "", parts[1]304 elif parts:305 d["nom"], d["soustype"], d["parc"] = parts[0], "", ""306 else:307 return {}308309 fiche = soup.find("div", class_="fiche")310 if fiche is None: # page générique (unité retirée) → ignorer311 return {}312313 m = _PRICE_RE.search(html)314 if m:315 import html as _h316 label = _h.unescape(re.sub(r"<[^>]+>", " ", m.group(1)))317 label = re.sub(r"[\s ]+", " ", label).strip()318 d["price_label"] = label319320 for key, rx in _DL_RE.items():321 mm = rx.search(html)322 if mm:323 d[key] = mm.group(1)324325 # sections h4 → items326 sections: dict[str, list[str]] = {}327 for sub in fiche.find_all("div", class_="fiche-sous-section"):328 h4 = sub.find("h4")329 if not h4:330 continue331 name = h4.get_text(" ", strip=True)332 lis = [li.get_text(" ", strip=True)333 for li in sub.find_all("li")]334 sections[name] = [x for x in lis if x]335 if "has-price" in (sub.get("class") or []):336 d["type_header"] = name # ex. « Villégiature - chalet »337 if lis:338 d["saison"] = lis[0]339 d["sections"] = sections340341 for name, lis in sections.items():342 low = name.lower()343 if low.startswith("capacité") and lis:344 mm = _CAP_RE.search(lis[0])345 if mm:346 d["capacity"] = int(mm.group(1))347 elif low.startswith("chambres et lits"):348 d["bedrooms"] = sum(1 for x in lis349 if x.lower().startswith("chambre"))350 beds = sum(int(n) for x in lis for n in _BED_RE.findall(x))351 if beds:352 d["beds"] = beds353 elif "hébergement touristique" in low and lis:354 mm = _CITQ_RE.search(" ".join(lis))355 if mm:356 d["citq"] = mm.group(1)357358 if soup.select_one(".is-chiens-non"):359 d["pets"] = "non"360 elif soup.select_one(".is-chiens-oui"):361 d["pets"] = "oui"362363 imgs = []364 for a in soup.select('[itemprop="contentUrl"]'):365 href = a.get("href") or ""366 if href.startswith("//"):367 href = "https:" + href368 if href and href not in imgs:369 imgs.append(href)370 d["images"] = imgs371 return d372373 # -- inventaire ----------------------------------------------------------------374 def fetch(self) -> list[StListing]:375 xml = self.get(SITEMAP).text376 slugs = sorted({loc[len(PREFIX):].strip("/")377 for loc in _LOC_RE.findall(xml)378 if loc.startswith(PREFIX) and loc[len(PREFIX):].strip("/")})379 slugset = set(slugs)380 # feuilles à ≥ 3 niveaux (étab/secteur/unité) = unités réservables381 units = [s for s in slugs382 if s.count("/") >= 2383 and not any(o.startswith(s + "/") for o in slugset if o != s)]384 etabs = sorted({s.split("/", 1)[0] for s in units})385386 # carte par établissement → géoloc / sous-type / secteur par unité387 geo: dict[str, dict] = {}388 for etab in etabs:389 sig = hashlib.sha1("|".join(390 u for u in units if u.startswith(etab + "/")).encode()391 ).hexdigest()[:16]392 try:393 payload = self.detail(f"etab:{etab}", sig,394 lambda e=etab: self._fetch_carte(e))395 except Exception as exc: # noqa: BLE001 — la géoloc est optionnelle396 print(f"[sepaq] carte {etab} : {exc}", file=sys.stderr)397 continue398 for it in payload.get("items", []):399 path = it.get("url") or ""400 if "/fr/reservation/chalet/" in path:401 path = path.split("/fr/reservation/chalet/", 1)[1].strip("/")402 if path:403 geo[path] = it404405 # description éditoriale par établissement (une visite, cache BD)406 editos: dict[str, str] = {}407 for etab in etabs:408 code = ETAB_EDITO.get(etab)409 if not code:410 continue411 try:412 payload = self.detail(f"edito:{etab}", "v1",413 lambda cd=code: self._fetch_edito(cd))414 except Exception as exc: # noqa: BLE001 — la description est optionnelle415 print(f"[sepaq] édito {etab} : {exc}", file=sys.stderr)416 continue417 if payload.get("description"):418 editos[etab] = payload["description"]419420 month = time.strftime("%Y-%m") # re-visite mensuelle (prix/saison)421 listings: list[StListing] = []422 for slug in sorted(set(units) | set(geo)):423 try:424 d = self.detail(slug, month, lambda s=slug: self._fetch_unit(s))425 except Exception as exc: # noqa: BLE001426 print(f"[sepaq] unité {slug} : {exc}", file=sys.stderr)427 continue428 if not d or not d.get("nom"):429 continue430 g = geo.get(slug, {})431 etab = slug.split("/", 1)[0]432433 nom = d.get("nom", "")434 soustype = d.get("soustype") or g.get("sous_type") or ""435 parc = d.get("parc", "")436 if soustype and not nom.lower().startswith(soustype.lower()):437 base_title = f"{soustype} {nom}"438 else:439 base_title = nom440 title = f"{base_title} – {parc}" if parc else base_title441442 secteur = g.get("secteur", "")443 details = {k: v for k, v in {444 "parc": parc,445 "secteur": secteur,446 "sous_type": soustype,447 "type_offre": d.get("type_header", ""),448 "saison": d.get("saison", ""),449 "item_id": d.get("item_id", ""),450 "acces": (d.get("sections") or {}).get("Accès") or None,451 }.items() if v}452453 amenities = (d.get("sections") or {}).get("Description") or []454455 # géo : coordonnées précises de l'unité (API carte) sinon repli456 # sur celles de l'établissement (précision « parc »)457 lat, lng = g.get("lat"), g.get("lng")458 if lat is None or lng is None:459 lat, lng = ETAB_COORDS.get(etab, (None, None))460 if lat is not None:461 details["geo_precision"] = "etablissement"462463 lst = StListing(464 source=self.source_id,465 external_id=slug,466 url=PREFIX + slug,467 title=title,468 property_type=_property_type(soustype, d.get("cat3", ""),469 d.get("type_header", "")),470 address=", ".join(x for x in (secteur, parc) if x),471 city="",472 region=REGION_ETAB.get(etab, ""),473 price_label=d.get("price_label", ""),474 capacity=float(d["capacity"]) if d.get("capacity") else None,475 bedrooms=(float(d["bedrooms"])476 if d.get("bedrooms") is not None else None),477 beds=float(d["beds"]) if d.get("beds") else None,478 pets=d.get("pets"),479 citq=d.get("citq", ""),480 description=editos.get(etab, ""),481 amenities=amenities,482 details=details,483 images=d.get("images") or [],484 lat=lat,485 lng=lng,486 )487 listings.append(lst)488 return listings489