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/chaleto.py : Chaleto (chaleto.ca) — gestionnaire québécois4# (Charlevoix, Capitale-Nationale, Laurentides…), ~440 fiches FR.5#6# Méthode : WordPress (vitrine) + Guesty (inventaire). Sitemap dédié7# /listings-sitemap.xml → URLs /chalets-et-condos-a-louer/<slug>-<guestyId>/8# (doublées en /en/ : on garde le FR). Tout est dans le HTML serveur de la9# page détail : h1, « Ville, Région », prix « À partir de N$ / nuit »,10# pictos (voyageurs/chambres/lits/salles de bain), commodités, description11# (contenant le no CITQ) et TOUTES les photos Guesty pleine résolution dans12# l'attribut data-images de la lightbox. Le lastmod du sitemap est identique13# partout : clé de cache mensuelle (refetch complet 1×/mois, ~440 requêtes).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as _html18import json19import re20import time2122from ...normalize import strip_accents23from ..schema import StListing, parse_price_night24from .base import StConnector2526SITEMAP = "https://chaleto.ca/listings-sitemap.xml"2728# URL FR : /chalets-et-condos-a-louer/<slug>-<id Guesty 24 hex>/29_URL_FR = re.compile(30 r"https://(?:www\.)?chaleto\.ca/chalets-et-condos-a-louer/"31 r"[\w-]+-([0-9a-f]{24})/?$")3233_TAG_RE = re.compile(r"<[^>]+>")3435# le site affiche la région ADMINISTRATIVE (« Capitale-Nationale »,36# « Gaspésie--Îles-de-la-Madeleine ») → région touristique canonique37_REGION_FIX = {38 "capitale-nationale": "Québec",39 "estrie": "Cantons-de-l'Est",40 "gaspesie-iles-de-la-madeleine": "Gaspésie",41 "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean",42}4344# municipalités de la Capitale-Nationale qui relèvent touristiquement45# de Charlevoix46_VILLES_CHARLEVOIX = {47 "baie-saint-paul", "petite-riviere-saint-francois", "la-malbaie",48 "les-eboulements", "saint-urbain", "saint-irenee", "saint-hilarion",49 "isle-aux-coudres", "l'isle-aux-coudres", "notre-dame-des-monts",50 "saint-aime-des-lacs", "clermont", "saint-simeon",51 "baie-sainte-catherine",52}5354# mot-clé du titre → type canonique Lou-Ka55_TYPE_HINTS = [56 ("condo", "Condo"), ("loft", "Loft"), ("studio", "Studio"),57 ("appartement", "Appartement"), ("maison", "Maison"),58 ("mini-maison", "Mini-maison"), ("chalet", "Chalet"),59]606162def _text(fragment: str) -> str:63 return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()646566class Chaleto(StConnector):67 source_id = "chaleto"6869 # -- inventaire (sitemap) ----------------------------------------------70 def _sitemap_urls(self) -> dict[str, str]:71 """id Guesty -> URL détail FR."""72 xml = self.get(SITEMAP).text73 urls: dict[str, str] = {}74 for loc in re.findall(r"<loc>([^<]+)</loc>", xml):75 m = _URL_FR.match(loc.strip())76 if m:77 urls.setdefault(m.group(1), loc.strip())78 return urls7980 # -- page détail ---------------------------------------------------------81 def _detail(self, url: str) -> dict:82 h = self.get(url).text83 d: dict = {}8485 m = re.search(r"<h1[^>]*>(.*?)</h1>", h, re.S)86 if m:87 d["title"] = _text(m.group(1))8889 # « Baie-Saint-Paul, Capitale-Nationale » sous le titre90 m = re.search(r'<p class="text-lg">([^<]+)</p>', h)91 if m:92 parts = [p.strip() for p in _text(m.group(1)).split(",")]93 if parts:94 d["city"] = parts[0]95 if len(parts) > 1:96 cle = re.sub(r"-{2,}", "-",97 strip_accents(parts[-1]).lower().replace(" ", "-"))98 region = _REGION_FIX.get(cle, parts[-1])99 ville = strip_accents(d.get("city", "")).lower().replace(" ", "-")100 if region == "Québec" and ville in _VILLES_CHARLEVOIX:101 region = "Charlevoix"102 d["region"] = region103104 # « À partir de <strong>200$</strong> / nuit »105 m = re.search(r"À partir de</span>\s*<span[^>]*>\s*"106 r"<strong>([^<]+)</strong>\s*/\s*nuit", h)107 if m:108 d["price_label"] = f"à partir de {_text(m.group(1))} / nuit"109110 # pictos : « 5 voyageurs », « 3 chambres », « 3 lits », « 2 salles de bain »111 for val, label in re.findall(112 r"<p>\s*([\d.,]+)\s+(voyageurs?|chambres?|lits?|"113 r"salles? de bain)\s*</p>", h):114 n = float(val.replace(",", "."))115 lab = label.lower()116 if lab.startswith("voyageur"):117 d["capacity"] = n118 elif lab.startswith("chambre"):119 d["bedrooms"] = n120 elif lab.startswith("lit"):121 d["beds"] = n122 else:123 d["bathrooms"] = n124125 # commodités : spans du bloc « Commodités » (grille + accordéon)126 i = h.find("Commodités</h2>")127 if i >= 0:128 j = h.find("<h2", i + 10)129 bloc = h[i:j if j > 0 else i + 20000]130 amen = []131 for a in re.findall(r'<span class="text-white">([^<]+)</span>', bloc):132 a = _text(a)133 if a and a not in amen:134 amen.append(a)135 d["amenities"] = amen136137 # description (1er paragraphe long — contient « CITQ : NNNNNN | Exp: … »)138 m = re.search(r'<p class="max-w-\[50rem\]">(.*?)</p>', h, re.S)139 if m:140 txt = _html.unescape(re.sub(r"<br\s*/?>", "\n",141 m.group(1)))142 txt = _TAG_RE.sub(" ", txt)143 txt = re.sub(r"[ \t]+", " ", txt).strip()144 d["description"] = txt[:5000]145 m2 = re.search(r"CITQ\s*:?\s*(\d{6})", txt)146 if m2:147 d["citq"] = m2.group(1)148149 # photos Guesty pleine résolution (lightbox data-images, JSON échappé)150 m = re.search(r'data-images="([^"]+)"', h)151 if m:152 try:153 imgs = json.loads(_html.unescape(m.group(1)))154 except ValueError:155 imgs = []156 d["images"] = [u for u in imgs if isinstance(u, str)][:20]157 return d158159 # -- contrat --------------------------------------------------------------160 def fetch(self) -> list[StListing]:161 cle = "detail-" + time.strftime("%Y-%m") # lastmod uniforme → mensuel162 listings: list[StListing] = []163 for eid, url in self._sitemap_urls().items():164 try:165 d = self.detail(eid, cle, lambda u=url: self._detail(u))166 except Exception: # une fiche cassée ≠ inventaire perdu167 d = {}168 title = d.get("title") or ""169 if not title:170 continue171172 ptype = ""173 hay = strip_accents(title).lower()174 for needle, canon in _TYPE_HINTS:175 if needle in hay:176 ptype = canon177 break178179 listings.append(StListing(180 source=self.source_id,181 external_id=eid, # id Guesty, stable182 url=url,183 title=title,184 property_type=ptype or "Chalet",185 city=d.get("city", ""),186 region=d.get("region", ""),187 price_night=parse_price_night(d.get("price_label", "")),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 citq=d.get("citq", ""),194 description=d.get("description", ""),195 amenities=d.get("amenities") or [],196 images=d.get("images") or [],197 ))198 return listings199