# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/chaleto.py : Chaleto (chaleto.ca) — gestionnaire québécois # (Charlevoix, Capitale-Nationale, Laurentides…), ~440 fiches FR. # # Méthode : WordPress (vitrine) + Guesty (inventaire). Sitemap dédié # /listings-sitemap.xml → URLs /chalets-et-condos-a-louer/-/ # (doublées en /en/ : on garde le FR). Tout est dans le HTML serveur de la # page détail : h1, « Ville, Région », prix « À partir de N$ / nuit », # pictos (voyageurs/chambres/lits/salles de bain), commodités, description # (contenant le no CITQ) et TOUTES les photos Guesty pleine résolution dans # l'attribut data-images de la lightbox. Le lastmod du sitemap est identique # partout : clé de cache mensuelle (refetch complet 1×/mois, ~440 requêtes). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import re import time from ...normalize import strip_accents from ..schema import StListing, parse_price_night from .base import StConnector SITEMAP = "https://chaleto.ca/listings-sitemap.xml" # URL FR : /chalets-et-condos-a-louer/-/ _URL_FR = re.compile( r"https://(?:www\.)?chaleto\.ca/chalets-et-condos-a-louer/" r"[\w-]+-([0-9a-f]{24})/?$") _TAG_RE = re.compile(r"<[^>]+>") # le site affiche la région ADMINISTRATIVE (« Capitale-Nationale », # « Gaspésie--Îles-de-la-Madeleine ») → région touristique canonique _REGION_FIX = { "capitale-nationale": "Québec", "estrie": "Cantons-de-l'Est", "gaspesie-iles-de-la-madeleine": "Gaspésie", "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean", } # municipalités de la Capitale-Nationale qui relèvent touristiquement # de Charlevoix _VILLES_CHARLEVOIX = { "baie-saint-paul", "petite-riviere-saint-francois", "la-malbaie", "les-eboulements", "saint-urbain", "saint-irenee", "saint-hilarion", "isle-aux-coudres", "l'isle-aux-coudres", "notre-dame-des-monts", "saint-aime-des-lacs", "clermont", "saint-simeon", "baie-sainte-catherine", } # mot-clé du titre → type canonique Lou-Ka _TYPE_HINTS = [ ("condo", "Condo"), ("loft", "Loft"), ("studio", "Studio"), ("appartement", "Appartement"), ("maison", "Maison"), ("mini-maison", "Mini-maison"), ("chalet", "Chalet"), ] def _text(fragment: str) -> str: return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip() class Chaleto(StConnector): source_id = "chaleto" # -- inventaire (sitemap) ---------------------------------------------- def _sitemap_urls(self) -> dict[str, str]: """id Guesty -> URL détail FR.""" xml = self.get(SITEMAP).text urls: dict[str, str] = {} for loc in re.findall(r"([^<]+)", xml): m = _URL_FR.match(loc.strip()) if m: urls.setdefault(m.group(1), loc.strip()) return urls # -- page détail --------------------------------------------------------- def _detail(self, url: str) -> dict: h = self.get(url).text d: dict = {} m = re.search(r"]*>(.*?)", h, re.S) if m: d["title"] = _text(m.group(1)) # « Baie-Saint-Paul, Capitale-Nationale » sous le titre m = re.search(r'

([^<]+)

', h) if m: parts = [p.strip() for p in _text(m.group(1)).split(",")] if parts: d["city"] = parts[0] if len(parts) > 1: cle = re.sub(r"-{2,}", "-", strip_accents(parts[-1]).lower().replace(" ", "-")) region = _REGION_FIX.get(cle, parts[-1]) ville = strip_accents(d.get("city", "")).lower().replace(" ", "-") if region == "Québec" and ville in _VILLES_CHARLEVOIX: region = "Charlevoix" d["region"] = region # « À partir de 200$ / nuit » m = re.search(r"À partir de\s*]*>\s*" r"([^<]+)\s*/\s*nuit", h) if m: d["price_label"] = f"à partir de {_text(m.group(1))} / nuit" # pictos : « 5 voyageurs », « 3 chambres », « 3 lits », « 2 salles de bain » for val, label in re.findall( r"

\s*([\d.,]+)\s+(voyageurs?|chambres?|lits?|" r"salles? de bain)\s*

", h): n = float(val.replace(",", ".")) lab = label.lower() if lab.startswith("voyageur"): d["capacity"] = n elif lab.startswith("chambre"): d["bedrooms"] = n elif lab.startswith("lit"): d["beds"] = n else: d["bathrooms"] = n # commodités : spans du bloc « Commodités » (grille + accordéon) i = h.find("Commodités") if i >= 0: j = h.find(" 0 else i + 20000] amen = [] for a in re.findall(r'([^<]+)', bloc): a = _text(a) if a and a not in amen: amen.append(a) d["amenities"] = amen # description (1er paragraphe long — contient « CITQ : NNNNNN | Exp: … ») m = re.search(r'

(.*?)

', h, re.S) if m: txt = _html.unescape(re.sub(r"", "\n", m.group(1))) txt = _TAG_RE.sub(" ", txt) txt = re.sub(r"[ \t]+", " ", txt).strip() d["description"] = txt[:5000] m2 = re.search(r"CITQ\s*:?\s*(\d{6})", txt) if m2: d["citq"] = m2.group(1) # photos Guesty pleine résolution (lightbox data-images, JSON échappé) m = re.search(r'data-images="([^"]+)"', h) if m: try: imgs = json.loads(_html.unescape(m.group(1))) except ValueError: imgs = [] d["images"] = [u for u in imgs if isinstance(u, str)][:20] return d # -- contrat -------------------------------------------------------------- def fetch(self) -> list[StListing]: cle = "detail-" + time.strftime("%Y-%m") # lastmod uniforme → mensuel listings: list[StListing] = [] for eid, url in self._sitemap_urls().items(): try: d = self.detail(eid, cle, lambda u=url: self._detail(u)) except Exception: # une fiche cassée ≠ inventaire perdu d = {} title = d.get("title") or "" if not title: continue ptype = "" hay = strip_accents(title).lower() for needle, canon in _TYPE_HINTS: if needle in hay: ptype = canon break listings.append(StListing( source=self.source_id, external_id=eid, # id Guesty, stable url=url, title=title, property_type=ptype or "Chalet", city=d.get("city", ""), region=d.get("region", ""), price_night=parse_price_night(d.get("price_label", "")), price_label=d.get("price_label", ""), capacity=d.get("capacity"), bedrooms=d.get("bedrooms"), beds=d.get("beds"), bathrooms=d.get("bathrooms"), citq=d.get("citq", ""), description=d.get("description", ""), amenities=d.get("amenities") or [], images=d.get("images") or [], )) return listings