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%
10.4 KB · 247 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/glampinghub.py : Glamping Hub (glampinghub.com) — hébergements4# insolites (dômes, yourtes, pods, cabanes…), ~200 fiches au Québec.5#6# Méthode : l'endpoint AJAX de la page de recherche est ouvert (aucun anti-bot) :7#   GET https://glampinghub.com/search-accommodations/?lang=en&page=N8#       &q=Quebec, Canada&location={"state": "Quebec", "country": "Canada"}9#       &adults=2&…&numberOfResultsPerPage=2410#   → search_results[…] + total_results. Chaque fiche est TRÈS riche : nom,11#   catégorie, ville + coordonnées, prix (estimated_rate.daily_rate en devise12#   originale, CAD au Québec), chambres/lits, capacité par unité13#   (units_distribution), note sur 5, commodités (nested_features), photos.14#   Une seule requête paginée suffit pour la liste ; seule la DESCRIPTION15#   n'y figure pas → elle est prise sur la page détail (aucun anti-bot non16#   plus), dans le bloc SSR <noscript id="description-content">, avec repli17#   sur la <meta name="description">. Cache permanent dans louka_ct.db.18#19# URL publique : https://glampinghub.com<absolute_url_en>. Région touristique20# déduite des coordonnées (centroïdes partagés avec airbnb.py).21# Réglages env : LOUKA_GLAMPINGHUB_LIMIT (nb max de fiches, 0 = tout),22#                LOUKA_GLAMPINGHUB_DETAIL_LIMIT (fetchs détail/sync, défaut 250).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import html as _html27import os28import re29import sys3031from ..schema import StListing32from .base import StConnector33from .airbnb import _region_from_latlng343536class _DetailSkip(Exception):37    """Fiche détail sautée (budget épuisé) — pas de mise en cache."""383940_DESC_RE = re.compile(r'<noscript id="description-content">(.*?)</noscript>',41                      re.S)42_META_RE = re.compile(r'<meta name="description" content="([^"]+)"')4344SITE = "https://glampinghub.com"45API = f"{SITE}/search-accommodations/"46PAGE_SIZE = 244748# Catégorie Glamping Hub → type canonique Lou-Ka49_CATEGORIES = {50    "cabins": "Chalet", "cottages": "Chalet", "log cabins": "Chalet",51    "vacation rentals": "Maison", "villas": "Maison",52    "designer rentals": "Maison", "unique stays": "Autre",53    "tiny houses": "Mini-maison", "hobbit houses": "Mini-maison",54    "domes": "Dôme", "yurts": "Yourte", "tipis": "Prêt-à-camper",55    "pods": "Prêt-à-camper", "bell tents": "Prêt-à-camper",56    "safari tents": "Prêt-à-camper", "tented cabins": "Prêt-à-camper",57    "canvas tents": "Prêt-à-camper", "airstreams": "Prêt-à-camper",58    "caravans": "Prêt-à-camper", "campervans": "Prêt-à-camper",59    "tree houses": "Autre", "barns": "Autre", "boats": "Autre",60    "islands": "Autre", "castles": "Autre", "condos": "Condo",61    "apartments": "Appartement", "lodges": "Auberge",62    "bed and breakfasts": "Gîte",63}646566def _num(v) -> float | None:67    try:68        return float(v) if v not in (None, "") else None69    except (TypeError, ValueError):70        return None717273class GlampingHub(StConnector):74    source_id = "glampinghub"75    request_delay = 0.87677    # -- liste --------------------------------------------------------------78    def _search_page(self, page: int) -> dict:79        resp = self.get(API, params={80            "lang": "en",81            "page": page,82            "q": "Quebec, Canada",83            "location": '{"state": "Quebec", "country": "Canada"}',84            "adults": 2, "children": 0, "infants": 0,85            "sort": "-ranking_engine_boost",86            "source": "rentalsearch",87            "numberOfResultsPerPage": PAGE_SIZE,88        }, headers={"Accept": "application/json",89                    "X-Requested-With": "XMLHttpRequest",90                    "Referer": f"{SITE}/rentalsearch/"})91        return resp.json()9293    def _all_items(self) -> list[dict]:94        items: list[dict] = []95        page, total = 0, 196        while len(items) < min(total, 2000) and page < 100:97            data = self._search_page(page)98            batch = data.get("search_results") or []99            total = data.get("total_results") or 0100            if not batch:101                break102            items.extend(batch)103            page += 1104        return items105106    # -- description (page détail, SSR ouvert) --------------------------------107    @staticmethod108    def _parse_description(html: str) -> dict:109        """{description} depuis le bloc <noscript> SSR (repli : meta)."""110        desc = ""111        m = _DESC_RE.search(html or "")112        if m:113            txt = re.sub(r"<br\s*/?>|</p>|</h2>", "\n", m.group(1))114            txt = _html.unescape(re.sub(r"<[^>]+>", " ", txt))115            lines = [re.sub(r"\s+", " ", ln).strip() for ln in txt.split("\n")]116            # écarter le bruit : lignes « … », slogan SEO de bas de bloc et117            # liste de commodités hors-plateforme (pas une description)118            lines = [ln for ln in lines if ln and ln not in ("...", "…")119                     and not re.match(r"^Book your dream .*!$", ln)120                     and not ln.startswith("Amenities not shown on")]121            desc = "\n".join(lines).strip()122        if len(desc) < 40:              # fiche laconique (« … ») : repli meta123            mm = _META_RE.search(html or "")124            meta = _html.unescape(mm.group(1)).strip() if mm else ""125            if len(meta) > len(desc):126                desc = meta127        return {"description": desc[:6000]} if desc else {}128129    def _enrich_details(self, listings: list[StListing]) -> None:130        """Complète la description via la page détail, sous budget (les hits131        de cache sont gratuits, seuls les fetchs réseau comptent)."""132        limit = max(0, int(os.environ.get("LOUKA_GLAMPINGHUB_DETAIL_LIMIT",133                                          "250") or 250))134        used = enriched = 0135        for lst in listings:136            if not lst.url.startswith(SITE + "/"):137                continue138            def fetch_fn(url=lst.url):139                nonlocal used140                if used >= limit:141                    raise _DetailSkip142                used += 1143                return self._parse_description(self.get(url).text)144145            try:146                d = self.detail(lst.external_id, "v1", fetch_fn)147            except _DetailSkip:148                continue149            except Exception:  # noqa: BLE001 — une fiche ne bloque pas le run150                continue151            if d.get("description") and len(d["description"]) > \152                    len(lst.description or ""):153                lst.description = d["description"]154                enriched += 1155        print(f"[glampinghub] détail : {enriched} descriptions"156              f" ({used}/{limit} fetchs réseau)", file=sys.stderr)157158    # -- contrat --------------------------------------------------------------159    def fetch(self) -> list[StListing]:160        limit = int(os.environ.get("LOUKA_GLAMPINGHUB_LIMIT", "0") or 0)161        listings: list[StListing] = []162        seen: set[str] = set()163        for it in self._all_items():164            gid = str(it.get("id") or "").strip()165            title = (it.get("name_en") or "").strip()166            loc = it.get("location") or {}167            if not gid or gid in seen or not title:168                continue169            if (loc.get("state_en") or "").strip().lower() != "quebec":170                continue171            seen.add(gid)172173            lat = lng = None174            coords = (loc.get("coords") or "").split(",")175            if len(coords) == 2:176                lat, lng = _num(coords[0]), _num(coords[1])177178            path = it.get("absolute_url_en") or ""179            if not path:180                for u in it.get("absolute_url") or []:181                    if (u or {}).get("lang") == "en":182                        path = u.get("url") or ""183                        break184185            images = []186            for ph in (it.get("images") or [])[:15]:187                u = (ph or {}).get("url") or ""188                if u.startswith("//"):189                    u = "https:" + u190                if u.startswith("https://") and u not in images:191                    images.append(u)192193            # prix : tarif nuit de base en devise originale (CAD au Québec)194            price_night, price_label = None, ""195            rate = it.get("estimated_rate") or {}196            daily = _num(rate.get("daily_rate"))197            if daily and (it.get("original_currency") or "") == "CAD":198                price_night = daily199                price_label = f"à partir de {daily:.0f} $ CAD / nuit"200201            units = it.get("units_distribution") or []202            capacity = max((_num(u.get("capacity")) or 0 for u in units),203                           default=0) or None204205            amenities = []206            for f in it.get("nested_features") or []:207                name = (f or {}).get("name_en") or ""208                if f.get("active") and name and name not in amenities:209                    amenities.append(name)210211            category = (it.get("category_en") or "").strip()212            details = {k: v for k, v in {213                "category": category,214                "units": len(units) or None,215                "min_stay": rate.get("min_stay"),216                "verified": bool(it.get("verified_accommodation")) or None,217                "remarkable": [f.get("name_en") for f in218                               (it.get("remarkable_features") or [])219                               if (f or {}).get("name_en")] or None,220            }.items() if v}221222            rating = _num(it.get("average_rate"))223            listings.append(StListing(224                source=self.source_id,225                external_id=gid,226                url=SITE + path if path else SITE,227                title=title,228                property_type=_CATEGORIES.get(category.lower(), "Autre"),229                city=loc.get("city_en") or "",230                region=_region_from_latlng(lat, lng),231                price_night=price_night,232                price_label=price_label,233                capacity=capacity,234                bedrooms=_num(it.get("bedrooms_number")),235                beds=_num(it.get("beds_number")),236                rating=rating if rating else None,237                amenities=amenities,238                details=details,239                images=images,240                lat=lat,241                lng=lng,242            ))243            if limit and len(listings) >= limit:244                break245        self._enrich_details(listings)246        return listings247