Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/chaletsalpins.py : Les Chalets Alpins (chaletsalpins.ca)4# — gestionnaire de Stoneham, ~180 chalets (Stoneham, Lac-Beauport,5# Charlevoix, Laurentides).6#7# Méthode : WordPress. Sitemap /chalets-sitemap.xml (lastmod fiable, doublons8# /en/ écartés) → pages /hebergement/<slug>/ (slug = adresse + no CITQ).9# La page détail porte un JSON-LD schema.org Hotel (nom, description,10# addressLocality, petsAllowed, amenityFeature FR) ; les compteurs vivent11# dans des <span class="capacity|bedrooms|beds|restrooms">…N</span> de la12# barre d'entête, et le prix dans l'encadré latéral13# « <h3>2 nuits à partir de (printemps) <span>1 025.00 $</span></h3> »14# (minimum 2 nuits → prix ramené à la nuit). Photos = uploads du carrousel15# (vignettes -150x150 écartées). Pas de géo dans le HTML.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import html as _html20import json21import re2223from ...normalize import strip_accents24from ..schema import StListing25from .base import StConnector2627SITEMAP = "https://chaletsalpins.ca/chalets-sitemap.xml"2829_URL_DETAIL = re.compile(30 r"https://(?:www\.)?chaletsalpins\.ca/hebergement/([\w-]+)/?$")3132# localités desservies → région touristique canonique33_VILLE_REGION = {34 "stoneham": "Québec",35 "stoneham-et-tewkesbury": "Québec",36 "lac-beauport": "Québec",37 "quebec": "Québec",38 "petite-riviere-saint-francois": "Charlevoix",39 "baie-saint-paul": "Charlevoix",40 "la-malbaie": "Charlevoix",41 "les-eboulements": "Charlevoix",42 "saint-sauveur": "Laurentides",43 "sainte-adele": "Laurentides",44 "mont-tremblant": "Laurentides",45}4647_STAT_RE = re.compile(48 r'<span class="(capacity|bedrooms|beds|restrooms)">'49 r"(?:(?!</span>).)*?([\d.,]+)\s*</span>", re.S)5051_PRIX_RE = re.compile(52 r"<h3>\s*(\d+)\s*nuits?\s*à partir de[^<]*<br\s*/?>\s*"53 r"<span>\s*([\d\s,. ]+)\s*\$\s*</span>", re.S)545556def _montant(raw: str) -> float | None:57 try:58 return float(re.sub(r"[\s ]", "", raw).replace(",", "."))59 except ValueError:60 return None616263class ChaletsAlpins(StConnector):64 source_id = "chaletsalpins"6566 # -- inventaire (sitemap FR + lastmod) -----------------------------------67 def _sitemap_urls(self) -> dict[str, tuple[str, str]]:68 """slug -> (url détail FR, lastmod)."""69 xml = self.get(SITEMAP).text70 urls: dict[str, tuple[str, str]] = {}71 for bloc in re.findall(r"<url>(.*?)</url>", xml, re.S):72 m = re.search(r"<loc>([^<]+)</loc>", bloc)73 if not m:74 continue75 loc = m.group(1).strip()76 mu = _URL_DETAIL.match(loc)77 if not mu or mu.group(1) in ("hebergement",):78 continue79 lastmod = re.search(r"<lastmod>([^<]+)</lastmod>", bloc)80 urls.setdefault(mu.group(1),81 (loc, lastmod.group(1) if lastmod else ""))82 return urls8384 # -- page détail ---------------------------------------------------------85 def _detail(self, url: str) -> dict:86 # ⚠️ le serveur ajoute parfois APRÈS </html> un second rendu avec des87 # chalets suggérés (autres compteurs/photos) : on tronque au 1er </html>88 h = self.get(url).text.split("</html>", 1)[0]89 d: dict = {}9091 # JSON-LD Hotel : nom, description, adresse, animaux, commodités92 for m in re.finditer(r'<script[^>]*application/ld\+json[^>]*>(.*?)'93 r"</script>", h, re.S):94 try:95 data = json.loads(m.group(1), strict=False)96 except ValueError:97 continue98 if not (isinstance(data, dict) and data.get("@type") == "Hotel"):99 continue100 d["title"] = (data.get("name") or "").strip()101 d["description"] = re.sub(102 r"\s+", " ", (data.get("description") or "")).strip()[:5000]103 addr = data.get("address") or {}104 d["city"] = (addr.get("addressLocality") or "").strip()105 if data.get("petsAllowed") is not None:106 d["pets"] = "oui" if str(data["petsAllowed"]) in (107 "True", "true", "1") else "non"108 amen = []109 for feat in data.get("amenityFeature") or []:110 nom = (feat.get("name") or "").strip() \111 if isinstance(feat, dict) else ""112 if nom and nom not in amen:113 amen.append(nom)114 if amen:115 d["amenities"] = amen116 break117118 # compteurs de l'entête (capacité, chambres, lits, salles de bain) —119 # 1re occurrence seulement (le chalet courant précède toute suggestion)120 for cls, val in _STAT_RE.findall(h):121 n = _montant(val)122 if n is None:123 continue124 d.setdefault({"capacity": "capacity", "bedrooms": "bedrooms",125 "beds": "beds", "restrooms": "bathrooms"}[cls], n)126127 # encadré latéral : « 2 nuits à partir de (printemps) 1 025.00 $ »128 # → prix / nuit ; certaines unités affichent « Location mensuelle »129 # (long terme : pas de prix à la nuit, mention conservée)130 m = _PRIX_RE.search(h)131 if m:132 nuits = int(m.group(1)) or 1133 montant = _montant(m.group(2))134 if montant:135 d["price_night"] = round(montant / nuits, 2)136 d["price_label"] = re.sub(137 r"\s+", " ", _html.unescape(138 re.sub(r"<[^>]+>", " ", m.group(0)))).strip()139 elif re.search(r'sidebar scrollbox">\s*<div class="top">\s*'140 r"<h3>\s*Location mensuelle", h):141 d["location_mensuelle"] = True142143 # no CITQ (dans le nom/slug « …(CITQ282170) » ou la description)144 m = re.search(r"CITQ\)?\s*:?\s*#?\s*(\d{6})",145 d.get("title", "") + " " + d.get("description", ""))146 if m:147 d["citq"] = m.group(1)148149 # photos du carrousel (vignettes et icônes écartées, dédoublonnage150 # sur le nom de base sans suffixe de taille -WxH)151 imgs, vus = [], set()152 for u in re.findall(r'(https://(?:www\.)?chaletsalpins\.ca/'153 r'wp-content/uploads/[^"\'\s>]+'154 r"\.(?:jpe?g|png|webp))", h):155 base = re.sub(r"-\d+x\d+(?=\.\w+$)", "", u)156 if base in vus or "-150x150" in u:157 continue158 vus.add(base)159 imgs.append(u)160 d["images"] = imgs[:20]161 return d162163 # -- contrat --------------------------------------------------------------164 def fetch(self) -> list[StListing]:165 listings: list[StListing] = []166 for slug, (url, lastmod) in self._sitemap_urls().items():167 try:168 d = self.detail(slug, lastmod or "sans-lastmod",169 lambda u=url: self._detail(u))170 except Exception: # une fiche cassée ≠ inventaire perdu171 d = {}172 if not d.get("title"):173 continue174 ville = d.get("city", "")175 region = _VILLE_REGION.get(176 strip_accents(ville).lower().replace(" ", "-"), "")177 details = ({"location_mensuelle": True}178 if d.get("location_mensuelle") else {})179 listings.append(StListing(180 source=self.source_id,181 external_id=slug, # slug WP, stable182 url=url,183 title=d["title"],184 property_type="Chalet",185 city=ville,186 region=region,187 price_night=d.get("price_night"),188 price_label=d.get("price_label", ""),189 capacity=d.get("capacity"),190 bedrooms=d.get("bedrooms"),191 beds=d.get("beds"),192 bathrooms=d.get("bathrooms"),193 pets=d.get("pets"),194 citq=d.get("citq", ""),195 description=d.get("description", ""),196 amenities=d.get("amenities") or [],197 details=details,198 images=d.get("images") or [],199 ))200 return listings201