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%
21.2 KB · 489 lines python
Raw Blame History
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