SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
12.6 KB · 308 lines python
Raw Blame History
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(?:&nbsp;|\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