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.1 KB · 158 lines python
Raw Blame History
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