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%
9.0 KB · 223 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/chaletsarabais.py : Chalet à Rabais (https://chaletarabais.com)4#5# ⚠️ Le domaine réel est chaletarabais.com (SANS « s » après chalet) —6#    chaletsarabais.com listé dans sources_ct.json ne résout plus (SERVFAIL).7#8# Méthode : WordPress (thème Homey). L'API REST expose le CPT `listing`9# (/wp-json/wp/v2/listings) avec taxonomies listing_state / listing_area /10# listing_city → inventaire complet paginé + filtre Québec (state `quebec`,11# id 614 ; les chalets Ontario sont exclus). La description vient de12# content.rendered ; la page détail (cache self.detail, clé = date de13# modification WP) fournit lat/lng, grille de prix saisonnière (« 1 nuit »),14# voyageurs/lits/salles de bain, chambres, commodités, animaux et photos15# (bucket photoschaletarabais.storage.googleapis.com).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import html as _html20import re2122from ..schema import StListing, parse_price_night23from .base import StConnector2425API = "https://chaletarabais.com/wp-json/wp/v2"2627# slugs listing_area → région touristique canonique Lou-Ka28_AREA_REGION = {29    "abitibi": "Abitibi-Témiscamingue",30    "bas-saint-laurent": "Bas-Saint-Laurent",31    "capitale-nationale": "Québec",32    "centre-du-quebec": "Centre-du-Québec",33    "charlevoix": "Charlevoix",34    "chaudiere-appalaches": "Chaudière-Appalaches",35    "estrie": "Cantons-de-l'Est",36    "gaspesie": "Gaspésie",37    "lanaudiere": "Lanaudière",38    "laurentides": "Laurentides",39    "mauricie": "Mauricie",40    "monteregie": "Montérégie",41    "outaouais": "Outaouais",42    "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean",43}4445_TAG_RE = re.compile(r"<[^>]+>")464748def _text(fragment: str) -> str:49    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()505152def _num(raw: str) -> float | None:53    m = re.search(r"\d+(?:[.,]\d+)?", raw or "")54    return float(m.group(0).replace(",", ".")) if m else None555657class ChaletsARabais(StConnector):58    source_id = "chaletsarabais"5960    # -- taxonomies -------------------------------------------------------61    def _terms(self, rest_base: str) -> dict[int, dict]:62        out: dict[int, dict] = {}63        page = 164        while True:65            try:66                resp = self.get(f"{API}/{rest_base}",67                                params={"per_page": 100, "page": page,68                                        "_fields": "id,name,slug"})69            except Exception:70                break71            batch = resp.json()72            if not isinstance(batch, list) or not batch:73                break74            for t in batch:75                out[t["id"]] = t76            if len(batch) < 100:77                break78            page += 179        return out8081    # -- page détail ------------------------------------------------------82    def _detail(self, url: str) -> dict:83        h = self.get(url).text84        d: dict = {}85        m = re.search(r'data-lat="(-?[\d.]+)"', h)86        m2 = re.search(r'data-long="(-?[\d.]+)"', h)87        if m and m2:88            d["lat"], d["lng"] = float(m.group(1)), float(m2.group(1))8990        # <li><i class="fa fa-angle-right"></i> Voyageurs: <strong>4</strong>91        for label, value in re.findall(92                r'(?s)<li>\s*<i class="fa fa-angle-right"[^>]*></i>\s*'93                r'([^<:]+):\s*<strong>([^<]*)</strong>', h):94            label, value = _text(label), _text(value)95            if label and value:96                d.setdefault("meta", {})[label] = value9798        # chambres : blocs <dt>Chambre …</dt>99        beds_dt = re.findall(r"<dt>([^<]*[Cc]hambre[^<]*)</dt>", h)100        if beds_dt:101            d["bedrooms"] = float(len(beds_dt))102103        # grille de prix saisonnière : colonne « 1 nuit »104        nightly: list[float] = []105        for row in re.findall(r"(?s)<tr[^>]*>(.*?)</tr>", h):106            cells = re.findall(r"(?s)<t[dh][^>]*>(.*?)</t[dh]>", row)107            if len(cells) < 2 or "nuit" in _text(cells[1]).lower():108                continue  # entête ou ligne calendrier109            prices = [p for p in (parse_price_night(v + " $") for v in110                      re.findall(r"(\d[\d\s,.]*)\s*\$", _text(cells[1]))) if p]111            if prices and re.search(r"\d{4}|janv|févr|mars|avril|mai|juin|juil|"112                                    r"août|sept|oct|nov|déc", _text(cells[0]),113                                    re.I):114                nightly.append(min(prices))  # prix rabais si affiché115        if nightly:116            d["price_night"] = min(nightly)117118        # commodités (icône svg + libellé)119        d["amenities"] = sorted({a.strip() for a in re.findall(120            r'<img[^>]+storage\.googleapis[^>]+\.svg"[^>]*>\s*([^<]{2,60})', h)121            if a.strip()})122123        # animaux (rangée « Animaux: » de la barre latérale)124        m = re.search(r'details-sidebar-1">\s*Animaux:\s*</div>\s*'125                      r'<div class="details-sidebar-1">(?:<strong>)?([^<]+)', h)126        if m:127            v = _text(m.group(1)).lower()128            d["pets"] = "non" if "non" in v else "oui"129130        # photos (bucket GCS, sans les icônes svg)131        imgs = []132        for u in re.findall(r'<img[^>]+(?:data-src|src)="'133                            r'(https://photoschaletarabais\.storage\.googleapis'134                            r'\.com/[^"]+\.(?:jpe?g|png|webp))"', h):135            if u not in imgs:136                imgs.append(u)137        d["images"] = imgs[:20]138        return d139140    # -- contrat ----------------------------------------------------------141    def fetch(self) -> list[StListing]:142        areas = self._terms("listing_areas")143        cities = self._terms("listing_cities")144        states = self._terms("listing_states")145        qc_state_ids = {i for i, t in states.items() if t["slug"] == "quebec"}146147        rows: list[dict] = []148        page = 1149        while True:150            try:151                resp = self.get(f"{API}/listings", params={152                    "per_page": 100, "page": page, "status": "publish",153                    "_fields": ("id,slug,link,modified,title,content,"154                                "class_list,listing_states,listing_areas,"155                                "listing_cities")})156            except Exception:157                break  # WP renvoie 400 après la dernière page158            batch = resp.json()159            if not isinstance(batch, list) or not batch:160                break161            rows.extend(batch)162            if len(batch) < 100:163                break164            page += 1165166        listings: list[StListing] = []167        for row in rows:168            classes = row.get("class_list") or []169            state_ids = set(row.get("listing_states") or [])170            # Québec seulement (exclut l'Ontario, identifiable par la taxonomie)171            if state_ids and not (state_ids & qc_state_ids):172                continue173            if not state_ids and "listing_state-quebec" not in classes:174                continue175176            url = row.get("link") or ""177            title = _text((row.get("title") or {}).get("rendered") or "")178            if not url or not title:179                continue180181            region = city = ""182            for aid in row.get("listing_areas") or []:183                slug = (areas.get(aid) or {}).get("slug", "")184                if slug in _AREA_REGION:185                    region = _AREA_REGION[slug]186                    break187            for cid in row.get("listing_cities") or []:188                name = (cities.get(cid) or {}).get("name", "")189                if name:190                    city = _text(name)191                    break192193            det = self.detail(str(row["id"]), row.get("modified") or "",194                              lambda u=url: self._detail(u))195            meta = det.get("meta") or {}196            lst = StListing(197                source=self.source_id,198                external_id=str(row["id"]),          # id WordPress, stable199                url=url,200                title=title,201                property_type="Chalet",202                city=city,203                region=region,204                price_night=det.get("price_night"),205                price_label=(f"à partir de {det['price_night']:.0f} $ / nuit"206                             if det.get("price_night") else ""),207                capacity=_num(meta.get("Voyageurs", "")),208                bedrooms=det.get("bedrooms"),209                beds=_num(meta.get("Lits", "")),210                bathrooms=_num(meta.get("Salles de bain", "")),211                pets=det.get("pets"),212                description=_text((row.get("content") or {})213                                  .get("rendered") or "")[:4000],214                amenities=det.get("amenities") or [],215                details={k: v for k, v in meta.items()216                         if k not in ("Voyageurs", "Lits", "Salles de bain")},217                images=det.get("images") or [],218                lat=det.get("lat"),219                lng=det.get("lng"),220            )221            listings.append(lst)222        return listings223