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%
11.6 KB · 275 lines python
Raw Blame History
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