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%
7.8 KB · 195 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/mcal.py : Maisons et Chalets à Louer (maisonsetchaletsalouer.com)4#   — gestionnaire locatif de Richmond (Cantons-de-l'Est), ~300 propriétés5#   au Québec.6#7# Méthode : inventaire complet depuis le sitemap /sitemap_chalet.cfm8#   (URLs détail /fr/chalet-a-louer/<id>-<slug>.html — id numérique stable).9#   Une seule requête par propriété : la variante ?carte=1 de la page détail10#   contient TOUT (JSON-LD Accommodation, microdonnées géo lat/lng, région +11#   ville, prix, commodités, description, no CITQ, photos), contrairement à12#   la page de base qui omet les coordonnées. Le sitemap n'a pas de lastmod :13#   clé de cache mensuelle (refetch complet 1×/mois, ~300 requêtes).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import re19import time20from urllib.parse import urljoin2122from bs4 import BeautifulSoup2324from ..schema import StListing25from .base import StConnector2627BASE = "https://www.maisonsetchaletsalouer.com"28SITEMAP = BASE + "/sitemap_chalet.cfm"2930_URL_DETAIL = re.compile(31    r"https://www\.maisonsetchaletsalouer\.com/fr/chalet-a-louer/(\d+)-[\w-]+\.html$")32_MONTANT = re.compile(r"(\d[\d\s  ]*(?:[.,]\d{2})?)\s*\$")333435def _prix_nuit(label: str) -> float | None:36    """« À partir de 350 $ pour 2 nuitées ménage inclus* » → 175.0."""37    if not label:38        return None39    m = _MONTANT.search(label)40    if not m:41        return None42    brut = re.sub(r"[\s  ]", "", m.group(1)).replace(",", ".")43    try:44        val = float(brut)45    except ValueError:46        return None47    lab = label.lower()48    if "mois" in lab:              # location au mois : pas un prix à la nuit49        return None50    m = re.search(r"(\d+)\s*nuit", lab)51    if m and int(m.group(1)) > 1:52        return round(val / int(m.group(1)), 2)53    if "sem" in lab:54        return round(val / 7, 2)55    return val565758class MaisonsEtChaletsALouer(StConnector):59    source_id = "mcal"6061    def fetch(self) -> list[StListing]:62        xml = self.get(SITEMAP).text63        urls: dict[str, str] = {}          # id -> url détail64        for loc in re.findall(r"<loc>([^<]+)</loc>", xml):65            m = _URL_DETAIL.match(loc.strip())66            if m:67                urls.setdefault(m.group(1), loc.strip())6869        cle = "carte-" + time.strftime("%Y-%m")   # refetch mensuel (pas de lastmod)70        listings: list[StListing] = []71        for eid, url in urls.items():72            try:73                d = self.detail(eid, cle, lambda u=url: self._detail(u))74            except Exception:75                d = {}76            if not d.get("title"):77                continue78            listings.append(StListing(79                source=self.source_id,80                external_id=eid,81                url=url,82                title=d["title"],83                property_type=d.get("property_type") or "Chalet",84                city=d.get("city", ""),85                region=d.get("region", ""),86                price_night=_prix_nuit(d.get("price_label", "")),87                price_label=d.get("price_label", ""),88                capacity=d.get("capacity"),89                bedrooms=d.get("bedrooms"),90                bathrooms=d.get("bathrooms"),91                pets=d.get("pets"),92                citq=d.get("citq", ""),93                description=d.get("description", ""),94                amenities=d.get("amenities") or [],95                details=d.get("details") or {},96                images=d.get("images") or [],97                lat=d.get("lat"),98                lng=d.get("lng"),99            ))100        return listings101102    # -- page détail (?carte=1 : contenu complet + géo) ------------------------103    def _detail(self, url: str) -> dict:104        html = self.get(url, params={"carte": 1}).text105        soup = BeautifulSoup(html, "html.parser")106        d: dict = {"details": {}}107108        # JSON-LD Accommodation : nom, chambres, sdb, capacité, animaux109        for script in soup.find_all("script", type="application/ld+json"):110            try:111                # strict=False : le site laisse des sauts de ligne littéraux112                # dans les chaînes JSON113                data = json.loads(script.string or "", strict=False)114            except (ValueError, TypeError):115                continue116            if isinstance(data, dict) and data.get("@type") == "Accommodation":117                d["title"] = (data.get("name") or "").strip()118                if data.get("numberOfBedrooms") is not None:119                    d["bedrooms"] = float(data["numberOfBedrooms"])120                if data.get("numberOfBathroomsTotal") is not None:121                    d["bathrooms"] = float(data["numberOfBathroomsTotal"])122                occ = data.get("Occupancy") or {}123                if occ.get("maxValue") is not None:124                    d["capacity"] = float(occ["maxValue"])125                if data.get("petsAllowed") is not None:126                    d["pets"] = "oui" if str(data["petsAllowed"]) in (127                        "1", "True", "true") else "non"128129        # titre de secours : h1 / balise title130        if not d.get("title"):131            h1 = soup.find("h1")132            if h1 is not None:133                d["title"] = h1.get_text(" ", strip=True)134135        # « Chalet à louer - <Région> - <Ville> »136        rv = soup.select_one("h2.regionVilleNom")137        if rv is not None:138            # séparateur = « - » entouré d'espaces (les tirets internes des139            # noms composés comme Centre-du-Québec n'en ont pas)140            texte = re.sub(r"\s+", " ", rv.get_text(" ", strip=True))141            parts = [p.strip() for p in texte.split(" - ") if p.strip()]142            if len(parts) >= 3:143                # « Cantons-de-l'Est / Estrie » → « Cantons-de-l'Est »144                d["region"] = parts[-2].split("/")[0].strip()145                d["city"] = parts[-1]146147        # géo (microdonnées, présentes seulement avec ?carte=1)148        lat = soup.select_one('[itemprop="latitude"]')149        lng = soup.select_one('[itemprop="longitude"]')150        try:151            d["lat"] = float(lat["content"])152            d["lng"] = float(lng["content"])153        except (TypeError, KeyError, ValueError):154            pass155156        # prix du chalet courant : « À partir de 350 $ pour 2 nuitées ménage157        # inclus* » (les autres .chalet_prix de la page = chalets suggérés)158        prix = soup.select_one(".boite_chalet-prix")159        if prix is not None:160            d["price_label"] = re.sub(r"\s+", " ",161                                      prix.get_text(" ", strip=True))162163        # description (contient le no CITQ) — sans le h2 région/ville164        desc = soup.select_one('[itemprop="description"]')165        if desc is not None:166            for h2 in desc.find_all("h2"):167                h2.decompose()168            texte = desc.get_text("\n", strip=True)169            m = re.search(r"CITQ\s*:?\s*(\d{4,8})", texte)170            if m:171                d["citq"] = m.group(1)172            d["description"] = texte[:5000]173174        # commodités (liste d'icônes)175        amen: list[str] = []176        for li in soup.select("li.liste-cat_item span"):177            t = li.get_text(" ", strip=True)178            if t and t not in amen:179                amen.append(t)180        if amen:181            d["amenities"] = amen182183        # photos — seulement les « _desktop » (les « _listing » de la page184        # sont les vignettes des chalets suggérés, pas celles du chalet courant)185        images: list[str] = []186        for m in re.finditer(187                r"/DATA/CHALET[_A-Z]*/[\w.-]+_desktop\.(?:jpe?g|png|webp)",188                html, re.I):189            src = urljoin(BASE, m.group(0))190            if src not in images:191                images.append(src)192        if images:193            d["images"] = images[:20]194        return d195