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%
8.5 KB · 220 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/chaletsdanslenord.py : Les Chalets dans le Nord4# (leschaletsdanslenord.com — Laurentides)5#6# Petite agence familiale (Sainte-Lucie-des-Laurentides / lac Sarrazin,7# ~6 chalets) — vitrine WordPress + moteur de réservation HOSTAWAY8# (reservation.leschaletsdanslenord.com, compte 96792).9#10# Méthode :11#   1. IDS : la racine du moteur Hostaway (Next.js rendu serveur) référence12#      tous les chalets via des liens "/listings/<id>" ; la homepage WP donne13#      en plus prix (« dès N $ / nuit ») et lien de la fiche vitrine (l'id14#      Hostaway est dans l'URL des photos S3 `96792-<id>-…`).15#   2. DÉTAIL (cache self.detail) : /listings/<id> du moteur embarque le JSON16#      complet dans le payload React Flight (`self.__next_f`) : prix de base17#      par nuit, lat/lng, ville, capacité, chambres, sdb, lits, type, note18#      (sur 10 → /2 par finalize), nb d'avis, ~50 photos, ~70 commodités et19#      description (référence Flight « $xx » résolue via les segments T<hex>).20#   External_id = id de listing Hostaway (stable, dans l'URL du moteur).21# -----------------------------------------------------------------------------22from __future__ import annotations2324import json25import re2627from ..schema import StListing28from .base import StConnector2930SITE = "https://leschaletsdanslenord.com"31ENGINE = "https://reservation.leschaletsdanslenord.com"323334def _num(v) -> float | None:35    try:36        return float(v) if v not in (None, "") else None37    except (TypeError, ValueError):38        return None394041def _flight_blob(html: str) -> str:42    parts = []43    for c in re.findall(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)',44                        html):45        try:46            parts.append(json.loads(f'"{c}"'))47        except ValueError:48            continue49    return "".join(parts)505152def _flight_text(blob: str, ref: str) -> str:53    """Résout une référence texte Flight « $xx » (segment `xx:T<len hex>,`,54    longueur en OCTETS utf-8)."""55    rid = ref.lstrip("$")56    m = re.search(rf"(?:^|\n){re.escape(rid)}:T([0-9a-f]+),", blob)57    if not m:58        return ""59    n = int(m.group(1), 16)60    raw = blob[m.end():].encode("utf-8")[:n]61    return raw.decode("utf-8", errors="ignore")626364class ChaletsDansLeNord(StConnector):65    source_id = "chaletsdanslenord"6667    # -- ids + carte prix/urls vitrine --------------------------------------68    def _engine_ids(self) -> list[str]:69        h = self.get(f"{ENGINE}/").text70        return sorted(set(re.findall(r'"/listings/(\d+)"', h)))7172    def _wp_cards(self) -> dict[str, dict]:73        """id Hostaway → {url fiche vitrine, prix « dès N $ »} (homepage WP)."""74        try:75            h = self.get(f"{SITE}/").text76        except Exception:77            return {}78        cards: dict[str, dict] = {}79        for block in re.split(r'<li class="lcdln-hsg__card"', h)[1:]:80            m = re.search(r"hostaway-platform[^\"]*/listing/96792-(\d+)-",81                          block)82            if not m:83                continue84            hid = m.group(1)85            card: dict = {}86            m = re.search(r'class="lcdln-hsg__card-btn" href="([^"]+)"', block)87            if m:88                card["url"] = m.group(1)89            m = re.search(r"dès\s*([\d ,]+)\s*\$\s*/\s*nuit", block)90            if m:91                card["price"] = float(m.group(1).replace(" ", "")92                                      .replace(",", "."))93            cards[hid] = card94        return cards9596    def _wp_fiche(self, url: str) -> dict:97        """Titre + description EN FRANÇAIS depuis la fiche vitrine WP."""98        h = self.get(url).text99        d: dict = {}100        m = re.search(r"(?s)<h1[^>]*>(.*?)</h1>", h)101        if m:102            d["title"] = re.sub(r"\s+", " ",103                                re.sub(r"<[^>]+>", " ", m.group(1))).strip()104        m = re.search(r"(?s)<main[^>]*>(.*?)</main>", h)105        if m:106            import html as _h107            paras, seen = [], set()108            for p in re.findall(r"(?s)<p[^>]*>(.*?)</p>", m.group(1)):109                t = _h.unescape(re.sub(r"\s+", " ",110                                       re.sub(r"<[^>]+>", " ", p))).strip()111                if len(t) < 60 or t in seen or "Voir les" in t[:30]:112                    continue113                seen.add(t)114                paras.append(t)115                if len(paras) >= 10:116                    break117            if paras:118                d["description"] = " ".join(paras)[:4000]119        return d120121    # -- détail (moteur Hostaway + fiche vitrine FR) --------------------------122    def _detail(self, hid: str, wp_url: str = "") -> dict:123        h = self.get(f"{ENGINE}/listings/{hid}").text124        blob = _flight_blob(h)125        i = blob.find(f'"listing":{{"id":{hid}')126        if i < 0:127            return {}128        obj, _ = json.JSONDecoder().raw_decode(blob[i + len('"listing":'):])129        inner = obj.get("listing") or {}130131        desc = str(inner.get("description") or "")132        if desc.startswith("$"):133            desc = _flight_text(blob, desc)134        desc = re.sub(r"\s+", " ", desc).strip()135136        images = []137        for ph in obj.get("listingImage") or []:138            u = (ph or {}).get("url")139            if u and u not in images:140                images.append(u)141            if len(images) >= 20:142                break143144        # fiche vitrine WP : titre + description en français (prioritaires)145        wp: dict = {}146        if wp_url:147            try:148                wp = self._wp_fiche(wp_url)149            except Exception:150                wp = {}151        if wp.get("description"):152            desc = wp["description"]153154        pt = ((inner.get("propertyType") or {}).get("name") or "").strip()155        return {156            "title": wp.get("title") or (inner.get("name") or "").strip(),157            "price": _num(inner.get("price")),158            "lat": _num(inner.get("lat")),159            "lng": _num(inner.get("lng")),160            "city": (inner.get("city") or "").strip(),161            "capacity": _num(inner.get("personCapacity")),162            "bedrooms": _num(inner.get("bedroomsNumber")),163            "beds": _num(inner.get("bedsNumber")),164            "bathrooms": _num(inner.get("bathroomsNumber")),165            "property_type": pt,166            "rating": _num(obj.get("averageReviewRating")),167            "reviews": obj.get("reviewsCount"),168            "description": desc[:4000],169            "amenities": [n for n in170                          (((a.get("amenity") or {}).get("name")171                            or a.get("name") or "").strip()172                           for a in obj.get("listingAmenity") or []173                           if isinstance(a, dict)) if n][:80],174            "images": images,175        }176177    # -- contrat ----------------------------------------------------------178    def fetch(self) -> list[StListing]:179        cards = self._wp_cards()180        listings: list[StListing] = []181        for hid in self._engine_ids():182            card = cards.get(hid) or {}183            key = json.dumps([hid, card.get("price"), card.get("url")])184            try:185                det = self.detail(186                    hid, key,187                    lambda i=hid, u=card.get("url") or "": self._detail(i, u))188            except Exception:189                det = {}190            if not det.get("title"):191                continue192193            price = det.get("price") or card.get("price")194            reviews = det.get("reviews")195            listings.append(StListing(196                source=self.source_id,197                external_id=hid,198                url=card.get("url") or f"{ENGINE}/listings/{hid}",199                title=det["title"],200                property_type=det.get("property_type") or "Chalet",201                city=det.get("city") or "",202                region="Laurentides",203                price_night=price,204                price_label=(f"à partir de {price:.0f} $ / nuit"205                             if price else ""),206                capacity=det.get("capacity"),207                bedrooms=det.get("bedrooms"),208                beds=det.get("beds"),209                bathrooms=det.get("bathrooms"),210                rating=det.get("rating"),211                reviews=int(reviews) if reviews else None,212                description=det.get("description") or "",213                amenities=det.get("amenities") or [],214                details={"booking_url": f"{ENGINE}/listings/{hid}"},215                images=det.get("images") or [],216                lat=det.get("lat"),217                lng=det.get("lng"),218            ))219        return listings220