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%
8.3 KB · 201 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/chaletsalpins.py : Les Chalets Alpins (chaletsalpins.ca)4#   — gestionnaire de Stoneham, ~180 chalets (Stoneham, Lac-Beauport,5#   Charlevoix, Laurentides).6#7# Méthode : WordPress. Sitemap /chalets-sitemap.xml (lastmod fiable, doublons8#   /en/ écartés) → pages /hebergement/<slug>/ (slug = adresse + no CITQ).9#   La page détail porte un JSON-LD schema.org Hotel (nom, description,10#   addressLocality, petsAllowed, amenityFeature FR) ; les compteurs vivent11#   dans des <span class="capacity|bedrooms|beds|restrooms">…N</span> de la12#   barre d'entête, et le prix dans l'encadré latéral13#   « <h3>2 nuits à partir de (printemps) <span>1 025.00 $</span></h3> »14#   (minimum 2 nuits → prix ramené à la nuit). Photos = uploads du carrousel15#   (vignettes -150x150 écartées). Pas de géo dans le HTML.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import html as _html20import json21import re2223from ...normalize import strip_accents24from ..schema import StListing25from .base import StConnector2627SITEMAP = "https://chaletsalpins.ca/chalets-sitemap.xml"2829_URL_DETAIL = re.compile(30    r"https://(?:www\.)?chaletsalpins\.ca/hebergement/([\w-]+)/?$")3132# localités desservies → région touristique canonique33_VILLE_REGION = {34    "stoneham": "Québec",35    "stoneham-et-tewkesbury": "Québec",36    "lac-beauport": "Québec",37    "quebec": "Québec",38    "petite-riviere-saint-francois": "Charlevoix",39    "baie-saint-paul": "Charlevoix",40    "la-malbaie": "Charlevoix",41    "les-eboulements": "Charlevoix",42    "saint-sauveur": "Laurentides",43    "sainte-adele": "Laurentides",44    "mont-tremblant": "Laurentides",45}4647_STAT_RE = re.compile(48    r'<span class="(capacity|bedrooms|beds|restrooms)">'49    r"(?:(?!</span>).)*?([\d.,]+)\s*</span>", re.S)5051_PRIX_RE = re.compile(52    r"<h3>\s*(\d+)\s*nuits?\s*à partir de[^<]*<br\s*/?>\s*"53    r"<span>\s*([\d\s,.  ]+)\s*\$\s*</span>", re.S)545556def _montant(raw: str) -> float | None:57    try:58        return float(re.sub(r"[\s  ]", "", raw).replace(",", "."))59    except ValueError:60        return None616263class ChaletsAlpins(StConnector):64    source_id = "chaletsalpins"6566    # -- inventaire (sitemap FR + lastmod) -----------------------------------67    def _sitemap_urls(self) -> dict[str, tuple[str, str]]:68        """slug -> (url détail FR, lastmod)."""69        xml = self.get(SITEMAP).text70        urls: dict[str, tuple[str, str]] = {}71        for bloc in re.findall(r"<url>(.*?)</url>", xml, re.S):72            m = re.search(r"<loc>([^<]+)</loc>", bloc)73            if not m:74                continue75            loc = m.group(1).strip()76            mu = _URL_DETAIL.match(loc)77            if not mu or mu.group(1) in ("hebergement",):78                continue79            lastmod = re.search(r"<lastmod>([^<]+)</lastmod>", bloc)80            urls.setdefault(mu.group(1),81                            (loc, lastmod.group(1) if lastmod else ""))82        return urls8384    # -- page détail ---------------------------------------------------------85    def _detail(self, url: str) -> dict:86        # ⚠️ le serveur ajoute parfois APRÈS </html> un second rendu avec des87        # chalets suggérés (autres compteurs/photos) : on tronque au 1er </html>88        h = self.get(url).text.split("</html>", 1)[0]89        d: dict = {}9091        # JSON-LD Hotel : nom, description, adresse, animaux, commodités92        for m in re.finditer(r'<script[^>]*application/ld\+json[^>]*>(.*?)'93                             r"</script>", h, re.S):94            try:95                data = json.loads(m.group(1), strict=False)96            except ValueError:97                continue98            if not (isinstance(data, dict) and data.get("@type") == "Hotel"):99                continue100            d["title"] = (data.get("name") or "").strip()101            d["description"] = re.sub(102                r"\s+", " ", (data.get("description") or "")).strip()[:5000]103            addr = data.get("address") or {}104            d["city"] = (addr.get("addressLocality") or "").strip()105            if data.get("petsAllowed") is not None:106                d["pets"] = "oui" if str(data["petsAllowed"]) in (107                    "True", "true", "1") else "non"108            amen = []109            for feat in data.get("amenityFeature") or []:110                nom = (feat.get("name") or "").strip() \111                    if isinstance(feat, dict) else ""112                if nom and nom not in amen:113                    amen.append(nom)114            if amen:115                d["amenities"] = amen116            break117118        # compteurs de l'entête (capacité, chambres, lits, salles de bain) —119        # 1re occurrence seulement (le chalet courant précède toute suggestion)120        for cls, val in _STAT_RE.findall(h):121            n = _montant(val)122            if n is None:123                continue124            d.setdefault({"capacity": "capacity", "bedrooms": "bedrooms",125                          "beds": "beds", "restrooms": "bathrooms"}[cls], n)126127        # encadré latéral : « 2 nuits à partir de (printemps) 1 025.00 $ »128        # → prix / nuit ; certaines unités affichent « Location mensuelle »129        # (long terme : pas de prix à la nuit, mention conservée)130        m = _PRIX_RE.search(h)131        if m:132            nuits = int(m.group(1)) or 1133            montant = _montant(m.group(2))134            if montant:135                d["price_night"] = round(montant / nuits, 2)136                d["price_label"] = re.sub(137                    r"\s+", " ", _html.unescape(138                        re.sub(r"<[^>]+>", " ", m.group(0)))).strip()139        elif re.search(r'sidebar scrollbox">\s*<div class="top">\s*'140                       r"<h3>\s*Location mensuelle", h):141            d["location_mensuelle"] = True142143        # no CITQ (dans le nom/slug « …(CITQ282170) » ou la description)144        m = re.search(r"CITQ\)?\s*:?\s*#?\s*(\d{6})",145                      d.get("title", "") + " " + d.get("description", ""))146        if m:147            d["citq"] = m.group(1)148149        # photos du carrousel (vignettes et icônes écartées, dédoublonnage150        # sur le nom de base sans suffixe de taille -WxH)151        imgs, vus = [], set()152        for u in re.findall(r'(https://(?:www\.)?chaletsalpins\.ca/'153                            r'wp-content/uploads/[^"\'\s>]+'154                            r"\.(?:jpe?g|png|webp))", h):155            base = re.sub(r"-\d+x\d+(?=\.\w+$)", "", u)156            if base in vus or "-150x150" in u:157                continue158            vus.add(base)159            imgs.append(u)160        d["images"] = imgs[:20]161        return d162163    # -- contrat --------------------------------------------------------------164    def fetch(self) -> list[StListing]:165        listings: list[StListing] = []166        for slug, (url, lastmod) in self._sitemap_urls().items():167            try:168                d = self.detail(slug, lastmod or "sans-lastmod",169                                lambda u=url: self._detail(u))170            except Exception:      # une fiche cassée ≠ inventaire perdu171                d = {}172            if not d.get("title"):173                continue174            ville = d.get("city", "")175            region = _VILLE_REGION.get(176                strip_accents(ville).lower().replace(" ", "-"), "")177            details = ({"location_mensuelle": True}178                       if d.get("location_mensuelle") else {})179            listings.append(StListing(180                source=self.source_id,181                external_id=slug,                 # slug WP, stable182                url=url,183                title=d["title"],184                property_type="Chalet",185                city=ville,186                region=region,187                price_night=d.get("price_night"),188                price_label=d.get("price_label", ""),189                capacity=d.get("capacity"),190                bedrooms=d.get("bedrooms"),191                beds=d.get("beds"),192                bathrooms=d.get("bathrooms"),193                pets=d.get("pets"),194                citq=d.get("citq", ""),195                description=d.get("description", ""),196                amenities=d.get("amenities") or [],197                details=details,198                images=d.get("images") or [],199            ))200        return listings201