Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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