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>
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¤cy=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¤cy=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(?: |\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 | ||