SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

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 (Aug 23, 2026) parent 38b708b Browse files

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
Large diff (291 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/connectors/rsvpchalets.py +190 −0
Large diff (190 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/connectors/sepaq.py +349 −0
Large diff (349 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/connectors/vrbo.py +191 −0
Large diff (191 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/connectors/wechalet.py +193 −0
Large diff (194 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme