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%
11.2 KB · 266 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/locationdechalets.py : Location de Chalets Lanaudière4#   (locationdechalets.com) — petit parc de ~10 chalets avec spa privé à5#   Notre-Dame-de-la-Merci et Saint-Donat (Lanaudière).6#7# Méthode : sitemap.xml → /fr/chalets/<slug>/ + lastmod (clé du cache détail).8#   Pages statiques (CMS maison) riches mais sans JSON-LD :9#     - h1 « Chalet Spa Le Héron … Capacité de 2 personnes » ;10#     - bloc CARACTÉRISTIQUES (« N personnes maximum / N chambre(s) /11#       N salle(s) de bain ») ;12#     - sections INTÉRIEUR / EXTÉRIEUR / INCLUS en <ul><li> → amenities ;13#     - « EN SUS … Animaux : 10 $ / jour » → pets = conditions ;14#     - bloc Coordonnées (rue, ville, « Lanaudière (Québec) », code postal) ;15#     - no CITQ dans le pied de page ; lat/lng dans le lien Google Maps (@…) ;16#     - TARIF RÉGULIER « 2 nuits : 498 $ … » → price_night = total/2 nuits.17#       ⚠️ anti-scrape : zéros de bourrage blancs sur blanc18#       (<span style='color: #ffffff;'>0</span>) à retirer AVANT le parsing ;19#     - galerie : background:url(/fichiersUploadOpt/…) du slider.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as _html24import re2526from ..schema import StListing27from .base import StConnector2829SITE = "https://www.locationdechalets.com"30SITEMAP = SITE + "/sitemap.xml"3132_TAG_RE = re.compile(r"<[^>]+>")3334# ville → région (repli quand la ligne « … (Québec) » manque)35_CITY_REGION = {36    "chute-st-philippe": "Laurentides",37    "chute-saint-philippe": "Laurentides",38    "la macaza": "Laurentides",39    "val-david": "Laurentides",40    "notre-dame-de-la-merci": "Lanaudière",41    "saint-donat": "Lanaudière",42    "st-donat": "Lanaudière",43}444546def _text(fragment: str) -> str:47    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()484950class LocationDeChalets(StConnector):51    source_id = "locationdechalets"52    request_delay = 1.05354    # -- page détail ----------------------------------------------------------55    def _detail(self, url: str) -> dict:56        h = self.get(url).text57        d: dict = {}5859        m = re.search(r"(?s)<h1[^>]*>(.*?)</h1>", h)60        if m:61            d["title"] = _text(re.split(r"<br\s*/?>", m.group(1))[0])62        # « Capacité de 2 personnes » / « Capacité de 2 à 4 personnes » (max)63        m = re.search(r"Capacité de (?:\d+\s+à\s+)?(\d+) personnes", h)64        if m:65            d["capacity"] = int(m.group(1))6667        # description : après <strong>Description</strong>, jusqu'à la68        # section suivante ; certaines pages n'ont pas ce marqueur → repli69        # sur le plus long <p> éditorial70        m = re.search(r"(?s)<strong>Description</strong>(.*?)"71                      r"(?:CARACTÉRISTIQUES|Caractéristiques|Disponibilités"72                      r"|<strong>INTÉRIEUR)", h)73        if m:74            texte = re.sub(r"<br\s*/?>", "\n", m.group(1))75            texte = _html.unescape(_TAG_RE.sub(" ", texte))76            texte = re.sub(r"[ \t]+", " ", texte)77            texte = re.sub(r"\n\s+", "\n", texte).strip()78            d["description"] = texte[:5000]79        else:80            paras = [_text(p) for p in81                     re.findall(r"(?s)<p[^>]*>(.*?)</p>", h)]82            paras = [p for p in paras if len(p) > 15083                     and "Coordonnées" not in p84                     and "Contactez-nous" not in p85                     and "Découvrez nos chalets" not in p]86            if paras:87                d["description"] = max(paras, key=len)[:5000]8889        # sections (deux styles : MAJUSCULES ou « Caractéristiques : ») :90        # <ul> qui suit chaque entête → amenities + compteurs91        amen: list[str] = []92        for sec in (r"CARACT[EÉ]RISTIQUES", r"INT[EÉ]RIEUR",93                    r"EXT[EÉ]RIEUR", r"INCLUS"):94            for m in re.finditer(sec, h, re.I):95                mu = re.search(r"(?s)<ul[^>]*>(.*?)</ul>",96                               h[m.start():m.start() + 3500])97                if not mu:98                    continue99                for li in re.findall(r"(?s)<li[^>]*>(.*?)</li>", mu.group(1)):100                    t = _text(li)101                    if t and t not in amen:102                        amen.append(t)103                break104        # compteurs extraits des puces (« 2 chambres (1 lit queen…) »,105        # « 1 salle de bain », « 2 personnes maximum ») + du texte qui suit106        # l'entête CARACTÉRISTIQUES (pages où ce sont des <p>, pas des <li>)107        blob = " | ".join(amen)108        m = re.search(r"CARACT[EÉ]RISTIQUES", h, re.I)109        if m:110            blob += " | " + _text(h[m.start():m.start() + 700])111        m = re.search(r"(\d+)\s+personnes?\s+maximum", blob)112        if m:113            d.setdefault("capacity", int(m.group(1)))114        m = re.search(r"(\d+(?:[.,]5)?)\s+chambres?", blob)115        if m:116            d["bedrooms"] = float(m.group(1).replace(",", "."))117        m = re.search(r"(\d+(?:[.,]5)?)\s+salles?\s+de\s+bain", blob)118        if m:119            d["bathrooms"] = float(m.group(1).replace(",", "."))120        amen = [a for a in amen121                if not re.fullmatch(r"\d+\s+(personnes?\s+maximum"122                                    r"|chambres?|salles?\s+de\s+bain)", a)]123        if amen:124            d["amenities"] = amen125126        # frais : « Animaux : 10 $ / jour » (section EN SUS)127        m = re.search(r"Animaux\s*:\s*([\d,]+\s*\$[^<]*)", h)128        if m:129            d["pets_fee"] = _text(m.group(1))130131        # coordonnées : rue / ville / « Lanaudière (Québec) » / code postal132        m = re.search(r"(?s)<div class=\"coord[^\"]*\"[^>]*>(.*?)</div>", h)133        if m:134            lines = [_text(x) for x in re.split(r"<br\s*/?>|</p>", m.group(1))]135            lines = [x for x in lines if x and "Coordonnées" not in x136                     and not re.fullmatch(r"[\d,]+", x)]137            reg = next((x for x in lines if "(Québec)" in x), "")138            if reg:139                j = lines.index(reg)140                d["region"] = reg.split("(")[0].strip()141                if j >= 1:142                    d["city"] = lines[j - 1].strip(", ")143                if j >= 2:144                    d["address"] = lines[j - 2].strip(", ")145                if j + 1 < len(lines):146                    d["postal_code"] = lines[j + 1]147            else:148                # variante sans ligne « … (Québec) » : rue / ville / postal149                postal = next((x for x in lines150                               if re.fullmatch(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d",151                                               x.strip())), "")152                rest = [x for x in lines if x != postal]153                if len(rest) >= 2:154                    d["address"] = rest[0].strip(", ")155                    d["city"] = rest[1].strip(", ")156                if postal:157                    d["postal_code"] = postal.strip()158159        # (pas de lat/lng : le lien Google Maps pointe l'agence, pas le160        # chalet — le géocodage se fera en aval sur adresse+ville)161        m = re.search(r"CITQ\D{0,25}(\d{6})", h)162        if m:163            d["citq"] = m.group(1)164165        # TARIF RÉGULIER : « 2 nuits : 498 $ » (en <p> ou en <table>) →166        # 249 $/nuit. ⚠️ anti-scrape : chiffres de bourrage blancs sur blanc,167        # PARFOIS IMBRIQUÉS (<span #fff>0<span #000>4</span></span>98) → on168        # élimine itérativement les <span> les plus internes : blancs = jetés169        # avec leur contenu, autres = dépliés (contenu conservé).170        i = h.find("TARIF RÉGULIER")171        if i >= 0:172            frag = h[i:i + 2500]173            for _ in range(20):174                frag2 = re.sub(175                    r"<span([^>]*)>([^<]*)</span>",176                    lambda m: (m.group(2).replace("0", "")177                               if re.search(r"color:\s*#f{3,6}\b",178                                            m.group(1), re.I)179                               else m.group(2)),180                    frag)181                if frag2 == frag:182                    break183                frag = frag2184            frag = _text(frag)185            m = re.search(r"(\d+)\s*nuits?\s*:\s*(\d[\d\s]*)\s*\$", frag)186            if m:187                nights = int(m.group(1))188                total = float(m.group(2).replace(" ", ""))189                if nights and 20 <= total / nights <= 20000:190                    d["price_night"] = round(total / nights)191                    d["price_ref"] = f"{nights} nuits : {total:g} $"192193        # galerie du slider194        imgs: list[str] = []195        for u in re.findall(r"background:\s*url\(([^)]+)\)", h):196            u = u.strip("'\" ")197            if u.startswith("/fichiersUploadOpt/"):198                u = SITE + u199            if u.startswith("https://") and u not in imgs:200                imgs.append(u)201            if len(imgs) >= 20:202                break203        if imgs:204            d["images"] = imgs205        return d206207    # -- contrat --------------------------------------------------------------208    def fetch(self) -> list[StListing]:209        xml = self.get(SITEMAP).text210        entries = re.findall(r"(?s)<url>\s*<loc>([^<]+)</loc>"211                             r"(?:\s*<lastmod>([^<]*)</lastmod>)?", xml)212213        listings: list[StListing] = []214        vus: set[str] = set()215        for url, lastmod in entries:216            m = re.match(r"https://www\.locationdechalets\.com/fr/chalets/"217                         r"([^/]+)/?$", url)218            if not m:219                continue220            slug = m.group(1)221            if slug in vus:222                continue223            vus.add(slug)224225            det = self.detail(slug, lastmod or "v1",226                              lambda u=url: self._detail(u))227            title = det.get("title") or ""228            if not title:229                continue230231            city = det.get("city") or ""232            region = det.get("region") \233                or _CITY_REGION.get(city.lower(), "Lanaudière")234235            price = det.get("price_night")236            details = {k: v for k, v in {237                "postal_code": det.get("postal_code") or "",238                "price_ref": det.get("price_ref") or "",239                "pets_fee": det.get("pets_fee") or "",240            }.items() if v}241242            listings.append(StListing(243                source=self.source_id,244                external_id=slug,245                url=url,246                title=title,247                property_type="Chalet",248                address=det.get("address") or "",249                city=city,250                region=region,251                price_night=float(price) if price else None,252                price_label=f"à partir de {price:g} $ / nuit" if price else "",253                capacity=float(det["capacity"]) if det.get("capacity") else None,254                bedrooms=det.get("bedrooms"),255                bathrooms=det.get("bathrooms"),256                pets="conditions" if det.get("pets_fee") else None,257                citq=det.get("citq") or "",258                description=det.get("description") or "",259                amenities=det.get("amenities") or [],260                details=details,261                images=det.get("images") or [],262                lat=det.get("lat"),263                lng=det.get("lng"),264            ))265        return listings266