diff --git a/README.md b/README.md index 1b9ea8a..cc62762 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ Start here: the **orchestration skills** call everything else automatically. | **rapidapi** | Booking.com hotel prices. | RapidAPI | | **serpapi** | Google Hotels search and destination discovery. | SerpAPI | | **ticketsatwork** | TicketsAtWork (EBG) corporate-perks portal. Hotels, theme park tickets, attractions, live events. Often beats portals by 10-30%. Docker: `ghcr.io/borski/ticketsatwork`. | None (requires TaW account + Patchright) | +| **vrbo** | VRBO whole-home, condo, and cabin search via Patchright. Complements Airbnb for group stays. | None (requires Patchright) | Also use **tripadvisor** (under Destinations) for hotel ratings, rankings, subratings, and reviews. diff --git a/llms.txt b/llms.txt index 7698280..c1e9519 100644 --- a/llms.txt +++ b/llms.txt @@ -51,6 +51,7 @@ Skill listings below are auto-generated from `skills/*/SKILL.md` frontmatter by - [Rapidapi](https://github.com/borski/travel-hacking-toolkit/blob/main/skills/rapidapi/SKILL.md): Booking.com hotel prices. - [Serpapi](https://github.com/borski/travel-hacking-toolkit/blob/main/skills/serpapi/SKILL.md): Google Hotels search and destination discovery. - [Ticketsatwork](https://github.com/borski/travel-hacking-toolkit/blob/main/skills/ticketsatwork/SKILL.md): TicketsAtWork (EBG) corporate-perks portal. Hotels, theme park tickets, attractions, live events. Often beats portals by 10-30%. Docker: `ghcr.io/borski/ticketsatwork`. +- [Vrbo](https://github.com/borski/travel-hacking-toolkit/blob/main/skills/vrbo/SKILL.md): VRBO whole-home, condo, and cabin search via Patchright. Complements Airbnb for group stays. ## Loyalty and Points diff --git a/plugins/travel-hacking-toolkit/skills/compare-hotels/SKILL.md b/plugins/travel-hacking-toolkit/skills/compare-hotels/SKILL.md index 927b947..73221b5 100644 --- a/plugins/travel-hacking-toolkit/skills/compare-hotels/SKILL.md +++ b/plugins/travel-hacking-toolkit/skills/compare-hotels/SKILL.md @@ -59,6 +59,7 @@ Run these in parallel where possible. **Never fail silently.** | Source | Skill/Tool | Speed | What It Finds | |--------|------------|-------|---------------| | Airbnb | `airbnb_search` MCP tool | ~5s | Entire homes, private rooms, with total pricing | +| VRBO | `vrbo` skill (Patchright) | ~20s | Whole homes, condos, cabins. Professionally-managed units often not on Airbnb. Headed/Docker — behind Akamai. | ### Premium Property Databases (local, instant) @@ -80,6 +81,9 @@ PARALLEL GROUP 1 (fast, ~3-5s): - Airbnb: vacation rentals in the area - Local data: check premium-hotels databases for the city +PARALLEL GROUP 1b (slower, ~20s, headed/Docker — behind Akamai): + - VRBO: whole homes / condos / cabins — vrbo skill (run alongside Airbnb for whole-home comparisons) + PARALLEL GROUP 2 (slow, ~45s, Docker): - Chase Travel: --hotel --dest "City" --checkin YYYY-MM-DD --checkout YYYY-MM-DD --json - Amex Travel: --hotel --dest "City" --checkin YYYY-MM-DD --checkout YYYY-MM-DD --json diff --git a/plugins/travel-hacking-toolkit/skills/vrbo/Dockerfile b/plugins/travel-hacking-toolkit/skills/vrbo/Dockerfile new file mode 100644 index 0000000..84b9cbf --- /dev/null +++ b/plugins/travel-hacking-toolkit/skills/vrbo/Dockerfile @@ -0,0 +1,9 @@ +FROM ghcr.io/borski/patchright-docker:latest + +COPY scripts/search_vrbo.py /app/search_vrbo.py +COPY scripts/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +ENV VRBO_IN_DOCKER=1 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/plugins/travel-hacking-toolkit/skills/vrbo/SKILL.md b/plugins/travel-hacking-toolkit/skills/vrbo/SKILL.md new file mode 100644 index 0000000..28162d7 --- /dev/null +++ b/plugins/travel-hacking-toolkit/skills/vrbo/SKILL.md @@ -0,0 +1,158 @@ +--- +name: vrbo +description: Search VRBO (Vrbo / Expedia Group) vacation rentals including entire homes, condos, and cabins via Patchright browser automation. VRBO sits behind Akamai Bot Manager so plain HTTP and standard Playwright get a 429 bot wall. Use for whole-home or group stays, multi-bedroom rentals, and Airbnb-vs-VRBO comparisons. Trigger phrases include search VRBO, Vrbo rentals, vacation rental, cabin rental, whole house, condo for the group. +category: hotels +summary: VRBO whole-home, condo, and cabin search via Patchright. Complements Airbnb for group stays. +api_key: None (requires Patchright) +--- + +# VRBO Search + +Search VRBO vacation rentals via Patchright and return whole-home / condo / cabin +listings with pricing and booking links. VRBO is the natural complement to the +Airbnb MCP for group and family stays — many mountain-town and resort property +managers cross-list on both, but VRBO carries professionally-managed condos and +cabins that don't always appear on Airbnb. + +**Requires Patchright** (undetected Playwright fork). VRBO sits behind **Akamai +Bot Manager** — plain HTTP, standard Playwright, `@playwright/mcp`, and +agent-browser all get a `429 Too Many Requests` ("Provisioned request rate has +been exceeded") bot wall. Patchright passes Akamai's sensor and earns the +`_abck` cookie. This is the same wall — and the same fix — as the chase-travel / +amex-travel / southwest skills. + +**Must run headed (`headless=False`).** Akamai flags headless browsers. On macOS +a Chrome window briefly appears. For background / unattended runs, use Docker +(Xvfb provides the virtual display). + +**No login required.** VRBO search results are public — unlike chase-travel / +amex-travel, there are no credentials. The persistent profile only caches the +Akamai cookie so repeat searches re-challenge less. + +## Prerequisites + +```bash +pip install patchright && patchright install chromium +``` + +Or use Docker (no local install, headed via Xvfb): + +```bash +docker build -t vrbo skills/vrbo/ +``` + +## When to Use + +- Group / family stays where you want a whole home, condo, or cabin +- "Compare Airbnb vs VRBO in " +- Multi-bedroom rentals with separate rooms (the multi-person trip pattern) +- Resort / mountain towns (Canmore, Banff-area, Whistler, Muskoka) where + professional property managers list condos on VRBO that aren't on Airbnb + +## When NOT to Use + +- **Completing a booking.** This skill finds rentals and returns links only. Do + not attempt to complete a purchase. +- **Hotels.** Use compare-hotels / serpapi / liteapi / the portal skills. +- **Single rooms / shared rooms.** VRBO is whole-property only; for private + rooms in a shared home use the Airbnb MCP (`private_room`). + +## Usage + +```bash +# Local (pops up a Chrome window on macOS) +python3 skills/vrbo/scripts/search_vrbo.py \ + --dest "Canmore, Alberta, Canada" \ + --checkin 2026-06-26 --checkout 2026-07-02 --adults 3 --json + +# Whole homes with 3+ bedrooms +python3 skills/vrbo/scripts/search_vrbo.py \ + --dest "Canmore, Alberta" --checkin 2026-06-26 --checkout 2026-07-02 \ + --adults 3 --min-bedrooms 3 --json + +# Docker (headless host, headed browser via Xvfb) +docker run --rm vrbo \ + --dest "Canmore, Alberta" --checkin 2026-06-26 --checkout 2026-07-02 \ + --adults 3 --json +``` + +### Arguments + +| Flag | Default | Notes | +|------|---------|-------| +| `--dest` | required | Destination keyword, e.g. `"Canmore, Alberta, Canada"`. VRBO resolves it to a regionId server-side. | +| `--checkin` / `--checkout` | required | ISO `YYYY-MM-DD`. Must be today or later. | +| `--adults` | 2 | Traveler count. | +| `--children` | 0 | | +| `--pets` | off | Pet-friendly only. | +| `--min-bedrooms` | 0 | Client-side filter (VRBO's URL filter is unreliable). | +| `--limit` | 20 | Max listings returned. | +| `--retries` | 4 | Akamai retry attempts (homepage warm-up → SERP). | +| `--json` | off | Emit JSON to stdout (use this for orchestration). | + +## Output + +JSON shape (with `--json`): + +```json +{ + "source": "vrbo", + "extractor": "__PLUS_REDUX_STORE__ | dom:lodging-card | dom:anchors", + "searchUrl": "https://www.vrbo.com/search?...", + "count": 12, + "listings": [ + { + "id": "1234567", + "name": "Mountain-View Condo Steps from Main St", + "bedrooms": 3, "bathrooms": 2, "sleeps": 6, + "rating": 4.9, "reviewCount": 84, + "priceText": "The current price is CA $312 CA $312 CA $2,184 total includes taxes & fees", + "pricePerNight": 312, "priceTotal": 2184, "priceIncludesTaxes": true, + "lat": 51.08, "lng": -115.35, + "url": "https://www.vrbo.com/en-ca/cottage-rental/p1234567?..." + } + ] +} +``` + +`extractor` tells you which layer produced the data. `__PLUS_REDUX_STORE__` is +richest (bedrooms, coords, review counts); `dom:lodging-card` is reliable but +thinner (the common case — names, prices, URLs); `dom:anchors` is a last-ditch +id+url scrape — if you see that, the page shape changed and selectors need a +refresh (file a P2 task). + +**Prices:** the SERP price string is parsed into `pricePerNight` and +`priceTotal`. Verified against live data: VRBO's SERP **total includes taxes & +fees** (`priceIncludesTaxes: true`) — `priceTotal` is the all-in for the whole +stay, `pricePerNight` is the lead nightly rate. This satisfies the +hotel-comparison standard's all-in requirement directly; still open the listing +URL to confirm before booking, since a damage deposit or optional add-ons may +not be in the SERP figure. + +## Tests + +The pure logic (search-URL building, Akamai block/challenge detection, SERP +price parsing, skeleton filtering, listing enrichment) is covered by stdlib +`unittest` tests — no browser, network, or Patchright needed (the `patchright` +import is lazy): + +```bash +python3 -m unittest discover -s skills/vrbo/tests -v # or: python3 -m pytest skills/vrbo/tests +``` + +17 tests, no third-party deps. The browser-driven path (Akamai bypass, DOM +extraction) can't be unit-tested without a live residential connection — verify +it manually with a real search (see Usage above). The repo smoke test +(`scripts/smoke-test.sh`) separately validates this skill's frontmatter and +structure. + +## Known fragility + +- **Akamai is IP-rate-limited.** Datacenter / CI / VPN IPs are frequently + blocked even with Patchright. Run on a residential connection for best + results. If every retry 429s, the script exits 1 with a clear message — that + is an environment block, not a code bug. +- **DOM selectors drift.** VRBO/Expedia revise their `data-stid` design system + periodically. The extractor tries the embedded state blob first (most stable), + then `data-stid` cards, then a generic anchor scrape. If `extractor` is + `dom:anchors`, refresh the Layer-1/2 selectors. diff --git a/plugins/travel-hacking-toolkit/skills/vrbo/scripts/entrypoint.sh b/plugins/travel-hacking-toolkit/skills/vrbo/scripts/entrypoint.sh new file mode 100755 index 0000000..3ef17a4 --- /dev/null +++ b/plugins/travel-hacking-toolkit/skills/vrbo/scripts/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Start a virtual display so Patchright can run headed (Akamai detects headless). +Xvfb :99 -screen 0 1440x900x24 -nolisten tcp & +export DISPLAY=:99 +sleep 1 + +exec python3 /app/search_vrbo.py "$@" diff --git a/plugins/travel-hacking-toolkit/skills/vrbo/scripts/search_vrbo.py b/plugins/travel-hacking-toolkit/skills/vrbo/scripts/search_vrbo.py new file mode 100755 index 0000000..75191e6 --- /dev/null +++ b/plugins/travel-hacking-toolkit/skills/vrbo/scripts/search_vrbo.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +""" +VRBO (Vrbo / Expedia Group) vacation-rental search via Patchright. + +VRBO sits behind Akamai Bot Manager ("Provisioned request rate has been +exceeded" / obfuscated sensor script). Standard Playwright, agent-browser, and +plain HTTP all get a 429 bot wall. Patchright (undetected Playwright fork), +running *headed* with a persistent profile, passes Akamai's sensor and earns +the `_abck` cookie — the same approach the chase-travel / amex-travel / +southwest skills use for their bot walls. + +No login is required: VRBO search results are public. The persistent profile +just lets the Akamai cookie survive between runs so repeat searches are faster +and less likely to re-challenge. + +Usage: + # Local (pops up a Chrome window on macOS) + python3 search_vrbo.py --dest "Canmore, Alberta, Canada" \ + --checkin 2026-06-26 --checkout 2026-07-02 --adults 3 + + # Filter to whole homes only, set a price ceiling (per-night, listing currency) + python3 search_vrbo.py --dest "Canmore, Alberta" \ + --checkin 2026-06-26 --checkout 2026-07-02 --adults 3 \ + --min-bedrooms 3 --max-price 400 + + # JSON output (for orchestration by compare-hotels / trip-planner) + python3 search_vrbo.py --dest "Canmore, Alberta" \ + --checkin 2026-06-26 --checkout 2026-07-02 --adults 3 --json + +Environment: + VRBO_PROFILE Profile directory (default: ~/.vrbo-profiles/default). + In Docker, a tmp profile is used automatically. + +Exit codes: + 0 results found (or empty result set with no error) + 1 blocked by Akamai after retries / navigation failure + 2 bad arguments +""" + +import argparse +import json +import os +import re +import sys +import tempfile +import time +from pathlib import Path +from urllib.parse import quote + + +SEARCH_URL = "https://www.vrbo.com/search" +HOME_URL = "https://www.vrbo.com/" + +# Akamai serves two flavours of block: +# 1. Hard 429 page — title/body carry these strings. +# 2. Soft interstitial challenge (Press & Hold / sensor / "verify you are +# human") — returns 200 with no listings, so we must detect it explicitly, +# otherwise it looks like an empty result set. +BLOCK_MARKERS = ( + "Too Many Requests", + "Provisioned request rate has been exceeded", + "Access Denied", + "verify you are human", + "Press & Hold", + "px-captcha", + "Pardon Our Interruption", +) + + +def in_docker(): + return Path("/.dockerenv").exists() or os.environ.get("VRBO_IN_DOCKER") == "1" + + +def get_profile_dir(): + if in_docker(): + return tempfile.mkdtemp(prefix="vrbo-") + env_dir = os.environ.get("VRBO_PROFILE") + if env_dir: + return env_dir + return str(Path.home() / ".vrbo-profiles" / "default") + + +def build_search_url(dest, checkin, checkout, adults, children, pets): + # VRBO accepts a destination keyword plus ISO dates and traveler counts. + # It resolves the keyword to a regionId server-side and 302s to the canonical + # SERP, so we don't need to pre-resolve the region. + params = [ + ("destination", dest), + ("startDate", checkin), + ("endDate", checkout), + ("d1", checkin), + ("d2", checkout), + ("adults", str(adults)), + ] + if children: + params.append(("children", str(children))) + if pets: + params.append(("petIncluded", "true")) + query = "&".join(f"{k}={quote(str(v))}" for k, v in params) + return f"{SEARCH_URL}?{query}" + + +def text_has_block_marker(text): + """True if `text` contains any Akamai block/challenge marker (case-insensitive).""" + if not text: + return False + low = text.lower() + return any(marker.lower() in low for marker in BLOCK_MARKERS) + + +def parse_price(price_text): + """Parse VRBO's SERP price string into structured fields. + + VRBO renders e.g. "The current price is CA $867 CA $867 CA $6,841 total + includes taxes & fees" — the smallest amount is the per-night lead price, + the largest is the all-in total (taxes + fees INCLUDED on the SERP). + """ + result = {"pricePerNight": None, "priceTotal": None, "priceIncludesTaxes": None} + if not price_text: + return result + amounts = re.findall(r"\$\s?([\d,]+)", price_text) + nums = [int(a.replace(",", "")) for a in amounts if a.replace(",", "").isdigit()] + if nums: + result["pricePerNight"] = min(nums) + result["priceTotal"] = max(nums) + result["priceIncludesTaxes"] = "taxes" in price_text.lower() + return result + + +def is_skeleton(listing): + """A card with no id, name, or url is a placeholder/sponsored skeleton.""" + return not (listing.get("id") or listing.get("name") or listing.get("url")) + + +def enrich_listings(listings, min_bedrooms=0, limit=0): + """Filter skeletons, parse prices, apply client-side filters. Pure (no browser).""" + out = [l for l in listings if not is_skeleton(l)] + for l in out: + l.update(parse_price(l.get("priceText"))) + if min_bedrooms: + out = [ + l for l in out + if (l.get("bedrooms") is None) or (l.get("bedrooms") and l["bedrooms"] >= min_bedrooms) + ] + if limit: + out = out[:limit] + return out + + +def is_blocked(page): + title = (page.title() or "") + if text_has_block_marker(title): + return True + # Akamai sometimes returns 200 with the block/challenge text in the body. + try: + body = page.evaluate("() => document.body ? document.body.innerText.slice(0, 300) : ''") + return text_has_block_marker(body) + except Exception: + return False + + +# ============================================================ +# Extraction +# ============================================================ + +# Runs in the page context. VRBO/Expedia share the "Lodging" design system, so +# listing cards are tagged with stable data-stid attributes. We try three layers, +# most-structured first, and stop at the first that yields rows: +# 1. The embedded Apollo/Redux state blob (richest: ids, coords, review counts) +# 2. data-stid="lodging-card-responsive" DOM cards (reliable, less rich) +# 3. Generic anchor scrape to // (last-ditch) +EXTRACT_JS = r""" +() => { + const out = { source: null, listings: [] }; + + // --- Layer 1: embedded state blob ---------------------------------------- + const stateKeys = ['__PLUS_REDUX_STORE__', '__APOLLO_STATE__', '__INITIAL_STATE__']; + for (const k of stateKeys) { + const blob = window[k]; + if (!blob) continue; + try { + const text = typeof blob === 'string' ? blob : JSON.stringify(blob); + // Property listings carry a propertyId + a unitName/headline. Pull the + // objects rather than guessing the exact tree shape, which VRBO changes. + const flat = typeof blob === 'string' ? JSON.parse(blob) : blob; + const found = []; + const walk = (node) => { + if (!node || typeof node !== 'object') return; + const id = node.propertyId || node.listingId || node.id; + const name = node.headline || node.unitName || node.name || node.title; + const priceObj = node.price || node.priceSummary || node.lead || node.displayPrice; + if (id && name && priceObj) { + found.push({ + id: String(id), + name: String(name), + bedrooms: node.bedrooms ?? node.bedroomCount ?? null, + bathrooms: node.bathrooms ?? node.bathroomCount ?? null, + sleeps: node.sleeps ?? node.maxOccupancy ?? null, + rating: node.averageRating ?? node.reviewScore ?? null, + reviewCount: node.reviewCount ?? node.totalReviews ?? null, + priceText: typeof priceObj === 'string' + ? priceObj + : (priceObj.formatted || priceObj.amount || priceObj.lead || JSON.stringify(priceObj)).toString(), + lat: node.latitude ?? node.lat ?? (node.geoLocation && node.geoLocation.latitude) ?? null, + lng: node.longitude ?? node.lng ?? (node.geoLocation && node.geoLocation.longitude) ?? null, + url: id ? `https://www.vrbo.com/${String(id)}` : null, + }); + } + for (const v of Object.values(node)) { + if (v && typeof v === 'object') walk(v); + } + }; + walk(flat); + // Dedupe by id. + const seen = new Set(); + for (const r of found) { + if (seen.has(r.id)) continue; + seen.add(r.id); + out.listings.push(r); + } + if (out.listings.length) { out.source = k; return out; } + } catch (e) { /* fall through to DOM */ } + } + + // --- Layer 2: data-stid cards -------------------------------------------- + const cards = document.querySelectorAll('[data-stid="lodging-card-responsive"], [data-stid="property-listing-results"] [data-stid*="card"]'); + if (cards.length) { + out.source = 'dom:lodging-card'; + cards.forEach((card) => { + const a = card.querySelector('a[href]'); + const href = a ? a.getAttribute('href') : null; + if (!href) return; // skeleton / sponsored placeholder — skip + const name = (card.querySelector('h3, h4, [data-stid="content-hotel-title"]') || {}).innerText || null; + const priceEl = card.querySelector('[data-stid="price-summary"], [data-test-id="price-summary"], [class*="price"]'); + const priceText = priceEl ? priceEl.innerText.replace(/\s+/g, ' ').trim() : null; + const ratingEl = card.querySelector('[aria-label*="out of"], [data-stid*="rating"]'); + out.listings.push({ + id: href ? (href.split('?')[0].split('/').filter(Boolean).pop() || null) : null, + name: name ? name.trim() : null, + priceText, + rating: ratingEl ? (ratingEl.getAttribute('aria-label') || ratingEl.innerText) : null, + url: href ? (href.startsWith('http') ? href : `https://www.vrbo.com${href}`) : null, + }); + }); + if (out.listings.length) return out; + } + + // --- Layer 3: anchor scrape ---------------------------------------------- + const anchors = Array.from(document.querySelectorAll('a[href*="/"]')) + .map(a => a.getAttribute('href')) + .filter(h => h && /^\/?\d{5,}/.test(h.replace('https://www.vrbo.com', ''))); + if (anchors.length) { + out.source = 'dom:anchors'; + const seen = new Set(); + anchors.forEach(h => { + const id = h.split('?')[0].split('/').filter(Boolean).pop(); + if (seen.has(id)) return; + seen.add(id); + out.listings.push({ id, name: null, priceText: null, url: h.startsWith('http') ? h : `https://www.vrbo.com${h}` }); + }); + } + return out; +} +""" + + +def search(args): + profile_dir = get_profile_dir() + os.makedirs(profile_dir, exist_ok=True) + + from patchright.sync_api import sync_playwright + + url = build_search_url( + args.dest, args.checkin, args.checkout, args.adults, args.children, args.pets + ) + + with sync_playwright() as p: + try: + ctx = p.chromium.launch_persistent_context( + user_data_dir=profile_dir, + channel="chrome" if not in_docker() else None, + headless=False, # Akamai detects headless — must run headed (Xvfb in Docker) + viewport={"width": 1400, "height": 900}, + locale="en-CA", + args=["--disable-blink-features=AutomationControlled"], + ) + except Exception: + ctx = p.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=False, + viewport={"width": 1400, "height": 900}, + locale="en-CA", + args=["--disable-blink-features=AutomationControlled"], + ) + + page = ctx.pages[0] if ctx.pages else ctx.new_page() + + # Warm the Akamai cookie on the homepage first, then hit the SERP. + # Retry the pair a few times — the first sensor round often 429s, then + # clears once _abck is minted. + blocked = True + for attempt in range(1, args.retries + 1): + try: + page.goto(HOME_URL, wait_until="domcontentloaded", timeout=45000) + time.sleep(2.5) + if is_blocked(page): + print(f"[attempt {attempt}] homepage 429, retrying…", file=sys.stderr) + time.sleep(4 * attempt) + continue + page.goto(url, wait_until="domcontentloaded", timeout=60000) + # Wait for either listing cards or the block page. + try: + page.wait_for_selector( + '[data-stid="lodging-card-responsive"], [data-stid="property-listing-results"]', + timeout=20000, + ) + except Exception: + pass + time.sleep(2.0) + if is_blocked(page): + print(f"[attempt {attempt}] SERP challenge/429, retrying…", file=sys.stderr) + time.sleep(4 * attempt) + continue + # Cards lazy-load — scroll to trigger render, then poll a few times. + cards_present = False + for _ in range(4): + n = page.evaluate( + "() => document.querySelectorAll('[data-stid=\"lodging-card-responsive\"]').length" + ) + if n: + cards_present = True + break + page.mouse.wheel(0, 4000) + time.sleep(1.5) + if not cards_present: + print(f"[attempt {attempt}] no cards rendered (soft challenge?), retrying…", file=sys.stderr) + time.sleep(4 * attempt) + continue + blocked = False + break + except Exception as e: + print(f"[attempt {attempt}] navigation error: {e}", file=sys.stderr) + time.sleep(3 * attempt) + + if blocked: + ctx.close() + print( + "ERROR: VRBO blocked by Akamai after retries. Run headed on a " + "residential connection, or retry later — the bot wall is " + "IP-rate-limited. (Datacenter / CI IPs are frequently blocked.)", + file=sys.stderr, + ) + sys.exit(1) + + data = page.evaluate(EXTRACT_JS) + final_url = page.url + ctx.close() + + # Filter skeletons, parse prices, apply client-side filters (pure helper). + listings = enrich_listings( + data.get("listings", []), min_bedrooms=args.min_bedrooms, limit=args.limit + ) + + result = { + "source": "vrbo", + "extractor": data.get("source"), + "searchUrl": final_url, + "query": { + "destination": args.dest, + "checkin": args.checkin, + "checkout": args.checkout, + "adults": args.adults, + "children": args.children, + "pets": args.pets, + }, + "count": len(listings), + "listings": listings, + } + return result + + +def main(): + ap = argparse.ArgumentParser(description="VRBO vacation-rental search via Patchright") + ap.add_argument("--dest", required=True, help='Destination keyword, e.g. "Canmore, Alberta, Canada"') + ap.add_argument("--checkin", required=True, help="Check-in date YYYY-MM-DD") + ap.add_argument("--checkout", required=True, help="Check-out date YYYY-MM-DD") + ap.add_argument("--adults", type=int, default=2, help="Number of adults (default 2)") + ap.add_argument("--children", type=int, default=0, help="Number of children") + ap.add_argument("--pets", action="store_true", help="Pet-friendly only") + ap.add_argument("--min-bedrooms", type=int, default=0, help="Minimum bedrooms") + ap.add_argument("--max-price", type=float, default=0, help="(informational) per-night ceiling for the agent to apply") + ap.add_argument("--limit", type=int, default=20, help="Max listings to return (default 20)") + ap.add_argument("--retries", type=int, default=4, help="Akamai retry attempts (default 4)") + ap.add_argument("--json", action="store_true", help="Emit JSON to stdout") + args = ap.parse_args() + + result = search(args) + + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"VRBO — {result['count']} listings ({result['extractor']})", file=sys.stderr) + print(f" {result['searchUrl']}\n", file=sys.stderr) + for l in result["listings"]: + bedbath = "" + if l.get("bedrooms"): + bedbath = f" {l['bedrooms']}BR" + if l.get("bathrooms"): + bedbath += f"/{l['bathrooms']}BA" + print(f"• {l.get('name') or l.get('id')}{bedbath}") + if l.get("priceTotal"): + tax = " incl. taxes & fees" if l.get("priceIncludesTaxes") else "" + per = f" (~${l['pricePerNight']}/night)" if l.get("pricePerNight") else "" + print(f" ${l['priceTotal']} total{tax}{per}") + elif l.get("priceText"): + print(f" {l['priceText']}") + if l.get("rating"): + print(f" {l['rating']}") + if l.get("url"): + print(f" {l['url']}") + + +if __name__ == "__main__": + main() diff --git a/plugins/travel-hacking-toolkit/skills/vrbo/tests/test_search_vrbo.py b/plugins/travel-hacking-toolkit/skills/vrbo/tests/test_search_vrbo.py new file mode 100644 index 0000000..5703616 --- /dev/null +++ b/plugins/travel-hacking-toolkit/skills/vrbo/tests/test_search_vrbo.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Unit tests for the vrbo skill's pure logic. + +No browser, network, or Patchright required — the `patchright` import in +search_vrbo.py is lazy (inside `search()`), so importing the module is safe. + +Run with either: + python3 -m unittest discover -s skills/vrbo/tests + python3 -m pytest skills/vrbo/tests +""" + +import importlib.util +import os +import unittest + +# Load search_vrbo.py by path (it lives in ../scripts, not on sys.path). +_HERE = os.path.dirname(os.path.abspath(__file__)) +_SCRIPT = os.path.join(_HERE, "..", "scripts", "search_vrbo.py") +_spec = importlib.util.spec_from_file_location("search_vrbo", _SCRIPT) +sv = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(sv) + + +class BuildSearchUrlTests(unittest.TestCase): + def test_includes_core_params(self): + url = sv.build_search_url("Canmore, Alberta, Canada", "2026-06-26", "2026-07-02", 3, 0, False) + self.assertTrue(url.startswith("https://www.vrbo.com/search?")) + self.assertIn("startDate=2026-06-26", url) + self.assertIn("endDate=2026-07-02", url) + self.assertIn("adults=3", url) + + def test_url_encodes_destination(self): + url = sv.build_search_url("Canmore, Alberta", "2026-06-26", "2026-07-02", 2, 0, False) + # Comma and space must be percent-encoded, not raw. + self.assertIn("destination=Canmore%2C%20Alberta", url) + self.assertNotIn("destination=Canmore, Alberta", url) + + def test_children_and_pets_optional(self): + without = sv.build_search_url("X", "2026-06-26", "2026-07-02", 2, 0, False) + self.assertNotIn("children=", without) + self.assertNotIn("petIncluded", without) + with_extras = sv.build_search_url("X", "2026-06-26", "2026-07-02", 2, 2, True) + self.assertIn("children=2", with_extras) + self.assertIn("petIncluded=true", with_extras) + + +class BlockMarkerTests(unittest.TestCase): + def test_detects_hard_429(self): + self.assertTrue(sv.text_has_block_marker("Too Many Requests")) + self.assertTrue(sv.text_has_block_marker("Provisioned request rate has been exceeded")) + + def test_detects_soft_interstitial(self): + self.assertTrue(sv.text_has_block_marker("Please verify you are human to continue")) + self.assertTrue(sv.text_has_block_marker("Press & Hold to confirm")) + self.assertTrue(sv.text_has_block_marker("Pardon Our Interruption")) + + def test_case_insensitive(self): + self.assertTrue(sv.text_has_block_marker("too many requests")) + self.assertTrue(sv.text_has_block_marker("ACCESS DENIED")) + + def test_benign_text_not_blocked(self): + self.assertFalse(sv.text_has_block_marker("Mountain Retreat w/ Views & Hot Tub")) + self.assertFalse(sv.text_has_block_marker("")) + self.assertFalse(sv.text_has_block_marker(None)) + + +class ParsePriceTests(unittest.TestCase): + def test_real_vrbo_string(self): + # Verified live SERP format. + p = sv.parse_price("The current price is CA $867 CA $867 CA $6,841 total includes taxes & fees") + self.assertEqual(p["pricePerNight"], 867) + self.assertEqual(p["priceTotal"], 6841) + self.assertTrue(p["priceIncludesTaxes"]) + + def test_strips_thousands_separator(self): + p = sv.parse_price("CA $9,999 CA $9,999 CA $69,519 total includes taxes & fees") + self.assertEqual(p["pricePerNight"], 9999) + self.assertEqual(p["priceTotal"], 69519) + + def test_total_without_taxes_phrase(self): + p = sv.parse_price("$312 total") + self.assertEqual(p["priceTotal"], 312) + self.assertFalse(p["priceIncludesTaxes"]) + + def test_no_price(self): + for empty in (None, "", "no digits here"): + p = sv.parse_price(empty) + self.assertIsNone(p["pricePerNight"]) + self.assertIsNone(p["priceTotal"]) + self.assertIsNone(p["priceIncludesTaxes"]) + + +class SkeletonTests(unittest.TestCase): + def test_all_null_is_skeleton(self): + self.assertTrue(sv.is_skeleton({"id": None, "name": None, "url": None, "priceText": None})) + + def test_any_identity_field_is_not_skeleton(self): + self.assertFalse(sv.is_skeleton({"id": "p123", "name": None, "url": None})) + self.assertFalse(sv.is_skeleton({"id": None, "name": "Cabin", "url": None})) + self.assertFalse(sv.is_skeleton({"id": None, "name": None, "url": "https://vrbo.com/p1"})) + + +class EnrichListingsTests(unittest.TestCase): + def _sample(self): + return [ + {"id": "p1", "name": "Condo", "url": "u1", "priceText": "CA $200 CA $200 CA $1,400 total includes taxes & fees", "bedrooms": 2}, + {"id": None, "name": None, "url": None, "priceText": None}, # skeleton + {"id": "p3", "name": "Cabin", "url": "u3", "priceText": "CA $500 CA $3,000 total", "bedrooms": 4}, + {"id": "p4", "name": "Studio", "url": "u4", "priceText": None, "bedrooms": None}, + ] + + def test_drops_skeletons_and_parses_prices(self): + out = sv.enrich_listings(self._sample()) + self.assertEqual(len(out), 3) # skeleton removed + first = next(l for l in out if l["id"] == "p1") + self.assertEqual(first["pricePerNight"], 200) + self.assertEqual(first["priceTotal"], 1400) + self.assertTrue(first["priceIncludesTaxes"]) + + def test_min_bedrooms_keeps_unknown(self): + out = sv.enrich_listings(self._sample(), min_bedrooms=3) + ids = {l["id"] for l in out} + self.assertIn("p3", ids) # 4 BR ≥ 3 → kept + self.assertIn("p4", ids) # unknown bedrooms → kept (don't drop on missing data) + self.assertNotIn("p1", ids) # 2 BR < 3 → dropped + + def test_limit(self): + out = sv.enrich_listings(self._sample(), limit=1) + self.assertEqual(len(out), 1) + + +class ExtractJsTests(unittest.TestCase): + def test_extract_js_defines_three_layers(self): + # Guard against accidentally deleting a fallback layer during refactors. + js = sv.EXTRACT_JS + self.assertIn("__PLUS_REDUX_STORE__", js) + self.assertIn("lodging-card-responsive", js) + self.assertIn("dom:anchors", js) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/skill-meta.tsv b/scripts/skill-meta.tsv index ce8fd53..22b297d 100644 --- a/scripts/skill-meta.tsv +++ b/scripts/skill-meta.tsv @@ -39,3 +39,4 @@ booking-guidance reference The booking flow, hold-before-transfer rule, phone nu lessons-learned reference Hard-won knowledge from real searches. The mandatory Seats.aero workflow, Southwest specifics, Companion Pass math, source accuracy, small-market caveats, Duffel limitations. Load before any award flight search. round-the-world reference RTW + Pacific Circle + Asia-Pacific Circle + regional distance-award reference. 13 active programs, 4 discontinued. status-match reference Per-program status match rules with lifetime / once-per-N-years warnings, free vs paid concierge distinction, real fees, plus card-granted renewable status as the alternative. +vrbo hotels VRBO whole-home, condo, and cabin search via Patchright. Complements Airbnb for group stays. None (requires Patchright)