# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/pourvoiries.py : Fédération des pourvoiries du Québec # (pourvoiries.com) — ~340 pourvoiries avec hébergement (chalets, camps, # pavillons, prêt-à-camper) partout en région. # # Méthode (TYPO3, aucun anti-bot) : # 1. sitemap officiel des établissements (/sitemap/outfitters/sitemap.xml) # → inventaire complet des fiches /pourvoiries/-- ; # 2. fiche établissement (cache self.detail, clé mensuelle) : nom, ville + # région (bandeau), description, coordonnées GPS (« Latitude : 48.805 »), # no d'établissement CITQ, période d'ouverture, type de restauration, # unités d'hébergement de l'onglet Hébergements (Pavillon / Chalet / # Camp / Prêt-à-camper… avec capacité et chambres), photos du carrousel ; # 3. prix : les fiches n'affichent pas de tarif d'hébergement ; le sitemap # des forfaits (/sitemap/packages/sitemap.xml, slug préfixé du no de # permis) donne un prix « par personne / nuit » pour ~80 pourvoiries → # price_label du forfait le plus bas (à défaut d'un vrai prix/nuit). # # Une annonce = un établissement (les unités individuelles ne sont pas # réservables en ligne — la liste des unités va dans details["unites"]). # Seules les fiches avec au moins une unité d'hébergement sont retenues. # Réglage env : LOUKA_POURVOIRIES_LIMIT (nb max de fiches, 0 = tout). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import sys import time from ..schema import StListing, normalize_region from .base import StConnector BASE = "https://www.pourvoiries.com" SITEMAP_FICHES = f"{BASE}/sitemap/outfitters/sitemap.xml" SITEMAP_FORFAITS = f"{BASE}/sitemap/packages/sitemap.xml" PREFIX_FICHE = f"{BASE}/pourvoiries/" PREFIX_FORFAIT = f"{BASE}/forfaits/" _LOC_RE = re.compile(r"\s*(https?://[^<\s]+)") _PERMIS_RE = re.compile(r"(\d{2}-\d{3})$") # fin du slug établissement _FORFAIT_PERMIS_RE = re.compile(r"^(\d{2}-\d{3})-") # début du slug forfait _LAT_RE = re.compile(r"Latitude\s*:\s*(-?\d+\.\d+)") _LNG_RE = re.compile(r"Longitude\s*:\s*(-?\d+\.\d+)") _CAP_RE = re.compile(r"Pour\s+(\d+)\s+personne", re.I) _CH_RE = re.compile(r"(\d+)\s+chambre", re.I) _PRIX_FORFAIT_RE = re.compile( r'\s*([\d\s,. ]+)\s*\$', re.S) # Régions FPQ → région touristique canonique (le reste passe par # normalize_region : Mauricie, Outaouais, Côte-Nord…) _REGIONS_FPQ = { "gaspésie et îles-de-la-madeleine": "Gaspésie", "gaspesie et iles-de-la-madeleine": "Gaspésie", "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean", "nord-du-québec": "Nord-du-Québec", "baie-james": "Eeyou Istchee Baie-James", } # Titre de section d'hébergement → type canonique Lou-Ka _TYPE_UNITE = { "chalet": "Chalet", "pavillon": "Auberge", "auberge": "Auberge", "camp": "Refuge", "refuge": "Refuge", "yourte": "Yourte", "dôme": "Dôme", "tente": "Prêt-à-camper", "prêt-à-camper": "Prêt-à-camper", "camping": "Camping", "chambre": "Chambre", "maison": "Maison", "condo": "Condo", } def _property_type(types_unites: list[str]) -> str: """Type dominant de l'établissement — le chalet prime (offre principale).""" canon = [_TYPE_UNITE.get(t.strip().lower(), "") for t in types_unites] for pref in ("Chalet", "Auberge", "Yourte", "Dôme", "Prêt-à-camper", "Refuge", "Maison", "Condo", "Chambre", "Camping"): if pref in canon: return pref return "Chalet" class Pourvoiries(StConnector): source_id = "pourvoiries" request_delay = 0.8 # -- fiche établissement -------------------------------------------------- def _fetch_fiche(self, slug: str) -> dict: from bs4 import BeautifulSoup html = self.get(PREFIX_FICHE + slug).text soup = BeautifulSoup(html, "html.parser") d: dict = {} h1 = soup.select_one("h1.page-title") if not h1: return {} d["nom"] = h1.get_text(" ", strip=True) # bandeau : « Rivière-Bonjour, Gaspésie et Îles-de-la-Madeleine » banner = soup.select_one(".banner-single .region") if banner: loc = banner.get_text(" ", strip=True) ville, _, region = loc.partition(", ") d["ville"], d["region"] = ville.strip(), region.strip() # description (premier bloc sous le h2 « Description ») for h2 in soup.find_all("h2"): if h2.get_text(strip=True).lower() == "description": paras = [p.get_text(" ", strip=True) for p in h2.find_all_next("p", limit=4)] d["description"] = "\n".join(x for x in paras if x)[:2500] break # onglet Informations : paires h3 → p infos: dict[str, str] = {} for h3 in soup.find_all("h3"): p = h3.find_next_sibling("p") if p is not None: infos[h3.get_text(" ", strip=True).lower()] = \ p.get_text(" ", strip=True) for label, key in (("numéro d'établissement", "citq"), ("période d'ouverture", "ouverture"), ("type de restauration", "restauration"), ("type de pourvoirie", "type_pourvoirie"), ("langue de service", "langues")): for k, v in infos.items(): if k.startswith(label): d[key] = v break m = _LAT_RE.search(html) if m: d["lat"] = float(m.group(1)) m = _LNG_RE.search(html) if m: d["lng"] = float(m.group(1)) # onglet Hébergements : sections (h2.block-title) → cartes d'unités unites: list[dict] = [] heb = soup.find(id="hebergements") if heb is not None: for bloc in heb.select(".block-slides"): t = bloc.select_one("h2.block-title") type_u = t.get_text(" ", strip=True) if t else "" for card in bloc.select(".card"): titre = card.select_one(".card-title") if titre is None: continue txt = card.get_text(" ", strip=True) cap = _CAP_RE.search(txt) ch = _CH_RE.search(txt) unites.append({ "type": type_u, "nom": titre.get_text(" ", strip=True), "capacite": int(cap.group(1)) if cap else None, "chambres": int(ch.group(1)) if ch else None, "etoiles": len(card.select(".card-icons-stars " ".icon-star")) or None, }) d["unites"] = unites # photos du carrousel principal imgs: list[str] = [] slider = soup.select_one(".block-slider-img") if slider is not None: for img in slider.find_all("img"): u = img.get("data-src") or img.get("src") or "" if u.startswith("/"): u = BASE + u if u.startswith("https://") and u not in imgs: imgs.append(u) d["images"] = imgs[:12] return d # -- page forfait (prix « par personne / nuit ») --------------------------- def _fetch_forfait(self, slug: str) -> dict: html = self.get(PREFIX_FORFAIT + slug).text m = _PRIX_FORFAIT_RE.search(html) if not m: return {} try: prix = float(re.sub(r"[\s ]", "", m.group(1)).replace(",", ".")) except ValueError: return {} unite = "" mm = re.search(r'class="card-pricing">.*?

([^<]+)

', html, re.S) if mm: unite = mm.group(1).strip() return {"prix": prix, "unite": unite} # -- inventaire ------------------------------------------------------------ def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_POURVOIRIES_LIMIT", "0") or 0) month = time.strftime("%Y-%m") # re-visite mensuelle des fiches xml = self.get(SITEMAP_FICHES).text slugs = sorted({loc[len(PREFIX_FICHE):].strip("/") for loc in _LOC_RE.findall(xml) if loc.startswith(PREFIX_FICHE)}) # forfaits groupés par no de permis (slug « 01-501-… ») forfaits: dict[str, list[str]] = {} try: xmlf = self.get(SITEMAP_FORFAITS).text for loc in _LOC_RE.findall(xmlf): if not loc.startswith(PREFIX_FORFAIT): continue fslug = loc[len(PREFIX_FORFAIT):].strip("/") m = _FORFAIT_PERMIS_RE.match(fslug) if m: forfaits.setdefault(m.group(1), []).append(fslug) except Exception as exc: # noqa: BLE001 — les forfaits sont optionnels print(f"[pourvoiries] sitemap forfaits : {exc}", file=sys.stderr) listings: list[StListing] = [] for slug in slugs: try: d = self.detail(slug, month, lambda s=slug: self._fetch_fiche(s)) except Exception as exc: # noqa: BLE001 print(f"[pourvoiries] fiche {slug} : {exc}", file=sys.stderr) continue unites = d.get("unites") or [] if not d.get("nom") or not unites: # pas d'hébergement → hors sujet continue # prix : forfait le moins cher de la pourvoirie (par pers. / nuit) price_label = "" m = _PERMIS_RE.search(slug) permis = m.group(1) if m else "" best: dict = {} for fslug in forfaits.get(permis, []): try: f = self.detail(f"forfait:{fslug}", month, lambda s=fslug: self._fetch_forfait(s)) except Exception as exc: # noqa: BLE001 print(f"[pourvoiries] forfait {fslug} : {exc}", file=sys.stderr) continue # seuls les forfaits tarifés à la nuit (ou au jour) sont # comparables — un prix « par personne / séjour » fausserait # le prix/nuit dérivé par finalize(). Attention : « séjour » # contient « jour », d'où l'exclusion explicite. unite = f.get("unite", "").lower() if "jour" not in unite and "nuit" not in unite: continue if "séjour" in unite or "sejour" in unite: continue if f.get("prix") and (not best or f["prix"] < best["prix"]): best = f if best: unite = best.get("unite") or "par personne / nuit" price_label = (f"forfait à partir de {best['prix']:.0f} $ " f"{unite}") region = d.get("region", "") region = _REGIONS_FPQ.get(region.lower(), normalize_region(region)) caps = [u["capacite"] for u in unites if u.get("capacite")] chs = [u["chambres"] for u in unites if u.get("chambres")] details = {k: v for k, v in { "permis": permis, "nb_unites": len(unites), "unites": unites[:40], "ouverture": d.get("ouverture", ""), "restauration": d.get("restauration", ""), "type_pourvoirie": d.get("type_pourvoirie", ""), "langues": d.get("langues", ""), }.items() if v} listings.append(StListing( source=self.source_id, external_id=slug, url=PREFIX_FICHE + slug, title=d["nom"], property_type=_property_type([u["type"] for u in unites]), city=d.get("ville", ""), region=region, price_label=price_label, capacity=float(max(caps)) if caps else None, bedrooms=float(max(chs)) if chs else None, citq=d.get("citq", ""), description=d.get("description", ""), details=details, images=d.get("images") or [], lat=d.get("lat"), lng=d.get("lng"), )) if limit and len(listings) >= limit: break return listings