SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.7 KB · 191 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/rsvpchalets.py : RSVP Chalets (https://www.rsvpchalets.com)4#5# Méthode : sitemap_fr_cottages.xml (~790 chalets Canada/France) filtré sur les6# régions québécoises (segment région de l'URL /chalets-a-louer/<région>/…).7# Chaque page détail (cache self.detail, clé = lastmod du sitemap) embarque un8# JSON-LD schema.org VacationRental complet : adresse, lat/lng, chambres,9# salles de bain, capacité, lits, animaux, note/avis, photos, type. Le prix10# vient de la grille « Tarifs de location » (rangées rates_day, ramenées au11# prix par nuit), la description et le no CITQ du HTML.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import html as _html16import json17import re1819from ..schema import StListing, parse_price_night20from .base import StConnector2122SITEMAP = "https://www.rsvpchalets.com/sitemap_fr_cottages.xml"2324# segment d'URL région → région touristique canonique (Québec seulement ;25# tout le reste — Maritimes, Ontario, C.-B., France… — est ignoré)26_REGION = {27    "abitibi-temiscamingue": "Abitibi-Témiscamingue",28    "bas-saint-laurent": "Bas-Saint-Laurent",29    "centre-du-quebec": "Centre-du-Québec",30    "charlevoix": "Charlevoix",31    "chaudiere-appalaches": "Chaudière-Appalaches",32    "cote-nord": "Côte-Nord",33    "estrie": "Cantons-de-l'Est",34    "gaspesie": "Gaspésie",35    "iles-de-la-madeleine": "Îles-de-la-Madeleine",36    "lanaudiere": "Lanaudière",37    "laurentides": "Laurentides",38    "mauricie": "Mauricie",39    "monteregie": "Montérégie",40    "outaouais": "Outaouais",41    "region-de-quebec": "Québec",42    "saguenay-lac-st-jean": "Saguenay–Lac-Saint-Jean",43}4445_TAG_RE = re.compile(r"<[^>]+>")464748def _text(fragment: str) -> str:49    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()505152def _f(v) -> float | None:53    try:54        return float(str(v).strip())55    except (TypeError, ValueError):56        return None575859class RsvpChalets(StConnector):60    source_id = "rsvpchalets"6162    # -- page détail ------------------------------------------------------63    def _detail(self, url: str, slug: str) -> dict:64        h = self.get(url).text65        d: dict = {}6667        for block in re.findall(r'<script type="application/ld\+json"[^>]*>'68                                r"(.*?)</script>", h, re.S):69            try:70                ld = json.loads(block)71            except ValueError:72                continue73            if (ld.get("@type") == "VacationRental"74                    and ld.get("identifier") == slug):75                d["ld"] = ld76                break7778        # grille « Tarifs de location » : rangées label / prix ; on ramène79        # chaque rangée au prix par nuit (« 3 nuits … 804 $ » → 268 $)80        best = None81        for label, price in re.findall(82                r'(?s)class="[^"]*ratesDay[^"]*"[^>]*>\s*(.*?)\s*</div>\s*'83                r'<div[^>]*class="[^"]*important-right[^"]*"[^>]*>\s*(.*?)\s*</div>',84                h):85            label, price = _text(label), _text(price)86            val = parse_price_night(price + (" /sem" if "sem" in label.lower()87                                             and "nuit" not in label.lower()88                                             else ""))89            if val is None:90                continue91            m = re.match(r"(\d+)\s*nuit", label.lower())92            if m and int(m.group(1)) > 1:93                val = round(val / int(m.group(1)), 2)94            if val and 20 <= val <= 20000 and (best is None or val < best[0]):95                best = (val, f"{price} / {label}")96        if best:97            d["price_night"], d["price_label"] = best9899        # description réelle (le champ description du JSON-LD est un100        # boilerplate RSVP) : section « Description du Chalet »101        m = re.search(r"(?s)Description du Chalet(.*?)(?:<div class=\"box|"102                      r"<span class=\"h2title)", h)103        if m:104            d["description"] = _text(m.group(1))[:4000]105106        m = re.search(r"No CITQ\s*:?\s*(?:</[^>]+>\s*)*(?:<[^>]+>\s*)*(\d{6})", h)107        if m:108            d["citq"] = m.group(1)109        return d110111    # -- contrat ----------------------------------------------------------112    def fetch(self) -> list[StListing]:113        xml = self.get(SITEMAP).text114        entries = re.findall(r"(?s)<url>\s*<loc>([^<]+)</loc>"115                             r"(?:\s*<lastmod>([^<]*)</lastmod>)?", xml)116117        listings: list[StListing] = []118        for url, lastmod in entries:119            parts = url.rstrip("/").split("/")120            # …/chalets-a-louer/<région>/<ville>/<slug>121            if len(parts) < 7 or parts[3] != "chalets-a-louer":122                continue123            region_slug, slug = parts[4], parts[-1]124            region = _REGION.get(region_slug)125            if not region:          # hors Québec (Maritimes, Ontario, France…)126                continue127128            det = self.detail(slug, lastmod or "",129                              lambda u=url, s=slug: self._detail(u, s))130            ld = det.get("ld") or {}131            if not ld:132                continue133            addr = ld.get("address") or {}134            place = ld.get("containsPlace") or {}135            agg = ld.get("aggregateRating") or {}136137            name = _text(str(ld.get("name") or ""))138            city = _text(str(addr.get("addressLocality") or ""))139            # « Chalet Le X, Ville, Région, Québec, Canada » → « Chalet Le X »140            title = name.split(f", {city},")[0].strip() if city else name141142            beds = None143            bed_items = place.get("bed") or []144            if isinstance(bed_items, list) and bed_items:145                counts = [b.get("numberOfBeds") for b in bed_items146                          if isinstance(b, dict)]147                if any(c is not None for c in counts):148                    beds = float(sum(c or 0 for c in counts))149150            amen = [a.get("name", "") for a in place.get("amenityFeature") or []151                    if isinstance(a, dict) and a.get("value") in (True, "True")]152153            pets = None154            if "petsAllowed" in ld:155                pets = "oui" if str(ld["petsAllowed"]).endswith("True") else "non"156157            imgs = ld.get("image") or []158            if isinstance(imgs, str):159                imgs = [imgs]160161            occupancy = (place.get("occupancy") or {}).get("value")162            reviews = agg.get("reviewCount")163            lst = StListing(164                source=self.source_id,165                external_id=slug,                     # identifiant du site166                url=url,167                title=title or name,168                property_type=_text(str(ld.get("additionalType") or "Chalet")),169                address=_text(str(addr.get("streetAddress") or "")),170                city=city,171                region=region,172                price_night=det.get("price_night"),173                price_label=det.get("price_label") or "",174                capacity=_f(occupancy),175                bedrooms=_f(ld.get("numberOfBedrooms")),176                beds=beds,177                bathrooms=_f(place.get("numberOfBathroomsTotal")),178                pets=pets,179                citq=det.get("citq") or "",180                rating=_f(agg.get("ratingValue")),181                reviews=int(reviews) if reviews else None,182                description=det.get("description") or "",183                amenities=[a for a in amen if a],184                details={"telephone": str(ld.get("telephone") or "")},185                images=[u for u in imgs if isinstance(u, str)][:20],186                lat=_f(ld.get("latitude")),187                lng=_f(ld.get("longitude")),188            )189            listings.append(lst)190        return listings191