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/captremblant.py : Cap Tremblant Mountain Resort4# (captremblant.com) — complexe de résidences de tourisme à Mont-Tremblant.5# 7 catégories de résidences (1 à 5 chambres), chacune regroupant plusieurs6# unités identiques (fiches « catégorie », pas unité par unité).7#8# Méthode : sitemap.xml → 7 URLs /residences/<slug> (version fr ; les /en/9# sont ignorées). Pas de lastmod → cache détail « v1 ». Chaque page embarque10# un JSON-LD @type ["HotelRoom","Product"] : nom, description, image11# (galaxy.tf) et offers.price = tarif À LA NUIT en CAD (ex. 251,10 $) →12# price_night « à partir de ». La liste des caractéristiques vit dans le13# <ul> du bloc <div class="m-content-object--content"> → amenities ;14# capacité extraite de « jusqu'à six personnes » (nombres en toutes15# lettres), chambres déduites du slug/titre. La galerie est chargée en JS :16# seule l'image JSON-LD est disponible statiquement.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import html as _html21import json22import re2324from ..schema import StListing25from .base import StConnector2627SITE = "https://www.captremblant.com"28SITEMAP = SITE + "/sitemap.xml"2930_WORDS = {"un": 1, "une": 1, "deux": 2, "trois": 3, "quatre": 4, "cinq": 5,31 "six": 6, "sept": 7, "huit": 8, "neuf": 9, "dix": 10, "onze": 11,32 "douze": 12}3334_TAG_RE = re.compile(r"<[^>]+>")353637def _text(fragment: str) -> str:38 return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()394041def _count(txt: str) -> int | None:42 """« six » ou « 6 » → 6."""43 t = txt.strip().lower()44 if t.isdigit():45 return int(t)46 return _WORDS.get(t)474849class CapTremblant(StConnector):50 source_id = "captremblant"51 request_delay = 0.85253 # -- page détail ----------------------------------------------------------54 def _detail(self, url: str) -> dict:55 h = self.get(url).text56 d: dict = {}5758 for block in re.findall(r'<script type="application/ld\+json"[^>]*>'59 r"(.*?)</script>", h, re.S):60 try:61 ld = json.loads(block)62 except ValueError:63 continue64 types = ld.get("@type")65 types = types if isinstance(types, list) else [types]66 if "HotelRoom" in types or "Product" in types:67 d["ld"] = ld68 break6970 # caractéristiques : <ul> du bloc m-content-object--content71 m = re.search(r'(?s)<div class="m-content-object--content[^"]*"[^>]*>'72 r"(.*?)</div>", h)73 if m:74 amen = []75 for li in re.findall(r"(?s)<li[^>]*>(.*?)</li>", m.group(1)):76 t = _text(li)77 if t and t not in amen:78 amen.append(t)79 if amen:80 d["amenities"] = amen81 return d8283 # -- contrat --------------------------------------------------------------84 def fetch(self) -> list[StListing]:85 xml = self.get(SITEMAP).text86 urls = [u for u in re.findall(r"<loc>([^<]+)</loc>", xml)87 if "/residences/" in u and "/en/" not in u]8889 listings: list[StListing] = []90 vus: set[str] = set()91 for url in urls:92 slug = url.rstrip("/").split("/")[-1]93 if slug in vus:94 continue95 vus.add(slug)9697 det = self.detail(slug, "v1", lambda u=url: self._detail(u))98 ld = det.get("ld") or {}99 if not ld:100 continue101102 title = _text(str(ld.get("name") or slug))103 amen = det.get("amenities") or []104 description = _text(str(ld.get("description") or ""))105 if amen and len(" ".join(amen)) > len(description):106 description = description or " ".join(amen[:1])107108 # capacité : « jusqu'à six personnes » / « de dix à douze109 # personnes » (description ou 1re puce) — on prend le maximum110 capacity = None111 hay = f"{description} {' '.join(amen[:2])}"112 m = (re.search(r"jusqu[’']à\s+(\w+)\s+personnes", hay, re.I)113 or re.search(r"de\s+\w+\s+à\s+(\w+)\s+personnes", hay, re.I))114 if m:115 capacity = _count(m.group(1))116117 # chambres : déduites du slug/titre (« deux-chambres », « cinq… »)118 bedrooms = None119 m = re.search(r"(\w+)[- ]chambres?", f"{slug} {title}".lower())120 if m:121 bedrooms = _count(m.group(1))122123 price = None124 try:125 price = float((ld.get("offers") or {}).get("price"))126 except (TypeError, ValueError):127 pass128 # garde-fou : la fiche « quatre chambres » publie 29520.00 $129 # (bogue du site, sans doute 295,20 $) → tarif rejeté130 if price is not None and not 50 <= price <= 5000:131 price = None132 price_label = (f"à partir de {price:g} $ / nuit"133 if price else "")134135 img = ld.get("image") or ""136 images = [img] if isinstance(img, str) \137 and img.startswith("https://") else []138139 listings.append(StListing(140 source=self.source_id,141 external_id=slug,142 url=url,143 title=title,144 property_type="Condo",145 address="240 Rue du Mont-Plaisant, Mont-Tremblant",146 city="Mont-Tremblant",147 region="Laurentides",148 price_night=price,149 price_label=price_label,150 capacity=float(capacity) if capacity else None,151 bedrooms=float(bedrooms) if bedrooms else None,152 description=description[:3000],153 amenities=amen,154 details={"multi_unit": True, "resort": "Cap Tremblant"},155 images=images,156 ))157 return listings158