# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/chaletsarabais.py : Chalet à Rabais (https://chaletarabais.com) # # ⚠️ Le domaine réel est chaletarabais.com (SANS « s » après chalet) — # chaletsarabais.com listé dans sources_ct.json ne résout plus (SERVFAIL). # # Méthode : WordPress (thème Homey). L'API REST expose le CPT `listing` # (/wp-json/wp/v2/listings) avec taxonomies listing_state / listing_area / # listing_city → inventaire complet paginé + filtre Québec (state `quebec`, # id 614 ; les chalets Ontario sont exclus). La description vient de # content.rendered ; la page détail (cache self.detail, clé = date de # modification WP) fournit lat/lng, grille de prix saisonnière (« 1 nuit »), # voyageurs/lits/salles de bain, chambres, commodités, animaux et photos # (bucket photoschaletarabais.storage.googleapis.com). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import re from ..schema import StListing, parse_price_night from .base import StConnector API = "https://chaletarabais.com/wp-json/wp/v2" # slugs listing_area → région touristique canonique Lou-Ka _AREA_REGION = { "abitibi": "Abitibi-Témiscamingue", "bas-saint-laurent": "Bas-Saint-Laurent", "capitale-nationale": "Québec", "centre-du-quebec": "Centre-du-Québec", "charlevoix": "Charlevoix", "chaudiere-appalaches": "Chaudière-Appalaches", "estrie": "Cantons-de-l'Est", "gaspesie": "Gaspésie", "lanaudiere": "Lanaudière", "laurentides": "Laurentides", "mauricie": "Mauricie", "monteregie": "Montérégie", "outaouais": "Outaouais", "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean", } _TAG_RE = re.compile(r"<[^>]+>") def _text(fragment: str) -> str: return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip() def _num(raw: str) -> float | None: m = re.search(r"\d+(?:[.,]\d+)?", raw or "") return float(m.group(0).replace(",", ".")) if m else None class ChaletsARabais(StConnector): source_id = "chaletsarabais" # -- taxonomies ------------------------------------------------------- def _terms(self, rest_base: str) -> dict[int, dict]: out: dict[int, dict] = {} page = 1 while True: try: resp = self.get(f"{API}/{rest_base}", params={"per_page": 100, "page": page, "_fields": "id,name,slug"}) except Exception: break batch = resp.json() if not isinstance(batch, list) or not batch: break for t in batch: out[t["id"]] = t if len(batch) < 100: break page += 1 return out # -- page détail ------------------------------------------------------ def _detail(self, url: str) -> dict: h = self.get(url).text d: dict = {} m = re.search(r'data-lat="(-?[\d.]+)"', h) m2 = re.search(r'data-long="(-?[\d.]+)"', h) if m and m2: d["lat"], d["lng"] = float(m.group(1)), float(m2.group(1)) #
  • Voyageurs: 4 for label, value in re.findall( r'(?s)
  • \s*]*>\s*' r'([^<:]+):\s*([^<]*)', h): label, value = _text(label), _text(value) if label and value: d.setdefault("meta", {})[label] = value # chambres : blocs
    Chambre …
    beds_dt = re.findall(r"
    ([^<]*[Cc]hambre[^<]*)
    ", h) if beds_dt: d["bedrooms"] = float(len(beds_dt)) # grille de prix saisonnière : colonne « 1 nuit » nightly: list[float] = [] for row in re.findall(r"(?s)]*>(.*?)", h): cells = re.findall(r"(?s)]*>(.*?)", row) if len(cells) < 2 or "nuit" in _text(cells[1]).lower(): continue # entête ou ligne calendrier prices = [p for p in (parse_price_night(v + " $") for v in re.findall(r"(\d[\d\s,.]*)\s*\$", _text(cells[1]))) if p] if prices and re.search(r"\d{4}|janv|févr|mars|avril|mai|juin|juil|" r"août|sept|oct|nov|déc", _text(cells[0]), re.I): nightly.append(min(prices)) # prix rabais si affiché if nightly: d["price_night"] = min(nightly) # commodités (icône svg + libellé) d["amenities"] = sorted({a.strip() for a in re.findall( r']+storage\.googleapis[^>]+\.svg"[^>]*>\s*([^<]{2,60})', h) if a.strip()}) # animaux (rangée « Animaux: » de la barre latérale) m = re.search(r'details-sidebar-1">\s*Animaux:\s*\s*' r'
    (?:)?([^<]+)', h) if m: v = _text(m.group(1)).lower() d["pets"] = "non" if "non" in v else "oui" # photos (bucket GCS, sans les icônes svg) imgs = [] for u in re.findall(r']+(?:data-src|src)="' r'(https://photoschaletarabais\.storage\.googleapis' r'\.com/[^"]+\.(?:jpe?g|png|webp))"', h): if u not in imgs: imgs.append(u) d["images"] = imgs[:20] return d # -- contrat ---------------------------------------------------------- def fetch(self) -> list[StListing]: areas = self._terms("listing_areas") cities = self._terms("listing_cities") states = self._terms("listing_states") qc_state_ids = {i for i, t in states.items() if t["slug"] == "quebec"} rows: list[dict] = [] page = 1 while True: try: resp = self.get(f"{API}/listings", params={ "per_page": 100, "page": page, "status": "publish", "_fields": ("id,slug,link,modified,title,content," "class_list,listing_states,listing_areas," "listing_cities")}) except Exception: break # WP renvoie 400 après la dernière page batch = resp.json() if not isinstance(batch, list) or not batch: break rows.extend(batch) if len(batch) < 100: break page += 1 listings: list[StListing] = [] for row in rows: classes = row.get("class_list") or [] state_ids = set(row.get("listing_states") or []) # Québec seulement (exclut l'Ontario, identifiable par la taxonomie) if state_ids and not (state_ids & qc_state_ids): continue if not state_ids and "listing_state-quebec" not in classes: continue url = row.get("link") or "" title = _text((row.get("title") or {}).get("rendered") or "") if not url or not title: continue region = city = "" for aid in row.get("listing_areas") or []: slug = (areas.get(aid) or {}).get("slug", "") if slug in _AREA_REGION: region = _AREA_REGION[slug] break for cid in row.get("listing_cities") or []: name = (cities.get(cid) or {}).get("name", "") if name: city = _text(name) break det = self.detail(str(row["id"]), row.get("modified") or "", lambda u=url: self._detail(u)) meta = det.get("meta") or {} lst = StListing( source=self.source_id, external_id=str(row["id"]), # id WordPress, stable url=url, title=title, property_type="Chalet", city=city, region=region, price_night=det.get("price_night"), price_label=(f"à partir de {det['price_night']:.0f} $ / nuit" if det.get("price_night") else ""), capacity=_num(meta.get("Voyageurs", "")), bedrooms=det.get("bedrooms"), beds=_num(meta.get("Lits", "")), bathrooms=_num(meta.get("Salles de bain", "")), pets=det.get("pets"), description=_text((row.get("content") or {}) .get("rendered") or "")[:4000], amenities=det.get("amenities") or [], details={k: v for k, v in meta.items() if k not in ("Voyageurs", "Lits", "Salles de bain")}, images=det.get("images") or [], lat=det.get("lat"), lng=det.get("lng"), ) listings.append(lst) return listings