# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/rsvpchalets.py : RSVP Chalets (https://www.rsvpchalets.com) # # Méthode : sitemap_fr_cottages.xml (~790 chalets Canada/France) filtré sur les # régions québécoises (segment région de l'URL /chalets-a-louer//…). # Chaque page détail (cache self.detail, clé = lastmod du sitemap) embarque un # JSON-LD schema.org VacationRental complet : adresse, lat/lng, chambres, # salles de bain, capacité, lits, animaux, note/avis, photos, type. Le prix # vient de la grille « Tarifs de location » (rangées rates_day, ramenées au # prix par nuit), la description et le no CITQ du HTML. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import re from ..schema import StListing, parse_price_night from .base import StConnector SITEMAP = "https://www.rsvpchalets.com/sitemap_fr_cottages.xml" # segment d'URL région → région touristique canonique (Québec seulement ; # tout le reste — Maritimes, Ontario, C.-B., France… — est ignoré) _REGION = { "abitibi-temiscamingue": "Abitibi-Témiscamingue", "bas-saint-laurent": "Bas-Saint-Laurent", "centre-du-quebec": "Centre-du-Québec", "charlevoix": "Charlevoix", "chaudiere-appalaches": "Chaudière-Appalaches", "cote-nord": "Côte-Nord", "estrie": "Cantons-de-l'Est", "gaspesie": "Gaspésie", "iles-de-la-madeleine": "Îles-de-la-Madeleine", "lanaudiere": "Lanaudière", "laurentides": "Laurentides", "mauricie": "Mauricie", "monteregie": "Montérégie", "outaouais": "Outaouais", "region-de-quebec": "Québec", "saguenay-lac-st-jean": "Saguenay–Lac-Saint-Jean", } _TAG_RE = re.compile(r"<[^>]+>") def _text(fragment: str) -> str: return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip() def _f(v) -> float | None: try: return float(str(v).strip()) except (TypeError, ValueError): return None class RsvpChalets(StConnector): source_id = "rsvpchalets" # -- page détail ------------------------------------------------------ def _detail(self, url: str, slug: str) -> dict: h = self.get(url).text d: dict = {} for block in re.findall(r'", h, re.S): try: ld = json.loads(block) except ValueError: continue if (ld.get("@type") == "VacationRental" and ld.get("identifier") == slug): d["ld"] = ld break # grille « Tarifs de location » : rangées label / prix ; on ramène # chaque rangée au prix par nuit (« 3 nuits … 804 $ » → 268 $) best = None for label, price in re.findall( r'(?s)class="[^"]*ratesDay[^"]*"[^>]*>\s*(.*?)\s*\s*' r']*class="[^"]*important-right[^"]*"[^>]*>\s*(.*?)\s*', h): label, price = _text(label), _text(price) val = parse_price_night(price + (" /sem" if "sem" in label.lower() and "nuit" not in label.lower() else "")) if val is None: continue m = re.match(r"(\d+)\s*nuit", label.lower()) if m and int(m.group(1)) > 1: val = round(val / int(m.group(1)), 2) if val and 20 <= val <= 20000 and (best is None or val < best[0]): best = (val, f"{price} / {label}") if best: d["price_night"], d["price_label"] = best # description réelle (le champ description du JSON-LD est un # boilerplate RSVP) : section « Description du Chalet » m = re.search(r"(?s)Description du Chalet(.*?)(?:
]+>\s*)*(?:<[^>]+>\s*)*(\d{6})", h) if m: d["citq"] = m.group(1) return d # -- contrat ---------------------------------------------------------- def fetch(self) -> list[StListing]: xml = self.get(SITEMAP).text entries = re.findall(r"(?s)\s*([^<]+)" r"(?:\s*([^<]*))?", xml) listings: list[StListing] = [] for url, lastmod in entries: parts = url.rstrip("/").split("/") # …/chalets-a-louer/// if len(parts) < 7 or parts[3] != "chalets-a-louer": continue region_slug, slug = parts[4], parts[-1] region = _REGION.get(region_slug) if not region: # hors Québec (Maritimes, Ontario, France…) continue det = self.detail(slug, lastmod or "", lambda u=url, s=slug: self._detail(u, s)) ld = det.get("ld") or {} if not ld: continue addr = ld.get("address") or {} place = ld.get("containsPlace") or {} agg = ld.get("aggregateRating") or {} name = _text(str(ld.get("name") or "")) city = _text(str(addr.get("addressLocality") or "")) # « Chalet Le X, Ville, Région, Québec, Canada » → « Chalet Le X » title = name.split(f", {city},")[0].strip() if city else name beds = None bed_items = place.get("bed") or [] if isinstance(bed_items, list) and bed_items: counts = [b.get("numberOfBeds") for b in bed_items if isinstance(b, dict)] if any(c is not None for c in counts): beds = float(sum(c or 0 for c in counts)) amen = [a.get("name", "") for a in place.get("amenityFeature") or [] if isinstance(a, dict) and a.get("value") in (True, "True")] pets = None if "petsAllowed" in ld: pets = "oui" if str(ld["petsAllowed"]).endswith("True") else "non" imgs = ld.get("image") or [] if isinstance(imgs, str): imgs = [imgs] occupancy = (place.get("occupancy") or {}).get("value") reviews = agg.get("reviewCount") lst = StListing( source=self.source_id, external_id=slug, # identifiant du site url=url, title=title or name, property_type=_text(str(ld.get("additionalType") or "Chalet")), address=_text(str(addr.get("streetAddress") or "")), city=city, region=region, price_night=det.get("price_night"), price_label=det.get("price_label") or "", capacity=_f(occupancy), bedrooms=_f(ld.get("numberOfBedrooms")), beds=beds, bathrooms=_f(place.get("numberOfBathroomsTotal")), pets=pets, citq=det.get("citq") or "", rating=_f(agg.get("ratingValue")), reviews=int(reviews) if reviews else None, description=det.get("description") or "", amenities=[a for a in amen if a], details={"telephone": str(ld.get("telephone") or "")}, images=[u for u in imgs if isinstance(u, str)][:20], lat=_f(ld.get("latitude")), lng=_f(ld.get("longitude")), ) listings.append(lst) return listings