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%
6.3 KB · 165 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/hebergia.py : Hébergia (hebergia.ca)4#5# Agence multi-régions (Cantons-de-l'Est, Laurentides, Lanaudière, Charlevoix,6# Centre-du-Québec) — WordPress + moteur Guesty, ~60 chalets.7#8# Méthode :9#   1. LISTE : la page /chalets/ embarque `window.chalets = [...]` (JSON10#      complet : gid Guesty, prix de base/nuit, url, titre, RÉGION, capacité,11#      chambres, lits, lat/lng, vignette). Une seule requête.12#   2. DÉTAIL (cache self.detail, clé = champs stables de la liste) : la page13#      WP fr /chalets/<slug>/ fournit ville (« Austin, Cantons-de-l'Est »),14#      salles de bain (bloc inshort), description, commodités (section15#      « Commodités incluses »), photos (assets.guesty.com) et numéro CITQ16#      (dans le règlement).17#   External_id = gid (id de listing Guesty, stable).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import html as _html22import json23import re2425from ..schema import StListing26from .base import StConnector2728SITE = "https://hebergia.ca"2930_TAG_RE = re.compile(r"<[^>]+>")313233def _text(fragment: str) -> str:34    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()353637def _num(v) -> float | None:38    try:39        return float(v) if v not in (None, "") else None40    except (TypeError, ValueError):41        return None424344class Hebergia(StConnector):45    source_id = "hebergia"4647    # -- liste ----------------------------------------------------------------48    def _list_items(self) -> list[dict]:49        h = self.get(f"{SITE}/chalets/").text50        m = re.search(r"window\.chalets\s*=\s*(\[.*?\]);", h, re.S)51        if not m:52            return []53        try:54            return json.loads(m.group(1))55        except ValueError:56            return []5758    # -- page détail ------------------------------------------------------59    def _detail(self, url: str) -> dict:60        h = self.get(url).text61        d: dict = {}6263        m = re.search(r'class="sous-titre-localisation">([^<]+)<', h)64        if m:65            loc = _text(m.group(1))66            d["city"] = loc.split(",")[0].strip()6768        # bloc « inshort » : 8 voyageurs / 3 chambres / lits / 2 salles de bain69        m = re.search(r"(\d+)\s*salles?\s*de\s*bain", h)70        if m:71            d["bathrooms"] = float(m.group(1))7273        # description : paragraphes de l'article avant les accordéons74        m = re.search(r"(?s)<article class=\"hbg-fiche-chalet-content\">"75                      r".*?</h1>(.*?)<h2 class=\"toggler\"", h)76        if m:77            frag = re.sub(r'(?s)<p class="sous-titre-localisation">.*?</p>',78                          " ", m.group(1))79            paras = re.findall(r"(?s)<p[^>]*>(.*?)</p>", frag)80            d["description"] = " ".join(_text(p) for p in paras81                                        if _text(p))[:4000]8283        # commodités : section « Commodités incluses » (lignes à puces)84        m = re.search(r'(?s)Commodités incluses</button></h2>\s*'85                      r'<div class="toToggle"[^>]*>(.*?)</div>', h)86        if m:87            amens = []88            for line in re.split(r"<br\s*/?>|</p>", m.group(1)):89                t = _text(line).lstrip("•· ").strip()90                if 2 <= len(t) <= 80 and not t.isupper():91                    amens.append(t)92            d["amenities"] = amens[:60]9394        # photos (CDN Guesty, dédupliquées)95        imgs: list[str] = []96        for u in re.findall(r'data-flickity-lazyload-src="'97                            r'(https://assets\.guesty\.com/[^"]+)"', h):98            if u not in imgs:99                imgs.append(u)100        d["images"] = imgs[:20]101102        m = re.search(r"CITQ\D{0,12}(\d{6})", h, re.I)103        if m:104            d["citq"] = m.group(1)105106        m = re.search(r"animaux non admis|pas d.animaux|no pets", h, re.I)107        if m:108            d["pets"] = "non"109        return d110111    # -- contrat ----------------------------------------------------------112    def fetch(self) -> list[StListing]:113        listings: list[StListing] = []114        for it in self._list_items():115            gid = str(it.get("gid") or "").strip()116            url = it.get("url") or ""117            title = _text(str(it.get("title") or ""))118            if not gid or not url or not title or "/en/" in url:119                continue120121            key = json.dumps([gid, title, it.get("price"),122                              it.get("accommodates"), it.get("bedrooms"),123                              it.get("beds"), it.get("shortDesc")],124                             ensure_ascii=False)125            try:126                det = self.detail(gid, key, lambda u=url: self._detail(u))127            except Exception:      # une fiche détail cassée ≠ annonce perdue128                det = {}129130            price = _num(it.get("price"))131            starting = _num(it.get("startingPrice"))132            if starting and starting > 0:133                price = starting134            desc = det.get("description") or ""135            short = _text(str(it.get("shortDesc") or ""))136            if short and short not in desc:137                desc = f"{short} {desc}".strip()138139            listings.append(StListing(140                source=self.source_id,141                external_id=gid,142                url=url,143                title=title,144                property_type="Chalet",145                city=det.get("city") or "",146                region=it.get("region") or "",147                price_night=price,148                price_label=(f"à partir de {price:.0f} $ / nuit"149                             if price else ""),150                capacity=_num(it.get("accommodates")),151                bedrooms=_num(it.get("bedrooms")),152                beds=_num(it.get("beds")),153                bathrooms=det.get("bathrooms"),154                pets=det.get("pets"),155                citq=det.get("citq") or "",156                description=desc[:4000],157                amenities=det.get("amenities") or [],158                details={"guesty_id": gid},159                images=det.get("images")160                       or ([it["thumbnail"]] if it.get("thumbnail") else []),161                lat=_num(it.get("lat")),162                lng=_num(it.get("lng")),163            ))164        return listings165