# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/chaletsalpins.py : Les Chalets Alpins (chaletsalpins.ca) # — gestionnaire de Stoneham, ~180 chalets (Stoneham, Lac-Beauport, # Charlevoix, Laurentides). # # Méthode : WordPress. Sitemap /chalets-sitemap.xml (lastmod fiable, doublons # /en/ écartés) → pages /hebergement// (slug = adresse + no CITQ). # La page détail porte un JSON-LD schema.org Hotel (nom, description, # addressLocality, petsAllowed, amenityFeature FR) ; les compteurs vivent # dans des …N de la # barre d'entête, et le prix dans l'encadré latéral # «

2 nuits à partir de (printemps) 1 025.00 $

» # (minimum 2 nuits → prix ramené à la nuit). Photos = uploads du carrousel # (vignettes -150x150 écartées). Pas de géo dans le HTML. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import re from ...normalize import strip_accents from ..schema import StListing from .base import StConnector SITEMAP = "https://chaletsalpins.ca/chalets-sitemap.xml" _URL_DETAIL = re.compile( r"https://(?:www\.)?chaletsalpins\.ca/hebergement/([\w-]+)/?$") # localités desservies → région touristique canonique _VILLE_REGION = { "stoneham": "Québec", "stoneham-et-tewkesbury": "Québec", "lac-beauport": "Québec", "quebec": "Québec", "petite-riviere-saint-francois": "Charlevoix", "baie-saint-paul": "Charlevoix", "la-malbaie": "Charlevoix", "les-eboulements": "Charlevoix", "saint-sauveur": "Laurentides", "sainte-adele": "Laurentides", "mont-tremblant": "Laurentides", } _STAT_RE = re.compile( r'' r"(?:(?!).)*?([\d.,]+)\s*", re.S) _PRIX_RE = re.compile( r"

\s*(\d+)\s*nuits?\s*à partir de[^<]*\s*" r"\s*([\d\s,. ]+)\s*\$\s*", re.S) def _montant(raw: str) -> float | None: try: return float(re.sub(r"[\s ]", "", raw).replace(",", ".")) except ValueError: return None class ChaletsAlpins(StConnector): source_id = "chaletsalpins" # -- inventaire (sitemap FR + lastmod) ----------------------------------- def _sitemap_urls(self) -> dict[str, tuple[str, str]]: """slug -> (url détail FR, lastmod).""" xml = self.get(SITEMAP).text urls: dict[str, tuple[str, str]] = {} for bloc in re.findall(r"(.*?)", xml, re.S): m = re.search(r"([^<]+)", bloc) if not m: continue loc = m.group(1).strip() mu = _URL_DETAIL.match(loc) if not mu or mu.group(1) in ("hebergement",): continue lastmod = re.search(r"([^<]+)", bloc) urls.setdefault(mu.group(1), (loc, lastmod.group(1) if lastmod else "")) return urls # -- page détail --------------------------------------------------------- def _detail(self, url: str) -> dict: # ⚠️ le serveur ajoute parfois APRÈS un second rendu avec des # chalets suggérés (autres compteurs/photos) : on tronque au 1er h = self.get(url).text.split("", 1)[0] d: dict = {} # JSON-LD Hotel : nom, description, adresse, animaux, commodités for m in re.finditer(r']*application/ld\+json[^>]*>(.*?)' r"", h, re.S): try: data = json.loads(m.group(1), strict=False) except ValueError: continue if not (isinstance(data, dict) and data.get("@type") == "Hotel"): continue d["title"] = (data.get("name") or "").strip() d["description"] = re.sub( r"\s+", " ", (data.get("description") or "")).strip()[:5000] addr = data.get("address") or {} d["city"] = (addr.get("addressLocality") or "").strip() if data.get("petsAllowed") is not None: d["pets"] = "oui" if str(data["petsAllowed"]) in ( "True", "true", "1") else "non" amen = [] for feat in data.get("amenityFeature") or []: nom = (feat.get("name") or "").strip() \ if isinstance(feat, dict) else "" if nom and nom not in amen: amen.append(nom) if amen: d["amenities"] = amen break # compteurs de l'entête (capacité, chambres, lits, salles de bain) — # 1re occurrence seulement (le chalet courant précède toute suggestion) for cls, val in _STAT_RE.findall(h): n = _montant(val) if n is None: continue d.setdefault({"capacity": "capacity", "bedrooms": "bedrooms", "beds": "beds", "restrooms": "bathrooms"}[cls], n) # encadré latéral : « 2 nuits à partir de (printemps) 1 025.00 $ » # → prix / nuit ; certaines unités affichent « Location mensuelle » # (long terme : pas de prix à la nuit, mention conservée) m = _PRIX_RE.search(h) if m: nuits = int(m.group(1)) or 1 montant = _montant(m.group(2)) if montant: d["price_night"] = round(montant / nuits, 2) d["price_label"] = re.sub( r"\s+", " ", _html.unescape( re.sub(r"<[^>]+>", " ", m.group(0)))).strip() elif re.search(r'sidebar scrollbox">\s*
\s*' r"

\s*Location mensuelle", h): d["location_mensuelle"] = True # no CITQ (dans le nom/slug « …(CITQ282170) » ou la description) m = re.search(r"CITQ\)?\s*:?\s*#?\s*(\d{6})", d.get("title", "") + " " + d.get("description", "")) if m: d["citq"] = m.group(1) # photos du carrousel (vignettes et icônes écartées, dédoublonnage # sur le nom de base sans suffixe de taille -WxH) imgs, vus = [], set() for u in re.findall(r'(https://(?:www\.)?chaletsalpins\.ca/' r'wp-content/uploads/[^"\'\s>]+' r"\.(?:jpe?g|png|webp))", h): base = re.sub(r"-\d+x\d+(?=\.\w+$)", "", u) if base in vus or "-150x150" in u: continue vus.add(base) imgs.append(u) d["images"] = imgs[:20] return d # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: listings: list[StListing] = [] for slug, (url, lastmod) in self._sitemap_urls().items(): try: d = self.detail(slug, lastmod or "sans-lastmod", lambda u=url: self._detail(u)) except Exception: # une fiche cassée ≠ inventaire perdu d = {} if not d.get("title"): continue ville = d.get("city", "") region = _VILLE_REGION.get( strip_accents(ville).lower().replace(" ", "-"), "") details = ({"location_mensuelle": True} if d.get("location_mensuelle") else {}) listings.append(StListing( source=self.source_id, external_id=slug, # slug WP, stable url=url, title=d["title"], property_type="Chalet", city=ville, region=region, price_night=d.get("price_night"), price_label=d.get("price_label", ""), capacity=d.get("capacity"), bedrooms=d.get("bedrooms"), beds=d.get("beds"), bathrooms=d.get("bathrooms"), pets=d.get("pets"), citq=d.get("citq", ""), description=d.get("description", ""), amenities=d.get("amenities") or [], details=details, images=d.get("images") or [], )) return listings