# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/bonjourquebec.py : BonjourQuebec.com — site officiel de Tourisme # Québec, répertoire de TOUT l'hébergement enregistré (CITQ). Catégories court # terme ciblées (hôtels, auberges de jeunesse et campings nus EXCLUS) : # - chalets / appartements / résidences de tourisme (pré-filtre où-dormir 37) # - hébergements insolites (pré-filtre 35) # - gîtes (pré-filtre 38 « hôtels-auberges-gîtes », filtré sur la # catégorie « Gîte touristique ») # # Méthode : # 1. la page « carte du Québec » de chaque pré-filtre embarque TOUT # l'inventaire dans drupalSettings.interactiveMap.items (nid, titre, # lat/lng, catégorie, vignette, description) → inventaire complet en # UNE requête par catégorie (pas de pagination) ; # 2. l'inventaire chalets/résidences (~11 500 fiches) est PLAFONNÉ (tri # stable par nid) pour rester à ~MAX_TOTAL annonces au total (consigne : # quelques milliers max) — gîtes et insolites sont gardés en entier ; # 3. fiche /fiche/ (cache self.detail — 1 seule visite par fiche) : # région touristique, ville, adresse, no d'enregistrement CITQ, # description, services/équipements, animaux, tarifs, photos. # PRIX : le site ne publie QUE des maximums par nuitée (widget Tarifs : # « Maximum pour l'unité la plus chère », « Prix maximum par nuitée # prêt-à-camper »). On les expose honnêtement via price_label # (« maximum X $ / nuit ») — finalize() en déduit price_night ; le # libellé garde la nuance (ce n'est pas un « à partir de »). Les # emplacements de camping nu sont ignorés (hors mandat). # CAPACITÉ : jamais publiée en « personnes » sur les fiches — on récupère # ce qui existe : chambres des gîtes (« Chambre : N unités ») et # mentions « N personnes » dans la description (rare). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import re import sys from ..schema import StListing from .base import StConnector BASE = "https://www.bonjourquebec.com" # (réf carte, catégories gardées — None = tout garder, type par défaut) MAPS = [ ("50?pre=37", None, "Chalet"), # chalets, apparts, rés. tourisme ("48?pre=35", None, "Autre"), # hébergements insolites ("51?pre=38", {"Gîte touristique"}, "Gîte"), # gîtes (hôtels exclus) ] MAX_TOTAL = 5000 # plafond global (consigne : quelques milliers max) _SETTINGS_RE = re.compile( r'data-drupal-selector="drupal-settings-json">(.*?)', re.S) _CITQ_RE = re.compile( r"enregistrement d.hébergement(?: |\s|:)*\s*(\d{5,7})") _TYPE_KEYWORDS = [ ("yourte", "Yourte"), ("dôme", "Dôme"), ("dome ", "Dôme"), ("mini-maison", "Mini-maison"), ("micro-chalet", "Mini-maison"), ("tipi", "Prêt-à-camper"), ("tepee", "Prêt-à-camper"), ("prêt-à-camper", "Prêt-à-camper"), ("pret-a-camper", "Prêt-à-camper"), ("tente", "Prêt-à-camper"), ("refuge", "Refuge"), ("condo", "Condo"), ("appartement", "Appartement"), ("appart", "Appartement"), ("studio", "Studio"), ("loft", "Loft"), ("chambre", "Chambre"), ("gîte", "Gîte"), ("gite", "Gîte"), ("auberge", "Auberge"), ("maison", "Maison"), ("chalet", "Chalet"), ] _PRICE_VAL_RE = re.compile(r"\d[\d\s ,.]*\$") _CAP_RE = re.compile(r"(\d{1,2})\s*personnes") _CHAMBRES_RE = re.compile(r"^Chambre\s*:\s*(\d+)\s*unité", re.I) def _price_label(tarifs: list[str]) -> str: """Libellé prix/nuit depuis le widget Tarifs (le site n'affiche que des maximums par nuitée). Priorité : unité la plus chère > prêt-à-camper > autre « par nuitée » — emplacements de camping nu exclus.""" pairs: list[tuple[str, str]] = [] label = "" for txt in tarifs or []: m = _PRICE_VAL_RE.search(txt) if m and label: pairs.append((label.lower(), re.sub(r"[\s ]+", " ", m.group(0)).strip())) label = "" elif not m and txt: label = txt def pick(needle: str, exclude: str = "") -> str: for lab, val in pairs: if needle in lab and (not exclude or exclude not in lab): return val return "" val = (pick("unité la plus chère") or pick("prêt-à-camper") or pick("nuit", exclude="camping")) return f"maximum {val} / nuit" if val else "" def _abs(url: str) -> str: url = _html.unescape(url or "").strip() if not url: return "" if url.startswith("//"): return "https:" + url if url.startswith("/"): return BASE + url return url def _property_type(category: str, title: str, fallback: str) -> str: if category == "Gîte touristique": return "Gîte" if category == "Camping et prêt-à-camper": fallback = "Prêt-à-camper" # insolites : surtout des prêts-à-camper blob = f"{title}".lower() for needle, ptype in _TYPE_KEYWORDS: if needle in blob: return ptype return fallback class BonjourQuebec(StConnector): source_id = "bonjourquebec" request_delay = 0.3 # CDN gouvernemental costaud, pas d'anti-bot # -- inventaire : items de la carte interactive ------------------------------ def _map_items(self, mapref: str) -> list[dict]: html = self.get(f"{BASE}/fr-ca/carte-du-quebec/fournisseur/{mapref}").text m = _SETTINGS_RE.search(html) if not m: return [] try: settings = json.loads(m.group(1)) except ValueError: return [] return (settings.get("interactiveMap") or {}).get("items") or [] # -- fiche détail (région, ville, adresse, CITQ, services…) ------------------ def _fetch_fiche(self, ext: str) -> dict: from bs4 import BeautifulSoup r = self.get(f"{BASE}/fiche/{ext}") html = r.text soup = BeautifulSoup(html, "html.parser") d: dict = {"url_final": str(getattr(r, "url", "") or "")} def _value(cls: str) -> str: node = soup.select_one( f".fiche-entreprise--info-general__region__item.{cls} " ".fiche-entreprise--info-general__region__item__value") return node.get_text(" ", strip=True) if node else "" d["region"] = _value("region") d["city"] = _value("ville") node = soup.select_one(".group-body .description") if node: d["description"] = node.get_text(" ", strip=True) m = _CITQ_RE.search(html) if m: d["citq"] = m.group(1) node = soup.select_one(".contact-adresse") if node: d["address"] = node.get_text(" ", strip=True) # widget Tarifs : uniquement des maximums → conservés en détails tarifs = [] for w in soup.select(".fiche-entreprise--widget--tarifs .card-body"): sub = [x.get_text(" ", strip=True) for x in w.select("p, h5")] tarifs += [x for x in sub if x] if tarifs: d["tarifs"] = tarifs # accordéons Services / Activités / Installations → commodités amenities, units = [], [] for grp in soup.select(".group-service"): h3 = grp.find("h3") gname = h3.get_text(" ", strip=True) if h3 else "" for li in grp.find_all("li"): txt = li.get_text(" ", strip=True) if not txt: continue if "unité" in txt and ":" in txt: units.append(txt) elif txt not in amenities: amenities.append(txt) low = txt.lower() if gname.lower().startswith("animaux") or "animaux" in low: if "non admis" in low or "pas admis" in low: d["pets"] = "non" elif "admis" in low: d["pets"] = ("conditions" if "payant" in low or "condition" in low else "oui") d["amenities"] = amenities if units: d["unites"] = units imgs = [] for img in soup.select( '[class*="modal-carousel-images-gallery"] img[src]'): src = _abs(img.get("src") or "") if src and src not in imgs: imgs.append(src) if not imgs: m = re.search(r'property="og:image" content="([^"]+)"', html) if m: imgs = [_abs(m.group(1))] d["images"] = imgs return d # -- contrat ------------------------------------------------------------------- def fetch(self) -> list[StListing]: seen: dict[str, tuple[dict, str]] = {} # id → (item carte, type défaut) capped: list[str] = [] # ids de la catégorie plafonnée for mapref, keep, fallback in MAPS: try: items = self._map_items(mapref) except Exception as exc: # noqa: BLE001 print(f"[bonjourquebec] carte {mapref} : {exc}", file=sys.stderr) continue for it in items: nid = str(it.get("nid") or "") ext = nid.split("-", 1)[0].strip() if not ext or ext in seen: continue if keep is not None and (it.get("category") or "") not in keep: continue seen[ext] = (it, fallback) if mapref.endswith("pre=37"): # catégorie énorme → plafonnée capped.append(ext) # plafond global stable (tri par identifiant, catégorie chalets rognée) overflow = len(seen) - MAX_TOTAL if overflow > 0: for ext in sorted(capped)[-overflow:]: seen.pop(ext, None) listings: list[StListing] = [] for ext in sorted(seen): it, fallback = seen[ext] title = _html.unescape(str(it.get("title") or "")).strip() if not title: continue if title.isupper(): title = title.title() try: d = self.detail(ext, title, lambda e=ext: self._fetch_fiche(e)) except Exception as exc: # noqa: BLE001 print(f"[bonjourquebec] fiche {ext} : {exc}", file=sys.stderr) d = {} geo = it.get("geoData") or {} category = str(it.get("category") or "") desc = d.get("description", "") if not desc: desc = re.sub(r"<[^>]+>", " ", str(it.get("description") or "")) desc = _html.unescape(re.sub(r"\s+", " ", desc)).strip() images = d.get("images") or [] thumb = _abs(str(it.get("image") or it.get("thumbnail") or "")) if thumb and "default_images" not in thumb and thumb not in images: images.append(thumb) details = {k: v for k, v in { "categorie": category, "tarifs": d.get("tarifs"), "unites": d.get("unites"), }.items() if v} # capacité : mention « N personnes » dans la description (rare) caps = [int(x) for x in _CAP_RE.findall(desc) if 1 <= int(x) <= 40] capacity = float(max(caps)) if caps else None # chambres : les gîtes déclarent « Chambre : N unités » bedrooms = None for u in d.get("unites") or []: m = _CHAMBRES_RE.match(u) if m and 0 < int(m.group(1)) <= 30: bedrooms = float(m.group(1)) url = d.get("url_final") or f"{BASE}/fiche/{ext}" lst = StListing( source=self.source_id, external_id=ext, url=url, title=title, property_type=_property_type(category, title, fallback), address=d.get("address", ""), city=d.get("city", ""), region=d.get("region", ""), price_label=_price_label(d.get("tarifs") or []), capacity=capacity, bedrooms=bedrooms, pets=d.get("pets"), citq=d.get("citq", ""), description=desc, amenities=d.get("amenities") or [], details=details, images=images, lat=geo.get("lat"), lng=geo.get("lon"), ) listings.append(lst) return listings