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/mcal.py : Maisons et Chalets à Louer (maisonsetchaletsalouer.com)4# — gestionnaire locatif de Richmond (Cantons-de-l'Est), ~300 propriétés5# au Québec.6#7# Méthode : inventaire complet depuis le sitemap /sitemap_chalet.cfm8# (URLs détail /fr/chalet-a-louer/<id>-<slug>.html — id numérique stable).9# Une seule requête par propriété : la variante ?carte=1 de la page détail10# contient TOUT (JSON-LD Accommodation, microdonnées géo lat/lng, région +11# ville, prix, commodités, description, no CITQ, photos), contrairement à12# la page de base qui omet les coordonnées. Le sitemap n'a pas de lastmod :13# clé de cache mensuelle (refetch complet 1×/mois, ~300 requêtes).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import re19import time20from urllib.parse import urljoin2122from bs4 import BeautifulSoup2324from ..schema import StListing25from .base import StConnector2627BASE = "https://www.maisonsetchaletsalouer.com"28SITEMAP = BASE + "/sitemap_chalet.cfm"2930_URL_DETAIL = re.compile(31 r"https://www\.maisonsetchaletsalouer\.com/fr/chalet-a-louer/(\d+)-[\w-]+\.html$")32_MONTANT = re.compile(r"(\d[\d\s ]*(?:[.,]\d{2})?)\s*\$")333435def _prix_nuit(label: str) -> float | None:36 """« À partir de 350 $ pour 2 nuitées ménage inclus* » → 175.0."""37 if not label:38 return None39 m = _MONTANT.search(label)40 if not m:41 return None42 brut = re.sub(r"[\s ]", "", m.group(1)).replace(",", ".")43 try:44 val = float(brut)45 except ValueError:46 return None47 lab = label.lower()48 if "mois" in lab: # location au mois : pas un prix à la nuit49 return None50 m = re.search(r"(\d+)\s*nuit", lab)51 if m and int(m.group(1)) > 1:52 return round(val / int(m.group(1)), 2)53 if "sem" in lab:54 return round(val / 7, 2)55 return val565758class MaisonsEtChaletsALouer(StConnector):59 source_id = "mcal"6061 def fetch(self) -> list[StListing]:62 xml = self.get(SITEMAP).text63 urls: dict[str, str] = {} # id -> url détail64 for loc in re.findall(r"<loc>([^<]+)</loc>", xml):65 m = _URL_DETAIL.match(loc.strip())66 if m:67 urls.setdefault(m.group(1), loc.strip())6869 cle = "carte-" + time.strftime("%Y-%m") # refetch mensuel (pas de lastmod)70 listings: list[StListing] = []71 for eid, url in urls.items():72 try:73 d = self.detail(eid, cle, lambda u=url: self._detail(u))74 except Exception:75 d = {}76 if not d.get("title"):77 continue78 listings.append(StListing(79 source=self.source_id,80 external_id=eid,81 url=url,82 title=d["title"],83 property_type=d.get("property_type") or "Chalet",84 city=d.get("city", ""),85 region=d.get("region", ""),86 price_night=_prix_nuit(d.get("price_label", "")),87 price_label=d.get("price_label", ""),88 capacity=d.get("capacity"),89 bedrooms=d.get("bedrooms"),90 bathrooms=d.get("bathrooms"),91 pets=d.get("pets"),92 citq=d.get("citq", ""),93 description=d.get("description", ""),94 amenities=d.get("amenities") or [],95 details=d.get("details") or {},96 images=d.get("images") or [],97 lat=d.get("lat"),98 lng=d.get("lng"),99 ))100 return listings101102 # -- page détail (?carte=1 : contenu complet + géo) ------------------------103 def _detail(self, url: str) -> dict:104 html = self.get(url, params={"carte": 1}).text105 soup = BeautifulSoup(html, "html.parser")106 d: dict = {"details": {}}107108 # JSON-LD Accommodation : nom, chambres, sdb, capacité, animaux109 for script in soup.find_all("script", type="application/ld+json"):110 try:111 # strict=False : le site laisse des sauts de ligne littéraux112 # dans les chaînes JSON113 data = json.loads(script.string or "", strict=False)114 except (ValueError, TypeError):115 continue116 if isinstance(data, dict) and data.get("@type") == "Accommodation":117 d["title"] = (data.get("name") or "").strip()118 if data.get("numberOfBedrooms") is not None:119 d["bedrooms"] = float(data["numberOfBedrooms"])120 if data.get("numberOfBathroomsTotal") is not None:121 d["bathrooms"] = float(data["numberOfBathroomsTotal"])122 occ = data.get("Occupancy") or {}123 if occ.get("maxValue") is not None:124 d["capacity"] = float(occ["maxValue"])125 if data.get("petsAllowed") is not None:126 d["pets"] = "oui" if str(data["petsAllowed"]) in (127 "1", "True", "true") else "non"128129 # titre de secours : h1 / balise title130 if not d.get("title"):131 h1 = soup.find("h1")132 if h1 is not None:133 d["title"] = h1.get_text(" ", strip=True)134135 # « Chalet à louer - <Région> - <Ville> »136 rv = soup.select_one("h2.regionVilleNom")137 if rv is not None:138 # séparateur = « - » entouré d'espaces (les tirets internes des139 # noms composés comme Centre-du-Québec n'en ont pas)140 texte = re.sub(r"\s+", " ", rv.get_text(" ", strip=True))141 parts = [p.strip() for p in texte.split(" - ") if p.strip()]142 if len(parts) >= 3:143 # « Cantons-de-l'Est / Estrie » → « Cantons-de-l'Est »144 d["region"] = parts[-2].split("/")[0].strip()145 d["city"] = parts[-1]146147 # géo (microdonnées, présentes seulement avec ?carte=1)148 lat = soup.select_one('[itemprop="latitude"]')149 lng = soup.select_one('[itemprop="longitude"]')150 try:151 d["lat"] = float(lat["content"])152 d["lng"] = float(lng["content"])153 except (TypeError, KeyError, ValueError):154 pass155156 # prix du chalet courant : « À partir de 350 $ pour 2 nuitées ménage157 # inclus* » (les autres .chalet_prix de la page = chalets suggérés)158 prix = soup.select_one(".boite_chalet-prix")159 if prix is not None:160 d["price_label"] = re.sub(r"\s+", " ",161 prix.get_text(" ", strip=True))162163 # description (contient le no CITQ) — sans le h2 région/ville164 desc = soup.select_one('[itemprop="description"]')165 if desc is not None:166 for h2 in desc.find_all("h2"):167 h2.decompose()168 texte = desc.get_text("\n", strip=True)169 m = re.search(r"CITQ\s*:?\s*(\d{4,8})", texte)170 if m:171 d["citq"] = m.group(1)172 d["description"] = texte[:5000]173174 # commodités (liste d'icônes)175 amen: list[str] = []176 for li in soup.select("li.liste-cat_item span"):177 t = li.get_text(" ", strip=True)178 if t and t not in amen:179 amen.append(t)180 if amen:181 d["amenities"] = amen182183 # photos — seulement les « _desktop » (les « _listing » de la page184 # sont les vignettes des chalets suggérés, pas celles du chalet courant)185 images: list[str] = []186 for m in re.finditer(187 r"/DATA/CHALET[_A-Z]*/[\w.-]+_desktop\.(?:jpe?g|png|webp)",188 html, re.I):189 src = urljoin(BASE, m.group(0))190 if src not in images:191 images.append(src)192 if images:193 d["images"] = images[:20]194 return d195