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/campingquebec.py : Camping Québec (campingquebec.com) —4# l'association des ~830 terrains de camping du Québec. On ne retient QUE5# les campings offrant du PRÊT-À-CAMPER / hébergement locatif (tentes6# aménagées, chalets, yourtes, roulottes…) : un camping « emplacements7# seulement » n'est pas un hébergement court terme pour Lou-Ka.8#9# Méthode (WordPress, aucun anti-bot) :10# 1. LISTE : l'endpoint AJAX de « Trouver un camping » est ouvert :11# GET /fr/wp-json/search/result?lang=fr&view=list12# &ready_to_camps[]=tous-types-de-pret-a-camper-disponible&paged=N13# → fragments HTML de 24 cartes/page (~600 campings filtrés prêt-à-camper).14# Carte : URL /fr/campings/<région>/<slug> (= external_id), nom, région.15# 2. FICHE (cache self.detail, clé mensuelle pour suivre les tarifs) :16# description, adresse + ville (bloc Informations), coordonnées (lien17# google.ca/maps?q=lat,lng), no d'enregistrement CITQ, tarifs (ligne18# « Nuitée, Prêt-à-camper » min-max → price_night), unités prêt-à-camper19# (« Prêt-à-camper disponibles : Tentes : 2 »), services (amenities),20# nb d'emplacements, dates de saison, photos. Garde-fou : la fiche doit21# confirmer le prêt-à-camper (unités ou tarif), sinon elle est écartée.22#23# Réglage env : LOUKA_CAMPINGQUEBEC_LIMIT (nb max de fiches, 0 = tout).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import sys30import time3132from ..schema import StListing, normalize_region33from .base import StConnector3435SITE = "https://www.campingquebec.com"36API = f"{SITE}/fr/wp-json/search/result"37PREFIX_FICHE = f"{SITE}/fr/campings/"38PAGE_MAX = 60 # garde-fou pagination3940_MAPS_RE = re.compile(r"google\.ca/maps\?q=(-?\d+\.\d+),(-?\d+\.\d+)")41_CITQ_RE = re.compile(r"No d[’']enregistrement\s*(\d{5,7})")42_PAGE_RE = re.compile(r'aria-label="Page (\d+)"')43_MONTANT_RE = re.compile(r"([\d\s ]+(?:[.,]\d{2})?)\s*\$")4445# Libellé d'unité prêt-à-camper → type canonique Lou-Ka (si type unique) ;46# le préfixe « location de » est retiré avant consultation.47_TYPE_UNITE = {48 "tente": "Prêt-à-camper", "tentes": "Prêt-à-camper",49 "chalet": "Chalet", "chalets": "Chalet",50 "yourte": "Yourte", "yourtes": "Yourte",51 "dôme": "Dôme", "dômes": "Dôme", "bulle ou dôme": "Dôme",52 "refuge": "Refuge", "refuges": "Refuge",53 "tipi": "Prêt-à-camper", "tipis": "Prêt-à-camper",54 "cabine": "Prêt-à-camper", "cabines": "Prêt-à-camper",55 "caravane": "Prêt-à-camper", "caravanes": "Prêt-à-camper",56}575859def _montant(txt: str) -> float | None:60 m = _MONTANT_RE.search(txt or "")61 if not m:62 return None63 try:64 return float(re.sub(r"[\s ]", "", m.group(1)).replace(",", "."))65 except ValueError:66 return None676869class CampingQuebec(StConnector):70 source_id = "campingquebec"71 request_delay = 0.87273 # -- liste (fragments HTML paginés) ----------------------------------------74 def _liste(self, limit: int = 0) -> list[dict]:75 from bs4 import BeautifulSoup76 items, vus = [], set()77 page, total_pages = 1, 178 while page <= min(total_pages, PAGE_MAX):79 if limit and len(items) >= limit:80 break81 html = self.get(API, params={82 "lang": "fr", "view": "list",83 "ready_to_camps[]": "tous-types-de-pret-a-camper-disponible",84 "paged": page,85 }).text86 pages = [int(p) for p in _PAGE_RE.findall(html)]87 if pages:88 total_pages = max(pages)89 soup = BeautifulSoup(html, "html.parser")90 nouveaux = 091 for a in soup.select(f'a.c-card[href^="{PREFIX_FICHE}"]'):92 path = a["href"][len(PREFIX_FICHE):].strip("/")93 if path.count("/") != 1 or path in vus:94 continue95 vus.add(path)96 nouveaux += 197 h4 = a.find("h4")98 span = a.select_one("span.u-text-transform-none")99 items.append({100 "id": path, # <région>/<slug>101 "nom": h4.get_text(" ", strip=True) if h4 else "",102 "region": span.get_text(" ", strip=True) if span else "",103 })104 if not nouveaux: # page vide → fin105 break106 page += 1107 return items108109 # -- fiche camping ----------------------------------------------------------110 def _fetch_fiche(self, path: str) -> dict:111 from bs4 import BeautifulSoup112 html = self.get(PREFIX_FICHE + path).text113 soup = BeautifulSoup(html, "html.parser")114 d: dict = {}115116 m = _MAPS_RE.search(html)117 if m:118 d["lat"], d["lng"] = float(m.group(1)), float(m.group(2))119 m = _CITQ_RE.search(html)120 if m:121 d["citq"] = m.group(1)122123 # description : bloc typographique sous l'en-tête « Description »124 for div in soup.find_all("div"):125 if div.get_text(strip=True) == "Description":126 typo = div.find_next_sibling("div")127 if typo is not None:128 d["description"] = typo.get_text("\n", strip=True)[:2500]129 break130131 # adresse + ville : paragraphe précédant « Voir sur la carte »132 carte = soup.find("a", string=re.compile("Voir sur la carte"))133 if carte is None:134 for a in soup.find_all("a"):135 if "Voir sur la carte" in a.get_text():136 carte = a137 break138 if carte is not None:139 p = carte.find_previous("p")140 if p is not None:141 lignes = [x.strip() for x in p.get_text("\n").split("\n")142 if x.strip()]143 if lignes:144 d["adresse"] = ", ".join(lignes)145 # « Saint-Sulpice J5W 3V5 » → ville sans le code postal146 d["ville"] = re.sub(147 r"\s*[A-Z]\d[A-Z]\s*\d[A-Z]\d\s*$", "",148 lignes[-1]).strip(" ,")149150 # sections h4 → listes (unités PAC, emplacements…)151 sections: dict[str, list[str]] = {}152 for h4 in soup.find_all("h4"):153 titre = h4.get_text(" ", strip=True)154 parent = h4.find_parent("div")155 bloc = parent.find_next_sibling("div") if parent else None156 if bloc is not None:157 lis = [li.get_text(" ", strip=True)158 for li in bloc.find_all("li")]159 if lis:160 sections[titre] = lis161162 pac: dict[str, int] = {}163 for titre, lis in sections.items():164 if titre.lower().startswith("prêt-à-camper"):165 for li in lis:166 nom, _, nb = li.partition(":")167 try:168 pac[nom.strip()] = int(nb.strip())169 except ValueError:170 pac[nom.strip()] = 0171 d["pac"] = pac172 for titre, lis in sections.items():173 if titre.lower().startswith("types d'emplacements"):174 d["emplacements"] = lis[:12]175176 # tarifs : lignes de la table « Durée / Min. / Max. »177 for tr in soup.select("table.c-table tr"):178 tds = [td.get_text(" ", strip=True) for td in tr.find_all("td")]179 if len(tds) >= 2 and "prêt-à-camper" in tds[0].lower():180 d["tarif_pac_min"] = _montant(tds[1])181 d["tarif_pac_max"] = _montant(tds[2]) if len(tds) > 2 else None182 elif len(tds) >= 2 and tds[0].lower() == "nuitée":183 d["tarif_nuit_min"] = _montant(tds[1])184185 # services offerts → amenities (panneau d'accordéon « services »)186 panneau = soup.select_one(187 'div.c-accordion__target[data-toggler-target*="services"]')188 if panneau is not None:189 d["services"] = [li.get_text(" ", strip=True)190 for li in panneau.find_all("li")][:40]191192 # saison193 texte = soup.get_text(" ", strip=True)194 m = re.search(r"Date d['’]ouverture\s*:\s*([\d]{1,2} \S+ \d{4})", texte)195 if m:196 d["ouverture"] = m.group(1)197 m = re.search(r"Date de fermeture\s*:\s*([\d]{1,2} \S+ \d{4})", texte)198 if m:199 d["fermeture"] = m.group(1)200201 # photos (galerie WordPress, en excluant logos et gabarits)202 imgs: list[str] = []203 for img in soup.find_all("img"):204 u = img.get("data-lazy-src") or img.get("src") or ""205 if (u.startswith(f"{SITE}/wp-content/uploads/20")206 and "logo" not in u.lower() and u not in imgs):207 imgs.append(u)208 d["images"] = imgs[:12]209 return d210211 # -- contrat ----------------------------------------------------------------212 def fetch(self) -> list[StListing]:213 limit = int(os.environ.get("LOUKA_CAMPINGQUEBEC_LIMIT", "0") or 0)214 month = time.strftime("%Y-%m") # re-visite mensuelle (tarifs)215 listings: list[StListing] = []216 # marge : certaines fiches de la liste seront écartées au garde-fou217 for it in self._liste(limit * 3 if limit else 0):218 path = it["id"]219 try:220 d = self.detail(path, month,221 lambda p=path: self._fetch_fiche(p))222 except Exception as exc: # noqa: BLE001223 print(f"[campingquebec] fiche {path} : {exc}", file=sys.stderr)224 continue225226 pac = d.get("pac") or {}227 prix_pac = d.get("tarif_pac_min")228 if not pac and not prix_pac: # aucun hébergement locatif confirmé229 continue230231 price_label = ""232 if prix_pac:233 pmax = d.get("tarif_pac_max")234 price_label = (f"prêt-à-camper {prix_pac:.2f} $"235 + (f" à {pmax:.2f} $" if pmax else "")236 + " / nuit")237238 # type : celui de l'unique famille d'unités, sinon Prêt-à-camper239 ptype = "Prêt-à-camper"240 if len(pac) == 1:241 libelle = re.sub(r"^location (de |d')", "",242 next(iter(pac)).lower()).strip()243 ptype = _TYPE_UNITE.get(libelle, ptype)244245 details = {k: v for k, v in {246 "unites_pret_a_camper": pac or None,247 "emplacements": d.get("emplacements"),248 "tarif_emplacement_min": d.get("tarif_nuit_min"),249 "ouverture": d.get("ouverture", ""),250 "fermeture": d.get("fermeture", ""),251 }.items() if v}252253 listings.append(StListing(254 source=self.source_id,255 external_id=path,256 url=PREFIX_FICHE + path,257 title=it.get("nom") or path.rsplit("/", 1)[-1],258 property_type=ptype,259 address=d.get("adresse", ""),260 city=d.get("ville", ""),261 region=normalize_region(it.get("region", "")),262 price_night=prix_pac,263 price_label=price_label,264 citq=d.get("citq", ""),265 description=d.get("description", ""),266 amenities=d.get("services") or [],267 details=details,268 images=d.get("images") or [],269 lat=d.get("lat"),270 lng=d.get("lng"),271 ))272 if limit and len(listings) >= limit:273 break274 return listings275