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/locationdechalets.py : Location de Chalets Lanaudière4# (locationdechalets.com) — petit parc de ~10 chalets avec spa privé à5# Notre-Dame-de-la-Merci et Saint-Donat (Lanaudière).6#7# Méthode : sitemap.xml → /fr/chalets/<slug>/ + lastmod (clé du cache détail).8# Pages statiques (CMS maison) riches mais sans JSON-LD :9# - h1 « Chalet Spa Le Héron … Capacité de 2 personnes » ;10# - bloc CARACTÉRISTIQUES (« N personnes maximum / N chambre(s) /11# N salle(s) de bain ») ;12# - sections INTÉRIEUR / EXTÉRIEUR / INCLUS en <ul><li> → amenities ;13# - « EN SUS … Animaux : 10 $ / jour » → pets = conditions ;14# - bloc Coordonnées (rue, ville, « Lanaudière (Québec) », code postal) ;15# - no CITQ dans le pied de page ; lat/lng dans le lien Google Maps (@…) ;16# - TARIF RÉGULIER « 2 nuits : 498 $ … » → price_night = total/2 nuits.17# ⚠️ anti-scrape : zéros de bourrage blancs sur blanc18# (<span style='color: #ffffff;'>0</span>) à retirer AVANT le parsing ;19# - galerie : background:url(/fichiersUploadOpt/…) du slider.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as _html24import re2526from ..schema import StListing27from .base import StConnector2829SITE = "https://www.locationdechalets.com"30SITEMAP = SITE + "/sitemap.xml"3132_TAG_RE = re.compile(r"<[^>]+>")3334# ville → région (repli quand la ligne « … (Québec) » manque)35_CITY_REGION = {36 "chute-st-philippe": "Laurentides",37 "chute-saint-philippe": "Laurentides",38 "la macaza": "Laurentides",39 "val-david": "Laurentides",40 "notre-dame-de-la-merci": "Lanaudière",41 "saint-donat": "Lanaudière",42 "st-donat": "Lanaudière",43}444546def _text(fragment: str) -> str:47 return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()484950class LocationDeChalets(StConnector):51 source_id = "locationdechalets"52 request_delay = 1.05354 # -- page détail ----------------------------------------------------------55 def _detail(self, url: str) -> dict:56 h = self.get(url).text57 d: dict = {}5859 m = re.search(r"(?s)<h1[^>]*>(.*?)</h1>", h)60 if m:61 d["title"] = _text(re.split(r"<br\s*/?>", m.group(1))[0])62 # « Capacité de 2 personnes » / « Capacité de 2 à 4 personnes » (max)63 m = re.search(r"Capacité de (?:\d+\s+à\s+)?(\d+) personnes", h)64 if m:65 d["capacity"] = int(m.group(1))6667 # description : après <strong>Description</strong>, jusqu'à la68 # section suivante ; certaines pages n'ont pas ce marqueur → repli69 # sur le plus long <p> éditorial70 m = re.search(r"(?s)<strong>Description</strong>(.*?)"71 r"(?:CARACTÉRISTIQUES|Caractéristiques|Disponibilités"72 r"|<strong>INTÉRIEUR)", h)73 if m:74 texte = re.sub(r"<br\s*/?>", "\n", m.group(1))75 texte = _html.unescape(_TAG_RE.sub(" ", texte))76 texte = re.sub(r"[ \t]+", " ", texte)77 texte = re.sub(r"\n\s+", "\n", texte).strip()78 d["description"] = texte[:5000]79 else:80 paras = [_text(p) for p in81 re.findall(r"(?s)<p[^>]*>(.*?)</p>", h)]82 paras = [p for p in paras if len(p) > 15083 and "Coordonnées" not in p84 and "Contactez-nous" not in p85 and "Découvrez nos chalets" not in p]86 if paras:87 d["description"] = max(paras, key=len)[:5000]8889 # sections (deux styles : MAJUSCULES ou « Caractéristiques : ») :90 # <ul> qui suit chaque entête → amenities + compteurs91 amen: list[str] = []92 for sec in (r"CARACT[EÉ]RISTIQUES", r"INT[EÉ]RIEUR",93 r"EXT[EÉ]RIEUR", r"INCLUS"):94 for m in re.finditer(sec, h, re.I):95 mu = re.search(r"(?s)<ul[^>]*>(.*?)</ul>",96 h[m.start():m.start() + 3500])97 if not mu:98 continue99 for li in re.findall(r"(?s)<li[^>]*>(.*?)</li>", mu.group(1)):100 t = _text(li)101 if t and t not in amen:102 amen.append(t)103 break104 # compteurs extraits des puces (« 2 chambres (1 lit queen…) »,105 # « 1 salle de bain », « 2 personnes maximum ») + du texte qui suit106 # l'entête CARACTÉRISTIQUES (pages où ce sont des <p>, pas des <li>)107 blob = " | ".join(amen)108 m = re.search(r"CARACT[EÉ]RISTIQUES", h, re.I)109 if m:110 blob += " | " + _text(h[m.start():m.start() + 700])111 m = re.search(r"(\d+)\s+personnes?\s+maximum", blob)112 if m:113 d.setdefault("capacity", int(m.group(1)))114 m = re.search(r"(\d+(?:[.,]5)?)\s+chambres?", blob)115 if m:116 d["bedrooms"] = float(m.group(1).replace(",", "."))117 m = re.search(r"(\d+(?:[.,]5)?)\s+salles?\s+de\s+bain", blob)118 if m:119 d["bathrooms"] = float(m.group(1).replace(",", "."))120 amen = [a for a in amen121 if not re.fullmatch(r"\d+\s+(personnes?\s+maximum"122 r"|chambres?|salles?\s+de\s+bain)", a)]123 if amen:124 d["amenities"] = amen125126 # frais : « Animaux : 10 $ / jour » (section EN SUS)127 m = re.search(r"Animaux\s*:\s*([\d,]+\s*\$[^<]*)", h)128 if m:129 d["pets_fee"] = _text(m.group(1))130131 # coordonnées : rue / ville / « Lanaudière (Québec) » / code postal132 m = re.search(r"(?s)<div class=\"coord[^\"]*\"[^>]*>(.*?)</div>", h)133 if m:134 lines = [_text(x) for x in re.split(r"<br\s*/?>|</p>", m.group(1))]135 lines = [x for x in lines if x and "Coordonnées" not in x136 and not re.fullmatch(r"[\d,]+", x)]137 reg = next((x for x in lines if "(Québec)" in x), "")138 if reg:139 j = lines.index(reg)140 d["region"] = reg.split("(")[0].strip()141 if j >= 1:142 d["city"] = lines[j - 1].strip(", ")143 if j >= 2:144 d["address"] = lines[j - 2].strip(", ")145 if j + 1 < len(lines):146 d["postal_code"] = lines[j + 1]147 else:148 # variante sans ligne « … (Québec) » : rue / ville / postal149 postal = next((x for x in lines150 if re.fullmatch(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d",151 x.strip())), "")152 rest = [x for x in lines if x != postal]153 if len(rest) >= 2:154 d["address"] = rest[0].strip(", ")155 d["city"] = rest[1].strip(", ")156 if postal:157 d["postal_code"] = postal.strip()158159 # (pas de lat/lng : le lien Google Maps pointe l'agence, pas le160 # chalet — le géocodage se fera en aval sur adresse+ville)161 m = re.search(r"CITQ\D{0,25}(\d{6})", h)162 if m:163 d["citq"] = m.group(1)164165 # TARIF RÉGULIER : « 2 nuits : 498 $ » (en <p> ou en <table>) →166 # 249 $/nuit. ⚠️ anti-scrape : chiffres de bourrage blancs sur blanc,167 # PARFOIS IMBRIQUÉS (<span #fff>0<span #000>4</span></span>98) → on168 # élimine itérativement les <span> les plus internes : blancs = jetés169 # avec leur contenu, autres = dépliés (contenu conservé).170 i = h.find("TARIF RÉGULIER")171 if i >= 0:172 frag = h[i:i + 2500]173 for _ in range(20):174 frag2 = re.sub(175 r"<span([^>]*)>([^<]*)</span>",176 lambda m: (m.group(2).replace("0", "")177 if re.search(r"color:\s*#f{3,6}\b",178 m.group(1), re.I)179 else m.group(2)),180 frag)181 if frag2 == frag:182 break183 frag = frag2184 frag = _text(frag)185 m = re.search(r"(\d+)\s*nuits?\s*:\s*(\d[\d\s]*)\s*\$", frag)186 if m:187 nights = int(m.group(1))188 total = float(m.group(2).replace(" ", ""))189 if nights and 20 <= total / nights <= 20000:190 d["price_night"] = round(total / nights)191 d["price_ref"] = f"{nights} nuits : {total:g} $"192193 # galerie du slider194 imgs: list[str] = []195 for u in re.findall(r"background:\s*url\(([^)]+)\)", h):196 u = u.strip("'\" ")197 if u.startswith("/fichiersUploadOpt/"):198 u = SITE + u199 if u.startswith("https://") and u not in imgs:200 imgs.append(u)201 if len(imgs) >= 20:202 break203 if imgs:204 d["images"] = imgs205 return d206207 # -- contrat --------------------------------------------------------------208 def fetch(self) -> list[StListing]:209 xml = self.get(SITEMAP).text210 entries = re.findall(r"(?s)<url>\s*<loc>([^<]+)</loc>"211 r"(?:\s*<lastmod>([^<]*)</lastmod>)?", xml)212213 listings: list[StListing] = []214 vus: set[str] = set()215 for url, lastmod in entries:216 m = re.match(r"https://www\.locationdechalets\.com/fr/chalets/"217 r"([^/]+)/?$", url)218 if not m:219 continue220 slug = m.group(1)221 if slug in vus:222 continue223 vus.add(slug)224225 det = self.detail(slug, lastmod or "v1",226 lambda u=url: self._detail(u))227 title = det.get("title") or ""228 if not title:229 continue230231 city = det.get("city") or ""232 region = det.get("region") \233 or _CITY_REGION.get(city.lower(), "Lanaudière")234235 price = det.get("price_night")236 details = {k: v for k, v in {237 "postal_code": det.get("postal_code") or "",238 "price_ref": det.get("price_ref") or "",239 "pets_fee": det.get("pets_fee") or "",240 }.items() if v}241242 listings.append(StListing(243 source=self.source_id,244 external_id=slug,245 url=url,246 title=title,247 property_type="Chalet",248 address=det.get("address") or "",249 city=city,250 region=region,251 price_night=float(price) if price else None,252 price_label=f"à partir de {price:g} $ / nuit" if price else "",253 capacity=float(det["capacity"]) if det.get("capacity") else None,254 bedrooms=det.get("bedrooms"),255 bathrooms=det.get("bathrooms"),256 pets="conditions" if det.get("pets_fee") else None,257 citq=det.get("citq") or "",258 description=det.get("description") or "",259 amenities=det.get("amenities") or [],260 details=details,261 images=det.get("images") or [],262 lat=det.get("lat"),263 lng=det.get("lng"),264 ))265 return listings266