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
3 days agolast push
HTML 98.9% Python 0.6%
10.2 KB · 257 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/hipcamp.py : Hipcamp (hipcamp.com) — camping et prêt-à-camper4# chez des hôtes privés (terres agricoles, boisés…), ~350 terrains au Québec.5#6# Méthode : le site (Next.js) parle à un GraphQL public, accessible sans7# session avec les en-têtes HIPCAMP-API-KEY (clé publique embarquée dans le8# bundle JS) et HIPCAMP-PLATFORM: Web.9#   1. LISTE : POST https://www.hipcamp.com/graphql/search — requête10#      LandsSearch(landFilter: {boundingBox: <bbox Québec>}) paginée par11#      offset/limit (50 par page). Chaque « land » (terrain, souvent12#      multi-emplacements) : nom, ville, coordonnées, prix/nuit (« CA$54.00 »),13#      types d'hébergement (tent/rv/house), photos, % de recommandations.14#      Le bbox mord sur l'Ontario et le Maine : on garde stateAbbrvName == QC.15#   2. DÉTAIL (cache self.detail) : POST /graphql/camper — requête Land par16#      maskedId : description (overview, souvent bilingue avec no CITQ),17#      capacité max, nb d'emplacements par type, commodités et activités.18#   3. Photos : CDN Cloudinary (https://hipcamp-res.cloudinary.com/…).19#20# Pas de note sur 5 chez Hipcamp : % de recommandations → details. Région21# touristique déduite des coordonnées (centroïdes partagés avec airbnb.py).22# Réglage env : LOUKA_HIPCAMP_LIMIT (nb max de terrains, 0 = tout).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import os27import re28import sys2930from ..schema import StListing31from .base import StConnector32from .airbnb import _in_quebec, _region_from_latlng3334SITE = "https://www.hipcamp.com"35GRAPHQL_SEARCH = f"{SITE}/graphql/search"36GRAPHQL_CAMPER = f"{SITE}/graphql/camper"37CDN = "https://hipcamp-res.cloudinary.com"3839# Clé API publique (embarquée dans le bundle JS du site, module 79018)40HEADERS = {41    "Content-Type": "application/json",42    "Accept": "application/json",43    "HIPCAMP-API-KEY": "Dp7qfhE8y8cTx73qSYu8b6M2",44    "HIPCAMP-PLATFORM": "Web",45}4647# Zone habitée du Québec (sud, ouest, nord, est) — même bbox qu'airbnb.py48QC_BBOX = (44.95, -79.80, 52.20, -56.90)49PAGE_SIZE = 505051LIST_QUERY = """query LandsSearch($landFilter: LandFilterInput!, $privateOffset: Int, $privateLimit: Int) {52  lands(landFilter: $landFilter) {53    privateLands(offset: $privateOffset, limit: $privateLimit) {54      total55      edges {56        availableAccommodationKeys57        availableCampsitesCount58        node {59          allAccommodationKeys60          cityName61          coordinate { latitude longitude }62          countryCode63          id64          maskedId65          name66          stateAbbrvName67          locationSummary68          topPhotos { filename }69          url70        }71        pricePerNight { symbol format minorAmount }72      }73    }74  }75}"""7677DETAIL_QUERY = """query Land($landId: ID!, $landIdType: LandIdTypeEnum!) {78  land(landId: $landId, landIdType: $landIdType) {79    maskedId fullName cityName countyName overview subheader80    maxSiteCapacity campsiteCount structureCount rvCount tentCount81    recommendsPercentage recommendsCount bookingsCount82    coordinate { lat lng }83    minPricePerNight { amount format isoCode }84    coreAmenities: landCampFeatures(type: CORE_AMENITY) { name }85    basicAmenities: landCampFeatures(type: BASIC_AMENITY) { name }86    activities: landCampFeatures(type: ACTIVITY) { name }87  }88}"""8990_PRICE_FMT_RE = re.compile(r"CA\$([\d,]+(?:\.\d{2})?)")919293def _photo_url(filename: str) -> str:94    """URL CDN d'une photo — deux formats de filename coexistent."""95    if not filename:96        return ""97    if filename.startswith("images/"):     # chemin complet : pas de transform98        return f"{CDN}/{filename}"99    return f"{CDN}/f_auto,c_limit,w_1120,q_auto/{filename}"100101102def _property_type(keys: list[str]) -> str:103    """tent/rv = emplacements nus, house = unités bâties (cabane, dôme…)."""104    ks = {str(k).lower() for k in (keys or [])}105    if "house" in ks:106        return "Chalet" if not (ks & {"tent", "rv"}) else "Prêt-à-camper"107    return "Camping"108109110def _price_cad(price: dict) -> tuple[float | None, str]:111    fmt = (price or {}).get("format") or ""112    m = _PRICE_FMT_RE.search(fmt)113    if not m:114        return None, fmt115    return float(m.group(1).replace(",", "")), f"{fmt} / nuit"116117118class Hipcamp(StConnector):119    source_id = "hipcamp"120    request_delay = 0.5121122    def _graphql(self, url: str, query: str, variables: dict) -> dict:123        resp = self.post(url, json={"query": query, "variables": variables},124                         headers=HEADERS)125        data = resp.json()126        if data.get("errors"):127            raise RuntimeError(str(data["errors"])[:200])128        return data.get("data") or {}129130    # -- liste ------------------------------------------------------------------131    def _all_edges(self) -> list[dict]:132        s, w, n, e = QC_BBOX133        land_filter = {"boundingBox": {134            "northeastLatitude": n, "northeastLongitude": e,135            "southwestLatitude": s, "southwestLongitude": w}}136        edges: list[dict] = []137        offset, total = 0, 1138        while offset < min(total, 3000):139            data = self._graphql(GRAPHQL_SEARCH, LIST_QUERY, {140                "landFilter": land_filter,141                "privateOffset": offset, "privateLimit": PAGE_SIZE})142            page = ((data.get("lands") or {}).get("privateLands") or {})143            batch = page.get("edges") or []144            total = page.get("total") or 0145            if not batch:146                break147            edges.extend(batch)148            offset += PAGE_SIZE149        return edges150151    # -- détail (cache BD) --------------------------------------------------------152    def _fetch_detail(self, masked_id: str) -> dict:153        data = self._graphql(GRAPHQL_CAMPER, DETAIL_QUERY,154                             {"landId": masked_id, "landIdType": "MASKED"})155        land = data.get("land") or {}156        if not land:157            return {}158        amenities = []159        for grp in ("coreAmenities", "basicAmenities"):160            for it in land.get(grp) or []:161                name = (it or {}).get("name") or ""162                if name and name not in amenities:163                    amenities.append(name)164        activities = [a.get("name") for a in (land.get("activities") or [])165                      if (a or {}).get("name")]166        overview = re.sub(r"\s+", " ", land.get("overview") or "").strip()167        price = land.get("minPricePerNight") or {}168        return {169            "description": overview[:4000],170            "subheader": land.get("subheader") or "",171            "county": land.get("countyName") or "",172            "capacity": land.get("maxSiteCapacity"),173            "campsites": land.get("campsiteCount"),174            "structures": land.get("structureCount"),175            "amenities": amenities,176            "activities": activities,177            "recommends_pct": land.get("recommendsPercentage"),178            "recommends_count": land.get("recommendsCount"),179            "min_price": (price.get("amount")180                          if price.get("isoCode") == "CAD" else None),181        }182183    # -- contrat --------------------------------------------------------------184    def fetch(self) -> list[StListing]:185        limit = int(os.environ.get("LOUKA_HIPCAMP_LIMIT", "0") or 0)186        listings: list[StListing] = []187        seen: set[str] = set()188        for edge in self._all_edges():189            node = edge.get("node") or {}190            mid = str(node.get("maskedId") or "").strip()191            title = (node.get("name") or "").strip()192            if not mid or mid in seen or not title:193                continue194            if (node.get("stateAbbrvName") or "").upper() != "QC":195                continue196            seen.add(mid)197198            coord = node.get("coordinate") or {}199            lat, lng = coord.get("latitude"), coord.get("longitude")200            if lat is not None and lng is not None and not _in_quebec(lat, lng):201                continue202203            images = []204            for ph in (node.get("topPhotos") or [])[:15]:205                u = _photo_url((ph or {}).get("filename") or "")206                if u and u not in images:207                    images.append(u)208209            keys = node.get("allAccommodationKeys") or []210            price_night, price_label = _price_cad(edge.get("pricePerNight"))211212            # clé de cache détail : sous-ensemble stable de la carte liste213            key = f"{title}|{','.join(sorted(keys))}|{len(images)}"214            try:215                det = self.detail(mid, key,216                                  lambda m=mid: self._fetch_detail(m))217            except Exception as exc:  # noqa: BLE001 — détail cassé ≠ perdu218                print(f"[hipcamp] détail {mid} : {exc}", file=sys.stderr)219                det = {}220221            if price_night is None and det.get("min_price"):222                price_night = float(det["min_price"])223                price_label = f"à partir de {price_night:.0f} $ / nuit"224225            details = {k: v for k, v in {226                "accommodation_keys": ", ".join(keys) or None,227                "campsites": det.get("campsites"),228                "structures": det.get("structures"),229                "county": det.get("county"),230                "recommends_pct": det.get("recommends_pct"),231                "recommends_count": det.get("recommends_count"),232                "activities": det.get("activities") or None,233            }.items() if v}234235            cap = det.get("capacity")236            listings.append(StListing(237                source=self.source_id,238                external_id=mid,239                url=SITE + (node.get("url") or f"/en-CA/land/{mid}"),240                title=title,241                property_type=_property_type(keys),242                city=node.get("cityName") or "",243                region=_region_from_latlng(lat, lng),244                price_night=price_night,245                price_label=price_label,246                capacity=float(cap) if cap else None,247                description=det.get("description") or "",248                amenities=det.get("amenities") or [],249                details=details,250                images=images,251                lat=lat,252                lng=lng,253            ))254            if limit and len(listings) >= limit:255                break256        return listings257