# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/chaletsalouer.py : ChaletsÀLouer.com — location de chalets # directement du propriétaire, sans commission (~3 300 chalets au Québec). # # Méthode : pagination de la liste globale /fr/location-de-chalet/?page=N # (~40 cartes/page, HTML statique). Chaque carte donne l'id stable # (data-noetablissement), le titre, la ville, la capacité, les chambres, # le prix (« À partir de 260 $ / 2 nuits ») et les photos (data-slider). # La page détail (via self.detail, cache BD) ajoute géolocalisation # (microdonnées schema.org/Place), description, type, no CITQ, salles de # bain, lits et commodités. Le site liste aussi quelques chalets hors # Québec (Nouveau-Brunswick, côte est américaine) : exclus. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from urllib.parse import urljoin, urlparse from bs4 import BeautifulSoup from ..schema import StListing from .base import StConnector BASE = "https://www.chaletsalouer.com" LISTE = BASE + "/fr/location-de-chalet/" # Slugs de « régions » hors Québec présents sur le site (Nouveau-Brunswick, # côte atlantique américaine…) — hors mandat Lou-Ka. _HORS_QC = ("rivage-acadien", "riviere-miramichi", "region-du-sud-est", "cote-atlantique", "madawaska", "nouveau-brunswick") _MONTANT = re.compile(r"(\d[\d\s  ]*(?:[.,]\d{2})?)\s*\$") def _prix_nuit(label: str) -> float | None: """« À partir de 260 $ / 2 nuits » → 130.0 ; « 1 150,00 $ / sem » → /7.""" if not label: return None m = _MONTANT.search(label) if not m: return None brut = re.sub(r"[\s  ]", "", m.group(1)).replace(",", ".") try: val = float(brut) except ValueError: return None lab = label.lower() if "mois" in lab: # location au mois : pas un prix à la nuit return None m = re.search(r"(\d+)\s*nuit", lab) if m and int(m.group(1)) > 1: return round(val / int(m.group(1)), 2) if "sem" in lab: return round(val / 7, 2) return val def _region_slug(url: str) -> str: """/fr/location-de-chalet//// → slug de région.""" parts = [p for p in urlparse(url).path.split("/") if p] return parts[2] if len(parts) >= 3 else "" def _region_canonique(slug: str) -> str: if slug.startswith("lanaudiere"): return "Lanaudière" if slug.startswith("cantons-de-l-est"): return "Cantons-de-l'Est" if slug.startswith("laurentides"): return "Laurentides" if slug.startswith("region-de-quebec"): return "Québec" return slug # normalize_region (finalize) ramène le reste au canonique class ChaletsALouer(StConnector): source_id = "chaletsalouer" def fetch(self) -> list[StListing]: listings: list[StListing] = [] vus: set[str] = set() page, max_page = 1, 1 while page <= max_page: html = self.get(LISTE, params={"page": page}).text soup = BeautifulSoup(html, "html.parser") for a in soup.select("a[href*='?page=']"): m = re.search(r"\?page=(\d+)", a.get("href", "")) if m: max_page = max(max_page, int(m.group(1))) cartes = soup.select("div.item-etablissement") if not cartes: break for carte in cartes: lst = self._carte(carte) if lst is not None and lst.external_id not in vus: vus.add(lst.external_id) listings.append(lst) page += 1 for lst in listings: cle = hashlib.sha1("|".join([ lst.title, lst.city, lst.price_label, str(lst.capacity), str(lst.bedrooms), ]).encode("utf-8")).hexdigest() try: d = self.detail(lst.external_id, cle, lambda u=lst.url: self._detail(u)) except Exception: d = {} if not d: continue lst.property_type = d.get("property_type") or lst.property_type lst.city = d.get("city") or lst.city # la région de la carte (slug d'URL, canonique) prime sur celle du # détail (« Lanaudière - Le Piémont » = sous-région non canonique) lst.region = lst.region or d.get("region", "") lst.lat = d.get("lat") lst.lng = d.get("lng") lst.description = d.get("description") or "" lst.citq = d.get("citq") or "" lst.amenities = d.get("amenities") or [] if d.get("bathrooms") is not None: lst.bathrooms = d["bathrooms"] if d.get("beds") is not None: lst.beds = d["beds"] if d.get("capacity") is not None: lst.capacity = d["capacity"] if d.get("bedrooms") is not None: lst.bedrooms = d["bedrooms"] if d.get("images"): lst.images = d["images"] lst.details.update(d.get("details") or {}) return listings # -- carte de la liste ---------------------------------------------------- def _carte(self, carte) -> StListing | None: caro = carte.select_one("[data-noetablissement]") titre = carte.select_one(".content a.title") if caro is None or titre is None: return None eid = caro.get("data-noetablissement", "").strip() url = urljoin(BASE, titre.get("href", "")) if not eid or not url: return None slug = _region_slug(url) if any(h in slug for h in _HORS_QC): return None # hors Québec (N.-B., côte est américaine) ville = "" loc = carte.select_one(".content .location a") if loc is not None: ville = loc.get_text(strip=True) capacite = chambres = None for span in carte.select(".content .location .nobreak"): txt = span.get_text(" ", strip=True) m = re.search(r"(\d+)\s*invité", txt) if m: capacite = float(m.group(1)) m = re.search(r"(\d+)\s*chambre", txt) if m: chambres = float(m.group(1)) prix_label = "" prix = carte.select_one(".pricing .text") if prix is not None: prix_label = re.sub(r"\s+", " ", prix.get_text(" ", strip=True)) images: list[str] = [] slider = caro.get("data-slider", "") if slider: try: for it in json.loads(slider): src = it.get("image") if src and src not in images: images.append(src) except ValueError: pass return StListing( source=self.source_id, external_id=eid, url=url, title=titre.get_text(strip=True), property_type="Chalet", city=ville, region=_region_canonique(slug), price_night=_prix_nuit(prix_label), price_label=prix_label, capacity=capacite, bedrooms=chambres, images=images[:15], ) # -- page détail (via cache self.detail) ----------------------------------- def _detail(self, url: str) -> dict: html = self.get(url).text soup = BeautifulSoup(html, "html.parser") d: dict = {"details": {}} # microdonnées schema.org/Place (bloc caché) : géo + ville + région place = soup.select_one('[itemtype*="schema.org/Place"]') if place is not None: lat = place.select_one('[itemprop="latitude"]') lng = place.select_one('[itemprop="longitude"]') try: d["lat"] = float(lat.get_text(strip=True)) d["lng"] = float(lng.get_text(strip=True)) except (AttributeError, ValueError): pass ville = place.select_one('[itemprop="addressLocality"]') region = place.select_one('[itemprop="addressRegion"]') if ville is not None: d["city"] = ville.get_text(strip=True) if region is not None: d["region"] = region.get_text(strip=True) # bandeau info : capacité / chambres / salles de bain / salles d'eau for span in soup.select("#etablissement-info .flex-info span"): txt = span.get_text(" ", strip=True) m = re.search(r"(\d+)\s*personnes", txt) if m: d["capacity"] = float(m.group(1)) m = re.search(r"(\d+)\s*chambre", txt) if m: d["bedrooms"] = float(m.group(1)) m = re.search(r"(\d+)\s*salles? de bain", txt) if m: d["bathrooms"] = float(m.group(1)) m = re.search(r"(\d+)\s*salles? d'eau", txt) if m: d["details"]["salles_eau"] = int(m.group(1)) # lits (« 3 lits doubles », « 2 divans-lits »…) lits = 0 for span in soup.select(".tiny-box-container span"): m = re.search(r"(\d+)\s+(?:lits?|divans?)", span.get_text(strip=True)) if m: lits += int(m.group(1)) if lits: d["beds"] = float(lits) # Résumé : « Annonce Or-9184, Chalet | … Numéro d’enregistrement : NNNNNN » # puis paragraphes de description ancre = soup.select_one('a[name="resume"]') boite = ancre.find_next(class_="hide-box") if ancre is not None else None if boite is not None: paras = [p.get_text(" ", strip=True) for p in boite.find_all("p")] if paras: entete = paras[0] m = re.search(r"Annonce\s+\S+\s*,\s*([^|]+?)\s*(?:\||$)", entete) if m: d["property_type"] = m.group(1).strip() m = re.search(r"enregistrement\s*:?\s*(\d{4,8})", entete) if m: d["citq"] = m.group(1) d["description"] = "\n\n".join( p for p in paras[1:] if p)[:5000] # commodités (liens de caractéristiques) + dimensions amen: list[str] = [] for a in soup.select("a.nomEtablissement"): t = a.get_text(" ", strip=True) if t and t not in amen: amen.append(t) if amen: d["amenities"] = amen # photos (pleine résolution CDN) images: list[str] = [] for m in re.finditer( r"https:\\?/\\?/chaletsalouer\.com[\w\\/=,.-]*" r"fichiersUpload\\?/fichiers\\?/[\w.-]+", html): src = m.group(0).replace("\\/", "/") if src not in images: images.append(src) if images: d["images"] = images[:20] return d