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%
6.5 KB · 169 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/chaletsbsl.py : Chalets BSL (chaletsbsl.com) — 4 chalets avec spa4#   sur un domaine privé de Saint-Simon-de-Rimouski (Bas-Saint-Laurent).5#6# Méthode : sitemap.xml (index) → sitemap_sections_*.xml → URLs /chalets/<slug>7#   + lastmod (clé du cache détail ; les /en/ sont ignorées). Pages statiques8#   (CMS maison Bootstrap) :9#     - <h3 class="mt-4 fw-600"> = titre (préfixe « Chalets BSL - » retiré) ;10#     - <h5 class="prix"> « À partir de 610$ pour 2 nuits » → price_night ;11#     - <p class="capacite"> « 2 pers. 1 chbre. 1 sdb. » ;12#     - description = <p> entre la capacité et le bloc country-info ;13#     - commodités = <h6> des blocs country-name ;14#     - galerie = var chaletImgs = {"ete": […], …} (JSON par saison) ;15#     - lat/lng = const myLatLng = { lat: …, lng: … } ; CITQ en pied de fiche.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import html as _html20import json21import re2223from ..schema import StListing24from .base import StConnector2526SITE = "https://chaletsbsl.com"27SITEMAP = SITE + "/sitemap.xml"2829_TAG_RE = re.compile(r"<[^>]+>")303132def _text(fragment: str) -> str:33    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()343536class ChaletsBsl(StConnector):37    source_id = "chaletsbsl"38    request_delay = 1.03940    # -- page détail ----------------------------------------------------------41    def _detail(self, url: str) -> dict:42        h = self.get(url).text43        d: dict = {}4445        m = re.search(r'(?s)<h3 class="mt-4 fw-600[^"]*"[^>]*>(.*?)</h3>', h)46        if m:47            d["title"] = re.sub(r"^Chalets BSL\s*-\s*", "", _text(m.group(1)))4849        # « À partir de 610$ pour 2 nuits » → 305 $/nuit50        m = re.search(r"À partir de\s*([\d\s]+)\$\s*pour\s*(\d+)\s*nuits", h)51        if m:52            total = float(m.group(1).replace(" ", ""))53            nights = int(m.group(2))54            if nights and 20 <= total / nights <= 20000:55                d["price_night"] = round(total / nights)56                d["price_ref"] = f"{total:g} $ pour {nights} nuits"5758        # « 2 pers. 1 chbre. 1 sdb. »59        m = re.search(r'(?s)<p class="capacite[^"]*"[^>]*>(.*?)</p>', h)60        if m:61            frag = _text(m.group(1))62            for pat, key in ((r"(\d+)\s*pers", "capacity"),63                             (r"(\d+)\s*chbre", "bedrooms"),64                             (r"(\d+)\s*sdb", "bathrooms")):65                mm = re.search(pat, frag)66                if mm:67                    d[key] = float(mm.group(1))6869        # description : les <p> entre la capacité et le bloc country-info70        i = h.find('class="capacite')71        j = h.find("country-info")72        if 0 < i < j:73            paras = [_text(p) for p in74                     re.findall(r"(?s)<p[^>]*>(.*?)</p>", h[i:j])]75            texte = "\n".join(p for p in paras if len(p) > 40)76            if texte:77                d["description"] = texte[:5000]7879        # commodités : les <h6> (tous portés par les blocs country-name)80        amen: list[str] = []81        for h6 in re.findall(r"(?s)<h6[^>]*>(.*?)</h6>", h):82            t = _text(h6)83            if t and t not in amen:84                amen.append(t)85        if amen:86            d["amenities"] = amen8788        m = re.search(r"const myLatLng = \{\s*lat:\s*(-?\d+\.\d+),"89                      r"\s*lng:\s*(-?\d+\.\d+)", h)90        if m:91            d["lat"], d["lng"] = float(m.group(1)), float(m.group(2))92        m = re.search(r"CITQ\D{0,25}(\d{6})", h)93        if m:94            d["citq"] = m.group(1)9596        # galerie : var chaletImgs = {"ete": […], "hiver": […]}97        m = re.search(r"var chaletImgs = (\{.*?\});", h, re.S)98        if m:99            try:100                seasons = json.loads(m.group(1))101                imgs: list[str] = []102                for key in ("ete", *sorted(k for k in seasons if k != "ete")):103                    for u in seasons.get(key) or []:104                        if isinstance(u, str) and u.startswith("https://") \105                                and u not in imgs and len(imgs) < 20:106                            imgs.append(u)107                if imgs:108                    d["images"] = imgs109            except ValueError:110                pass111        return d112113    # -- contrat --------------------------------------------------------------114    def fetch(self) -> list[StListing]:115        index = self.get(SITEMAP).text116        entries: list[tuple[str, str]] = []117        for sub in re.findall(r"<loc>([^<]+)</loc>", index):118            if "sitemap_sections" not in sub:119                continue120            xml = self.get(sub).text121            entries += re.findall(r"(?s)<url>\s*<loc>([^<]+)</loc>"122                                  r"(?:\s*<lastmod>([^<]*)</lastmod>)?", xml)123124        listings: list[StListing] = []125        vus: set[str] = set()126        for url, lastmod in entries:127            m = re.match(r"https://chaletsbsl\.com/chalets/([^/]+)/?$", url)128            if not m:129                continue130            slug = m.group(1)131            if slug in vus:132                continue133            vus.add(slug)134135            det = self.detail(slug, lastmod or "v1",136                              lambda u=url: self._detail(u))137            title = det.get("title") or ""138            if not title:139                continue140141            price = det.get("price_night")142            details = {k: v for k, v in {143                "price_ref": det.get("price_ref") or "",144                "domain": "Chalets BSL",145            }.items() if v}146147            listings.append(StListing(148                source=self.source_id,149                external_id=slug,150                url=url,151                title=title,152                property_type="Chalet",153                city="Saint-Simon-de-Rimouski",154                region="Bas-Saint-Laurent",155                price_night=float(price) if price else None,156                price_label=f"à partir de {price:g} $ / nuit" if price else "",157                capacity=det.get("capacity"),158                bedrooms=det.get("bedrooms"),159                bathrooms=det.get("bathrooms"),160                citq=det.get("citq") or "",161                description=det.get("description") or "",162                amenities=det.get("amenities") or [],163                details=details,164                images=det.get("images") or [],165                lat=det.get("lat"),166                lng=det.get("lng"),167            ))168        return listings169