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%
9.5 KB · 225 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/tremblantliving.py : Tremblant Living (tremblantliving.com →4#   tremblantliving.ca) — agence de chalets et condos de luxe à Mont-Tremblant5#   (~35 propriétés, moteur Streamline VRS sur WordPress).6#7# Méthode : property-sitemap.xml (~37 fiches /property/ et /rental/, lastmod8#   = clé du cache détail). Chaque page détail embarque un JSON-LD schema.org9#   VacationRental complet : identifiant Streamline (unit_id), chambres,10#   salles de bain, capacité, note/avis, adresse, lat/lng, photos (galerie11#   streamlinevrs.com). La description longue vient du bloc12#   <div class="description block">, les commodités des <li class="amenity_item">.13#   PRIX : pas de prix statique dans le HTML, mais l'API Streamline passe par14#   admin-ajax.php avec action=streamlinecore-api-request et le corps JSON15#   {methodName, params} DANS LA QUERY STRING (format du plugin Angular) —16#   contrairement au POST classique, ce format n'est pas bloqué par17#   Cloudflare. GetPropertyRatesRawData(unit_id) retourne la grille des18#   tarifs saisonniers ($/nuit) → price_night = minimum des périodes19#   courantes/futures (« à partir de »). Rafraîchi à chaque run (37 appels).20#   Les /monthly-rentals/ (units-sitemap.xml) sont du long terme : ignorés.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import datetime as _dt25import html as _html26import json27import os28import re29import sys30from urllib.parse import urlencode3132from ..schema import StListing33from .base import StConnector3435SITE = "https://www.tremblantliving.ca"36SITEMAP = SITE + "/property-sitemap.xml"37AJAX = SITE + "/wp-admin/admin-ajax.php"3839# type déduit du nom de la fiche (agence ~100 % chalets et condos)40_TYPE_HINTS = [41    ("penthouse", "Condo"), ("condo", "Condo"), ("appartement", "Appartement"),42    ("apartment", "Appartement"), ("loft", "Loft"), ("studio", "Studio"),43    ("estate", "Maison"), ("maison", "Maison"), ("house", "Maison"),44    ("villa", "Maison"), ("chalet", "Chalet"), ("cottage", "Chalet"),45    ("cabin", "Chalet"), ("lodge", "Chalet"),46]4748_TAG_RE = re.compile(r"<[^>]+>")495051def _text(fragment: str) -> str:52    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()535455def _f(v) -> float | None:56    try:57        return float(v) if v is not None else None58    except (TypeError, ValueError):59        return None606162class TremblantLiving(StConnector):63    source_id = "tremblant_living"6465    # -- API Streamline (via admin-ajax, JSON en query string) ----------------66    def _api(self, method: str, params: dict) -> dict:67        req = json.dumps({"methodName": method, "params": params},68                         separators=(",", ":"))69        q = urlencode({"action": "streamlinecore-api-request", "params": req})70        resp = self.post(f"{AJAX}?{q}",71                         headers={"Content-Type": "application/json"})72        return resp.json()7374    def _price_from_rates(self, unit_id: str) -> tuple[float | None, str]:75        """Prix « à partir de » = minimum $/nuit des périodes tarifaires76        courantes et futures (GetPropertyRatesRawData). Jamais mis en cache :77        les tarifs saisonniers bougent sans que la page change."""78        data = self._api("GetPropertyRatesRawData",79                         {"unit_id": int(unit_id)}).get("data") or {}80        rates = data.get("rates") or []81        today = _dt.date.today()82        prices: list[float] = []83        for r in rates if isinstance(rates, list) else [rates]:84            try:85                end = _dt.datetime.strptime(86                    str(r.get("period_end") or ""), "%m/%d/%Y").date()87            except ValueError:88                end = today             # période sans date : on la garde89            if end < today:90                continue                # saison passée91            for k in ("daily_first_interval_price",92                      "daily_second_interval_price"):93                m = re.search(r"(\d[\d,]*(?:\.\d+)?)", str(r.get(k) or ""))94                if m:95                    v = float(m.group(1).replace(",", ""))96                    if 20 <= v <= 20000:97                        prices.append(v)98        if not prices:99            return None, ""100        mn = min(prices)101        mn = int(mn) if mn == int(mn) else mn102        return float(mn), f"à partir de {mn} $ / nuit"103104    # -- page détail --------------------------------------------------------105    def _detail(self, url: str) -> dict:106        h = self.get(url).text107        d: dict = {}108109        for block in re.findall(r'<script type="application/ld\+json"[^>]*>'110                                r"(.*?)</script>", h, re.S):111            try:112                ld = json.loads(block)113            except ValueError:114                continue115            if ld.get("@type") == "VacationRental":116                d["ld"] = ld117                break118119        # description longue : <div class="description block"><article>…120        m = re.search(r'(?s)<div class="description block[^"]*"[^>]*>.*?'121                      r"<article[^>]*>(.*?)</article>", h)122        if m:123            texte = re.sub(r"<br\s*/?>", "\n", m.group(1))124            texte = _html.unescape(_TAG_RE.sub(" ", texte))125            texte = re.sub(r"[ \t]+", " ", texte)126            texte = re.sub(r"\n\s+", "\n", texte).strip()127            d["description"] = texte[:5000]128129        # commodités : <li class="amenity_item"> avec coche (les entêtes de130        # catégorie sont des <li> en gras sans icône fa-check)131        amen: list[str] = []132        for li in re.findall(r'(?s)<li class="amenity_item"[^>]*>(.*?)</li>', h):133            if "fa-check" not in li:134                continue135            t = _text(li)136            if t and t not in amen:137                amen.append(t)138        if amen:139            d["amenities"] = amen140        return d141142    # -- contrat ------------------------------------------------------------143    def fetch(self) -> list[StListing]:144        limit = int(os.environ.get("LOUKA_TREMBLANT_LIMIT", "0") or 0)145        xml = self.get(SITEMAP).text146        entries = re.findall(r"(?s)<url>\s*<loc>([^<]+)</loc>"147                             r"(?:\s*<lastmod>([^<]*)</lastmod>)?", xml)148149        listings: list[StListing] = []150        vus: set[str] = set()151        for url, lastmod in entries:152            parts = [p for p in url.split("/") if p]153            # …/property/<slug>/ ou …/rental/<slug>/ (les /monthly-rentals/154            # sont dans units-sitemap.xml : long terme, hors mandat)155            if len(parts) < 4 or parts[-2] not in ("property", "rental"):156                continue157            slug = parts[-1]158            if slug in vus:159                continue160            vus.add(slug)161162            det = self.detail(slug, lastmod or "v1",163                              lambda u=url: self._detail(u))164            ld = det.get("ld") or {}165            if not ld:166                continue167            place = ld.get("containsPlace") or {}168            addr = ld.get("address") or {}169            agg = ld.get("aggregateRating") or {}170            occupancy = (place.get("occupancy") or {}).get("value")171172            title = _text(str(ld.get("name") or slug))173            hay = f"{title} {slug}".lower()174            ptype = next((canon for needle, canon in _TYPE_HINTS175                          if needle in hay), "Chalet")176177            amen = det.get("amenities") or []178            pets = "oui" if any("pet friendly" in a.lower()179                                for a in amen) else None180181            imgs = ld.get("image") or []182            if isinstance(imgs, str):183                imgs = [imgs]184            reviews = agg.get("reviewCount")185186            # tarif « à partir de » via l'API Streamline (hors cache détail)187            price_night, price_label = None, ""188            unit_id = str(ld.get("identifier") or "")189            if unit_id.isdigit():190                try:191                    price_night, price_label = self._price_from_rates(unit_id)192                except Exception as exc:  # tarif manquant ≠ annonce perdue193                    print(f"[tremblant_living] tarifs {unit_id} : {exc}",194                          file=sys.stderr)195196            listings.append(StListing(197                source=self.source_id,198                external_id=str(ld.get("identifier") or slug),199                url=url,200                title=title,201                property_type=ptype,202                address=_text(str(addr.get("streetAddress") or "")),203                city=_text(str(addr.get("addressLocality") or "Mont-Tremblant")),204                region="Laurentides",205                price_night=price_night,206                price_label=price_label,207                capacity=_f(occupancy),208                bedrooms=_f(place.get("numberOfBedrooms")),209                bathrooms=_f(place.get("numberOfBathroomsTotal")),210                pets=pets,211                rating=_f(agg.get("ratingValue")),212                reviews=int(reviews) if reviews else None,213                description=det.get("description")214                            or _text(str(ld.get("description") or "")),215                amenities=amen,216                details={"postal_code": addr.get("postalCode") or ""},217                images=[u for u in imgs if isinstance(u, str)218                        and u.startswith("https://")][:20],219                lat=_f(ld.get("latitude")),220                lng=_f(ld.get("longitude")),221            ))222            if limit and len(listings) >= limit:223                break224        return listings225