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/chaletsalouer.py : ChaletsÀLouer.com — location de chalets4# 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=N7# (~40 cartes/page, HTML statique). Chaque carte donne l'id stable8# (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éolocalisation11# (microdonnées schema.org/Place), description, type, no CITQ, salles de12# bain, lits et commodités. Le site liste aussi quelques chalets hors13# Québec (Nouveau-Brunswick, côte est américaine) : exclus.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import json19import re20from urllib.parse import urljoin, urlparse2122from bs4 import BeautifulSoup2324from ..schema import StListing25from .base import StConnector2627BASE = "https://www.chaletsalouer.com"28LISTE = BASE + "/fr/location-de-chalet/"2930# 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")3435_MONTANT = re.compile(r"(\d[\d\s ]*(?:[.,]\d{2})?)\s*\$")363738def _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 None42 m = _MONTANT.search(label)43 if not m:44 return None45 brut = re.sub(r"[\s ]", "", m.group(1)).replace(",", ".")46 try:47 val = float(brut)48 except ValueError:49 return None50 lab = label.lower()51 if "mois" in lab: # location au mois : pas un prix à la nuit52 return None53 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 val596061def _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 ""656667def _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 canonique777879class ChaletsALouer(StConnector):80 source_id = "chaletsalouer"8182 def fetch(self) -> list[StListing]:83 listings: list[StListing] = []84 vus: set[str] = set()85 page, max_page = 1, 186 while page <= max_page:87 html = self.get(LISTE, params={"page": page}).text88 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 break96 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 += 1102103 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 continue115 lst.property_type = d.get("property_type") or lst.property_type116 lst.city = d.get("city") or lst.city117 # la région de la carte (slug d'URL, canonique) prime sur celle du118 # 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 listings137138 # -- 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 None144 eid = caro.get("data-noetablissement", "").strip()145 url = urljoin(BASE, titre.get("href", ""))146 if not eid or not url:147 return None148 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)151152 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 = None157 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))165166 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))170171 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 pass181182 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 )196197 # -- page détail (via cache self.detail) -----------------------------------198 def _detail(self, url: str) -> dict:199 html = self.get(url).text200 soup = BeautifulSoup(html, "html.parser")201 d: dict = {"details": {}}202203 # microdonnées schema.org/Place (bloc caché) : géo + ville + région204 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 pass213 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)219220 # bandeau info : capacité / chambres / salles de bain / salles d'eau221 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))235236 # lits (« 3 lits doubles », « 2 divans-lits »…)237 lits = 0238 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)244245 # Résumé : « Annonce Or-9184, Chalet | … Numéro d’enregistrement : NNNNNN »246 # puis paragraphes de description247 ancre = soup.select_one('a[name="resume"]')248 boite = ancre.find_next(class_="hide-box") if ancre is not None else None249 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]261262 # commodités (liens de caractéristiques) + dimensions263 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"] = amen270271 # 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 d282