# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/mcal.py : Maisons et Chalets à Louer (maisonsetchaletsalouer.com) # — gestionnaire locatif de Richmond (Cantons-de-l'Est), ~300 propriétés # au Québec. # # Méthode : inventaire complet depuis le sitemap /sitemap_chalet.cfm # (URLs détail /fr/chalet-a-louer/-.html — id numérique stable). # Une seule requête par propriété : la variante ?carte=1 de la page détail # contient TOUT (JSON-LD Accommodation, microdonnées géo lat/lng, région + # ville, prix, commodités, description, no CITQ, photos), contrairement à # la page de base qui omet les coordonnées. Le sitemap n'a pas de lastmod : # clé de cache mensuelle (refetch complet 1×/mois, ~300 requêtes). # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import time from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import StListing from .base import StConnector BASE = "https://www.maisonsetchaletsalouer.com" SITEMAP = BASE + "/sitemap_chalet.cfm" _URL_DETAIL = re.compile( r"https://www\.maisonsetchaletsalouer\.com/fr/chalet-a-louer/(\d+)-[\w-]+\.html$") _MONTANT = re.compile(r"(\d[\d\s ]*(?:[.,]\d{2})?)\s*\$") def _prix_nuit(label: str) -> float | None: """« À partir de 350 $ pour 2 nuitées ménage inclus* » → 175.0.""" 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 class MaisonsEtChaletsALouer(StConnector): source_id = "mcal" def fetch(self) -> list[StListing]: xml = self.get(SITEMAP).text urls: dict[str, str] = {} # id -> url détail for loc in re.findall(r"([^<]+)", xml): m = _URL_DETAIL.match(loc.strip()) if m: urls.setdefault(m.group(1), loc.strip()) cle = "carte-" + time.strftime("%Y-%m") # refetch mensuel (pas de lastmod) listings: list[StListing] = [] for eid, url in urls.items(): try: d = self.detail(eid, cle, lambda u=url: self._detail(u)) except Exception: d = {} if not d.get("title"): continue listings.append(StListing( source=self.source_id, external_id=eid, url=url, title=d["title"], property_type=d.get("property_type") or "Chalet", city=d.get("city", ""), region=d.get("region", ""), price_night=_prix_nuit(d.get("price_label", "")), price_label=d.get("price_label", ""), capacity=d.get("capacity"), bedrooms=d.get("bedrooms"), bathrooms=d.get("bathrooms"), pets=d.get("pets"), citq=d.get("citq", ""), description=d.get("description", ""), amenities=d.get("amenities") or [], details=d.get("details") or {}, images=d.get("images") or [], lat=d.get("lat"), lng=d.get("lng"), )) return listings # -- page détail (?carte=1 : contenu complet + géo) ------------------------ def _detail(self, url: str) -> dict: html = self.get(url, params={"carte": 1}).text soup = BeautifulSoup(html, "html.parser") d: dict = {"details": {}} # JSON-LD Accommodation : nom, chambres, sdb, capacité, animaux for script in soup.find_all("script", type="application/ld+json"): try: # strict=False : le site laisse des sauts de ligne littéraux # dans les chaînes JSON data = json.loads(script.string or "", strict=False) except (ValueError, TypeError): continue if isinstance(data, dict) and data.get("@type") == "Accommodation": d["title"] = (data.get("name") or "").strip() if data.get("numberOfBedrooms") is not None: d["bedrooms"] = float(data["numberOfBedrooms"]) if data.get("numberOfBathroomsTotal") is not None: d["bathrooms"] = float(data["numberOfBathroomsTotal"]) occ = data.get("Occupancy") or {} if occ.get("maxValue") is not None: d["capacity"] = float(occ["maxValue"]) if data.get("petsAllowed") is not None: d["pets"] = "oui" if str(data["petsAllowed"]) in ( "1", "True", "true") else "non" # titre de secours : h1 / balise title if not d.get("title"): h1 = soup.find("h1") if h1 is not None: d["title"] = h1.get_text(" ", strip=True) # « Chalet à louer - - » rv = soup.select_one("h2.regionVilleNom") if rv is not None: # séparateur = « - » entouré d'espaces (les tirets internes des # noms composés comme Centre-du-Québec n'en ont pas) texte = re.sub(r"\s+", " ", rv.get_text(" ", strip=True)) parts = [p.strip() for p in texte.split(" - ") if p.strip()] if len(parts) >= 3: # « Cantons-de-l'Est / Estrie » → « Cantons-de-l'Est » d["region"] = parts[-2].split("/")[0].strip() d["city"] = parts[-1] # géo (microdonnées, présentes seulement avec ?carte=1) lat = soup.select_one('[itemprop="latitude"]') lng = soup.select_one('[itemprop="longitude"]') try: d["lat"] = float(lat["content"]) d["lng"] = float(lng["content"]) except (TypeError, KeyError, ValueError): pass # prix du chalet courant : « À partir de 350 $ pour 2 nuitées ménage # inclus* » (les autres .chalet_prix de la page = chalets suggérés) prix = soup.select_one(".boite_chalet-prix") if prix is not None: d["price_label"] = re.sub(r"\s+", " ", prix.get_text(" ", strip=True)) # description (contient le no CITQ) — sans le h2 région/ville desc = soup.select_one('[itemprop="description"]') if desc is not None: for h2 in desc.find_all("h2"): h2.decompose() texte = desc.get_text("\n", strip=True) m = re.search(r"CITQ\s*:?\s*(\d{4,8})", texte) if m: d["citq"] = m.group(1) d["description"] = texte[:5000] # commodités (liste d'icônes) amen: list[str] = [] for li in soup.select("li.liste-cat_item span"): t = li.get_text(" ", strip=True) if t and t not in amen: amen.append(t) if amen: d["amenities"] = amen # photos — seulement les « _desktop » (les « _listing » de la page # sont les vignettes des chalets suggérés, pas celles du chalet courant) images: list[str] = [] for m in re.finditer( r"/DATA/CHALET[_A-Z]*/[\w.-]+_desktop\.(?:jpe?g|png|webp)", html, re.I): src = urljoin(BASE, m.group(0)) if src not in images: images.append(src) if images: d["images"] = images[:20] return d