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/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