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/bonjourquebec.py : BonjourQuebec.com — site officiel de Tourisme4# Québec, répertoire de TOUT l'hébergement enregistré (CITQ). Catégories court5# terme ciblées (hôtels, auberges de jeunesse et campings nus EXCLUS) :6# - chalets / appartements / résidences de tourisme (pré-filtre où-dormir 37)7# - hébergements insolites (pré-filtre 35)8# - gîtes (pré-filtre 38 « hôtels-auberges-gîtes », filtré sur la9# catégorie « Gîte touristique »)10#11# Méthode :12# 1. la page « carte du Québec » de chaque pré-filtre embarque TOUT13# l'inventaire dans drupalSettings.interactiveMap.items (nid, titre,14# lat/lng, catégorie, vignette, description) → inventaire complet en15# UNE requête par catégorie (pas de pagination) ;16# 2. l'inventaire chalets/résidences (~11 500 fiches) est PLAFONNÉ (tri17# stable par nid) pour rester à ~MAX_TOTAL annonces au total (consigne :18# quelques milliers max) — gîtes et insolites sont gardés en entier ;19# 3. fiche /fiche/<id> (cache self.detail — 1 seule visite par fiche) :20# région touristique, ville, adresse, no d'enregistrement CITQ,21# description, services/équipements, animaux, tarifs, photos.22# PRIX : le site ne publie QUE des maximums par nuitée (widget Tarifs :23# « Maximum pour l'unité la plus chère », « Prix maximum par nuitée24# prêt-à-camper »). On les expose honnêtement via price_label25# (« maximum X $ / nuit ») — finalize() en déduit price_night ; le26# libellé garde la nuance (ce n'est pas un « à partir de »). Les27# emplacements de camping nu sont ignorés (hors mandat).28# CAPACITÉ : jamais publiée en « personnes » sur les fiches — on récupère29# ce qui existe : chambres des gîtes (« Chambre : N unités ») et30# mentions « N personnes » dans la description (rare).31# -----------------------------------------------------------------------------32from __future__ import annotations3334import html as _html35import json36import re37import sys3839from ..schema import StListing40from .base import StConnector4142BASE = "https://www.bonjourquebec.com"4344# (réf carte, catégories gardées — None = tout garder, type par défaut)45MAPS = [46 ("50?pre=37", None, "Chalet"), # chalets, apparts, rés. tourisme47 ("48?pre=35", None, "Autre"), # hébergements insolites48 ("51?pre=38", {"Gîte touristique"}, "Gîte"), # gîtes (hôtels exclus)49]50MAX_TOTAL = 5000 # plafond global (consigne : quelques milliers max)5152_SETTINGS_RE = re.compile(53 r'data-drupal-selector="drupal-settings-json">(.*?)</script>', re.S)54_CITQ_RE = re.compile(55 r"enregistrement d.hébergement(?: |\s|:)*</span>\s*(\d{5,7})")56_TYPE_KEYWORDS = [57 ("yourte", "Yourte"), ("dôme", "Dôme"), ("dome ", "Dôme"),58 ("mini-maison", "Mini-maison"), ("micro-chalet", "Mini-maison"),59 ("tipi", "Prêt-à-camper"), ("tepee", "Prêt-à-camper"),60 ("prêt-à-camper", "Prêt-à-camper"), ("pret-a-camper", "Prêt-à-camper"),61 ("tente", "Prêt-à-camper"), ("refuge", "Refuge"),62 ("condo", "Condo"), ("appartement", "Appartement"), ("appart", "Appartement"),63 ("studio", "Studio"), ("loft", "Loft"), ("chambre", "Chambre"),64 ("gîte", "Gîte"), ("gite", "Gîte"), ("auberge", "Auberge"),65 ("maison", "Maison"), ("chalet", "Chalet"),66]676869_PRICE_VAL_RE = re.compile(r"\d[\d\s ,.]*\$")70_CAP_RE = re.compile(r"(\d{1,2})\s*personnes")71_CHAMBRES_RE = re.compile(r"^Chambre\s*:\s*(\d+)\s*unité", re.I)727374def _price_label(tarifs: list[str]) -> str:75 """Libellé prix/nuit depuis le widget Tarifs (le site n'affiche que des76 maximums par nuitée). Priorité : unité la plus chère > prêt-à-camper >77 autre « par nuitée » — emplacements de camping nu exclus."""78 pairs: list[tuple[str, str]] = []79 label = ""80 for txt in tarifs or []:81 m = _PRICE_VAL_RE.search(txt)82 if m and label:83 pairs.append((label.lower(), re.sub(r"[\s ]+", " ",84 m.group(0)).strip()))85 label = ""86 elif not m and txt:87 label = txt8889 def pick(needle: str, exclude: str = "") -> str:90 for lab, val in pairs:91 if needle in lab and (not exclude or exclude not in lab):92 return val93 return ""9495 val = (pick("unité la plus chère")96 or pick("prêt-à-camper")97 or pick("nuit", exclude="camping"))98 return f"maximum {val} / nuit" if val else ""99100101def _abs(url: str) -> str:102 url = _html.unescape(url or "").strip()103 if not url:104 return ""105 if url.startswith("//"):106 return "https:" + url107 if url.startswith("/"):108 return BASE + url109 return url110111112def _property_type(category: str, title: str, fallback: str) -> str:113 if category == "Gîte touristique":114 return "Gîte"115 if category == "Camping et prêt-à-camper":116 fallback = "Prêt-à-camper" # insolites : surtout des prêts-à-camper117 blob = f"{title}".lower()118 for needle, ptype in _TYPE_KEYWORDS:119 if needle in blob:120 return ptype121 return fallback122123124class BonjourQuebec(StConnector):125 source_id = "bonjourquebec"126 request_delay = 0.3 # CDN gouvernemental costaud, pas d'anti-bot127128 # -- inventaire : items de la carte interactive ------------------------------129 def _map_items(self, mapref: str) -> list[dict]:130 html = self.get(f"{BASE}/fr-ca/carte-du-quebec/fournisseur/{mapref}").text131 m = _SETTINGS_RE.search(html)132 if not m:133 return []134 try:135 settings = json.loads(m.group(1))136 except ValueError:137 return []138 return (settings.get("interactiveMap") or {}).get("items") or []139140 # -- fiche détail (région, ville, adresse, CITQ, services…) ------------------141 def _fetch_fiche(self, ext: str) -> dict:142 from bs4 import BeautifulSoup143 r = self.get(f"{BASE}/fiche/{ext}")144 html = r.text145 soup = BeautifulSoup(html, "html.parser")146 d: dict = {"url_final": str(getattr(r, "url", "") or "")}147148 def _value(cls: str) -> str:149 node = soup.select_one(150 f".fiche-entreprise--info-general__region__item.{cls} "151 ".fiche-entreprise--info-general__region__item__value")152 return node.get_text(" ", strip=True) if node else ""153154 d["region"] = _value("region")155 d["city"] = _value("ville")156157 node = soup.select_one(".group-body .description")158 if node:159 d["description"] = node.get_text(" ", strip=True)160161 m = _CITQ_RE.search(html)162 if m:163 d["citq"] = m.group(1)164165 node = soup.select_one(".contact-adresse")166 if node:167 d["address"] = node.get_text(" ", strip=True)168169 # widget Tarifs : uniquement des maximums → conservés en détails170 tarifs = []171 for w in soup.select(".fiche-entreprise--widget--tarifs .card-body"):172 sub = [x.get_text(" ", strip=True) for x in w.select("p, h5")]173 tarifs += [x for x in sub if x]174 if tarifs:175 d["tarifs"] = tarifs176177 # accordéons Services / Activités / Installations → commodités178 amenities, units = [], []179 for grp in soup.select(".group-service"):180 h3 = grp.find("h3")181 gname = h3.get_text(" ", strip=True) if h3 else ""182 for li in grp.find_all("li"):183 txt = li.get_text(" ", strip=True)184 if not txt:185 continue186 if "unité" in txt and ":" in txt:187 units.append(txt)188 elif txt not in amenities:189 amenities.append(txt)190 low = txt.lower()191 if gname.lower().startswith("animaux") or "animaux" in low:192 if "non admis" in low or "pas admis" in low:193 d["pets"] = "non"194 elif "admis" in low:195 d["pets"] = ("conditions"196 if "payant" in low or "condition" in low197 else "oui")198 d["amenities"] = amenities199 if units:200 d["unites"] = units201202 imgs = []203 for img in soup.select(204 '[class*="modal-carousel-images-gallery"] img[src]'):205 src = _abs(img.get("src") or "")206 if src and src not in imgs:207 imgs.append(src)208 if not imgs:209 m = re.search(r'property="og:image" content="([^"]+)"', html)210 if m:211 imgs = [_abs(m.group(1))]212 d["images"] = imgs213 return d214215 # -- contrat -------------------------------------------------------------------216 def fetch(self) -> list[StListing]:217 seen: dict[str, tuple[dict, str]] = {} # id → (item carte, type défaut)218 capped: list[str] = [] # ids de la catégorie plafonnée219 for mapref, keep, fallback in MAPS:220 try:221 items = self._map_items(mapref)222 except Exception as exc: # noqa: BLE001223 print(f"[bonjourquebec] carte {mapref} : {exc}", file=sys.stderr)224 continue225 for it in items:226 nid = str(it.get("nid") or "")227 ext = nid.split("-", 1)[0].strip()228 if not ext or ext in seen:229 continue230 if keep is not None and (it.get("category") or "") not in keep:231 continue232 seen[ext] = (it, fallback)233 if mapref.endswith("pre=37"): # catégorie énorme → plafonnée234 capped.append(ext)235236 # plafond global stable (tri par identifiant, catégorie chalets rognée)237 overflow = len(seen) - MAX_TOTAL238 if overflow > 0:239 for ext in sorted(capped)[-overflow:]:240 seen.pop(ext, None)241242 listings: list[StListing] = []243 for ext in sorted(seen):244 it, fallback = seen[ext]245 title = _html.unescape(str(it.get("title") or "")).strip()246 if not title:247 continue248 if title.isupper():249 title = title.title()250 try:251 d = self.detail(ext, title, lambda e=ext: self._fetch_fiche(e))252 except Exception as exc: # noqa: BLE001253 print(f"[bonjourquebec] fiche {ext} : {exc}", file=sys.stderr)254 d = {}255256 geo = it.get("geoData") or {}257 category = str(it.get("category") or "")258 desc = d.get("description", "")259 if not desc:260 desc = re.sub(r"<[^>]+>", " ", str(it.get("description") or ""))261 desc = _html.unescape(re.sub(r"\s+", " ", desc)).strip()262263 images = d.get("images") or []264 thumb = _abs(str(it.get("image") or it.get("thumbnail") or ""))265 if thumb and "default_images" not in thumb and thumb not in images:266 images.append(thumb)267268 details = {k: v for k, v in {269 "categorie": category,270 "tarifs": d.get("tarifs"),271 "unites": d.get("unites"),272 }.items() if v}273274 # capacité : mention « N personnes » dans la description (rare)275 caps = [int(x) for x in _CAP_RE.findall(desc) if 1 <= int(x) <= 40]276 capacity = float(max(caps)) if caps else None277 # chambres : les gîtes déclarent « Chambre : N unités »278 bedrooms = None279 for u in d.get("unites") or []:280 m = _CHAMBRES_RE.match(u)281 if m and 0 < int(m.group(1)) <= 30:282 bedrooms = float(m.group(1))283284 url = d.get("url_final") or f"{BASE}/fiche/{ext}"285 lst = StListing(286 source=self.source_id,287 external_id=ext,288 url=url,289 title=title,290 property_type=_property_type(category, title, fallback),291 address=d.get("address", ""),292 city=d.get("city", ""),293 region=d.get("region", ""),294 price_label=_price_label(d.get("tarifs") or []),295 capacity=capacity,296 bedrooms=bedrooms,297 pets=d.get("pets"),298 citq=d.get("citq", ""),299 description=desc,300 amenities=d.get("amenities") or [],301 details=details,302 images=images,303 lat=geo.get("lat"),304 lng=geo.get("lon"),305 )306 listings.append(lst)307 return listings308