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%
9.6 KB · 241 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/vrbo.py : Vrbo (groupe Expedia) — locations de vacances au Québec.4#5# Méthode : anti-bot Expedia costaud + page 100 % client-side (le SSR ne6# contient qu'un squelette ; __PLUGIN_STATE__/__APOLLO_STATE__ ne portent PAS7# les résultats — vérifié 2026-08-22). On passe donc par Scrapfly ASP avec8# rendu JS + wait_for_selector sur les cartes, puis on parse le DOM des9# cartes `[data-stid="lodging-card-responsive"]`.10#11# Limites assumées : ~18 cartes rendues par destination (liste virtualisée,12# le scroll ne persiste pas plus de cartes dans le snapshot DOM), pas de13# lat/lng ni d'adresse sur les cartes, images présentes seulement sur les14# cartes proches du viewport initial.15# Recherche SANS dates : Vrbo affiche alors un prix « à partir de » par nuit16# sur les prochaines dates disponibles → price_label + price_night plancher.17#18# Enrichissement : la page détail (Scrapfly ASP SANS rendu JS — le SSR suffit)19# porte description, commodités, capacité, lat/lng et ~6 photos (voir20# _expediadetail.py). ⚠️ certains slugs ont un suffixe (p1234567vb) : l'URL21# sans suffixe redirige vers une page région — on conserve le slug complet.22# Réglage env : LOUKA_VRBO_DETAIL_LIMIT (fetchs détail par sync, défaut 100 ;23# cache permanent dans louka_ct.db, le parc se complète au fil des syncs).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import sys30from urllib.parse import quote3132from bs4 import BeautifulSoup3334from ..schema import StListing35from . import _expediadetail as _ed36from .base import StConnector373839class _DetailSkip(Exception):40    """Fiche détail sautée (budget épuisé / page invalide) — pas de cache."""4142# (destination Vrbo, ville affichée, région touristique QC)43DESTINATIONS = [44    ("Mont-Tremblant, Québec, Canada", "Mont-Tremblant", "Laurentides"),45    ("Saint-Sauveur, Québec, Canada", "Saint-Sauveur", "Laurentides"),46    ("Magog, Québec, Canada", "Magog", "Cantons-de-l'Est"),47    ("Bromont, Québec, Canada", "Bromont", "Cantons-de-l'Est"),48    ("Baie-Saint-Paul, Québec, Canada", "Baie-Saint-Paul", "Charlevoix"),49    ("La Malbaie, Québec, Canada", "La Malbaie", "Charlevoix"),50    ("Québec, Québec, Canada", "Québec", "Québec"),51    ("Montréal, Québec, Canada", "Montréal", "Montréal"),52    ("Percé, Québec, Canada", "Percé", "Gaspésie"),53    ("Rimouski, Québec, Canada", "Rimouski", "Bas-Saint-Laurent"),54    ("Saguenay, Québec, Canada", "Saguenay", "Saguenay–Lac-Saint-Jean"),55    ("Shawinigan, Québec, Canada", "Shawinigan", "Mauricie"),56    ("Gatineau, Québec, Canada", "Gatineau", "Outaouais"),57]5859# libellé Vrbo (fr) → type canonique Lou-Ka60TYPE_MAP = {61    "appartement": "Appartement",62    "condo": "Condo",63    "chalet": "Chalet",64    "maison": "Maison",65    "villa": "Maison",66    "studio": "Studio",67    "loft": "Loft",68    "bungalow": "Maison",69    "cottage": "Chalet",70    "cabane": "Chalet",71    "chambre": "Chambre",72    "gîte": "Gîte",73    "auberge": "Auberge",74    "hébergement": "Autre",75}7677# slug complet (p123vb) ET id numérique — le suffixe est requis dans l'URL78_ID_RE = re.compile(r"/location/(p(\d+)[a-z]{0,2})")79_TYPELINE_RE = re.compile(80    r"^([A-ZÀ-Ý][\w’' -]{2,30})\s*·", re.UNICODE)81_BEDROOMS_RE = re.compile(r"(\d+)\s*chambres?")82_BEDS_RE = re.compile(r"(\d+)\s*(?:grands?\s+|très\s+grands?\s+|petits?\s+)?lits?\b")83_RATING_RE = re.compile(r"([\d,.]+)\s*sur\s*10")84_REVIEWS_RE = re.compile(r"\((\d[\d\s]*)\s*avis\)")85_PRICE_RE = re.compile(r"Le prix actuel est de\s*([\d\s,.]+)\s*\$")86_CAPACITY_RE = re.compile(r"(\d+)\s*(?:voyageurs?|personnes?)")878889class Vrbo(StConnector):90    source_id = "vrbo"91    request_delay = 1.09293    # -- parsing d'une carte ----------------------------------------------------94    def _parse_card(self, card, city: str, region: str) -> StListing | None:95        link = card.select_one('a[data-stid="open-product-information"]') \96            or card.select_one('a[href*="/location/"]')97        href = (link.get("href") if link else "") or ""98        m = _ID_RE.search(href)99        if not m:100            return None101        external_id = m.group(2)102        url = f"https://www.vrbo.com/fr-ca/location/{m.group(1)}"103104        title = ""105        for h in card.find_all("h3"):106            cls = " ".join(h.get("class") or [])107            if "is-visually-hidden" not in cls:108                title = h.get_text(strip=True)109                break110        if not title:111            return None112113        segs = list(card.stripped_strings)114        blob = " | ".join(segs)115116        property_type, bedrooms, beds, capacity = "", None, None, None117        for seg in segs:118            tm = _TYPELINE_RE.match(seg)119            if tm and ("lit" in seg or "chambre" in seg or "voyageur" in seg):120                property_type = TYPE_MAP.get(tm.group(1).strip().lower(), "Autre")121                bm = _BEDROOMS_RE.search(seg)122                if bm:123                    bedrooms = float(bm.group(1))124                lm = _BEDS_RE.search(seg)125                if lm:126                    beds = float(lm.group(1))127                cm = _CAPACITY_RE.search(seg)128                if cm:129                    capacity = float(cm.group(1))130                break131132        rating = reviews = None133        rm = _RATING_RE.search(blob)134        if rm:135            try:136                rating = round(float(rm.group(1).replace(",", ".")) / 2, 2)137            except ValueError:138                pass139        vm = _REVIEWS_RE.search(blob)140        if vm:141            reviews = int(vm.group(1).replace(" ", ""))142143        price_night, price_label = None, ""144        pm = _PRICE_RE.search(blob)145        if pm:146            try:147                price_night = float(pm.group(1).replace(" ", "")148                                    .replace(",", "."))149            except ValueError:150                price_night = None151            if price_night:152                price_label = (f"à partir de {price_night:.0f} $ / nuit "153                               "(prochaines dates disponibles)")154155        images = []156        for img in card.select("img[src]"):157            src = img.get("src") or ""158            if src.startswith("https://media.vrbo.com/") and src not in images:159                images.append(src)160            if len(images) >= 5:161                break162163        return StListing(164            source=self.source_id,165            external_id=external_id,166            url=url,167            title=title,168            property_type=property_type,169            city=city,170            region=region,171            price_night=price_night,172            price_label=price_label,173            capacity=capacity,174            bedrooms=bedrooms,175            beds=beds,176            rating=rating,177            reviews=reviews,178            images=images,179        )180181    # -- enrichissement par la page détail ---------------------------------------182    def _enrich_details(self, listings: list[StListing]) -> None:183        """Visite les fiches détail via le cache self.detail() sous budget :184        les hits de cache sont gratuits, seuls les fetchs réseau comptent."""185        limit = max(0, int(os.environ.get("LOUKA_VRBO_DETAIL_LIMIT", "100")186                           or 100))187        used = enriched = streak = 0188        for lst in listings:189            def fetch_fn(url=lst.url):190                nonlocal used, streak191                if used >= limit or streak >= 5:   # tempête anti-bot : on coupe192                    raise _DetailSkip193                used += 1194                html = self.get_scrapfly(url, render_js=False, asp=True)195                payload = _ed.parse_detail(html)196                if not payload:197                    streak += 1198                    raise _DetailSkip              # blocage/vide : pas de cache199                streak = 0200                return payload201202            try:203                d = self.detail(lst.external_id, "v1", fetch_fn)204            except _DetailSkip:205                continue206            except Exception:  # noqa: BLE001 — une fiche ne bloque pas le run207                continue208            if d:209                _ed.apply_detail(lst, d)210                enriched += 1211        print(f"[vrbo] détail : {enriched} annonces enrichies"212              f" ({used}/{limit} fetchs réseau)", file=sys.stderr)213214    # -- contrat -----------------------------------------------------------------215    def fetch(self) -> list[StListing]:216        listings: dict[str, StListing] = {}217        for dest, city, region in DESTINATIONS:218            url = ("https://www.vrbo.com/fr-ca/search?destination="219                   + quote(dest) + "&adults=2")220            try:221                html = self.get_scrapfly(222                    url, render_js=True, asp=True, rendering_wait=3000,223                    wait_for_selector='[data-stid="lodging-card-responsive"]')224            except Exception as exc:  # noqa: BLE001225                print(f"[vrbo] {city} : {exc}", file=sys.stderr)226                continue227            if not html:228                print(f"[vrbo] {city} : page vide (rendu raté)", file=sys.stderr)229                continue230            soup = BeautifulSoup(html, "html.parser")231            for card in soup.select('[data-stid="lodging-card-responsive"]'):232                try:233                    lst = self._parse_card(card, city, region)234                except Exception:  # noqa: BLE001235                    continue236                if lst and lst.external_id not in listings:237                    listings[lst.external_id] = lst238        out = list(listings.values())239        self._enrich_details(out)240        return out241