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
3 days agolast push
HTML 98.9% Python 0.6%
10.9 KB · 282 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/chaletsalouer.py : ChaletsÀLouer.com — location de chalets4#   directement du propriétaire, sans commission (~3 300 chalets au Québec).5#6# Méthode : pagination de la liste globale /fr/location-de-chalet/?page=N7#   (~40 cartes/page, HTML statique). Chaque carte donne l'id stable8#   (data-noetablissement), le titre, la ville, la capacité, les chambres,9#   le prix (« À partir de 260 $ / 2 nuits ») et les photos (data-slider).10#   La page détail (via self.detail, cache BD) ajoute géolocalisation11#   (microdonnées schema.org/Place), description, type, no CITQ, salles de12#   bain, lits et commodités. Le site liste aussi quelques chalets hors13#   Québec (Nouveau-Brunswick, côte est américaine) : exclus.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import json19import re20from urllib.parse import urljoin, urlparse2122from bs4 import BeautifulSoup2324from ..schema import StListing25from .base import StConnector2627BASE = "https://www.chaletsalouer.com"28LISTE = BASE + "/fr/location-de-chalet/"2930# Slugs de « régions » hors Québec présents sur le site (Nouveau-Brunswick,31# côte atlantique américaine…) — hors mandat Lou-Ka.32_HORS_QC = ("rivage-acadien", "riviere-miramichi", "region-du-sud-est",33            "cote-atlantique", "madawaska", "nouveau-brunswick")3435_MONTANT = re.compile(r"(\d[\d\s  ]*(?:[.,]\d{2})?)\s*\$")363738def _prix_nuit(label: str) -> float | None:39    """« À partir de 260 $ / 2 nuits » → 130.0 ; « 1 150,00 $ / sem » → /7."""40    if not label:41        return None42    m = _MONTANT.search(label)43    if not m:44        return None45    brut = re.sub(r"[\s  ]", "", m.group(1)).replace(",", ".")46    try:47        val = float(brut)48    except ValueError:49        return None50    lab = label.lower()51    if "mois" in lab:              # location au mois : pas un prix à la nuit52        return None53    m = re.search(r"(\d+)\s*nuit", lab)54    if m and int(m.group(1)) > 1:55        return round(val / int(m.group(1)), 2)56    if "sem" in lab:57        return round(val / 7, 2)58    return val596061def _region_slug(url: str) -> str:62    """/fr/location-de-chalet/<région>/<ville>/<slug>/ → slug de région."""63    parts = [p for p in urlparse(url).path.split("/") if p]64    return parts[2] if len(parts) >= 3 else ""656667def _region_canonique(slug: str) -> str:68    if slug.startswith("lanaudiere"):69        return "Lanaudière"70    if slug.startswith("cantons-de-l-est"):71        return "Cantons-de-l'Est"72    if slug.startswith("laurentides"):73        return "Laurentides"74    if slug.startswith("region-de-quebec"):75        return "Québec"76    return slug  # normalize_region (finalize) ramène le reste au canonique777879class ChaletsALouer(StConnector):80    source_id = "chaletsalouer"8182    def fetch(self) -> list[StListing]:83        listings: list[StListing] = []84        vus: set[str] = set()85        page, max_page = 1, 186        while page <= max_page:87            html = self.get(LISTE, params={"page": page}).text88            soup = BeautifulSoup(html, "html.parser")89            for a in soup.select("a[href*='?page=']"):90                m = re.search(r"\?page=(\d+)", a.get("href", ""))91                if m:92                    max_page = max(max_page, int(m.group(1)))93            cartes = soup.select("div.item-etablissement")94            if not cartes:95                break96            for carte in cartes:97                lst = self._carte(carte)98                if lst is not None and lst.external_id not in vus:99                    vus.add(lst.external_id)100                    listings.append(lst)101            page += 1102103        for lst in listings:104            cle = hashlib.sha1("|".join([105                lst.title, lst.city, lst.price_label,106                str(lst.capacity), str(lst.bedrooms),107            ]).encode("utf-8")).hexdigest()108            try:109                d = self.detail(lst.external_id, cle,110                                lambda u=lst.url: self._detail(u))111            except Exception:112                d = {}113            if not d:114                continue115            lst.property_type = d.get("property_type") or lst.property_type116            lst.city = d.get("city") or lst.city117            # la région de la carte (slug d'URL, canonique) prime sur celle du118            # détail (« Lanaudière - Le Piémont » = sous-région non canonique)119            lst.region = lst.region or d.get("region", "")120            lst.lat = d.get("lat")121            lst.lng = d.get("lng")122            lst.description = d.get("description") or ""123            lst.citq = d.get("citq") or ""124            lst.amenities = d.get("amenities") or []125            if d.get("bathrooms") is not None:126                lst.bathrooms = d["bathrooms"]127            if d.get("beds") is not None:128                lst.beds = d["beds"]129            if d.get("capacity") is not None:130                lst.capacity = d["capacity"]131            if d.get("bedrooms") is not None:132                lst.bedrooms = d["bedrooms"]133            if d.get("images"):134                lst.images = d["images"]135            lst.details.update(d.get("details") or {})136        return listings137138    # -- carte de la liste ----------------------------------------------------139    def _carte(self, carte) -> StListing | None:140        caro = carte.select_one("[data-noetablissement]")141        titre = carte.select_one(".content a.title")142        if caro is None or titre is None:143            return None144        eid = caro.get("data-noetablissement", "").strip()145        url = urljoin(BASE, titre.get("href", ""))146        if not eid or not url:147            return None148        slug = _region_slug(url)149        if any(h in slug for h in _HORS_QC):150            return None            # hors Québec (N.-B., côte est américaine)151152        ville = ""153        loc = carte.select_one(".content .location a")154        if loc is not None:155            ville = loc.get_text(strip=True)156        capacite = chambres = None157        for span in carte.select(".content .location .nobreak"):158            txt = span.get_text(" ", strip=True)159            m = re.search(r"(\d+)\s*invité", txt)160            if m:161                capacite = float(m.group(1))162            m = re.search(r"(\d+)\s*chambre", txt)163            if m:164                chambres = float(m.group(1))165166        prix_label = ""167        prix = carte.select_one(".pricing .text")168        if prix is not None:169            prix_label = re.sub(r"\s+", " ", prix.get_text(" ", strip=True))170171        images: list[str] = []172        slider = caro.get("data-slider", "")173        if slider:174            try:175                for it in json.loads(slider):176                    src = it.get("image")177                    if src and src not in images:178                        images.append(src)179            except ValueError:180                pass181182        return StListing(183            source=self.source_id,184            external_id=eid,185            url=url,186            title=titre.get_text(strip=True),187            property_type="Chalet",188            city=ville,189            region=_region_canonique(slug),190            price_night=_prix_nuit(prix_label),191            price_label=prix_label,192            capacity=capacite,193            bedrooms=chambres,194            images=images[:15],195        )196197    # -- page détail (via cache self.detail) -----------------------------------198    def _detail(self, url: str) -> dict:199        html = self.get(url).text200        soup = BeautifulSoup(html, "html.parser")201        d: dict = {"details": {}}202203        # microdonnées schema.org/Place (bloc caché) : géo + ville + région204        place = soup.select_one('[itemtype*="schema.org/Place"]')205        if place is not None:206            lat = place.select_one('[itemprop="latitude"]')207            lng = place.select_one('[itemprop="longitude"]')208            try:209                d["lat"] = float(lat.get_text(strip=True))210                d["lng"] = float(lng.get_text(strip=True))211            except (AttributeError, ValueError):212                pass213            ville = place.select_one('[itemprop="addressLocality"]')214            region = place.select_one('[itemprop="addressRegion"]')215            if ville is not None:216                d["city"] = ville.get_text(strip=True)217            if region is not None:218                d["region"] = region.get_text(strip=True)219220        # bandeau info : capacité / chambres / salles de bain / salles d'eau221        for span in soup.select("#etablissement-info .flex-info span"):222            txt = span.get_text(" ", strip=True)223            m = re.search(r"(\d+)\s*personnes", txt)224            if m:225                d["capacity"] = float(m.group(1))226            m = re.search(r"(\d+)\s*chambre", txt)227            if m:228                d["bedrooms"] = float(m.group(1))229            m = re.search(r"(\d+)\s*salles? de bain", txt)230            if m:231                d["bathrooms"] = float(m.group(1))232            m = re.search(r"(\d+)\s*salles? d'eau", txt)233            if m:234                d["details"]["salles_eau"] = int(m.group(1))235236        # lits (« 3 lits doubles », « 2 divans-lits »…)237        lits = 0238        for span in soup.select(".tiny-box-container span"):239            m = re.search(r"(\d+)\s+(?:lits?|divans?)", span.get_text(strip=True))240            if m:241                lits += int(m.group(1))242        if lits:243            d["beds"] = float(lits)244245        # Résumé : « Annonce Or-9184, Chalet | … Numéro d’enregistrement : NNNNNN »246        # puis paragraphes de description247        ancre = soup.select_one('a[name="resume"]')248        boite = ancre.find_next(class_="hide-box") if ancre is not None else None249        if boite is not None:250            paras = [p.get_text(" ", strip=True) for p in boite.find_all("p")]251            if paras:252                entete = paras[0]253                m = re.search(r"Annonce\s+\S+\s*,\s*([^|]+?)\s*(?:\||$)", entete)254                if m:255                    d["property_type"] = m.group(1).strip()256                m = re.search(r"enregistrement\s*:?\s*(\d{4,8})", entete)257                if m:258                    d["citq"] = m.group(1)259                d["description"] = "\n\n".join(260                    p for p in paras[1:] if p)[:5000]261262        # commodités (liens de caractéristiques) + dimensions263        amen: list[str] = []264        for a in soup.select("a.nomEtablissement"):265            t = a.get_text(" ", strip=True)266            if t and t not in amen:267                amen.append(t)268        if amen:269            d["amenities"] = amen270271        # photos (pleine résolution CDN)272        images: list[str] = []273        for m in re.finditer(274                r"https:\\?/\\?/chaletsalouer\.com[\w\\/=,.-]*"275                r"fichiersUpload\\?/fichiers\\?/[\w.-]+", html):276            src = m.group(0).replace("\\/", "/")277            if src not in images:278                images.append(src)279        if images:280            d["images"] = images[:20]281        return d282