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 · 199 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/chaleto.py : Chaleto (chaleto.ca) — gestionnaire québécois4#   (Charlevoix, Capitale-Nationale, Laurentides…), ~440 fiches FR.5#6# Méthode : WordPress (vitrine) + Guesty (inventaire). Sitemap dédié7#   /listings-sitemap.xml → URLs /chalets-et-condos-a-louer/<slug>-<guestyId>/8#   (doublées en /en/ : on garde le FR). Tout est dans le HTML serveur de la9#   page détail : h1, « Ville, Région », prix « À partir de N$ / nuit »,10#   pictos (voyageurs/chambres/lits/salles de bain), commodités, description11#   (contenant le no CITQ) et TOUTES les photos Guesty pleine résolution dans12#   l'attribut data-images de la lightbox. Le lastmod du sitemap est identique13#   partout : clé de cache mensuelle (refetch complet 1×/mois, ~440 requêtes).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as _html18import json19import re20import time2122from ...normalize import strip_accents23from ..schema import StListing, parse_price_night24from .base import StConnector2526SITEMAP = "https://chaleto.ca/listings-sitemap.xml"2728# URL FR : /chalets-et-condos-a-louer/<slug>-<id Guesty 24 hex>/29_URL_FR = re.compile(30    r"https://(?:www\.)?chaleto\.ca/chalets-et-condos-a-louer/"31    r"[\w-]+-([0-9a-f]{24})/?$")3233_TAG_RE = re.compile(r"<[^>]+>")3435# le site affiche la région ADMINISTRATIVE (« Capitale-Nationale »,36# « Gaspésie--Îles-de-la-Madeleine ») → région touristique canonique37_REGION_FIX = {38    "capitale-nationale": "Québec",39    "estrie": "Cantons-de-l'Est",40    "gaspesie-iles-de-la-madeleine": "Gaspésie",41    "saguenay-lac-saint-jean": "Saguenay–Lac-Saint-Jean",42}4344# municipalités de la Capitale-Nationale qui relèvent touristiquement45# de Charlevoix46_VILLES_CHARLEVOIX = {47    "baie-saint-paul", "petite-riviere-saint-francois", "la-malbaie",48    "les-eboulements", "saint-urbain", "saint-irenee", "saint-hilarion",49    "isle-aux-coudres", "l'isle-aux-coudres", "notre-dame-des-monts",50    "saint-aime-des-lacs", "clermont", "saint-simeon",51    "baie-sainte-catherine",52}5354# mot-clé du titre → type canonique Lou-Ka55_TYPE_HINTS = [56    ("condo", "Condo"), ("loft", "Loft"), ("studio", "Studio"),57    ("appartement", "Appartement"), ("maison", "Maison"),58    ("mini-maison", "Mini-maison"), ("chalet", "Chalet"),59]606162def _text(fragment: str) -> str:63    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()646566class Chaleto(StConnector):67    source_id = "chaleto"6869    # -- inventaire (sitemap) ----------------------------------------------70    def _sitemap_urls(self) -> dict[str, str]:71        """id Guesty -> URL détail FR."""72        xml = self.get(SITEMAP).text73        urls: dict[str, str] = {}74        for loc in re.findall(r"<loc>([^<]+)</loc>", xml):75            m = _URL_FR.match(loc.strip())76            if m:77                urls.setdefault(m.group(1), loc.strip())78        return urls7980    # -- page détail ---------------------------------------------------------81    def _detail(self, url: str) -> dict:82        h = self.get(url).text83        d: dict = {}8485        m = re.search(r"<h1[^>]*>(.*?)</h1>", h, re.S)86        if m:87            d["title"] = _text(m.group(1))8889        # « Baie-Saint-Paul, Capitale-Nationale » sous le titre90        m = re.search(r'<p class="text-lg">([^<]+)</p>', h)91        if m:92            parts = [p.strip() for p in _text(m.group(1)).split(",")]93            if parts:94                d["city"] = parts[0]95            if len(parts) > 1:96                cle = re.sub(r"-{2,}", "-",97                             strip_accents(parts[-1]).lower().replace(" ", "-"))98                region = _REGION_FIX.get(cle, parts[-1])99                ville = strip_accents(d.get("city", "")).lower().replace(" ", "-")100                if region == "Québec" and ville in _VILLES_CHARLEVOIX:101                    region = "Charlevoix"102                d["region"] = region103104        # « À partir de <strong>200$</strong> / nuit »105        m = re.search(r"À partir de</span>\s*<span[^>]*>\s*"106                      r"<strong>([^<]+)</strong>\s*/\s*nuit", h)107        if m:108            d["price_label"] = f"à partir de {_text(m.group(1))} / nuit"109110        # pictos : « 5 voyageurs », « 3 chambres », « 3 lits », « 2 salles de bain »111        for val, label in re.findall(112                r"<p>\s*([\d.,]+)\s+(voyageurs?|chambres?|lits?|"113                r"salles? de bain)\s*</p>", h):114            n = float(val.replace(",", "."))115            lab = label.lower()116            if lab.startswith("voyageur"):117                d["capacity"] = n118            elif lab.startswith("chambre"):119                d["bedrooms"] = n120            elif lab.startswith("lit"):121                d["beds"] = n122            else:123                d["bathrooms"] = n124125        # commodités : spans du bloc « Commodités » (grille + accordéon)126        i = h.find("Commodités</h2>")127        if i >= 0:128            j = h.find("<h2", i + 10)129            bloc = h[i:j if j > 0 else i + 20000]130            amen = []131            for a in re.findall(r'<span class="text-white">([^<]+)</span>', bloc):132                a = _text(a)133                if a and a not in amen:134                    amen.append(a)135            d["amenities"] = amen136137        # description (1er paragraphe long — contient « CITQ : NNNNNN | Exp: … »)138        m = re.search(r'<p class="max-w-\[50rem\]">(.*?)</p>', h, re.S)139        if m:140            txt = _html.unescape(re.sub(r"<br\s*/?>", "\n",141                                        m.group(1)))142            txt = _TAG_RE.sub(" ", txt)143            txt = re.sub(r"[ \t]+", " ", txt).strip()144            d["description"] = txt[:5000]145            m2 = re.search(r"CITQ\s*:?\s*(\d{6})", txt)146            if m2:147                d["citq"] = m2.group(1)148149        # photos Guesty pleine résolution (lightbox data-images, JSON échappé)150        m = re.search(r'data-images="([^"]+)"', h)151        if m:152            try:153                imgs = json.loads(_html.unescape(m.group(1)))154            except ValueError:155                imgs = []156            d["images"] = [u for u in imgs if isinstance(u, str)][:20]157        return d158159    # -- contrat --------------------------------------------------------------160    def fetch(self) -> list[StListing]:161        cle = "detail-" + time.strftime("%Y-%m")   # lastmod uniforme → mensuel162        listings: list[StListing] = []163        for eid, url in self._sitemap_urls().items():164            try:165                d = self.detail(eid, cle, lambda u=url: self._detail(u))166            except Exception:      # une fiche cassée ≠ inventaire perdu167                d = {}168            title = d.get("title") or ""169            if not title:170                continue171172            ptype = ""173            hay = strip_accents(title).lower()174            for needle, canon in _TYPE_HINTS:175                if needle in hay:176                    ptype = canon177                    break178179            listings.append(StListing(180                source=self.source_id,181                external_id=eid,                  # id Guesty, stable182                url=url,183                title=title,184                property_type=ptype or "Chalet",185                city=d.get("city", ""),186                region=d.get("region", ""),187                price_night=parse_price_night(d.get("price_label", "")),188                price_label=d.get("price_label", ""),189                capacity=d.get("capacity"),190                bedrooms=d.get("bedrooms"),191                beds=d.get("beds"),192                bathrooms=d.get("bathrooms"),193                citq=d.get("citq", ""),194                description=d.get("description", ""),195                amenities=d.get("amenities") or [],196                images=d.get("images") or [],197            ))198        return listings199