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.8 KB · 174 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/accestremblant.py : Accès Tremblant (accestremblant.ca)4#5# Agence de condos à Mont-Tremblant (~17 fiches) — WordPress Avada6# (portfolio) + moteur Guesty (guestybookings.com).7#8# Méthode :9#   1. LISTE : sitemap https://accestremblant.ca/avada_portfolio-sitemap.xml10#      → /condos/<slug>/ (lastmod = clé du cache détail).11#   2. DÉTAIL (cache self.detail) : la fiche WP fournit spécs (spans12#      icon-condos : « 8 Personnes », « 3 chambres », « 3 salles de bain » +13#      extras type « Foyer au gaz »), description (JSON-LD Yoast), photos14#      (wp-content/uploads) et l'id Guesty (lien guestybookings.com).15#   3. PRIX : chaque fiche contient un carrousel « autres condos » avec16#      « À partir de N $ » pour les AUTRES fiches ; chaque payload détail17#      mémorise cette carte slug→prix et fetch() fusionne le tout (le prix18#      d'une fiche vient donc des autres pages, même à froid depuis le cache).19#   External_id = slug WP (stable) ; l'id Guesty est gardé dans details.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as _html24import json25import re2627from ..schema import StListing28from .base import StConnector2930SITE = "https://accestremblant.ca"31SITEMAP = f"{SITE}/avada_portfolio-sitemap.xml"3233_TAG_RE = re.compile(r"<[^>]+>")343536def _text(fragment: str) -> str:37    return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip()383940class AccesTremblant(StConnector):41    source_id = "accestremblant"4243    # -- liste (sitemap) ----------------------------------------------------44    def _sitemap(self) -> list[tuple[str, str]]:45        """[(url fiche, lastmod)] — dédupliqué, sans la page index."""46        xml = self.get(SITEMAP).text47        seen: dict[str, str] = {}48        for m in re.finditer(r"(?s)<url>\s*<loc>([^<]+)</loc>"49                             r"(?:\s*<lastmod>([^<]+)</lastmod>)?", xml):50            url, lastmod = m.group(1).strip(), (m.group(2) or "").strip()51            if re.fullmatch(rf"{re.escape(SITE)}/condos/[^/]+/", url):52                seen.setdefault(url, lastmod)53        return sorted(seen.items())5455    # -- page détail ------------------------------------------------------56    def _detail(self, url: str) -> dict:57        h = self.get(url).text58        d: dict = {}5960        # spécs : <span class="icon-condos">… 8 Personnes / 3 chambres / …61        extras: list[str] = []62        for raw in re.findall(r'(?s)<span class="icon-condos"[^>]*>(.*?)</span>',63                              h):64            t = _text(raw)65            if not t:66                continue67            m = re.match(r"(\d+)\s*[Pp]ersonnes?", t)68            if m:69                d["capacity"] = float(m.group(1))70                continue71            m = re.match(r"(\d+)\s*[Cc]hambres?", t)72            if m:73                d["bedrooms"] = float(m.group(1))74                continue75            m = re.match(r"(\d+)\s*[Ss]alles?\s*de\s*bain", t)76            if m:77                d["bathrooms"] = float(m.group(1))78                continue79            if len(t) <= 60:80                extras.append(t)81        if extras:82            d["amenities"] = extras8384        # description : JSON-LD Yoast (WebPage.description)85        m = re.search(r'(?s)<script type="application/ld\+json"[^>]*>'86                      r"(.*?)</script>", h)87        if m:88            try:89                graph = json.loads(m.group(1)).get("@graph") or []90                for node in graph:91                    if node.get("@type") == "WebPage" and node.get("description"):92                        d["description"] = _text(node["description"])[:4000]93                        break94            except ValueError:95                pass9697        # id Guesty (lien « réserver » guestybookings.com)98        m = re.search(r"guestybookings\.com/(?:fr/)?properties/([a-f0-9]{24})",99                      h)100        if m:101            d["guesty_id"] = m.group(1)102103        # photos : uploads WP (originaux, sans logos ni vignettes -NxN)104        imgs: list[str] = []105        for u in re.findall(r'(https://accestremblant\.ca/wp-content/uploads/'106                            r'20\d\d/\d\d/[^" ]+\.(?:jpe?g|png|webp))', h):107            if re.search(r"-\d{2,4}x\d{2,4}\.", u):108                continue109            if re.search(r"logo|favicon|icon", u, re.I):110                continue111            if u not in imgs:112                imgs.append(u)113        d["images"] = imgs[:20]114115        # carte des prix « autres condos » : slug → à partir de N $116        prices: dict[str, float] = {}117        for slug, val in re.findall(118                r'href="https://accestremblant\.ca/condos/([^"/]+)/"[^>]*>'119                r"\s*À partir de\s*([\d ,]+)\s*\$", h):120            try:121                prices[slug] = float(val.replace(" ", "").replace(",", "."))122            except ValueError:123                continue124        d["prices_seen"] = prices125        return d126127    # -- contrat ----------------------------------------------------------128    def fetch(self) -> list[StListing]:129        rows = []130        price_map: dict[str, float] = {}131        for url, lastmod in self._sitemap():132            slug = url.rstrip("/").rsplit("/", 1)[-1]133            try:134                det = self.detail(slug, lastmod, lambda u=url: self._detail(u))135            except Exception:136                continue137            price_map.update(det.get("prices_seen") or {})138            rows.append((slug, url, det))139140        listings: list[StListing] = []141        for slug, url, det in rows:142            title = slug.replace("-", " ").title()143            desc = det.get("description") or ""144            m = re.match(r"([^|]{2,60})\|", desc)145            if m:                      # « Verbier C | Découvrez… »146                title = m.group(1).strip()147                desc = desc.split("|", 1)[1].strip()148            price = price_map.get(slug)149150            details = {k: v for k, v in {151                "guesty_id": det.get("guesty_id"),152            }.items() if v}153154            listings.append(StListing(155                source=self.source_id,156                external_id=slug,157                url=url,158                title=title,159                property_type="Condo",160                city="Mont-Tremblant",161                region="Laurentides",162                price_night=price,163                price_label=(f"à partir de {price:.0f} $ / nuit"164                             if price else ""),165                capacity=det.get("capacity"),166                bedrooms=det.get("bedrooms"),167                bathrooms=det.get("bathrooms"),168                description=desc[:4000],169                amenities=det.get("amenities") or [],170                details=details,171                images=det.get("images") or [],172            ))173        return listings174