# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/campingquebec.py : Camping Québec (campingquebec.com) — # l'association des ~830 terrains de camping du Québec. On ne retient QUE # les campings offrant du PRÊT-À-CAMPER / hébergement locatif (tentes # aménagées, chalets, yourtes, roulottes…) : un camping « emplacements # seulement » n'est pas un hébergement court terme pour Lou-Ka. # # Méthode (WordPress, aucun anti-bot) : # 1. LISTE : l'endpoint AJAX de « Trouver un camping » est ouvert : # GET /fr/wp-json/search/result?lang=fr&view=list # &ready_to_camps[]=tous-types-de-pret-a-camper-disponible&paged=N # → fragments HTML de 24 cartes/page (~600 campings filtrés prêt-à-camper). # Carte : URL /fr/campings// (= external_id), nom, région. # 2. FICHE (cache self.detail, clé mensuelle pour suivre les tarifs) : # description, adresse + ville (bloc Informations), coordonnées (lien # google.ca/maps?q=lat,lng), no d'enregistrement CITQ, tarifs (ligne # « Nuitée, Prêt-à-camper » min-max → price_night), unités prêt-à-camper # (« Prêt-à-camper disponibles : Tentes : 2 »), services (amenities), # nb d'emplacements, dates de saison, photos. Garde-fou : la fiche doit # confirmer le prêt-à-camper (unités ou tarif), sinon elle est écartée. # # Réglage env : LOUKA_CAMPINGQUEBEC_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 SITE = "https://www.campingquebec.com" API = f"{SITE}/fr/wp-json/search/result" PREFIX_FICHE = f"{SITE}/fr/campings/" PAGE_MAX = 60 # garde-fou pagination _MAPS_RE = re.compile(r"google\.ca/maps\?q=(-?\d+\.\d+),(-?\d+\.\d+)") _CITQ_RE = re.compile(r"No d[’']enregistrement\s*(\d{5,7})") _PAGE_RE = re.compile(r'aria-label="Page (\d+)"') _MONTANT_RE = re.compile(r"([\d\s ]+(?:[.,]\d{2})?)\s*\$") # Libellé d'unité prêt-à-camper → type canonique Lou-Ka (si type unique) ; # le préfixe « location de » est retiré avant consultation. _TYPE_UNITE = { "tente": "Prêt-à-camper", "tentes": "Prêt-à-camper", "chalet": "Chalet", "chalets": "Chalet", "yourte": "Yourte", "yourtes": "Yourte", "dôme": "Dôme", "dômes": "Dôme", "bulle ou dôme": "Dôme", "refuge": "Refuge", "refuges": "Refuge", "tipi": "Prêt-à-camper", "tipis": "Prêt-à-camper", "cabine": "Prêt-à-camper", "cabines": "Prêt-à-camper", "caravane": "Prêt-à-camper", "caravanes": "Prêt-à-camper", } def _montant(txt: str) -> float | None: m = _MONTANT_RE.search(txt or "") if not m: return None try: return float(re.sub(r"[\s ]", "", m.group(1)).replace(",", ".")) except ValueError: return None class CampingQuebec(StConnector): source_id = "campingquebec" request_delay = 0.8 # -- liste (fragments HTML paginés) ---------------------------------------- def _liste(self, limit: int = 0) -> list[dict]: from bs4 import BeautifulSoup items, vus = [], set() page, total_pages = 1, 1 while page <= min(total_pages, PAGE_MAX): if limit and len(items) >= limit: break html = self.get(API, params={ "lang": "fr", "view": "list", "ready_to_camps[]": "tous-types-de-pret-a-camper-disponible", "paged": page, }).text pages = [int(p) for p in _PAGE_RE.findall(html)] if pages: total_pages = max(pages) soup = BeautifulSoup(html, "html.parser") nouveaux = 0 for a in soup.select(f'a.c-card[href^="{PREFIX_FICHE}"]'): path = a["href"][len(PREFIX_FICHE):].strip("/") if path.count("/") != 1 or path in vus: continue vus.add(path) nouveaux += 1 h4 = a.find("h4") span = a.select_one("span.u-text-transform-none") items.append({ "id": path, # / "nom": h4.get_text(" ", strip=True) if h4 else "", "region": span.get_text(" ", strip=True) if span else "", }) if not nouveaux: # page vide → fin break page += 1 return items # -- fiche camping ---------------------------------------------------------- def _fetch_fiche(self, path: str) -> dict: from bs4 import BeautifulSoup html = self.get(PREFIX_FICHE + path).text soup = BeautifulSoup(html, "html.parser") d: dict = {} m = _MAPS_RE.search(html) if m: d["lat"], d["lng"] = float(m.group(1)), float(m.group(2)) m = _CITQ_RE.search(html) if m: d["citq"] = m.group(1) # description : bloc typographique sous l'en-tête « Description » for div in soup.find_all("div"): if div.get_text(strip=True) == "Description": typo = div.find_next_sibling("div") if typo is not None: d["description"] = typo.get_text("\n", strip=True)[:2500] break # adresse + ville : paragraphe précédant « Voir sur la carte » carte = soup.find("a", string=re.compile("Voir sur la carte")) if carte is None: for a in soup.find_all("a"): if "Voir sur la carte" in a.get_text(): carte = a break if carte is not None: p = carte.find_previous("p") if p is not None: lignes = [x.strip() for x in p.get_text("\n").split("\n") if x.strip()] if lignes: d["adresse"] = ", ".join(lignes) # « Saint-Sulpice J5W 3V5 » → ville sans le code postal d["ville"] = re.sub( r"\s*[A-Z]\d[A-Z]\s*\d[A-Z]\d\s*$", "", lignes[-1]).strip(" ,") # sections h4 → listes (unités PAC, emplacements…) sections: dict[str, list[str]] = {} for h4 in soup.find_all("h4"): titre = h4.get_text(" ", strip=True) parent = h4.find_parent("div") bloc = parent.find_next_sibling("div") if parent else None if bloc is not None: lis = [li.get_text(" ", strip=True) for li in bloc.find_all("li")] if lis: sections[titre] = lis pac: dict[str, int] = {} for titre, lis in sections.items(): if titre.lower().startswith("prêt-à-camper"): for li in lis: nom, _, nb = li.partition(":") try: pac[nom.strip()] = int(nb.strip()) except ValueError: pac[nom.strip()] = 0 d["pac"] = pac for titre, lis in sections.items(): if titre.lower().startswith("types d'emplacements"): d["emplacements"] = lis[:12] # tarifs : lignes de la table « Durée / Min. / Max. » for tr in soup.select("table.c-table tr"): tds = [td.get_text(" ", strip=True) for td in tr.find_all("td")] if len(tds) >= 2 and "prêt-à-camper" in tds[0].lower(): d["tarif_pac_min"] = _montant(tds[1]) d["tarif_pac_max"] = _montant(tds[2]) if len(tds) > 2 else None elif len(tds) >= 2 and tds[0].lower() == "nuitée": d["tarif_nuit_min"] = _montant(tds[1]) # services offerts → amenities (panneau d'accordéon « services ») panneau = soup.select_one( 'div.c-accordion__target[data-toggler-target*="services"]') if panneau is not None: d["services"] = [li.get_text(" ", strip=True) for li in panneau.find_all("li")][:40] # saison texte = soup.get_text(" ", strip=True) m = re.search(r"Date d['’]ouverture\s*:\s*([\d]{1,2} \S+ \d{4})", texte) if m: d["ouverture"] = m.group(1) m = re.search(r"Date de fermeture\s*:\s*([\d]{1,2} \S+ \d{4})", texte) if m: d["fermeture"] = m.group(1) # photos (galerie WordPress, en excluant logos et gabarits) imgs: list[str] = [] for img in soup.find_all("img"): u = img.get("data-lazy-src") or img.get("src") or "" if (u.startswith(f"{SITE}/wp-content/uploads/20") and "logo" not in u.lower() and u not in imgs): imgs.append(u) d["images"] = imgs[:12] return d # -- contrat ---------------------------------------------------------------- def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_CAMPINGQUEBEC_LIMIT", "0") or 0) month = time.strftime("%Y-%m") # re-visite mensuelle (tarifs) listings: list[StListing] = [] # marge : certaines fiches de la liste seront écartées au garde-fou for it in self._liste(limit * 3 if limit else 0): path = it["id"] try: d = self.detail(path, month, lambda p=path: self._fetch_fiche(p)) except Exception as exc: # noqa: BLE001 print(f"[campingquebec] fiche {path} : {exc}", file=sys.stderr) continue pac = d.get("pac") or {} prix_pac = d.get("tarif_pac_min") if not pac and not prix_pac: # aucun hébergement locatif confirmé continue price_label = "" if prix_pac: pmax = d.get("tarif_pac_max") price_label = (f"prêt-à-camper {prix_pac:.2f} $" + (f" à {pmax:.2f} $" if pmax else "") + " / nuit") # type : celui de l'unique famille d'unités, sinon Prêt-à-camper ptype = "Prêt-à-camper" if len(pac) == 1: libelle = re.sub(r"^location (de |d')", "", next(iter(pac)).lower()).strip() ptype = _TYPE_UNITE.get(libelle, ptype) details = {k: v for k, v in { "unites_pret_a_camper": pac or None, "emplacements": d.get("emplacements"), "tarif_emplacement_min": d.get("tarif_nuit_min"), "ouverture": d.get("ouverture", ""), "fermeture": d.get("fermeture", ""), }.items() if v} listings.append(StListing( source=self.source_id, external_id=path, url=PREFIX_FICHE + path, title=it.get("nom") or path.rsplit("/", 1)[-1], property_type=ptype, address=d.get("adresse", ""), city=d.get("ville", ""), region=normalize_region(it.get("region", "")), price_night=prix_pac, price_label=price_label, citq=d.get("citq", ""), description=d.get("description", ""), amenities=d.get("services") or [], details=details, images=d.get("images") or [], lat=d.get("lat"), lng=d.get("lng"), )) if limit and len(listings) >= limit: break return listings