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%

court terme : 12 connecteurs supplémentaires + Airbnb réécrit en quadrillage carte (quadtree)

- airbnb : couverture provinciale robuste via search_by_map + bbox — subdivision
  en 4 quand une cellule sature (15 cursors ≈ 270 résultats), région déduite des
  coordonnées (centroïde le plus proche), dédoublonnage par room id.
  Env : LOUKA_AIRBNB_PAGES / LOUKA_AIRBNB_BUDGET / LOUKA_AIRBNB_DEPTH.
- Nouveaux connecteurs QC : monsieurchalets, wechalet, chaletsalouer,
  chaletsarabais, mcal, qldc, rsvpchalets, sepaq, bonjourquebec (CITQ), vrbo.
- chaletsquebec (domaine mort), chaletsdirect, locationschalets (redirigé)
  livrés désactivés.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 38b708b

13 changed files +2,688 −70

modified louka/shortterm/connectors/airbnb.py +141 −70
@@ -2,25 +2,29 @@
2 2 # Lou-Ka — Location court terme
3 3 # connectors/airbnb.py : Airbnb (airbnb.ca) — source vedette, toute la province.
4 4 #
5 −# Méthode : les pages de recherche https://www.airbnb.ca/s/<Lieu>--Québec--
6 −# Canada/homes embarquent un JSON complet dans <script id="data-deferred-
7 −# state-0"> (niobeClientData → presentation.staysSearch.results) avec, pour
8 −# chaque carte : id (base64 "DemandStayListing:<room id>"), nom, coordonnées,
9 −# note/avis, chambres/lits/sdb, photos et prix détaillé ("N nights x $X CAD").
10 −# On récupère ce HTML via Bright Data Web Unlocker (rapide, pas de rendu JS
11 −# nécessaire), avec Scrapfly ASP en secours. Couverture provinciale par ~25
12 −# recherches (une par ville/région touristique), 2 pages chacune par défaut
13 −# (Airbnb plafonne à ~270 résultats / 15 pages par recherche ; le connecteur
14 −# est incrémental, chaque sync rafraîchit). Pagination par les cursors
15 −# fournis dans paginationInfo.pageCursors. Dédoublonnage par room id.
5 +# Méthode : les pages de recherche Airbnb embarquent un JSON complet dans
6 +# <script id="data-deferred-state-0"> (niobeClientData → presentation.
7 +# staysSearch.results) avec, pour chaque carte : id (base64
8 +# "DemandStayListing:<room id>"), nom, coordonnées, note/avis, chambres/lits/
9 +# sdb, photos et prix détaillé ("N nights x $X CAD"). HTML via Bright Data
10 +# Web Unlocker (pas de rendu JS nécessaire), Scrapfly ASP en secours.
16 11 #
17 −# Réglages env : LOUKA_AIRBNB_PAGES (pages par recherche, défaut 2),
18 −# LOUKA_AIRBNB_LIMIT (nb de recherches, 0 = toutes — debug).
12 +# Couverture provinciale ROBUSTE par quadrillage carte (search_by_map=true +
13 +# bbox ne_lat/ne_lng/sw_lat/sw_lng) : Airbnb plafonne chaque recherche à
14 +# ~270 résultats (15 pages × 18 cartes) ; quand une cellule sature (15
15 +# cursors), on la subdivise en 4 et on recommence (quadtree). En recherche
16 +# par carte, 100 % des cartes ont des coordonnées → la région touristique
17 +# est déduite du point (centroïde le plus proche). Dédoublonnage par room id.
18 +#
19 +# Réglages env : LOUKA_AIRBNB_PAGES (pages par cellule feuille, défaut 15),
20 +# LOUKA_AIRBNB_BUDGET (budget de requêtes HTML, défaut 900),
21 +# LOUKA_AIRBNB_DEPTH (profondeur max du quadtree, défaut 7).
19 22 # -----------------------------------------------------------------------------
20 23 from __future__ import annotations
21 24
22 25 import base64
23 26 import json
27 +import math
24 28 import os
25 29 import re
26 30 import sys
@@ -34,37 +38,46 @@ from .base import StConnector
34 38
35 39 BRIGHTDATA_API = "https://api.brightdata.com/request"
36 40
37 −# (terme de recherche géocodé par Airbnb, région touristique QC de rattachement)
38 −SEARCHES: list[tuple[str, str]] = [
39 − ("Montréal", "Montréal"),
40 − ("Laval", "Laval"),
41 − ("Longueuil", "Montérégie"),
42 − ("Ville de Québec", "Québec"),
43 − ("Lévis", "Chaudière-Appalaches"),
44 − ("Mont-Tremblant", "Laurentides"),
45 − ("Saint-Sauveur", "Laurentides"),
46 − ("Sainte-Agathe-des-Monts", "Laurentides"),
47 − ("Saint-Donat", "Lanaudière"),
48 − ("Rawdon", "Lanaudière"),
49 − ("Magog", "Cantons-de-l'Est"),
50 − ("Sutton", "Cantons-de-l'Est"),
51 − ("Bromont", "Cantons-de-l'Est"),
52 − ("Baie-Saint-Paul", "Charlevoix"),
53 − ("La Malbaie", "Charlevoix"),
54 − ("Shawinigan", "Mauricie"),
55 − ("Trois-Rivières", "Mauricie"),
56 − ("Gatineau", "Outaouais"),
57 − ("Gaspé", "Gaspésie"),
58 − ("Percé", "Gaspésie"),
59 − ("Rimouski", "Bas-Saint-Laurent"),
60 − ("Saguenay", "Saguenay–Lac-Saint-Jean"),
61 − ("Alma", "Saguenay–Lac-Saint-Jean"),
62 − ("Îles-de-la-Madeleine", "Îles-de-la-Madeleine"),
63 − ("Rouyn-Noranda", "Abitibi-Témiscamingue"),
64 − ("Tadoussac", "Côte-Nord"),
65 − ("Drummondville", "Centre-du-Québec"),
41 +# Zone habitée du Québec (sud de la baie James) : (sud, ouest, nord, est).
42 +# Découpée d'emblée en cellules de départ pour éviter un tronc trop profond.
43 +QC_BBOX = (44.95, -79.80, 52.20, -56.90)
44 +GRID0 = (6, 8) # lignes × colonnes de départ (cellules ~1,2° × ~2,9°)
45 +
46 +# Centroïdes approximatifs des régions touristiques (lat, lng) — la région
47 +# d'une annonce = centroïde le plus proche de ses coordonnées.
48 +REGION_CENTROIDS: list[tuple[str, float, float]] = [
49 + ("Montréal", 45.55, -73.65),
50 + ("Laval", 45.60, -73.72),
51 + ("Montérégie", 45.35, -73.20),
52 + ("Québec", 46.85, -71.30),
53 + ("Chaudière-Appalaches", 46.45, -70.80),
54 + ("Laurentides", 46.05, -74.35),
55 + ("Lanaudière", 46.15, -73.60),
56 + ("Cantons-de-l'Est", 45.35, -72.10),
57 + ("Charlevoix", 47.55, -70.35),
58 + ("Mauricie", 46.85, -72.85),
59 + ("Centre-du-Québec", 46.05, -72.15),
60 + ("Outaouais", 45.80, -75.85),
61 + ("Abitibi-Témiscamingue", 48.20, -78.35),
62 + ("Saguenay–Lac-Saint-Jean", 48.50, -71.65),
63 + ("Bas-Saint-Laurent", 48.10, -68.75),
64 + ("Gaspésie", 48.80, -65.40),
65 + ("Îles-de-la-Madeleine", 47.38, -61.90),
66 + ("Côte-Nord", 49.60, -67.20),
67 + ("Nord-du-Québec", 51.50, -77.00),
66 68 ]
67 69
70 +
71 +def _region_from_latlng(lat: float | None, lng: float | None) -> str:
72 + if lat is None or lng is None:
73 + return ""
74 + best, best_d = "", 1e9
75 + for name, clat, clng in REGION_CENTROIDS:
76 + d = (lat - clat) ** 2 + ((lng - clng) * 0.7) ** 2
77 + if d < best_d:
78 + best, best_d = name, d
79 + return best
80 +
68 81 # Type de carte Airbnb ("Home in X", "Chalet in X"…) → type canonique Lou-Ka.
69 82 # Clés en minuscules, les plus longues testées d'abord (startswith).
70 83 TYPE_MAP = {
@@ -299,42 +312,100 @@ class Airbnb(StConnector):
299 312 lng=lng if isinstance(lng, (int, float)) else None,
300 313 ).finalize()
301 314
315 + # -- quadtree ------------------------------------------------------------
316 + @staticmethod
317 + def _map_url(s: float, w: float, n: float, e: float) -> str:
318 + zoom = min(18, max(4, round(math.log2(360.0 / max(e - w, 1e-6))) + 1))
319 + return ("https://www.airbnb.ca/s/Qu%C3%A9bec--Canada/homes"
320 + "?locale=en&currency=CAD&search_by_map=true"
321 + f"&ne_lat={n:.5f}&ne_lng={e:.5f}"
322 + f"&sw_lat={s:.5f}&sw_lng={w:.5f}&zoom_level={zoom}")
323 +
324 + def _ingest(self, cards: list[dict], out: list[StListing],
325 + seen: set[str]) -> int:
326 + added = 0
327 + for card in cards:
328 + coord = (((card.get("demandStayListing") or {})
329 + .get("location") or {}).get("coordinate") or {})
330 + region = _region_from_latlng(coord.get("latitude"),
331 + coord.get("longitude"))
332 + lst = self._to_listing(card, region)
333 + if lst is None or lst.external_id in seen:
334 + continue
335 + seen.add(lst.external_id)
336 + out.append(lst)
337 + added += 1
338 + return added
339 +
302 340 # -- contrat ------------------------------------------------------------
303 341 def fetch(self) -> list[StListing]:
304 − pages = max(1, int(os.environ.get("LOUKA_AIRBNB_PAGES", "2") or 2))
305 − limit = int(os.environ.get("LOUKA_AIRBNB_LIMIT", "0") or 0)
306 − searches = SEARCHES[:limit] if limit > 0 else SEARCHES
342 + pages = max(1, int(os.environ.get("LOUKA_AIRBNB_PAGES", "15") or 15))
343 + budget = max(1, int(os.environ.get("LOUKA_AIRBNB_BUDGET", "900") or 900))
344 + max_depth = max(0, int(os.environ.get("LOUKA_AIRBNB_DEPTH", "7") or 7))
345 +
346 + s0, w0, n0, e0 = QC_BBOX
347 + rows, cols = GRID0
348 + dlat, dlng = (n0 - s0) / rows, (e0 - w0) / cols
349 + stack: list[tuple[float, float, float, float, int]] = [
350 + (s0 + i * dlat, w0 + j * dlng,
351 + s0 + (i + 1) * dlat, w0 + (j + 1) * dlng, 0)
352 + for i in range(rows) for j in range(cols)
353 + ]
354 + stack.reverse()
307 355
308 356 out: list[StListing] = []
309 357 seen: set[str] = set()
310 − for term, region in searches:
311 − slug = urllib.parse.quote(term.replace(" ", "-"), safe="-")
312 − base = (f"https://www.airbnb.ca/s/{slug}--Qu%C3%A9bec--Canada/homes"
313 − f"?locale=en&currency=CAD")
314 − n_before = len(out)
358 + used = 0
359 +
360 + while stack and used < budget:
361 + cs, cw, cn, ce, depth = stack.pop()
362 + base = self._map_url(cs, cw, cn, ce)
363 + used += 1
315 364 try:
316 365 html = self._search_html(base)
317 366 results, cursors = self._deferred_results(html)
318 − pages_html = [results]
319 − for cur in cursors[1:pages]:
320 − more, _ = self._deferred_results(
321 − self._search_html(base + "&cursor="
322 − + urllib.parse.quote(cur, safe="")))
323 − if not more:
324 − break
325 − pages_html.append(more)
326 − for page in pages_html:
327 − for card in page:
328 − lst = self._to_listing(card, region)
329 − if lst is None or lst.external_id in seen:
330 − continue
331 − seen.add(lst.external_id)
332 − out.append(lst)
333 − except Exception as exc: # noqa: BLE001 — une recherche ne bloque pas les autres
334 − print(f"[airbnb] recherche '{term}' en échec : {exc}",
367 + except Exception as exc: # noqa: BLE001 — une cellule ne bloque pas les autres
368 + print(f"[airbnb] cellule ({cs:.2f},{cw:.2f}) en échec : {exc}",
335 369 file=sys.stderr)
336 370 continue
337 − print(f"[airbnb] {term} ({region}) : "
338 − f"{len(out) - n_before} annonces (total {len(out)})",
339 − file=sys.stderr)
371 + if not results:
372 + continue
373 +
374 + if len(cursors) >= 15 and depth < max_depth:
375 + # Cellule saturée (~270 résultats) → on garde la page 1 (déjà
376 + # payée, dédoublonnée) et on subdivise en 4.
377 + self._ingest(results, out, seen)
378 + mlat, mlng = (cs + cn) / 2, (cw + ce) / 2
379 + stack.extend([
380 + (cs, cw, mlat, mlng, depth + 1),
381 + (cs, mlng, mlat, ce, depth + 1),
382 + (mlat, cw, cn, mlng, depth + 1),
383 + (mlat, mlng, cn, ce, depth + 1),
384 + ])
385 + print(f"[airbnb] cellule ({cs:.2f},{cw:.2f})→({cn:.2f},{ce:.2f})"
386 + f" saturée → subdivision (prof. {depth + 1},"
387 + f" req {used}/{budget})", file=sys.stderr)
388 + continue
389 +
390 + n_before = len(out)
391 + self._ingest(results, out, seen)
392 + for cur in cursors[1:pages]:
393 + if used >= budget:
394 + break
395 + used += 1
396 + try:
397 + more, _ = self._deferred_results(self._search_html(
398 + base + "&cursor=" + urllib.parse.quote(cur, safe="")))
399 + except Exception: # noqa: BLE001
400 + break
401 + if not more:
402 + break
403 + self._ingest(more, out, seen)
404 + print(f"[airbnb] cellule ({cs:.2f},{cw:.2f})→({cn:.2f},{ce:.2f}) :"
405 + f" {len(out) - n_before} nouvelles (total {len(out)},"
406 + f" req {used}/{budget})", file=sys.stderr)
407 +
408 + if stack:
409 + print(f"[airbnb] budget épuisé ({budget} req),"
410 + f" {len(stack)} cellules non visitées", file=sys.stderr)
340 411 return out
added louka/shortterm/connectors/bonjourquebec.py +257 −0
@@ -0,0 +1,257 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/bonjourquebec.py : BonjourQuebec.com — site officiel de Tourisme
4 +# Québec, répertoire de TOUT l'hébergement enregistré (CITQ). Catégories court
5 +# terme ciblées (hôtels, auberges de jeunesse et campings nus EXCLUS) :
6 +# - chalets / appartements / résidences de tourisme (pré-filtre où-dormir 37)
7 +# - hébergements insolites (pré-filtre 35)
8 +# - gîtes (pré-filtre 38 « hôtels-auberges-gîtes », filtré sur la
9 +# catégorie « Gîte touristique »)
10 +#
11 +# Méthode :
12 +# 1. la page « carte du Québec » de chaque pré-filtre embarque TOUT
13 +# l'inventaire dans drupalSettings.interactiveMap.items (nid, titre,
14 +# lat/lng, catégorie, vignette, description) → inventaire complet en
15 +# UNE requête par catégorie (pas de pagination) ;
16 +# 2. l'inventaire chalets/résidences (~11 500 fiches) est PLAFONNÉ (tri
17 +# stable par nid) pour rester à ~MAX_TOTAL annonces au total (consigne :
18 +# quelques milliers max) — gîtes et insolites sont gardés en entier ;
19 +# 3. fiche /fiche/<id> (cache self.detail — 1 seule visite par fiche) :
20 +# région touristique, ville, adresse, no d'enregistrement CITQ,
21 +# description, services/équipements, animaux, tarifs max (détails),
22 +# photos. Pas de prix « à partir de » sur le site → seuls les maximums
23 +# affichés sont conservés dans details (jamais utilisés comme
24 +# price_night pour ne pas fausser le « à partir de »).
25 +# -----------------------------------------------------------------------------
26 +from __future__ import annotations
27 +
28 +import html as _html
29 +import json
30 +import re
31 +import sys
32 +
33 +from ..schema import StListing
34 +from .base import StConnector
35 +
36 +BASE = "https://www.bonjourquebec.com"
37 +
38 +# (réf carte, catégories gardées — None = tout garder, type par défaut)
39 +MAPS = [
40 + ("50?pre=37", None, "Chalet"), # chalets, apparts, rés. tourisme
41 + ("48?pre=35", None, "Autre"), # hébergements insolites
42 + ("51?pre=38", {"Gîte touristique"}, "Gîte"), # gîtes (hôtels exclus)
43 +]
44 +MAX_TOTAL = 5000 # plafond global (consigne : quelques milliers max)
45 +
46 +_SETTINGS_RE = re.compile(
47 + r'data-drupal-selector="drupal-settings-json">(.*?)</script>', re.S)
48 +_CITQ_RE = re.compile(
49 + r"enregistrement d.hébergement(?:&nbsp;|\s|:)*</span>\s*(\d{5,7})")
50 +_TYPE_KEYWORDS = [
51 + ("yourte", "Yourte"), ("dôme", "Dôme"), ("dome ", "Dôme"),
52 + ("mini-maison", "Mini-maison"), ("micro-chalet", "Mini-maison"),
53 + ("tipi", "Prêt-à-camper"), ("tepee", "Prêt-à-camper"),
54 + ("prêt-à-camper", "Prêt-à-camper"), ("pret-a-camper", "Prêt-à-camper"),
55 + ("tente", "Prêt-à-camper"), ("refuge", "Refuge"),
56 + ("condo", "Condo"), ("appartement", "Appartement"), ("appart", "Appartement"),
57 + ("studio", "Studio"), ("loft", "Loft"), ("chambre", "Chambre"),
58 + ("gîte", "Gîte"), ("gite", "Gîte"), ("auberge", "Auberge"),
59 + ("maison", "Maison"), ("chalet", "Chalet"),
60 +]
61 +
62 +
63 +def _abs(url: str) -> str:
64 + url = _html.unescape(url or "").strip()
65 + if not url:
66 + return ""
67 + if url.startswith("//"):
68 + return "https:" + url
69 + if url.startswith("/"):
70 + return BASE + url
71 + return url
72 +
73 +
74 +def _property_type(category: str, title: str, fallback: str) -> str:
75 + if category == "Gîte touristique":
76 + return "Gîte"
77 + if category == "Camping et prêt-à-camper":
78 + fallback = "Prêt-à-camper" # insolites : surtout des prêts-à-camper
79 + blob = f"{title}".lower()
80 + for needle, ptype in _TYPE_KEYWORDS:
81 + if needle in blob:
82 + return ptype
83 + return fallback
84 +
85 +
86 +class BonjourQuebec(StConnector):
87 + source_id = "bonjourquebec"
88 + request_delay = 0.3 # CDN gouvernemental costaud, pas d'anti-bot
89 +
90 + # -- inventaire : items de la carte interactive ------------------------------
91 + def _map_items(self, mapref: str) -> list[dict]:
92 + html = self.get(f"{BASE}/fr-ca/carte-du-quebec/fournisseur/{mapref}").text
93 + m = _SETTINGS_RE.search(html)
94 + if not m:
95 + return []
96 + try:
97 + settings = json.loads(m.group(1))
98 + except ValueError:
99 + return []
100 + return (settings.get("interactiveMap") or {}).get("items") or []
101 +
102 + # -- fiche détail (région, ville, adresse, CITQ, services…) ------------------
103 + def _fetch_fiche(self, ext: str) -> dict:
104 + from bs4 import BeautifulSoup
105 + r = self.get(f"{BASE}/fiche/{ext}")
106 + html = r.text
107 + soup = BeautifulSoup(html, "html.parser")
108 + d: dict = {"url_final": str(getattr(r, "url", "") or "")}
109 +
110 + def _value(cls: str) -> str:
111 + node = soup.select_one(
112 + f".fiche-entreprise--info-general__region__item.{cls} "
113 + ".fiche-entreprise--info-general__region__item__value")
114 + return node.get_text(" ", strip=True) if node else ""
115 +
116 + d["region"] = _value("region")
117 + d["city"] = _value("ville")
118 +
119 + node = soup.select_one(".group-body .description")
120 + if node:
121 + d["description"] = node.get_text(" ", strip=True)
122 +
123 + m = _CITQ_RE.search(html)
124 + if m:
125 + d["citq"] = m.group(1)
126 +
127 + node = soup.select_one(".contact-adresse")
128 + if node:
129 + d["address"] = node.get_text(" ", strip=True)
130 +
131 + # widget Tarifs : uniquement des maximums → conservés en détails
132 + tarifs = []
133 + for w in soup.select(".fiche-entreprise--widget--tarifs .card-body"):
134 + sub = [x.get_text(" ", strip=True) for x in w.select("p, h5")]
135 + tarifs += [x for x in sub if x]
136 + if tarifs:
137 + d["tarifs"] = tarifs
138 +
139 + # accordéons Services / Activités / Installations → commodités
140 + amenities, units = [], []
141 + for grp in soup.select(".group-service"):
142 + h3 = grp.find("h3")
143 + gname = h3.get_text(" ", strip=True) if h3 else ""
144 + for li in grp.find_all("li"):
145 + txt = li.get_text(" ", strip=True)
146 + if not txt:
147 + continue
148 + if "unité" in txt and ":" in txt:
149 + units.append(txt)
150 + elif txt not in amenities:
151 + amenities.append(txt)
152 + low = txt.lower()
153 + if gname.lower().startswith("animaux") or "animaux" in low:
154 + if "non admis" in low or "pas admis" in low:
155 + d["pets"] = "non"
156 + elif "admis" in low:
157 + d["pets"] = ("conditions"
158 + if "payant" in low or "condition" in low
159 + else "oui")
160 + d["amenities"] = amenities
161 + if units:
162 + d["unites"] = units
163 +
164 + imgs = []
165 + for img in soup.select(
166 + '[class*="modal-carousel-images-gallery"] img[src]'):
167 + src = _abs(img.get("src") or "")
168 + if src and src not in imgs:
169 + imgs.append(src)
170 + if not imgs:
171 + m = re.search(r'property="og:image" content="([^"]+)"', html)
172 + if m:
173 + imgs = [_abs(m.group(1))]
174 + d["images"] = imgs
175 + return d
176 +
177 + # -- contrat -------------------------------------------------------------------
178 + def fetch(self) -> list[StListing]:
179 + seen: dict[str, tuple[dict, str]] = {} # id → (item carte, type défaut)
180 + capped: list[str] = [] # ids de la catégorie plafonnée
181 + for mapref, keep, fallback in MAPS:
182 + try:
183 + items = self._map_items(mapref)
184 + except Exception as exc: # noqa: BLE001
185 + print(f"[bonjourquebec] carte {mapref} : {exc}", file=sys.stderr)
186 + continue
187 + for it in items:
188 + nid = str(it.get("nid") or "")
189 + ext = nid.split("-", 1)[0].strip()
190 + if not ext or ext in seen:
191 + continue
192 + if keep is not None and (it.get("category") or "") not in keep:
193 + continue
194 + seen[ext] = (it, fallback)
195 + if mapref.endswith("pre=37"): # catégorie énorme → plafonnée
196 + capped.append(ext)
197 +
198 + # plafond global stable (tri par identifiant, catégorie chalets rognée)
199 + overflow = len(seen) - MAX_TOTAL
200 + if overflow > 0:
201 + for ext in sorted(capped)[-overflow:]:
202 + seen.pop(ext, None)
203 +
204 + listings: list[StListing] = []
205 + for ext in sorted(seen):
206 + it, fallback = seen[ext]
207 + title = _html.unescape(str(it.get("title") or "")).strip()
208 + if not title:
209 + continue
210 + if title.isupper():
211 + title = title.title()
212 + try:
213 + d = self.detail(ext, title, lambda e=ext: self._fetch_fiche(e))
214 + except Exception as exc: # noqa: BLE001
215 + print(f"[bonjourquebec] fiche {ext} : {exc}", file=sys.stderr)
216 + d = {}
217 +
218 + geo = it.get("geoData") or {}
219 + category = str(it.get("category") or "")
220 + desc = d.get("description", "")
221 + if not desc:
222 + desc = re.sub(r"<[^>]+>", " ", str(it.get("description") or ""))
223 + desc = _html.unescape(re.sub(r"\s+", " ", desc)).strip()
224 +
225 + images = d.get("images") or []
226 + thumb = _abs(str(it.get("image") or it.get("thumbnail") or ""))
227 + if thumb and "default_images" not in thumb and thumb not in images:
228 + images.append(thumb)
229 +
230 + details = {k: v for k, v in {
231 + "categorie": category,
232 + "tarifs": d.get("tarifs"),
233 + "unites": d.get("unites"),
234 + }.items() if v}
235 +
236 + url = d.get("url_final") or f"{BASE}/fiche/{ext}"
237 + lst = StListing(
238 + source=self.source_id,
239 + external_id=ext,
240 + url=url,
241 + title=title,
242 + property_type=_property_type(category, title, fallback),
243 + address=d.get("address", ""),
244 + city=d.get("city", ""),
245 + region=d.get("region", ""),
246 + capacity=None,
247 + pets=d.get("pets"),
248 + citq=d.get("citq", ""),
249 + description=desc,
250 + amenities=d.get("amenities") or [],
251 + details=details,
252 + images=images,
253 + lat=geo.get("lat"),
254 + lng=geo.get("lon"),
255 + )
256 + listings.append(lst)
257 + return listings
added louka/shortterm/connectors/chaletsalouer.py +281 −0
@@ -0,0 +1,281 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/chaletsalouer.py : ChaletsÀLouer.com — location de chalets
4 +# directement du propriétaire, sans commission (~3 300 chalets au Québec).
5 +#
6 +# Méthode : pagination de la liste globale /fr/location-de-chalet/?page=N
7 +# (~40 cartes/page, HTML statique). Chaque carte donne l'id stable
8 +# (data-noetablissement), le titre, la ville, la capacité, les chambres,
9 +# le prix (« À partir de 260 $ / 2 nuits ») et les photos (data-slider).
10 +# La page détail (via self.detail, cache BD) ajoute géolocalisation
11 +# (microdonnées schema.org/Place), description, type, no CITQ, salles de
12 +# bain, lits et commodités. Le site liste aussi quelques chalets hors
13 +# Québec (Nouveau-Brunswick, côte est américaine) : exclus.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import hashlib
18 +import json
19 +import re
20 +from urllib.parse import urljoin, urlparse
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import StListing
25 +from .base import StConnector
26 +
27 +BASE = "https://www.chaletsalouer.com"
28 +LISTE = BASE + "/fr/location-de-chalet/"
29 +
30 +# Slugs de « régions » hors Québec présents sur le site (Nouveau-Brunswick,
31 +# côte atlantique américaine…) — hors mandat Lou-Ka.
32 +_HORS_QC = ("rivage-acadien", "riviere-miramichi", "region-du-sud-est",
33 + "cote-atlantique", "madawaska", "nouveau-brunswick")
34 +
35 +_MONTANT = re.compile(r"(\d[\d\s  ]*(?:[.,]\d{2})?)\s*\$")
36 +
37 +
38 +def _prix_nuit(label: str) -> float | None:
39 + """« À partir de 260 $ / 2 nuits » → 130.0 ; « 1 150,00 $ / sem » → /7."""
40 + if not label:
41 + return None
42 + m = _MONTANT.search(label)
43 + if not m:
44 + return None
45 + brut = re.sub(r"[\s  ]", "", m.group(1)).replace(",", ".")
46 + try:
47 + val = float(brut)
48 + except ValueError:
49 + return None
50 + lab = label.lower()
51 + if "mois" in lab: # location au mois : pas un prix à la nuit
52 + return None
53 + m = re.search(r"(\d+)\s*nuit", lab)
54 + if m and int(m.group(1)) > 1:
55 + return round(val / int(m.group(1)), 2)
56 + if "sem" in lab:
57 + return round(val / 7, 2)
58 + return val
59 +
60 +
61 +def _region_slug(url: str) -> str:
62 + """/fr/location-de-chalet/<région>/<ville>/<slug>/ → slug de région."""
63 + parts = [p for p in urlparse(url).path.split("/") if p]
64 + return parts[2] if len(parts) >= 3 else ""
65 +
66 +
67 +def _region_canonique(slug: str) -> str:
68 + if slug.startswith("lanaudiere"):
69 + return "Lanaudière"
70 + if slug.startswith("cantons-de-l-est"):
71 + return "Cantons-de-l'Est"
72 + if slug.startswith("laurentides"):
73 + return "Laurentides"
74 + if slug.startswith("region-de-quebec"):
75 + return "Québec"
76 + return slug # normalize_region (finalize) ramène le reste au canonique
77 +
78 +
79 +class ChaletsALouer(StConnector):
80 + source_id = "chaletsalouer"
81 +
82 + def fetch(self) -> list[StListing]:
83 + listings: list[StListing] = []
84 + vus: set[str] = set()
85 + page, max_page = 1, 1
86 + while page <= max_page:
87 + html = self.get(LISTE, params={"page": page}).text
88 + soup = BeautifulSoup(html, "html.parser")
89 + for a in soup.select("a[href*='?page=']"):
90 + m = re.search(r"\?page=(\d+)", a.get("href", ""))
91 + if m:
92 + max_page = max(max_page, int(m.group(1)))
93 + cartes = soup.select("div.item-etablissement")
94 + if not cartes:
95 + break
96 + for carte in cartes:
97 + lst = self._carte(carte)
98 + if lst is not None and lst.external_id not in vus:
99 + vus.add(lst.external_id)
100 + listings.append(lst)
101 + page += 1
102 +
103 + for lst in listings:
104 + cle = hashlib.sha1("|".join([
105 + lst.title, lst.city, lst.price_label,
106 + str(lst.capacity), str(lst.bedrooms),
107 + ]).encode("utf-8")).hexdigest()
108 + try:
109 + d = self.detail(lst.external_id, cle,
110 + lambda u=lst.url: self._detail(u))
111 + except Exception:
112 + d = {}
113 + if not d:
114 + continue
115 + lst.property_type = d.get("property_type") or lst.property_type
116 + lst.city = d.get("city") or lst.city
117 + # la région de la carte (slug d'URL, canonique) prime sur celle du
118 + # détail (« Lanaudière - Le Piémont » = sous-région non canonique)
119 + lst.region = lst.region or d.get("region", "")
120 + lst.lat = d.get("lat")
121 + lst.lng = d.get("lng")
122 + lst.description = d.get("description") or ""
123 + lst.citq = d.get("citq") or ""
124 + lst.amenities = d.get("amenities") or []
125 + if d.get("bathrooms") is not None:
126 + lst.bathrooms = d["bathrooms"]
127 + if d.get("beds") is not None:
128 + lst.beds = d["beds"]
129 + if d.get("capacity") is not None:
130 + lst.capacity = d["capacity"]
131 + if d.get("bedrooms") is not None:
132 + lst.bedrooms = d["bedrooms"]
133 + if d.get("images"):
134 + lst.images = d["images"]
135 + lst.details.update(d.get("details") or {})
136 + return listings
137 +
138 + # -- carte de la liste ----------------------------------------------------
139 + def _carte(self, carte) -> StListing | None:
140 + caro = carte.select_one("[data-noetablissement]")
141 + titre = carte.select_one(".content a.title")
142 + if caro is None or titre is None:
143 + return None
144 + eid = caro.get("data-noetablissement", "").strip()
145 + url = urljoin(BASE, titre.get("href", ""))
146 + if not eid or not url:
147 + return None
148 + slug = _region_slug(url)
149 + if any(h in slug for h in _HORS_QC):
150 + return None # hors Québec (N.-B., côte est américaine)
151 +
152 + ville = ""
153 + loc = carte.select_one(".content .location a")
154 + if loc is not None:
155 + ville = loc.get_text(strip=True)
156 + capacite = chambres = None
157 + for span in carte.select(".content .location .nobreak"):
158 + txt = span.get_text(" ", strip=True)
159 + m = re.search(r"(\d+)\s*invité", txt)
160 + if m:
161 + capacite = float(m.group(1))
162 + m = re.search(r"(\d+)\s*chambre", txt)
163 + if m:
164 + chambres = float(m.group(1))
165 +
166 + prix_label = ""
167 + prix = carte.select_one(".pricing .text")
168 + if prix is not None:
169 + prix_label = re.sub(r"\s+", " ", prix.get_text(" ", strip=True))
170 +
171 + images: list[str] = []
172 + slider = caro.get("data-slider", "")
173 + if slider:
174 + try:
175 + for it in json.loads(slider):
176 + src = it.get("image")
177 + if src and src not in images:
178 + images.append(src)
179 + except ValueError:
180 + pass
181 +
182 + return StListing(
183 + source=self.source_id,
184 + external_id=eid,
185 + url=url,
186 + title=titre.get_text(strip=True),
187 + property_type="Chalet",
188 + city=ville,
189 + region=_region_canonique(slug),
190 + price_night=_prix_nuit(prix_label),
191 + price_label=prix_label,
192 + capacity=capacite,
193 + bedrooms=chambres,
194 + images=images[:15],
195 + )
196 +
197 + # -- page détail (via cache self.detail) -----------------------------------
198 + def _detail(self, url: str) -> dict:
199 + html = self.get(url).text
200 + soup = BeautifulSoup(html, "html.parser")
201 + d: dict = {"details": {}}
202 +
203 + # microdonnées schema.org/Place (bloc caché) : géo + ville + région
204 + place = soup.select_one('[itemtype*="schema.org/Place"]')
205 + if place is not None:
206 + lat = place.select_one('[itemprop="latitude"]')
207 + lng = place.select_one('[itemprop="longitude"]')
208 + try:
209 + d["lat"] = float(lat.get_text(strip=True))
210 + d["lng"] = float(lng.get_text(strip=True))
211 + except (AttributeError, ValueError):
212 + pass
213 + ville = place.select_one('[itemprop="addressLocality"]')
214 + region = place.select_one('[itemprop="addressRegion"]')
215 + if ville is not None:
216 + d["city"] = ville.get_text(strip=True)
217 + if region is not None:
218 + d["region"] = region.get_text(strip=True)
219 +
220 + # bandeau info : capacité / chambres / salles de bain / salles d'eau
221 + for span in soup.select("#etablissement-info .flex-info span"):
222 + txt = span.get_text(" ", strip=True)
223 + m = re.search(r"(\d+)\s*personnes", txt)
224 + if m:
225 + d["capacity"] = float(m.group(1))
226 + m = re.search(r"(\d+)\s*chambre", txt)
227 + if m:
228 + d["bedrooms"] = float(m.group(1))
229 + m = re.search(r"(\d+)\s*salles? de bain", txt)
230 + if m:
231 + d["bathrooms"] = float(m.group(1))
232 + m = re.search(r"(\d+)\s*salles? d'eau", txt)
233 + if m:
234 + d["details"]["salles_eau"] = int(m.group(1))
235 +
236 + # lits (« 3 lits doubles », « 2 divans-lits »…)
237 + lits = 0
238 + for span in soup.select(".tiny-box-container span"):
239 + m = re.search(r"(\d+)\s+(?:lits?|divans?)", span.get_text(strip=True))
240 + if m:
241 + lits += int(m.group(1))
242 + if lits:
243 + d["beds"] = float(lits)
244 +
245 + # Résumé : « Annonce Or-9184, Chalet | … Numéro d’enregistrement : NNNNNN »
246 + # puis paragraphes de description
247 + ancre = soup.select_one('a[name="resume"]')
248 + boite = ancre.find_next(class_="hide-box") if ancre is not None else None
249 + if boite is not None:
250 + paras = [p.get_text(" ", strip=True) for p in boite.find_all("p")]
251 + if paras:
252 + entete = paras[0]
253 + m = re.search(r"Annonce\s+\S+\s*,\s*([^|]+?)\s*(?:\||$)", entete)
254 + if m:
255 + d["property_type"] = m.group(1).strip()
256 + m = re.search(r"enregistrement\s*:?\s*(\d{4,8})", entete)
257 + if m:
258 + d["citq"] = m.group(1)
259 + d["description"] = "\n\n".join(
260 + p for p in paras[1:] if p)[:5000]
261 +
262 + # commodités (liens de caractéristiques) + dimensions
263 + amen: list[str] = []
264 + for a in soup.select("a.nomEtablissement"):
265 + t = a.get_text(" ", strip=True)
266 + if t and t not in amen:
267 + amen.append(t)
268 + if amen:
269 + d["amenities"] = amen
270 +
271 + # photos (pleine résolution CDN)
272 + images: list[str] = []
273 + for m in re.finditer(
274 + r"https:\\?/\\?/chaletsalouer\.com[\w\\/=,.-]*"
275 + r"fichiersUpload\\?/fichiers\\?/[\w.-]+", html):
276 + src = m.group(0).replace("\\/", "/")
277 + if src not in images:
278 + images.append(src)
279 + if images:
280 + d["images"] = images[:20]
281 + return d
added louka/shortterm/connectors/chaletsarabais.py +222 −0
@@ -0,0 +1,222 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# 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 de
12 +# content.rendered ; la page détail (cache self.detail, clé = date de
13 +# modification WP) fournit lat/lng, grille de prix saisonnière (« 1 nuit »),
14 +# voyageurs/lits/salles de bain, chambres, commodités, animaux et photos
15 +# (bucket photoschaletarabais.storage.googleapis.com).
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import html as _html
20 +import re
21 +
22 +from ..schema import StListing, parse_price_night
23 +from .base import StConnector
24 +
25 +API = "https://chaletarabais.com/wp-json/wp/v2"
26 +
27 +# slugs listing_area → région touristique canonique Lou-Ka
28 +_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 +}
44 +
45 +_TAG_RE = re.compile(r"<[^>]+>")
46 +
47 +
48 +def _text(fragment: str) -> str:
49 + return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()
50 +
51 +
52 +def _num(raw: str) -> float | None:
53 + m = re.search(r"\d+(?:[.,]\d+)?", raw or "")
54 + return float(m.group(0).replace(",", ".")) if m else None
55 +
56 +
57 +class ChaletsARabais(StConnector):
58 + source_id = "chaletsarabais"
59 +
60 + # -- taxonomies -------------------------------------------------------
61 + def _terms(self, rest_base: str) -> dict[int, dict]:
62 + out: dict[int, dict] = {}
63 + page = 1
64 + 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 + break
71 + batch = resp.json()
72 + if not isinstance(batch, list) or not batch:
73 + break
74 + for t in batch:
75 + out[t["id"]] = t
76 + if len(batch) < 100:
77 + break
78 + page += 1
79 + return out
80 +
81 + # -- page détail ------------------------------------------------------
82 + def _detail(self, url: str) -> dict:
83 + h = self.get(url).text
84 + 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))
89 +
90 + # <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] = value
97 +
98 + # 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))
102 +
103 + # 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 calendrier
109 + prices = [p for p in (parse_price_night(v + " $") for v in
110 + 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)
117 +
118 + # 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()})
122 +
123 + # 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"
129 +
130 + # 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 d
139 +
140 + # -- 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"}
146 +
147 + rows: list[dict] = []
148 + page = 1
149 + 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 page
158 + batch = resp.json()
159 + if not isinstance(batch, list) or not batch:
160 + break
161 + rows.extend(batch)
162 + if len(batch) < 100:
163 + break
164 + page += 1
165 +
166 + 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 + continue
173 + if not state_ids and "listing_state-quebec" not in classes:
174 + continue
175 +
176 + url = row.get("link") or ""
177 + title = _text((row.get("title") or {}).get("rendered") or "")
178 + if not url or not title:
179 + continue
180 +
181 + 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 + break
187 + for cid in row.get("listing_cities") or []:
188 + name = (cities.get(cid) or {}).get("name", "")
189 + if name:
190 + city = _text(name)
191 + break
192 +
193 + 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, stable
199 + 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 listings
modified louka/shortterm/connectors/chaletsauquebec.py +14 −0
@@ -90,6 +90,18 @@ class ChaletsAuQuebec(StConnector):
90 90 except ValueError:
91 91 pass
92 92
93 + # évaluations (microdonnées AggregateRating, note sur 5)
94 + agg = soup.find(attrs={"itemprop": "aggregateRating"})
95 + if agg is not None:
96 + for prop, key, cast in (("ratingValue", "rating", float),
97 + ("reviewCount", "reviews", int)):
98 + tag = agg.find("meta", attrs={"itemprop": prop})
99 + if tag and tag.get("content"):
100 + try:
101 + d[key] = cast(tag["content"])
102 + except ValueError:
103 + pass
104 +
93 105 m = _REGION_RE.search(html)
94 106 d["region"] = m.group(1).strip() if m else ""
95 107 m = _VILLE_RE.search(html)
@@ -197,6 +209,8 @@ class ChaletsAuQuebec(StConnector):
197 209 bathrooms=d.get("bathrooms"),
198 210 pets=d.get("pets"),
199 211 citq=str(d.get("citq", "") or ""),
212 + rating=d.get("rating"),
213 + reviews=d.get("reviews"),
200 214 description=d.get("description", ""),
201 215 amenities=d.get("amenities", []),
202 216 details=d.get("details", {}),
added louka/shortterm/connectors/chaletsquebec.py +24 −0
@@ -0,0 +1,24 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/chaletsquebec.py : Chalets Québec (www.chaletsquebec.com)
4 +#
5 +# SOURCE INFAISABLE (vérifié le 2026-08-22) : le domaine chaletsquebec.com
6 +# n'existe plus — aucun enregistrement NS/A (SERVFAIL à la délégation, y
7 +# compris via 1.1.1.1/8.8.8.8), injoignable via Scrapfly et Bright Data,
8 +# aucun instantané dans la Wayback Machine et aucune page indexée par Google
9 +# (site:chaletsquebec.com → 0 résultat). Ne pas confondre avec
10 +# chaletsauquebec.com (source distincte `chaletsauquebec` dans
11 +# sources_ct.json). À réactiver seulement si le domaine revit un jour.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +from ..schema import StListing
16 +from .base import StConnector
17 +
18 +
19 +class ChaletsQuebec(StConnector):
20 + source_id = "chaletsquebec"
21 + disabled = True # domaine mort — voir l'en-tête
22 +
23 + def fetch(self) -> list[StListing]:
24 + raise RuntimeError("chaletsquebec.com : domaine inexistant (2026-08-22)")
added louka/shortterm/connectors/mcal.py +194 −0
@@ -0,0 +1,194 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/mcal.py : Maisons et Chalets à Louer (maisonsetchaletsalouer.com)
4 +# — gestionnaire locatif de Richmond (Cantons-de-l'Est), ~300 propriétés
5 +# au Québec.
6 +#
7 +# Méthode : inventaire complet depuis le sitemap /sitemap_chalet.cfm
8 +# (URLs détail /fr/chalet-a-louer/<id>-<slug>.html — id numérique stable).
9 +# Une seule requête par propriété : la variante ?carte=1 de la page détail
10 +# contient TOUT (JSON-LD Accommodation, microdonnées géo lat/lng, région +
11 +# ville, prix, commodités, description, no CITQ, photos), contrairement à
12 +# la page de base qui omet les coordonnées. Le sitemap n'a pas de lastmod :
13 +# clé de cache mensuelle (refetch complet 1×/mois, ~300 requêtes).
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import json
18 +import re
19 +import time
20 +from urllib.parse import urljoin
21 +
22 +from bs4 import BeautifulSoup
23 +
24 +from ..schema import StListing
25 +from .base import StConnector
26 +
27 +BASE = "https://www.maisonsetchaletsalouer.com"
28 +SITEMAP = BASE + "/sitemap_chalet.cfm"
29 +
30 +_URL_DETAIL = re.compile(
31 + r"https://www\.maisonsetchaletsalouer\.com/fr/chalet-a-louer/(\d+)-[\w-]+\.html$")
32 +_MONTANT = re.compile(r"(\d[\d\s ]*(?:[.,]\d{2})?)\s*\$")
33 +
34 +
35 +def _prix_nuit(label: str) -> float | None:
36 + """« À partir de 350 $ pour 2 nuitées ménage inclus* » → 175.0."""
37 + if not label:
38 + return None
39 + m = _MONTANT.search(label)
40 + if not m:
41 + return None
42 + brut = re.sub(r"[\s ]", "", m.group(1)).replace(",", ".")
43 + try:
44 + val = float(brut)
45 + except ValueError:
46 + return None
47 + lab = label.lower()
48 + if "mois" in lab: # location au mois : pas un prix à la nuit
49 + return None
50 + m = re.search(r"(\d+)\s*nuit", lab)
51 + if m and int(m.group(1)) > 1:
52 + return round(val / int(m.group(1)), 2)
53 + if "sem" in lab:
54 + return round(val / 7, 2)
55 + return val
56 +
57 +
58 +class MaisonsEtChaletsALouer(StConnector):
59 + source_id = "mcal"
60 +
61 + def fetch(self) -> list[StListing]:
62 + xml = self.get(SITEMAP).text
63 + urls: dict[str, str] = {} # id -> url détail
64 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
65 + m = _URL_DETAIL.match(loc.strip())
66 + if m:
67 + urls.setdefault(m.group(1), loc.strip())
68 +
69 + cle = "carte-" + time.strftime("%Y-%m") # refetch mensuel (pas de lastmod)
70 + listings: list[StListing] = []
71 + for eid, url in urls.items():
72 + try:
73 + d = self.detail(eid, cle, lambda u=url: self._detail(u))
74 + except Exception:
75 + d = {}
76 + if not d.get("title"):
77 + continue
78 + listings.append(StListing(
79 + source=self.source_id,
80 + external_id=eid,
81 + url=url,
82 + title=d["title"],
83 + property_type=d.get("property_type") or "Chalet",
84 + city=d.get("city", ""),
85 + region=d.get("region", ""),
86 + price_night=_prix_nuit(d.get("price_label", "")),
87 + price_label=d.get("price_label", ""),
88 + capacity=d.get("capacity"),
89 + bedrooms=d.get("bedrooms"),
90 + bathrooms=d.get("bathrooms"),
91 + pets=d.get("pets"),
92 + citq=d.get("citq", ""),
93 + description=d.get("description", ""),
94 + amenities=d.get("amenities") or [],
95 + details=d.get("details") or {},
96 + images=d.get("images") or [],
97 + lat=d.get("lat"),
98 + lng=d.get("lng"),
99 + ))
100 + return listings
101 +
102 + # -- page détail (?carte=1 : contenu complet + géo) ------------------------
103 + def _detail(self, url: str) -> dict:
104 + html = self.get(url, params={"carte": 1}).text
105 + soup = BeautifulSoup(html, "html.parser")
106 + d: dict = {"details": {}}
107 +
108 + # JSON-LD Accommodation : nom, chambres, sdb, capacité, animaux
109 + for script in soup.find_all("script", type="application/ld+json"):
110 + try:
111 + # strict=False : le site laisse des sauts de ligne littéraux
112 + # dans les chaînes JSON
113 + data = json.loads(script.string or "", strict=False)
114 + except (ValueError, TypeError):
115 + continue
116 + if isinstance(data, dict) and data.get("@type") == "Accommodation":
117 + d["title"] = (data.get("name") or "").strip()
118 + if data.get("numberOfBedrooms") is not None:
119 + d["bedrooms"] = float(data["numberOfBedrooms"])
120 + if data.get("numberOfBathroomsTotal") is not None:
121 + d["bathrooms"] = float(data["numberOfBathroomsTotal"])
122 + occ = data.get("Occupancy") or {}
123 + if occ.get("maxValue") is not None:
124 + d["capacity"] = float(occ["maxValue"])
125 + if data.get("petsAllowed") is not None:
126 + d["pets"] = "oui" if str(data["petsAllowed"]) in (
127 + "1", "True", "true") else "non"
128 +
129 + # titre de secours : h1 / balise title
130 + if not d.get("title"):
131 + h1 = soup.find("h1")
132 + if h1 is not None:
133 + d["title"] = h1.get_text(" ", strip=True)
134 +
135 + # « Chalet à louer - <Région> - <Ville> »
136 + rv = soup.select_one("h2.regionVilleNom")
137 + if rv is not None:
138 + # séparateur = « - » entouré d'espaces (les tirets internes des
139 + # noms composés comme Centre-du-Québec n'en ont pas)
140 + texte = re.sub(r"\s+", " ", rv.get_text(" ", strip=True))
141 + parts = [p.strip() for p in texte.split(" - ") if p.strip()]
142 + if len(parts) >= 3:
143 + # « Cantons-de-l'Est / Estrie » → « Cantons-de-l'Est »
144 + d["region"] = parts[-2].split("/")[0].strip()
145 + d["city"] = parts[-1]
146 +
147 + # géo (microdonnées, présentes seulement avec ?carte=1)
148 + lat = soup.select_one('[itemprop="latitude"]')
149 + lng = soup.select_one('[itemprop="longitude"]')
150 + try:
151 + d["lat"] = float(lat["content"])
152 + d["lng"] = float(lng["content"])
153 + except (TypeError, KeyError, ValueError):
154 + pass
155 +
156 + # prix du chalet courant : « À partir de 350 $ pour 2 nuitées ménage
157 + # inclus* » (les autres .chalet_prix de la page = chalets suggérés)
158 + prix = soup.select_one(".boite_chalet-prix")
159 + if prix is not None:
160 + d["price_label"] = re.sub(r"\s+", " ",
161 + prix.get_text(" ", strip=True))
162 +
163 + # description (contient le no CITQ) — sans le h2 région/ville
164 + desc = soup.select_one('[itemprop="description"]')
165 + if desc is not None:
166 + for h2 in desc.find_all("h2"):
167 + h2.decompose()
168 + texte = desc.get_text("\n", strip=True)
169 + m = re.search(r"CITQ\s*:?\s*(\d{4,8})", texte)
170 + if m:
171 + d["citq"] = m.group(1)
172 + d["description"] = texte[:5000]
173 +
174 + # commodités (liste d'icônes)
175 + amen: list[str] = []
176 + for li in soup.select("li.liste-cat_item span"):
177 + t = li.get_text(" ", strip=True)
178 + if t and t not in amen:
179 + amen.append(t)
180 + if amen:
181 + d["amenities"] = amen
182 +
183 + # photos — seulement les « _desktop » (les « _listing » de la page
184 + # sont les vignettes des chalets suggérés, pas celles du chalet courant)
185 + images: list[str] = []
186 + for m in re.finditer(
187 + r"/DATA/CHALET[_A-Z]*/[\w.-]+_desktop\.(?:jpe?g|png|webp)",
188 + html, re.I):
189 + src = urljoin(BASE, m.group(0))
190 + if src not in images:
191 + images.append(src)
192 + if images:
193 + d["images"] = images[:20]
194 + return d
added louka/shortterm/connectors/monsieurchalets.py +341 −0
@@ -0,0 +1,341 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/monsieurchalets.py : MonsieurChalets (monsieurchalets.com)
4 +#
5 +# Plateforme québécoise de location de chalets (~650 propriétés, 17 régions).
6 +# Méthode (aucun anti-bot, tout en JSON) :
7 +# 1. LISTE : POST https://api.monsieurchalets.com/api/v1/public/search/properties
8 +# {"limit": 200, "page": N} → {properties: […], total, current_page}.
9 +# Chaque item contient déjà prix (basic_pricing), capacité, chambres,
10 +# salles de bain, lits, adresse complète, lat/lng, photos CloudFront,
11 +# note/avis et breadcrumbs (région/ville touristique).
12 +# 2. DÉTAIL (cache self.detail) : le site est un Next.js « pages router » —
13 +# GET /_next/data/<buildId>/chalets-a-louer/<...chemin>/<handle>.json
14 +# → pageProps.property : description_fr, amenities, registrations (CITQ),
15 +# rules (pets_allowed…). Le buildId est lu dans le __NEXT_DATA__ de
16 +# l'accueil et rafraîchi si l'endpoint répond 404 (déploiement du site).
17 +# ⚠️ PIÈGE : le chemin de la fiche = breadcrumbs s'ils sont présents,
18 +# sinon <administrative_area>/<city> slugifiés (logique reproduite du
19 +# bundle _app du site) — mais pour Charlevoix le site utilise la MRC
20 +# (« charlevoix ») plutôt que la région administrative
21 +# (« capitale-nationale »). On essaie donc plusieurs candidats (un 404
22 +# coûte ~0,3 s) et on mémorise le chemin résolu dans le cache détail,
23 +# qui sert aussi d'URL publique de l'annonce.
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import json
28 +import re
29 +import unicodedata
30 +
31 +import requests
32 +
33 +from ..schema import StListing
34 +from .base import StConnector
35 +
36 +API = "https://api.monsieurchalets.com/api/v1"
37 +SITE = "https://www.monsieurchalets.com"
38 +
39 +# breadcrumb[0] (slug région du site) → région touristique canonique
40 +_REGION_SLUGS = {
41 + "abitibi-temiscamingue": "Abitibi-Témiscamingue",
42 + "bas-saint-laurent": "Bas-Saint-Laurent",
43 + "capitale-nationale": "Québec",
44 + "centre-du-quebec": "Centre-du-Québec",
45 + "charlevoix": "Charlevoix",
46 + "chaudiere-appalaches": "Chaudière-Appalaches",
47 + "cote-nord": "Côte-Nord",
48 + "estrie": "Cantons-de-l'Est",
49 + "gaspesie": "Gaspésie",
50 + "lanaudiere": "Lanaudière",
51 + "laurentides": "Laurentides",
52 + "laval": "Laval",
53 + "mauricie": "Mauricie",
54 + "monteregie": "Montérégie",
55 + "montreal": "Montréal",
56 + "outaouais": "Outaouais",
57 + "quebec": "Québec",
58 + "saguenay": "Saguenay–Lac-Saint-Jean",
59 + "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean",
60 +}
61 +
62 +# type d'hébergement déduit du nom/sous-titre (le site est ~100 % chalets,
63 +# mais quelques fiches sont des dômes, mini-chalets, condos…)
64 +_TYPE_HINTS = [
65 + ("dome", "Dôme"), ("dôme", "Dôme"),
66 + ("yourte", "Yourte"),
67 + ("mini-chalet", "Mini-maison"), ("mini chalet", "Mini-maison"),
68 + ("micro-chalet", "Mini-maison"), ("mini-maison", "Mini-maison"),
69 + ("pret-a-camper", "Prêt-à-camper"), ("prêt-à-camper", "Prêt-à-camper"),
70 + ("condo", "Condo"),
71 + ("appartement", "Appartement"),
72 + ("loft", "Loft"),
73 + ("refuge", "Refuge"),
74 + ("gite", "Gîte"), ("gîte", "Gîte"),
75 + ("auberge", "Auberge"),
76 + ("maison", "Maison"),
77 + ("chalet", "Chalet"), ("cottage", "Chalet"),
78 +]
79 +
80 +
81 +def _property_type(*texts: str) -> str:
82 + hay = " ".join(t or "" for t in texts).lower()
83 + for needle, canon in _TYPE_HINTS:
84 + if needle in hay:
85 + return canon
86 + return ""
87 +
88 +
89 +def _num(v) -> float | None:
90 + try:
91 + return float(v) if v is not None else None
92 + except (TypeError, ValueError):
93 + return None
94 +
95 +
96 +def _slug(s: str) -> str:
97 + """Slugification équivalente à celle du site (accents → ascii, '-')."""
98 + s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
99 + return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
100 +
101 +
102 +def _city_variants(city_slug: str) -> list[str]:
103 + """Variantes du slug de ville observées sur le site :
104 + « lanse-saint-jean » → « anse-saint-jean » (article élidé),
105 + « stoneham-et-tewkesbury » → « stoneham » (nom composé tronqué)."""
106 + out = [city_slug] if city_slug else []
107 + if city_slug.startswith(("l-", "le-", "la-", "les-")):
108 + out.append(city_slug.split("-", 1)[1])
109 + elif city_slug.startswith("l") and not city_slug.startswith("l-"):
110 + pass
111 + if city_slug.startswith("lanse-"):
112 + out.append(city_slug[1:]) # lanse-… → anse-…
113 + if "-et-" in city_slug:
114 + out.append(city_slug.split("-et-")[0])
115 + if "-de-" in city_slug:
116 + out.append(city_slug.split("-de-")[0])
117 + seen, uniq = set(), []
118 + for v in out:
119 + if v and v not in seen:
120 + seen.add(v)
121 + uniq.append(v)
122 + return uniq
123 +
124 +
125 +def _candidate_paths(it: dict) -> list[str]:
126 + """Chemins possibles de la fiche (sous /chalets-a-louer), ordre de vraisemblance."""
127 + handle = (it.get("handle") or "").strip()
128 + addr = (it.get("addresses") or [{}])[0] or {}
129 + admin = _slug(addr.get("administrative_area") or "")
130 + mrc = _slug(addr.get("mrc") or "")
131 + mrc2 = _slug(addr.get("mrc_other") or "") # ex. « Charlevoix »
132 + county = _slug(addr.get("county") or "")
133 + cities = _city_variants(_slug(addr.get("city") or ""))
134 + crumbs = [_slug(c) for c in (it.get("breadcrumbs") or [])
135 + if isinstance(c, str) and c]
136 + cands: list[list[str]] = []
137 + if crumbs:
138 + cands.append(crumbs)
139 + combos: list[list[str]] = []
140 + for region in (admin, mrc2, mrc, county):
141 + for city in cities:
142 + combos.append([region, city])
143 + # ville absente (ou slug divergent) : le site retombe sur <admin>/<mrc>
144 + combos += [[admin, mrc], [admin, mrc2], [mrc2, mrc],
145 + [admin], [mrc2], [mrc], *[[c] for c in cities], []]
146 + for parts in combos:
147 + parts = [p for p in parts if p]
148 + if parts not in cands:
149 + cands.append(parts)
150 + return ["/".join(["chalets-a-louer", *parts, handle]) for parts in cands]
151 +
152 +
153 +class MonsieurChalets(StConnector):
154 + source_id = "monsieurchalets"
155 + request_delay = 0.35 # API + pages _next/data légères — reste poli
156 +
157 + def __init__(self) -> None:
158 + super().__init__()
159 + self._build_id: str | None = None
160 +
161 + # -- liste ------------------------------------------------------------
162 + def _search_page(self, page: int) -> dict:
163 + resp = self.post(
164 + f"{API}/public/search/properties",
165 + json={"limit": 200, "page": page},
166 + headers={"Accept": "application/json",
167 + "Origin": SITE, "Referer": SITE + "/"})
168 + return resp.json()
169 +
170 + def _all_items(self) -> list[dict]:
171 + items: list[dict] = []
172 + page = 1
173 + while True:
174 + data = self._search_page(page)
175 + batch = data.get("properties") or []
176 + if not batch:
177 + break
178 + items.extend(batch)
179 + total = data.get("total") or 0
180 + if len(items) >= total or page > 30: # garde-fou
181 + break
182 + page += 1
183 + return items
184 +
185 + # -- détail (Next.js _next/data, via cache BD) -------------------------
186 + def _fetch_build_id(self) -> str:
187 + html = self.get(SITE + "/").text
188 + m = re.search(r'"buildId"\s*:\s*"([^"]+)"', html)
189 + if not m:
190 + raise RuntimeError("buildId Next.js introuvable sur l'accueil")
191 + return m.group(1)
192 +
193 + def _next_data(self, path: str) -> dict | None:
194 + """pageProps de la page détail via l'endpoint JSON de Next.js
195 + (None si la page n'existe pas — 404)."""
196 + if not self._build_id:
197 + self._build_id = self._fetch_build_id()
198 + try:
199 + resp = self.get(f"{SITE}/_next/data/{self._build_id}/{path}.json",
200 + headers={"Accept": "application/json"})
201 + return resp.json().get("pageProps") or {}
202 + except requests.HTTPError as exc:
203 + r = getattr(exc, "response", None)
204 + if r is not None and r.status_code == 404:
205 + return None
206 + raise
207 + except ValueError:
208 + return {}
209 +
210 + def _fetch_detail(self, paths: list[str]) -> dict:
211 + """Essaie les chemins candidats ; ne conserve que les champs utiles
212 + (le payload complet fait ~150 Ko). Mémorise le chemin résolu."""
213 + prop, found = {}, ""
214 + for attempt in (1, 2):
215 + for path in paths:
216 + pp = self._next_data(path)
217 + if pp is None: # 404 → candidat suivant
218 + continue
219 + prop = pp.get("property") or {}
220 + found = path
221 + break
222 + if found or attempt == 2:
223 + break
224 + # tous 404 : le site a peut-être été redéployé (buildId périmé)
225 + self._build_id = self._fetch_build_id()
226 + if not prop:
227 + return {"path": found} if found else {}
228 + citq = ""
229 + for reg in prop.get("registrations") or []:
230 + if (reg or {}).get("type") == "citq" and reg.get("unique_number"):
231 + citq = str(reg["unique_number"])
232 + if reg.get("is_current"):
233 + break
234 + rules = [r for r in (prop.get("rules") or []) if isinstance(r, str)]
235 + return {
236 + "path": found,
237 + "description": prop.get("description_fr")
238 + or prop.get("description_en") or "",
239 + "amenities": [a.replace("_", " ").replace("/", " ").strip()
240 + for a in (prop.get("amenities") or [])
241 + if isinstance(a, str)],
242 + "citq": citq,
243 + "pets": "oui" if "pets_allowed" in rules else None,
244 + "rules": rules,
245 + }
246 +
247 + # -- contrat ------------------------------------------------------------
248 + def fetch(self) -> list[StListing]:
249 + listings: list[StListing] = []
250 + for it in self._all_items():
251 + uid = str(it.get("uid") or it.get("id") or "")
252 + handle = (it.get("handle") or "").strip()
253 + if not uid or not handle:
254 + continue
255 + crumbs = [c for c in (it.get("breadcrumbs") or [])
256 + if isinstance(c, str) and c]
257 + paths = _candidate_paths(it)
258 + addr = (it.get("addresses") or [{}])[0] or {}
259 + pricing = it.get("basic_pricing") or {}
260 + fee = it.get("basic_fee") or {}
261 + rental = it.get("rental_parameter") or {}
262 + low = _num(pricing.get("calculated_lowest_rate")) \
263 + or _num(pricing.get("base_rate"))
264 + high = _num(pricing.get("calculated_highest_rate"))
265 +
266 + title = (it.get("name_fr") or it.get("name_en") or handle).strip()
267 + sub = (it.get("sub_name_fr") or it.get("sub_name_en") or "").strip()
268 +
269 + images = []
270 + for ph in sorted(it.get("photos") or [],
271 + key=lambda p: (p or {}).get("order") or 0):
272 + u = ((ph or {}).get("image") or {}).get("url")
273 + if u and u not in images:
274 + images.append(u)
275 + if len(images) >= 15:
276 + break
277 +
278 + # clé de cache détail : sous-ensemble STABLE du contenu liste
279 + # (le prix bouge tous les jours — l'exclure évite de revisiter
280 + # les 650 fiches à chaque synchronisation)
281 + key = json.dumps([uid, handle, title, sub, it.get("photo_count"),
282 + it.get("number_of_rooms"),
283 + it.get("maximum_guests")], ensure_ascii=False)
284 + try:
285 + det = self.detail(uid, key,
286 + lambda p=paths: self._fetch_detail(p))
287 + except Exception: # une fiche détail cassée ≠ annonce perdue
288 + det = {}
289 + url = f"{SITE}/{det.get('path') or paths[0]}"
290 +
291 + # région touristique : 1er segment du chemin résolu (la vraie
292 + # région du site), sinon breadcrumb, sinon région administrative
293 + seg = (det.get("path") or "").split("/")
294 + region = (_REGION_SLUGS.get(seg[1]) if len(seg) > 2 else None) \
295 + or (_REGION_SLUGS.get(crumbs and _slug(crumbs[0]) or "")) \
296 + or _REGION_SLUGS.get(_slug(addr.get("administrative_area") or "")) \
297 + or addr.get("administrative_area") or ""
298 +
299 + pets = det.get("pets")
300 + if pets is None and _num(fee.get("animal_fee")):
301 + pets = "oui" # frais « animaux » affiché ⇒ admis
302 +
303 + details = {k: v for k, v in {
304 + "sub_name": sub,
305 + "mrc": addr.get("mrc"),
306 + "min_nights": rental.get("min_nights_to_rent"),
307 + "cleaning_fee": _num(fee.get("cleaning_fee")),
308 + "animal_fee": _num(fee.get("animal_fee")),
309 + "quality": it.get("quality"),
310 + }.items() if v not in (None, "", 0)}
311 +
312 + rating = _num(it.get("average_rating"))
313 + listings.append(StListing(
314 + source=self.source_id,
315 + external_id=uid,
316 + url=url,
317 + title=title,
318 + property_type=_property_type(title, sub),
319 + address=addr.get("full_address") or "",
320 + city=addr.get("city") or "",
321 + region=region,
322 + price_night=low,
323 + price_label=(f"à partir de {low:g} $ / nuit"
324 + if low and high and high > low
325 + else (f"{low:g} $ / nuit" if low else "")),
326 + capacity=_num(it.get("maximum_guests")),
327 + bedrooms=_num(it.get("number_of_rooms")),
328 + beds=_num(it.get("number_of_beds")),
329 + bathrooms=_num(it.get("number_of_rooms_bath")),
330 + pets=pets,
331 + citq=det.get("citq") or "",
332 + rating=rating if rating else None,
333 + reviews=int(it["review_count"]) if it.get("review_count") else None,
334 + description=det.get("description") or "",
335 + amenities=det.get("amenities") or [],
336 + details=details,
337 + images=images,
338 + lat=_num(it.get("latitude")),
339 + lng=_num(it.get("longitude")),
340 + ))
341 + return listings
added louka/shortterm/connectors/qldc.py +291 −0
@@ -0,0 +1,291 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/qldc.py : Québec Location de Chalets (quebeclocationdechalets.com)
4 +# — répertoire de chalets en ligne depuis 2004, contact direct avec les
5 +# propriétaires (pas de réservation en ligne), ~1 700 chalets.
6 +#
7 +# Méthode : pagination de la liste globale /chalets-a-louer?page=N (site
8 +# ASP.NET WebForms, 12 cartes/page, HTML statique — la pagination « infinie »
9 +# accepte le paramètre ?page). Cartes : id stable (/chalet-a-louer/<id>),
10 +# titre, région + ville, capacité, chambres, photo. La page détail (via
11 +# self.detail, cache BD) en variante ?map=o ajoute lat/lng (champs cachés
12 +# InfoLocalisation_hf_lat/long — absents de la page de base), grille de
13 +# tarifs, description, no CITQ, sdb/lits, commodités et photos.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import hashlib
18 +import math
19 +import re
20 +import time
21 +from urllib.parse import urljoin
22 +
23 +from bs4 import BeautifulSoup
24 +
25 +from ..schema import StListing
26 +from .base import StConnector
27 +
28 +BASE = "https://www.quebeclocationdechalets.com"
29 +LISTE = BASE + "/chalets-a-louer"
30 +
31 +_MONTANT = re.compile(r"(\d[\d\s ]*(?:[.,]\d{2})?)\s*\$")
32 +
33 +# libellés de la région affichée → forme canonique Lou-Ka (le reste passe
34 +# tel quel à normalize_region dans finalize())
35 +_REGIONS = {
36 + "Saguenay-Lac-St-Jean": "Saguenay–Lac-Saint-Jean",
37 + "Estrie-Cantons-de-lest": "Cantons-de-l'Est",
38 + "Laval-Rive-nord": "Laval",
39 +}
40 +
41 +
42 +def _prix_nuit(periode: str, prix: str) -> float | None:
43 + """(« Week-end 2 nuits », « 995$ - 1195$ ») → 497.5 (le plus bas)."""
44 + montants = []
45 + for m in _MONTANT.finditer(prix or ""):
46 + try:
47 + montants.append(float(
48 + re.sub(r"[\s ]", "", m.group(1)).replace(",", ".")))
49 + except ValueError:
50 + pass
51 + if not montants:
52 + return None
53 + val = min(montants)
54 + lab = (periode or "").lower()
55 + m = re.search(r"(\d+)\s*(?:nuit|jour)", lab)
56 + if m and int(m.group(1)) >= 1:
57 + return round(val / int(m.group(1)), 2)
58 + if "sem" in lab:
59 + return round(val / 7, 2)
60 + if "mois" in lab:
61 + return None
62 + return val
63 +
64 +
65 +class QuebecLocationDeChalets(StConnector):
66 + source_id = "qldc"
67 +
68 + def fetch(self) -> list[StListing]:
69 + listings: list[StListing] = []
70 + vus: set[str] = set()
71 + page, max_page = 1, 1
72 + while page <= max_page:
73 + html = self.get(LISTE, params={"page": page}).text
74 + soup = BeautifulSoup(html, "html.parser")
75 + if page == 1: # « 1722 chalets à louer » → nombre de pages
76 + m = re.search(r"(\d+)\s+chalets à louer", html)
77 + if m:
78 + max_page = math.ceil(int(m.group(1)) / 12) + 2
79 + nouveaux = 0
80 + for a in soup.select('a[href^="/chalet-a-louer/"]'):
81 + lst = self._carte(a)
82 + if lst is not None and lst.external_id not in vus:
83 + vus.add(lst.external_id)
84 + listings.append(lst)
85 + nouveaux += 1
86 + if nouveaux == 0 and page > 1:
87 + break
88 + page += 1
89 +
90 + for lst in listings:
91 + cle = hashlib.sha1(("|".join([
92 + lst.title, lst.city, lst.region,
93 + str(lst.capacity), str(lst.bedrooms),
94 + ]) + time.strftime("|%Y-%m")).encode("utf-8")).hexdigest()
95 + try:
96 + d = self.detail(lst.external_id, cle,
97 + lambda u=lst.url: self._detail(u))
98 + except Exception:
99 + d = {}
100 + if not d:
101 + continue
102 + lst.price_night = d.get("price_night")
103 + lst.price_label = d.get("price_label") or ""
104 + lst.description = d.get("description") or ""
105 + lst.citq = d.get("citq") or ""
106 + lst.amenities = d.get("amenities") or []
107 + lst.lat = d.get("lat")
108 + lst.lng = d.get("lng")
109 + lst.pets = d.get("pets")
110 + if d.get("bathrooms") is not None:
111 + lst.bathrooms = d["bathrooms"]
112 + if d.get("beds") is not None:
113 + lst.beds = d["beds"]
114 + if d.get("capacity") is not None:
115 + lst.capacity = d["capacity"]
116 + if d.get("bedrooms") is not None:
117 + lst.bedrooms = d["bedrooms"]
118 + if d.get("images"):
119 + lst.images = d["images"]
120 + lst.details.update(d.get("details") or {})
121 + return listings
122 +
123 + # -- carte de la liste ------------------------------------------------------
124 + def _carte(self, lien) -> StListing | None:
125 + m = re.match(r"/chalet-a-louer/(\d+)$", lien.get("href", ""))
126 + if m is None:
127 + return None
128 + # on ancre sur le <h3> (lien-titre) pour ne traiter chaque carte qu'une
129 + # fois (le même href apparaît aussi sur la photo)
130 + h3 = lien.find_parent("h3")
131 + if h3 is None:
132 + return None
133 + eid = m.group(1)
134 + carte = h3.parent # conteneur de la carte
135 + titre = lien.get_text(" ", strip=True)
136 +
137 + region = ville = ""
138 + bloc = h3.find_next_sibling("div")
139 + if bloc is not None:
140 + morceaux = [t.strip() for t in bloc.stripped_strings if t.strip()]
141 + if len(morceaux) >= 2:
142 + region, ville = morceaux[0], morceaux[1]
143 + elif morceaux:
144 + region = morceaux[0]
145 +
146 + capacite = chambres = None
147 + texte = carte.get_text(" ", strip=True) if carte else ""
148 + m2 = re.search(r"Capacité\s*(\d+)", texte)
149 + if m2:
150 + capacite = float(m2.group(1))
151 + m2 = re.search(r"Chambres\s*(\d+)", texte)
152 + if m2:
153 + chambres = float(m2.group(1))
154 +
155 + images = []
156 + conteneur = carte.parent if carte is not None else None
157 + img = conteneur.find("img", src=re.compile("PhotoChalets")) \
158 + if conteneur is not None else None
159 + if img is not None:
160 + images.append(urljoin(BASE, img["src"].split("?")[0]))
161 +
162 + return StListing(
163 + source=self.source_id,
164 + external_id=eid,
165 + url=f"{BASE}/chalet-a-louer/{eid}",
166 + title=titre,
167 + property_type="Chalet",
168 + city=ville,
169 + region=_REGIONS.get(region, region),
170 + capacity=capacite,
171 + bedrooms=chambres,
172 + images=images,
173 + )
174 +
175 + # -- page détail (?map=o : contenu complet + géo) ----------------------------
176 + def _detail(self, url: str) -> dict:
177 + html = self.get(url, params={"map": "o"}).text
178 + soup = BeautifulSoup(html, "html.parser")
179 + d: dict = {"details": {}}
180 +
181 + def _champ(id_, conv=str):
182 + el = soup.find(id=id_)
183 + if el is None:
184 + return None
185 + val = (el.get("value") or el.get_text(" ", strip=True)).strip()
186 + if not val:
187 + return None
188 + try:
189 + return conv(val.replace(",", "."))
190 + except ValueError:
191 + return None
192 +
193 + lat = _champ("InfoLocalisation_hf_lat", float)
194 + lng = _champ("InfoLocalisation_hf_long", float)
195 + if lat is not None and lng is not None:
196 + d["lat"], d["lng"] = lat, lng
197 + ville = soup.find(id="InfoLocalisation_lblVille")
198 + if ville is not None and ville.get_text(strip=True):
199 + d["details"]["ville"] = ville.get_text(strip=True)
200 + lac = soup.find(id="InfoLocalisation_lblLacRiviere")
201 + if lac is not None and lac.get_text(strip=True):
202 + d["details"]["plan_eau"] = lac.get_text(strip=True)
203 +
204 + d["capacity"] = _champ("InfoCapaciteConfort_lblintCapacite", float)
205 + d["bedrooms"] = _champ("InfoCapaciteConfort_lblintChambre", float)
206 + d["bathrooms"] = _champ("InfoCapaciteConfort_lblSalleDeBain", float)
207 + lits = 0
208 + for id_ in ("InfoCapaciteConfort_lblLitSimple",
209 + "InfoCapaciteConfort_lblLitdouble",
210 + "InfoCapaciteConfort_lblintLitQueen",
211 + "InfoCapaciteConfort_lblintLitKing"):
212 + n = _champ(id_, float)
213 + if n:
214 + lits += int(n)
215 + if lits:
216 + d["beds"] = float(lits)
217 +
218 + # grille de tarifs : lignes (période | prix | commentaires) —
219 + # prix/nuit = le plus bas de toutes les lignes
220 + grille = soup.find(id="ctl16_pnlGrilleTarrif")
221 + meilleurs = []
222 + if grille is not None:
223 + for ligne in grille.select("div.flex.flex-wrap"):
224 + cols = [c.get_text(" ", strip=True)
225 + for c in ligne.find_all("div", recursive=False)]
226 + if len(cols) >= 2 and "$" in cols[1]:
227 + pn = _prix_nuit(cols[0] + " " + (cols[2] if len(cols) > 2
228 + else ""), cols[1])
229 + if pn:
230 + meilleurs.append(pn)
231 + if meilleurs:
232 + d["price_night"] = min(meilleurs)
233 + fourchette = soup.find(itemprop="priceRange")
234 + if fourchette is not None:
235 + d["price_label"] = re.sub(r"\s+", " ",
236 + fourchette.get_text(" ", strip=True))
237 + if "price_night" not in d:
238 + d["price_night"] = _prix_nuit(d["price_label"],
239 + d["price_label"])
240 +
241 + desc = soup.find(id="InfoDescription_pnlDescription")
242 + if desc is not None:
243 + texte = desc.get_text("\n", strip=True)
244 + texte = re.sub(r"^Descriptif de la location\n?", "", texte)
245 + d["description"] = texte[:5000]
246 + citq = soup.find(id="InfoDescription_lblvchNumCITQ")
247 + if citq is not None:
248 + m = re.search(r"(\d{4,8})", citq.get_text(" ", strip=True))
249 + if m:
250 + d["citq"] = m.group(1)
251 + restr = soup.find(id="InfoDescription_lblvchRestriction")
252 + if restr is not None and restr.get_text(strip=True):
253 + texte = restr.get_text(" ", strip=True)
254 + d["details"]["restrictions"] = texte[:1000]
255 + if re.search(r"animaux\s+(permis|accept|admis)", texte, re.I):
256 + d["pets"] = "oui"
257 + elif re.search(r"animaux\s+(non|interdit|refus)|pas d.animaux",
258 + texte, re.I):
259 + d["pets"] = "non"
260 +
261 + # équipements : libellés dont l'icône n'est pas « -red » (= absent)
262 + amen: list[str] = []
263 + exclus = ("Maximum de personnes", "Nombre Chambres", "Salles de bain",
264 + "Lits simples", "Lits doubles", "Lits Queen", "Lits King")
265 + for img in soup.select("img[src*='tailwind-img']"):
266 + p = img.find_parent("div")
267 + p = p.find("p") if p is not None else None
268 + if p is None:
269 + continue
270 + libelle = p.get_text(" ", strip=True)
271 + if (not libelle or libelle in exclus or libelle in amen
272 + or "-red" in (img.get("src") or "")):
273 + continue
274 + amen.append(libelle)
275 + if libelle == "Animaux" and "pets" not in d:
276 + d["pets"] = "oui"
277 + if amen:
278 + d["amenities"] = [a for a in amen if a not in ("Animaux", "Fumeur")]
279 + # animaux : icône rouge = interdit
280 + for img in soup.select("img[alt='Animaux'][src*='-red']"):
281 + d.setdefault("pets", "non")
282 +
283 + images: list[str] = []
284 + for m in re.finditer(r"images/PhotoChalets/[\w./-]+\.(?:jpe?g|png|webp)",
285 + html, re.I):
286 + src = urljoin(BASE + "/", m.group(0))
287 + if src not in images:
288 + images.append(src)
289 + if images:
290 + d["images"] = images[:20]
291 + return d
added louka/shortterm/connectors/rsvpchalets.py +190 −0
@@ -0,0 +1,190 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/rsvpchalets.py : RSVP Chalets (https://www.rsvpchalets.com)
4 +#
5 +# Méthode : sitemap_fr_cottages.xml (~790 chalets Canada/France) filtré sur les
6 +# régions québécoises (segment région de l'URL /chalets-a-louer/<région>/…).
7 +# Chaque page détail (cache self.detail, clé = lastmod du sitemap) embarque un
8 +# JSON-LD schema.org VacationRental complet : adresse, lat/lng, chambres,
9 +# salles de bain, capacité, lits, animaux, note/avis, photos, type. Le prix
10 +# vient de la grille « Tarifs de location » (rangées rates_day, ramenées au
11 +# prix par nuit), la description et le no CITQ du HTML.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import html as _html
16 +import json
17 +import re
18 +
19 +from ..schema import StListing, parse_price_night
20 +from .base import StConnector
21 +
22 +SITEMAP = "https://www.rsvpchalets.com/sitemap_fr_cottages.xml"
23 +
24 +# segment d'URL région → région touristique canonique (Québec seulement ;
25 +# tout le reste — Maritimes, Ontario, C.-B., France… — est ignoré)
26 +_REGION = {
27 + "abitibi-temiscamingue": "Abitibi-Témiscamingue",
28 + "bas-saint-laurent": "Bas-Saint-Laurent",
29 + "centre-du-quebec": "Centre-du-Québec",
30 + "charlevoix": "Charlevoix",
31 + "chaudiere-appalaches": "Chaudière-Appalaches",
32 + "cote-nord": "Côte-Nord",
33 + "estrie": "Cantons-de-l'Est",
34 + "gaspesie": "Gaspésie",
35 + "iles-de-la-madeleine": "Îles-de-la-Madeleine",
36 + "lanaudiere": "Lanaudière",
37 + "laurentides": "Laurentides",
38 + "mauricie": "Mauricie",
39 + "monteregie": "Montérégie",
40 + "outaouais": "Outaouais",
41 + "region-de-quebec": "Québec",
42 + "saguenay-lac-st-jean": "Saguenay–Lac-Saint-Jean",
43 +}
44 +
45 +_TAG_RE = re.compile(r"<[^>]+>")
46 +
47 +
48 +def _text(fragment: str) -> str:
49 + return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()
50 +
51 +
52 +def _f(v) -> float | None:
53 + try:
54 + return float(str(v).strip())
55 + except (TypeError, ValueError):
56 + return None
57 +
58 +
59 +class RsvpChalets(StConnector):
60 + source_id = "rsvpchalets"
61 +
62 + # -- page détail ------------------------------------------------------
63 + def _detail(self, url: str, slug: str) -> dict:
64 + h = self.get(url).text
65 + d: dict = {}
66 +
67 + for block in re.findall(r'<script type="application/ld\+json"[^>]*>'
68 + r"(.*?)</script>", h, re.S):
69 + try:
70 + ld = json.loads(block)
71 + except ValueError:
72 + continue
73 + if (ld.get("@type") == "VacationRental"
74 + and ld.get("identifier") == slug):
75 + d["ld"] = ld
76 + break
77 +
78 + # grille « Tarifs de location » : rangées label / prix ; on ramène
79 + # chaque rangée au prix par nuit (« 3 nuits … 804 $ » → 268 $)
80 + best = None
81 + for label, price in re.findall(
82 + r'(?s)class="[^"]*ratesDay[^"]*"[^>]*>\s*(.*?)\s*</div>\s*'
83 + r'<div[^>]*class="[^"]*important-right[^"]*"[^>]*>\s*(.*?)\s*</div>',
84 + h):
85 + label, price = _text(label), _text(price)
86 + val = parse_price_night(price + (" /sem" if "sem" in label.lower()
87 + and "nuit" not in label.lower()
88 + else ""))
89 + if val is None:
90 + continue
91 + m = re.match(r"(\d+)\s*nuit", label.lower())
92 + if m and int(m.group(1)) > 1:
93 + val = round(val / int(m.group(1)), 2)
94 + if val and 20 <= val <= 20000 and (best is None or val < best[0]):
95 + best = (val, f"{price} / {label}")
96 + if best:
97 + d["price_night"], d["price_label"] = best
98 +
99 + # description réelle (le champ description du JSON-LD est un
100 + # boilerplate RSVP) : section « Description du Chalet »
101 + m = re.search(r"(?s)Description du Chalet(.*?)(?:<div class=\"box|"
102 + r"<span class=\"h2title)", h)
103 + if m:
104 + d["description"] = _text(m.group(1))[:4000]
105 +
106 + m = re.search(r"No CITQ\s*:?\s*(?:</[^>]+>\s*)*(?:<[^>]+>\s*)*(\d{6})", h)
107 + if m:
108 + d["citq"] = m.group(1)
109 + return d
110 +
111 + # -- contrat ----------------------------------------------------------
112 + def fetch(self) -> list[StListing]:
113 + xml = self.get(SITEMAP).text
114 + entries = re.findall(r"(?s)<url>\s*<loc>([^<]+)</loc>"
115 + r"(?:\s*<lastmod>([^<]*)</lastmod>)?", xml)
116 +
117 + listings: list[StListing] = []
118 + for url, lastmod in entries:
119 + parts = url.rstrip("/").split("/")
120 + # …/chalets-a-louer/<région>/<ville>/<slug>
121 + if len(parts) < 7 or parts[3] != "chalets-a-louer":
122 + continue
123 + region_slug, slug = parts[4], parts[-1]
124 + region = _REGION.get(region_slug)
125 + if not region: # hors Québec (Maritimes, Ontario, France…)
126 + continue
127 +
128 + det = self.detail(slug, lastmod or "",
129 + lambda u=url, s=slug: self._detail(u, s))
130 + ld = det.get("ld") or {}
131 + if not ld:
132 + continue
133 + addr = ld.get("address") or {}
134 + place = ld.get("containsPlace") or {}
135 + agg = ld.get("aggregateRating") or {}
136 +
137 + name = _text(str(ld.get("name") or ""))
138 + city = _text(str(addr.get("addressLocality") or ""))
139 + # « Chalet Le X, Ville, Région, Québec, Canada » → « Chalet Le X »
140 + title = name.split(f", {city},")[0].strip() if city else name
141 +
142 + beds = None
143 + bed_items = place.get("bed") or []
144 + if isinstance(bed_items, list) and bed_items:
145 + counts = [b.get("numberOfBeds") for b in bed_items
146 + if isinstance(b, dict)]
147 + if any(c is not None for c in counts):
148 + beds = float(sum(c or 0 for c in counts))
149 +
150 + amen = [a.get("name", "") for a in place.get("amenityFeature") or []
151 + if isinstance(a, dict) and a.get("value") in (True, "True")]
152 +
153 + pets = None
154 + if "petsAllowed" in ld:
155 + pets = "oui" if str(ld["petsAllowed"]).endswith("True") else "non"
156 +
157 + imgs = ld.get("image") or []
158 + if isinstance(imgs, str):
159 + imgs = [imgs]
160 +
161 + occupancy = (place.get("occupancy") or {}).get("value")
162 + reviews = agg.get("reviewCount")
163 + lst = StListing(
164 + source=self.source_id,
165 + external_id=slug, # identifiant du site
166 + url=url,
167 + title=title or name,
168 + property_type=_text(str(ld.get("additionalType") or "Chalet")),
169 + address=_text(str(addr.get("streetAddress") or "")),
170 + city=city,
171 + region=region,
172 + price_night=det.get("price_night"),
173 + price_label=det.get("price_label") or "",
174 + capacity=_f(occupancy),
175 + bedrooms=_f(ld.get("numberOfBedrooms")),
176 + beds=beds,
177 + bathrooms=_f(place.get("numberOfBathroomsTotal")),
178 + pets=pets,
179 + citq=det.get("citq") or "",
180 + rating=_f(agg.get("ratingValue")),
181 + reviews=int(reviews) if reviews else None,
182 + description=det.get("description") or "",
183 + amenities=[a for a in amen if a],
184 + details={"telephone": str(ld.get("telephone") or "")},
185 + images=[u for u in imgs if isinstance(u, str)][:20],
186 + lat=_f(ld.get("latitude")),
187 + lng=_f(ld.get("longitude")),
188 + )
189 + listings.append(lst)
190 + return listings
added louka/shortterm/connectors/sepaq.py +349 −0
@@ -0,0 +1,349 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/sepaq.py : Sépaq (sepaq.com) — chalets, yourtes, camps rustiques
4 +# et refuges des parcs nationaux, réserves fauniques et centres touristiques
5 +# du Québec (~630 unités, 32 établissements).
6 +#
7 +# Méthode :
8 +# 1. sitemap officiel des réservations « chalet »
9 +# (/sitemaps/fr/reservations/chalet.xml) → inventaire COMPLET des unités
10 +# (les feuilles /fr/reservation/chalet/<établissement>/<secteur>/<unité>) ;
11 +# 2. par établissement : la page /fr/reservation/chalet/<étab> pose le
12 +# contexte de recherche côté serveur (cookie JSESSIONID_TRANSAC), puis
13 +# l'API JSON interne de la carte (/fr/reservation/carte/resultats,
14 +# rejouée avec ce cookie) donne lat/lng, sous-type (Chalet/Yourte/…),
15 +# surnom et secteur de chaque unité — cache self.detail invalidé quand
16 +# la liste d'unités de l'établissement change dans le sitemap ;
17 +# 3. page détail de l'unité (cache self.detail, clé mensuelle pour suivre
18 +# les prix) : « À partir de X $ /nuit », capacité, chambres/lits,
19 +# commodités, chiens, no d'enregistrement CITQ, photos (galerie S3).
20 +#
21 +# Non couvert : le prêt-à-camper (section /fr/reservation/camping/), dont les
22 +# unités sont noyées parmi ~7 500 emplacements de camping nus sans marqueur
23 +# distinctif dans le sitemap (il faudrait crawler chaque boucle de camping).
24 +# Région touristique déduite de l'établissement (table statique ci-dessous).
25 +# Le site est derrière Cloudflare : self.get() escalade automatiquement.
26 +# -----------------------------------------------------------------------------
27 +from __future__ import annotations
28 +
29 +import hashlib
30 +import re
31 +import sys
32 +import time
33 +
34 +from ..schema import StListing
35 +from .base import StConnector
36 +
37 +BASE = "https://www.sepaq.com"
38 +SITEMAP = f"{BASE}/sitemaps/fr/reservations/chalet.xml"
39 +PREFIX = f"{BASE}/fr/reservation/chalet/"
40 +CARTE = f"{BASE}/fr/reservation/carte/resultats"
41 +
42 +# Région touristique de chaque établissement (fait géographique stable).
43 +REGION_ETAB = {
44 + "centre-touristique-du-lac-kenogami": "Saguenay–Lac-Saint-Jean",
45 + "centre-touristique-du-lac-simon": "Outaouais",
46 + "parc-national-d-aiguebelle": "Abitibi-Témiscamingue",
47 + "parc-national-d-oka": "Laurentides",
48 + "parc-national-de-frontenac": "Chaudière-Appalaches",
49 + "parc-national-de-la-gaspesie": "Gaspésie",
50 + "parc-national-de-la-jacques-cartier": "Québec",
51 + "parc-national-de-la-pointe-taillon": "Saguenay–Lac-Saint-Jean",
52 + "parc-national-de-la-yamaska": "Cantons-de-l'Est",
53 + "parc-national-de-plaisance": "Outaouais",
54 + "parc-national-des-grands-jardins": "Charlevoix",
55 + "parc-national-des-hautes-gorges-de-la-riviere-malbaie": "Charlevoix",
56 + "parc-national-des-monts-valin": "Saguenay–Lac-Saint-Jean",
57 + "parc-national-du-bic": "Bas-Saint-Laurent",
58 + "parc-national-du-fjord-du-saguenay": "Saguenay–Lac-Saint-Jean",
59 + "parc-national-du-mont-megantic": "Cantons-de-l'Est",
60 + "parc-national-du-mont-orford": "Cantons-de-l'Est",
61 + "parc-national-du-mont-tremblant": "Laurentides",
62 + "reserve-faunique-ashuapmushuan": "Saguenay–Lac-Saint-Jean",
63 + "reserve-faunique-de-matane": "Gaspésie",
64 + "reserve-faunique-de-papineau-labelle": "Outaouais",
65 + "reserve-faunique-de-port-cartier-sept-iles": "Côte-Nord",
66 + "reserve-faunique-de-port-daniel": "Gaspésie",
67 + "reserve-faunique-de-portneuf": "Québec",
68 + "reserve-faunique-de-rimouski": "Bas-Saint-Laurent",
69 + "reserve-faunique-des-chic-chocs": "Gaspésie",
70 + "reserve-faunique-des-laurentides": "Québec",
71 + "reserve-faunique-du-saint-maurice": "Mauricie",
72 + "reserve-faunique-la-verendrye": "Outaouais",
73 + "reserve-faunique-mastigouche": "Mauricie",
74 + "reserve-faunique-rouge-matawin": "Lanaudière",
75 + "sepaq-anticosti": "Côte-Nord",
76 + "station-touristique-duchesnay": "Québec",
77 + "auberge-de-montagne-des-chic-chocs": "Gaspésie",
78 +}
79 +
80 +_LOC_RE = re.compile(r"<loc>\s*(https?://[^<\s]+)")
81 +_COOKIE_RE = re.compile(r"(?:JSESSIONID_TRANSAC|__cf_bm)=[^;,\s]+")
82 +_JSON_ARRAY_RE = re.compile(r"\[.*\]", re.S)
83 +_PRICE_RE = re.compile(
84 + r'class="fiche-section-price"[^>]*>(.*?)</p>', re.S)
85 +_DL_RE = {
86 + "item_id": re.compile(r'item_id\s*:\s*"([^"]*)"'),
87 + "cat3": re.compile(r'item_category3\s*:\s*"([^"]*)"'),
88 + "dl_price": re.compile(r'\bprice\s*:\s*"([\d.,]+)"'),
89 +}
90 +_BED_RE = re.compile(r"(\d+)\s+lit", re.I)
91 +_CAP_RE = re.compile(r"(\d+)\s*personne", re.I)
92 +_CITQ_RE = re.compile(r"enregistrement\s*:?\s*(\d{5,7})")
93 +
94 +
95 +def _property_type(*labels: str) -> str:
96 + blob = " ".join(l or "" for l in labels).lower()
97 + if "yourte" in blob:
98 + return "Yourte"
99 + if "refuge" in blob or "camp" in blob: # camp rustique → Refuge
100 + return "Refuge"
101 + if "dôme" in blob or "dome" in blob:
102 + return "Dôme"
103 + if "tente" in blob or "prêt-à-camper" in blob or "pret-a-camper" in blob:
104 + return "Prêt-à-camper"
105 + if "auberge" in blob:
106 + return "Auberge"
107 + return "Chalet"
108 +
109 +
110 +class Sepaq(StConnector):
111 + source_id = "sepaq"
112 + request_delay = 0.4
113 +
114 + # -- helpers ----------------------------------------------------------------
115 + @staticmethod
116 + def _resp_json_array(resp):
117 + """L'API carte peut revenir enrobée de HTML (<pre>) via l'escalade ASP."""
118 + import json
119 + m = _JSON_ARRAY_RE.search(resp.text or "")
120 + if not m:
121 + return []
122 + try:
123 + return json.loads(m.group(0))
124 + except ValueError:
125 + return []
126 +
127 + @staticmethod
128 + def _set_cookie(resp) -> str:
129 + hdrs = resp.headers or {}
130 + try:
131 + items = hdrs.items()
132 + except AttributeError:
133 + items = []
134 + raw = ""
135 + for k, v in items:
136 + if str(k).lower() == "set-cookie":
137 + raw += str(v) + ", "
138 + return "; ".join(_COOKIE_RE.findall(raw))
139 +
140 + # -- carte par établissement (coords + sous-type + secteur) -----------------
141 + def _fetch_carte(self, etab: str) -> dict:
142 + # le contexte est posé par la visite de la page établissement (cookie
143 + # JSESSIONID_TRANSAC) ; si la 1re visite est passée par l'escalade
144 + # anti-bot (pas de Set-Cookie exploitable), on retente une fois.
145 + raw = []
146 + for _ in range(2):
147 + r = self.get(PREFIX + etab)
148 + cookies = self._set_cookie(r)
149 + if not cookies:
150 + continue
151 + hdrs = {"X-Requested-With": "XMLHttpRequest",
152 + "Accept": "application/json, text/javascript, */*",
153 + "Referer": PREFIX + etab,
154 + "Cookie": cookies}
155 + raw = self._resp_json_array(self.get(CARTE, headers=hdrs))
156 + if raw:
157 + break
158 + items = []
159 + for it in raw:
160 + if not isinstance(it, dict) or it.get("type") != "unite":
161 + continue
162 + coord = it.get("coordonnees") or {}
163 + parent = it.get("parent") or {}
164 + items.append({
165 + "url": it.get("url") or "",
166 + "lat": coord.get("lat"), "lng": coord.get("lng"),
167 + "sous_type": it.get("groupeSousType") or "",
168 + "surnom": it.get("surnom") or "",
169 + "nom": it.get("nom") or "",
170 + "secteur": parent.get("nom") or "",
171 + })
172 + return {"items": items}
173 +
174 + # -- page détail d'une unité --------------------------------------------------
175 + def _fetch_unit(self, slug: str) -> dict:
176 + from bs4 import BeautifulSoup
177 + html = self.get(PREFIX + slug).text
178 + soup = BeautifulSoup(html, "html.parser")
179 + d: dict = {}
180 +
181 + tt = soup.title.get_text(strip=True) if soup.title else ""
182 + parts = [p.strip() for p in tt.split(" - ") if p.strip()]
183 + if parts and parts[-1].lower() == "sépaq":
184 + parts.pop()
185 + if len(parts) >= 3:
186 + d["nom"], d["soustype"], d["parc"] = parts[0], parts[1], parts[2]
187 + elif len(parts) == 2:
188 + d["nom"], d["soustype"], d["parc"] = parts[0], "", parts[1]
189 + elif parts:
190 + d["nom"], d["soustype"], d["parc"] = parts[0], "", ""
191 + else:
192 + return {}
193 +
194 + fiche = soup.find("div", class_="fiche")
195 + if fiche is None: # page générique (unité retirée) → ignorer
196 + return {}
197 +
198 + m = _PRICE_RE.search(html)
199 + if m:
200 + import html as _h
201 + label = _h.unescape(re.sub(r"<[^>]+>", " ", m.group(1)))
202 + label = re.sub(r"[\s ]+", " ", label).strip()
203 + d["price_label"] = label
204 +
205 + for key, rx in _DL_RE.items():
206 + mm = rx.search(html)
207 + if mm:
208 + d[key] = mm.group(1)
209 +
210 + # sections h4 → items
211 + sections: dict[str, list[str]] = {}
212 + for sub in fiche.find_all("div", class_="fiche-sous-section"):
213 + h4 = sub.find("h4")
214 + if not h4:
215 + continue
216 + name = h4.get_text(" ", strip=True)
217 + lis = [li.get_text(" ", strip=True)
218 + for li in sub.find_all("li")]
219 + sections[name] = [x for x in lis if x]
220 + if "has-price" in (sub.get("class") or []):
221 + d["type_header"] = name # ex. « Villégiature - chalet »
222 + if lis:
223 + d["saison"] = lis[0]
224 + d["sections"] = sections
225 +
226 + for name, lis in sections.items():
227 + low = name.lower()
228 + if low.startswith("capacité") and lis:
229 + mm = _CAP_RE.search(lis[0])
230 + if mm:
231 + d["capacity"] = int(mm.group(1))
232 + elif low.startswith("chambres et lits"):
233 + d["bedrooms"] = sum(1 for x in lis
234 + if x.lower().startswith("chambre"))
235 + beds = sum(int(n) for x in lis for n in _BED_RE.findall(x))
236 + if beds:
237 + d["beds"] = beds
238 + elif "hébergement touristique" in low and lis:
239 + mm = _CITQ_RE.search(" ".join(lis))
240 + if mm:
241 + d["citq"] = mm.group(1)
242 +
243 + if soup.select_one(".is-chiens-non"):
244 + d["pets"] = "non"
245 + elif soup.select_one(".is-chiens-oui"):
246 + d["pets"] = "oui"
247 +
248 + imgs = []
249 + for a in soup.select('[itemprop="contentUrl"]'):
250 + href = a.get("href") or ""
251 + if href.startswith("//"):
252 + href = "https:" + href
253 + if href and href not in imgs:
254 + imgs.append(href)
255 + d["images"] = imgs
256 + return d
257 +
258 + # -- inventaire ----------------------------------------------------------------
259 + def fetch(self) -> list[StListing]:
260 + xml = self.get(SITEMAP).text
261 + slugs = sorted({loc[len(PREFIX):].strip("/")
262 + for loc in _LOC_RE.findall(xml)
263 + if loc.startswith(PREFIX) and loc[len(PREFIX):].strip("/")})
264 + slugset = set(slugs)
265 + # feuilles à ≥ 3 niveaux (étab/secteur/unité) = unités réservables
266 + units = [s for s in slugs
267 + if s.count("/") >= 2
268 + and not any(o.startswith(s + "/") for o in slugset if o != s)]
269 + etabs = sorted({s.split("/", 1)[0] for s in units})
270 +
271 + # carte par établissement → géoloc / sous-type / secteur par unité
272 + geo: dict[str, dict] = {}
273 + for etab in etabs:
274 + sig = hashlib.sha1("|".join(
275 + u for u in units if u.startswith(etab + "/")).encode()
276 + ).hexdigest()[:16]
277 + try:
278 + payload = self.detail(f"etab:{etab}", sig,
279 + lambda e=etab: self._fetch_carte(e))
280 + except Exception as exc: # noqa: BLE001 — la géoloc est optionnelle
281 + print(f"[sepaq] carte {etab} : {exc}", file=sys.stderr)
282 + continue
283 + for it in payload.get("items", []):
284 + path = it.get("url") or ""
285 + if "/fr/reservation/chalet/" in path:
286 + path = path.split("/fr/reservation/chalet/", 1)[1].strip("/")
287 + if path:
288 + geo[path] = it
289 +
290 + month = time.strftime("%Y-%m") # re-visite mensuelle (prix/saison)
291 + listings: list[StListing] = []
292 + for slug in sorted(set(units) | set(geo)):
293 + try:
294 + d = self.detail(slug, month, lambda s=slug: self._fetch_unit(s))
295 + except Exception as exc: # noqa: BLE001
296 + print(f"[sepaq] unité {slug} : {exc}", file=sys.stderr)
297 + continue
298 + if not d or not d.get("nom"):
299 + continue
300 + g = geo.get(slug, {})
301 + etab = slug.split("/", 1)[0]
302 +
303 + nom = d.get("nom", "")
304 + soustype = d.get("soustype") or g.get("sous_type") or ""
305 + parc = d.get("parc", "")
306 + if soustype and not nom.lower().startswith(soustype.lower()):
307 + base_title = f"{soustype} {nom}"
308 + else:
309 + base_title = nom
310 + title = f"{base_title} – {parc}" if parc else base_title
311 +
312 + secteur = g.get("secteur", "")
313 + details = {k: v for k, v in {
314 + "parc": parc,
315 + "secteur": secteur,
316 + "sous_type": soustype,
317 + "type_offre": d.get("type_header", ""),
318 + "saison": d.get("saison", ""),
319 + "item_id": d.get("item_id", ""),
320 + "acces": (d.get("sections") or {}).get("Accès") or None,
321 + }.items() if v}
322 +
323 + amenities = (d.get("sections") or {}).get("Description") or []
324 +
325 + lst = StListing(
326 + source=self.source_id,
327 + external_id=slug,
328 + url=PREFIX + slug,
329 + title=title,
330 + property_type=_property_type(soustype, d.get("cat3", ""),
331 + d.get("type_header", "")),
332 + address=", ".join(x for x in (secteur, parc) if x),
333 + city="",
334 + region=REGION_ETAB.get(etab, ""),
335 + price_label=d.get("price_label", ""),
336 + capacity=float(d["capacity"]) if d.get("capacity") else None,
337 + bedrooms=(float(d["bedrooms"])
338 + if d.get("bedrooms") is not None else None),
339 + beds=float(d["beds"]) if d.get("beds") else None,
340 + pets=d.get("pets"),
341 + citq=d.get("citq", ""),
342 + amenities=amenities,
343 + details=details,
344 + images=d.get("images") or [],
345 + lat=g.get("lat"),
346 + lng=g.get("lng"),
347 + )
348 + listings.append(lst)
349 + return listings
added louka/shortterm/connectors/vrbo.py +191 −0
@@ -0,0 +1,191 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/vrbo.py : Vrbo (groupe Expedia) — locations de vacances au Québec.
4 +#
5 +# Méthode : anti-bot Expedia costaud + page 100 % client-side (le SSR ne
6 +# contient qu'un squelette ; __PLUGIN_STATE__/__APOLLO_STATE__ ne portent PAS
7 +# les résultats — vérifié 2026-08-22). On passe donc par Scrapfly ASP avec
8 +# rendu JS + wait_for_selector sur les cartes, puis on parse le DOM des
9 +# cartes `[data-stid="lodging-card-responsive"]`.
10 +#
11 +# Limites assumées : ~18 cartes rendues par destination (liste virtualisée,
12 +# le scroll ne persiste pas plus de cartes dans le snapshot DOM), pas de
13 +# lat/lng ni d'adresse sur les cartes (géocodage aval possible), images
14 +# présentes seulement sur les cartes proches du viewport initial.
15 +# Recherche SANS dates : Vrbo affiche alors un prix « à partir de » par nuit
16 +# sur les prochaines dates disponibles → price_label + price_night plancher.
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import re
21 +import sys
22 +from urllib.parse import quote
23 +
24 +from bs4 import BeautifulSoup
25 +
26 +from ..schema import StListing
27 +from .base import StConnector
28 +
29 +# (destination Vrbo, ville affichée, région touristique QC)
30 +DESTINATIONS = [
31 + ("Mont-Tremblant, Québec, Canada", "Mont-Tremblant", "Laurentides"),
32 + ("Saint-Sauveur, Québec, Canada", "Saint-Sauveur", "Laurentides"),
33 + ("Magog, Québec, Canada", "Magog", "Cantons-de-l'Est"),
34 + ("Bromont, Québec, Canada", "Bromont", "Cantons-de-l'Est"),
35 + ("Baie-Saint-Paul, Québec, Canada", "Baie-Saint-Paul", "Charlevoix"),
36 + ("La Malbaie, Québec, Canada", "La Malbaie", "Charlevoix"),
37 + ("Québec, Québec, Canada", "Québec", "Québec"),
38 + ("Montréal, Québec, Canada", "Montréal", "Montréal"),
39 + ("Percé, Québec, Canada", "Percé", "Gaspésie"),
40 + ("Rimouski, Québec, Canada", "Rimouski", "Bas-Saint-Laurent"),
41 + ("Saguenay, Québec, Canada", "Saguenay", "Saguenay–Lac-Saint-Jean"),
42 + ("Shawinigan, Québec, Canada", "Shawinigan", "Mauricie"),
43 + ("Gatineau, Québec, Canada", "Gatineau", "Outaouais"),
44 +]
45 +
46 +# libellé Vrbo (fr) → type canonique Lou-Ka
47 +TYPE_MAP = {
48 + "appartement": "Appartement",
49 + "condo": "Condo",
50 + "chalet": "Chalet",
51 + "maison": "Maison",
52 + "villa": "Maison",
53 + "studio": "Studio",
54 + "loft": "Loft",
55 + "bungalow": "Maison",
56 + "cottage": "Chalet",
57 + "cabane": "Chalet",
58 + "chambre": "Chambre",
59 + "gîte": "Gîte",
60 + "auberge": "Auberge",
61 + "hébergement": "Autre",
62 +}
63 +
64 +_ID_RE = re.compile(r"/location/p(\d+)")
65 +_TYPELINE_RE = re.compile(
66 + r"^([A-ZÀ-Ý][\w’' -]{2,30})\s*·", re.UNICODE)
67 +_BEDROOMS_RE = re.compile(r"(\d+)\s*chambres?")
68 +_BEDS_RE = re.compile(r"(\d+)\s*(?:grands?\s+|très\s+grands?\s+|petits?\s+)?lits?\b")
69 +_RATING_RE = re.compile(r"([\d,.]+)\s*sur\s*10")
70 +_REVIEWS_RE = re.compile(r"\((\d[\d\s]*)\s*avis\)")
71 +_PRICE_RE = re.compile(r"Le prix actuel est de\s*([\d\s,.]+)\s*\$")
72 +_CAPACITY_RE = re.compile(r"(\d+)\s*(?:voyageurs?|personnes?)")
73 +
74 +
75 +class Vrbo(StConnector):
76 + source_id = "vrbo"
77 + request_delay = 1.0
78 +
79 + # -- parsing d'une carte ----------------------------------------------------
80 + def _parse_card(self, card, city: str, region: str) -> StListing | None:
81 + link = card.select_one('a[data-stid="open-product-information"]') \
82 + or card.select_one('a[href*="/location/"]')
83 + href = (link.get("href") if link else "") or ""
84 + m = _ID_RE.search(href)
85 + if not m:
86 + return None
87 + external_id = m.group(1)
88 + url = f"https://www.vrbo.com/fr-ca/location/p{external_id}"
89 +
90 + title = ""
91 + for h in card.find_all("h3"):
92 + cls = " ".join(h.get("class") or [])
93 + if "is-visually-hidden" not in cls:
94 + title = h.get_text(strip=True)
95 + break
96 + if not title:
97 + return None
98 +
99 + segs = list(card.stripped_strings)
100 + blob = " | ".join(segs)
101 +
102 + property_type, bedrooms, beds, capacity = "", None, None, None
103 + for seg in segs:
104 + tm = _TYPELINE_RE.match(seg)
105 + if tm and ("lit" in seg or "chambre" in seg or "voyageur" in seg):
106 + property_type = TYPE_MAP.get(tm.group(1).strip().lower(), "Autre")
107 + bm = _BEDROOMS_RE.search(seg)
108 + if bm:
109 + bedrooms = float(bm.group(1))
110 + lm = _BEDS_RE.search(seg)
111 + if lm:
112 + beds = float(lm.group(1))
113 + cm = _CAPACITY_RE.search(seg)
114 + if cm:
115 + capacity = float(cm.group(1))
116 + break
117 +
118 + rating = reviews = None
119 + rm = _RATING_RE.search(blob)
120 + if rm:
121 + try:
122 + rating = round(float(rm.group(1).replace(",", ".")) / 2, 2)
123 + except ValueError:
124 + pass
125 + vm = _REVIEWS_RE.search(blob)
126 + if vm:
127 + reviews = int(vm.group(1).replace(" ", ""))
128 +
129 + price_night, price_label = None, ""
130 + pm = _PRICE_RE.search(blob)
131 + if pm:
132 + try:
133 + price_night = float(pm.group(1).replace(" ", "")
134 + .replace(",", "."))
135 + except ValueError:
136 + price_night = None
137 + if price_night:
138 + price_label = (f"à partir de {price_night:.0f} $ / nuit "
139 + "(prochaines dates disponibles)")
140 +
141 + images = []
142 + for img in card.select("img[src]"):
143 + src = img.get("src") or ""
144 + if src.startswith("https://media.vrbo.com/") and src not in images:
145 + images.append(src)
146 + if len(images) >= 5:
147 + break
148 +
149 + return StListing(
150 + source=self.source_id,
151 + external_id=external_id,
152 + url=url,
153 + title=title,
154 + property_type=property_type,
155 + city=city,
156 + region=region,
157 + price_night=price_night,
158 + price_label=price_label,
159 + capacity=capacity,
160 + bedrooms=bedrooms,
161 + beds=beds,
162 + rating=rating,
163 + reviews=reviews,
164 + images=images,
165 + )
166 +
167 + # -- contrat -----------------------------------------------------------------
168 + def fetch(self) -> list[StListing]:
169 + listings: dict[str, StListing] = {}
170 + for dest, city, region in DESTINATIONS:
171 + url = ("https://www.vrbo.com/fr-ca/search?destination="
172 + + quote(dest) + "&adults=2")
173 + try:
174 + html = self.get_scrapfly(
175 + url, render_js=True, asp=True, rendering_wait=3000,
176 + wait_for_selector='[data-stid="lodging-card-responsive"]')
177 + except Exception as exc: # noqa: BLE001
178 + print(f"[vrbo] {city} : {exc}", file=sys.stderr)
179 + continue
180 + if not html:
181 + print(f"[vrbo] {city} : page vide (rendu raté)", file=sys.stderr)
182 + continue
183 + soup = BeautifulSoup(html, "html.parser")
184 + for card in soup.select('[data-stid="lodging-card-responsive"]'):
185 + try:
186 + lst = self._parse_card(card, city, region)
187 + except Exception: # noqa: BLE001
188 + continue
189 + if lst and lst.external_id not in listings:
190 + listings[lst.external_id] = lst
191 + return list(listings.values())
added louka/shortterm/connectors/wechalet.py +193 −0
@@ -0,0 +1,193 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/wechalet.py : WeChalet (wechalet.com)
4 +#
5 +# Plateforme québécoise d'écotourisme (chalets, dômes, mini-maisons…),
6 +# ~3 300 fiches dont ~90 % au Québec. SPA React, mais l'API Laravel interne
7 +# est ouverte (aucun anti-bot) :
8 +# 1. LISTE : POST https://api.wechalet.com/v1/search?page=N&per_page=100
9 +# (body JSON vide) → data[…] + meta.last_page/total. Chaque item :
10 +# prix/nuit, type, capacité, chambres, lits, note/avis, TOUTES les
11 +# photos et location {city, state, lat, lng}. On garde le Québec.
12 +# 2. DÉTAIL (cache self.detail) : GET /v1/listings/<uuid> →
13 +# description fr/en, amenities, space.washrooms_count, house_rules
14 +# (allow-pets…), licence_number = numéro CITQ (souvent vérifié).
15 +# 3. URL publique : https://wechalet.com/fr/proprietes/<uuid>
16 +# (format confirmé par properties_sitemap_fr.xml).
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import json
21 +import re
22 +
23 +from ...normalize import strip_accents
24 +from ..schema import StListing
25 +from .base import StConnector
26 +
27 +API = "https://api.wechalet.com/v1"
28 +SITE = "https://wechalet.com"
29 +
30 +# space_type WeChalet → type canonique Lou-Ka
31 +_SPACE_TYPES = {
32 + "chalet": "Chalet", "cottage": "Chalet", "cabin": "Chalet",
33 + "home": "Maison", "house": "Maison", "townhouse": "Maison",
34 + "farm-stay": "Maison", "villa": "Maison",
35 + "condo": "Condo", "condominium": "Condo",
36 + "apartment": "Appartement", "loft": "Loft", "studio": "Studio",
37 + "guest-suite": "Chambre", "room": "Chambre", "guesthouse": "Gîte",
38 + "bed-and-breakfast": "Gîte",
39 + "dome": "Dôme", "dome-house": "Dôme", "yurt": "Yourte",
40 + "tiny-house": "Mini-maison", "micro-chalet": "Mini-maison",
41 + "camper-rv": "Prêt-à-camper", "tent": "Prêt-à-camper",
42 + "campsite": "Camping", "treehouse": "Autre", "boat": "Autre",
43 +}
44 +
45 +
46 +def _num(v) -> float | None:
47 + try:
48 + return float(v) if v not in (None, "") else None
49 + except (TypeError, ValueError):
50 + return None
51 +
52 +
53 +def _is_quebec(loc: dict) -> bool:
54 + state = strip_accents((loc.get("state") or "")).strip().lower()
55 + country = strip_accents((loc.get("country") or "")).strip().lower()
56 + if state in ("quebec", "qc"):
57 + return True
58 + # état absent mais coordonnées fournies : finalize() borne déjà au Québec —
59 + # on ne garde ici que ce qui est explicitement québécois ou canadien sans
60 + # état contradictoire.
61 + return not state and country in ("canada", "ca")
62 +
63 +
64 +class WeChalet(StConnector):
65 + source_id = "wechalet"
66 + request_delay = 0.3 # API JSON légère (~3 000 fiches au 1er run)
67 +
68 + # -- liste --------------------------------------------------------------
69 + def _search_page(self, page: int) -> dict:
70 + resp = self.post(
71 + f"{API}/search?page={page}&per_page=100",
72 + json={},
73 + headers={"Accept": "application/json",
74 + "Origin": SITE, "Referer": SITE + "/"})
75 + return resp.json()
76 +
77 + def _all_items(self) -> list[dict]:
78 + items: list[dict] = []
79 + page, last = 1, 1
80 + while page <= last:
81 + data = self._search_page(page)
82 + batch = data.get("data") or []
83 + if not batch:
84 + break
85 + items.extend(batch)
86 + last = min((data.get("meta") or {}).get("last_page") or page, 60)
87 + page += 1
88 + return items
89 +
90 + # -- détail (cache BD) ----------------------------------------------------
91 + def _fetch_detail(self, listing_id: str) -> dict:
92 + resp = self.get(f"{API}/listings/{listing_id}",
93 + headers={"Accept": "application/json"})
94 + try:
95 + d = resp.json().get("data") or {}
96 + except ValueError:
97 + return {}
98 + if not d:
99 + return {}
100 + desc = d.get("description") or {}
101 + txt = ""
102 + for lang in ("fr", "en"):
103 + body = (desc.get(lang) or {}).get("description") or ""
104 + if body:
105 + txt = re.sub(r"<[^>]+>", " ", body)
106 + txt = re.sub(r"\s+", " ", txt).strip()
107 + break
108 + space = d.get("space") or {}
109 + rules = [r for r in (d.get("house_rules") or []) if isinstance(r, str)]
110 + licence = str(d.get("licence_number") or "").strip()
111 + return {
112 + "description": txt,
113 + "amenities": [a.replace("-", " ").strip()
114 + for a in (d.get("amenities") or [])
115 + if isinstance(a, str)],
116 + "washrooms": _num(space.get("washrooms_count")),
117 + "citq": licence if re.fullmatch(r"\d{6}", licence) else "",
118 + "licence_status": d.get("licence_number_status") or "",
119 + "pets": ("oui" if "allow-pets" in rules
120 + else ("non" if rules else None)),
121 + "min_stay": (d.get("min_stay") if isinstance(d.get("min_stay"),
122 + (int, float)) else None),
123 + }
124 +
125 + # -- contrat --------------------------------------------------------------
126 + def fetch(self) -> list[StListing]:
127 + listings: list[StListing] = []
128 + for it in self._all_items():
129 + lid = str(it.get("id") or "").strip()
130 + title = (it.get("name") or "").strip()
131 + loc = it.get("location") or {}
132 + if not lid or not title or not _is_quebec(loc):
133 + continue
134 +
135 + images = []
136 + for ph in sorted(it.get("photos") or [],
137 + key=lambda p: (p or {}).get("order") or 0):
138 + u = (ph or {}).get("url")
139 + if u and u not in images:
140 + images.append(u)
141 + if len(images) >= 15:
142 + break
143 + if not images and it.get("main_picture"):
144 + images = [it["main_picture"]]
145 +
146 + # clé de cache détail : sous-ensemble stable (sans avg_price,
147 + # recalculé en continu par la plateforme)
148 + key = json.dumps([lid, title, it.get("space_type"),
149 + it.get("guests_count"), it.get("bedrooms_count"),
150 + it.get("beds_count"), len(images)],
151 + ensure_ascii=False)
152 + try:
153 + det = self.detail(lid, key, lambda i=lid: self._fetch_detail(i))
154 + except Exception: # une fiche détail cassée ≠ annonce perdue
155 + det = {}
156 +
157 + details = {k: v for k, v in {
158 + "space_type": it.get("space_type"),
159 + "rent_type": it.get("rent_type"),
160 + "instant_booking": bool(it.get("instant_booking")) or None,
161 + "licence_status": det.get("licence_status"),
162 + "min_stay": det.get("min_stay"),
163 + }.items() if v not in (None, "", 0)}
164 +
165 + rating = _num(it.get("rating"))
166 + reviews = it.get("reviews")
167 + listings.append(StListing(
168 + source=self.source_id,
169 + external_id=lid,
170 + url=f"{SITE}/fr/proprietes/{lid}",
171 + title=title,
172 + property_type=_SPACE_TYPES.get(
173 + (it.get("space_type") or "").strip().lower(), "Autre")
174 + if it.get("space_type") else "",
175 + city=loc.get("city") or "",
176 + region="", # non exposée par l'API
177 + price_night=_num(it.get("price")),
178 + capacity=_num(it.get("guests_count")),
179 + bedrooms=_num(it.get("bedrooms_count")),
180 + beds=_num(it.get("beds_count")),
181 + bathrooms=det.get("washrooms"),
182 + pets=det.get("pets"),
183 + citq=det.get("citq") or "",
184 + rating=rating if rating else None,
185 + reviews=int(reviews) if reviews else None,
186 + description=det.get("description") or "",
187 + amenities=det.get("amenities") or [],
188 + details=details,
189 + images=images,
190 + lat=_num(loc.get("latitude")),
191 + lng=_num(loc.get("longitude")),
192 + ))
193 + return listings
194