Price Win

Other

When the user asks for hotel deals, hotel prices, or wants to compare hotel prices across booking sites for travel dates, FOLLOW THIS SKILL. It uses the `terminal` tool EXCLUSIVELY (running `node bin/browse.js ...`) to drive a stealth Patchright daemon. DO NOT use any `browser_*` native tool (browser_navigate / browser_click / browser_type / browser_snapshot / browser_close) — those will be detected and timed out by Booking.com / Agoda. Discovers DOM selectors at runtime so it works across locales without hardcoded selectors.

Install

openclaw skills install price-win

OpenTravel Deal Finder

🚨 IMPORTANT — HOW TO USE THIS SKILL

This skill is NOT an async task. There is no background queue. If you do not run the shell commands below using the terminal tool, the user will get nothing. Do not tell the user "I'm waiting for results" before you have actually invoked any tool.

Your VERY FIRST action when this skill is activated must be to call the terminal tool with this exact command (substitute the user's city + dates):

node bin/browse.js opentravel "<city>" <checkInYYYY-MM-DD> <checkOutYYYY-MM-DD> <adults>

That call returns within ~200ms with direct-booking hotels. Then continue with the Booking discovery flow described below.

All commands below assume your shell is already in this skill's folder (the runtime sets CWD before executing terminal commands from a skill context). If you ever see "Cannot find module 'bin/browse.js'", run pwd and cd into the folder that contains this SKILL.md.

🚨 CRITICAL RULES — FOLLOW EVERY TIME

RULE 0 — FORBIDDEN TOOLS. Read this twice. This skill drives a long-running Patchright daemon via the terminal tool ONLY. Your runtime exposes several other tools that LOOK convenient but are STRICTLY FORBIDDEN inside this skill:

browser_navigate / browser_open — FORBIDDEN ❌ browser_click — FORBIDDEN ❌ browser_type / browser_fill — FORBIDDEN ❌ browser_snapshot — FORBIDDEN ❌ browser_close — FORBIDDEN ❌ Any other browser_* native tool — FORBIDDEN

Why: those native tools spawn a vanilla Chromium without stealth, so Booking.com and Agoda detect the bot within seconds and the requests just hang until the runtime kills them with "Command timed out after 30/60 seconds". You will burn 5+ minutes on timeouts and the user will get nothing. The Patchright daemon launched via terminal survives bot-detection.

✅ The ONLY allowed way to drive a browser in this skill is:

terminal: node bin/browse.js <subcommand> ...

e.g. node bin/browse.js goto https://..., node bin/browse.js snapshot, node bin/browse.js click <ref>, node bin/browse.js close.

If you catch yourself about to call browser_navigate or any browser_* tool: STOP, then issue the equivalent terminal: node bin/browse.js ... command instead. There are no exceptions, not even for "just a quick test page."

RULE 1 — Detect the user's language from their message, then ALL your replies (including the notice below) MUST be in that language. Vietnamese in → Vietnamese out. English in → English out. Thai in → Thai out. Do not default to one language.

RULE 2 — Send a "first-time discovery may take a while" notice to the user BEFORE running any browser command. Phrase it in the user's language. The intent (translate to user's language):

🔍 Searching hotels for you… The first search on Booking.com in your region takes about 2–4 minutes so I can learn the page layout. Future searches will be much faster (~30 seconds). Hang tight…

🚨 CRITICAL — Sending this notice does NOT end your turn. Immediately in the SAME turn (no waiting for the user to reply), continue with the next terminal: node bin/browse.js ... command per the playbook (Step 2 try-extract, then Step 3 Booking discovery, then Step 3.5 Agoda). The user already triggered the search — they expect you to keep working autonomously until you reply with the full Booking + Agoda + OpenTravel results. Do not finish the turn with finish_reason=stop after just the notice. Do not wait for a confirmation. The notice is a courtesy update, not a question.

RULE 3 — Never expose command names, file paths, error stacks, or developer jargon to the user.

❌ Don't say: "snapshot command timed out", "browse.js failed", "skill opentravel-deal-finder is unavailable". ✅ Say (in user's language): "I'm having trouble loading the page, retrying", "Booking is loading slowly".

RULE 4 — Source priority (always in this order):

  1. OpenTravel direct listings (one HTTPS API call, ~200ms) — always run first.
  2. Booking.com (browser flow, multi-step) — run second.
  3. Agoda (browser flow, similar to Booking but with calendar popup) — run third.
  4. Traveloka — skip in v0.2.x.

RULE 7 — Never call snapshot immediately after type if you expect a dropdown to be open. The snapshot is no-scroll by design, but if the agent calls scroll between type and snapshot, autocomplete will close. Order is: type → wait ~2s → snapshot and inspect the result.

RULE 8 — Use keyboard-press <key> (e.g. Escape) to dismiss popups that block other UI. Use scroll <y> only when you need to surface lazy-loaded content (e.g., paginated search results). Never scroll before a snapshot you depend on for dropdown options.

RULE 9 — Always attempt BOTH Booking and Agoda for every search. Booking succeeding does not replace Agoda; the user wants prices from all sources to compare. After Booking finishes (success or fail), continue to Agoda's flow (Step 3.5). Only fall back to OpenTravel-only when BOTH OTAs failed. If one of them succeeds, present its results merged with OpenTravel even if the other is still failing.

RULE 10 — Send a progress update to the user every ~60 seconds. The first-time discovery loop is long. After every 2-3 tool calls (or whenever you're about to start a new site), send a one-line update:

Đang phân tích Booking.com…
Đang chuyển sang Agoda…
Đã có kết quả Agoda, đang tổng hợp…

Keep updates short. Without them, the user thinks the bot is frozen.

RULE 11 — Daemon commands have a 30-second ceiling in this runtime. If snapshot / click / type / goto ever returns "Command timed out after 30 seconds", do NOT retry the same command unchanged. Instead:

  1. Call current-url to see where the page actually ended up.
  2. Re-snapshot to capture the new state.
  3. Decide next action from that fresh snapshot — don't assume the previous ref map is still valid.

RULE 12 — VERIFY URL after clicking the search button. Before assuming Booking returned results, call current-url and confirm the URL contains /searchresults. For Agoda, after switch-to-tab-matching, confirm the URL contains /search? and NOT /activities/ or /hotel/. If verification fails:

  • The click went to the wrong element (often a hotel-detail link in a calendar promo banner).
  • Treat it as Booking/Agoda discovery failure, mark that OTA as failed for this run, and CONTINUE to the next source.
  • DO NOT say "Đã có kết quả" if you haven't verified you're on a results page.

RULE 13 — Agoda is mandatory. Always attempt it. Even if Booking just succeeded with great data, after replying with Booking + OpenTravel, immediately start Agoda's flow (Step 3.5). The user wants BOTH OTAs every time so they can compare. The only acceptable reason to skip Agoda is "Booking AND Agoda both failed" (then OpenTravel-only). Skipping Agoda just because Booking already gave results is a bug — don't do that.

RULE 5 — Close the browser silently. After you've sent the hotel results to the user, run the close command but DO NOT show its {"status":"closed"} output as a message. It's an internal cleanup step; the user should never see "Browser closed." / "Đã đóng trình duyệt." / equivalent.

RULE 6 — Detect a real captcha block. The chal_t URL parameter alone is just a tracking token; do NOT bail on it. Only bail if the snapshot text shows visible captcha widgets (search for "captcha", "verify you're human", "Cloudflare", "challenge-form"). When you do detect a true captcha, tell the user (in their language) "Booking is requesting verification, here are the direct-booking options I have:" then list the OpenTravel hotels.

Step-by-step playbook

Step 1 — Always call OpenTravel API first

This is one shell command, returns instant JSON:

node bin/browse.js opentravel "<city>" <checkInYYYY-MM-DD> <checkOutYYYY-MM-DD> <adults>

Example:

node bin/browse.js opentravel "Mai Chau" 2026-06-05 2026-06-08 2

Output is JSON {"status":"opentravel-ok","hotels":[...],"indicativeHotels":[...]}. Keep these results — you'll merge them with Booking later.

Step 1.5 — Fast parallel path (use whenever both Booking + Agoda are cached)

If both Booking AND Agoda already have cached selectors + URL templates, you can fetch everything (OpenTravel + Booking + Agoda) in parallel with one call:

node bin/browse.js multi-extract "<city>" <checkIn> <checkOut> <adults> <locale>

<locale> is REQUIRED. Derive it from the user's message language:

  • Vietnamese → vi-vn
  • English → en-us (or en-gb for UK English)
  • Thai → th-th
  • Indonesian → id-id
  • Japanese → ja-jp

The same <locale> value MUST be used everywhere in this run (Step 2's try-extract, Step 3 / 3.5's save-selectors, and here). Cache is keyed per-locale, and the locale value gets substituted back into the cached URL template at replay time, so passing the wrong locale will either miss the cache or hit Booking/Agoda in the wrong language.

The daemon spawns an isolated browser context per OTA, navigates each cached URL in parallel, and runs the saved selectors. Result returns ~5-8 seconds total instead of ~15-20 seconds sequential.

  • If output contains "status":"multi-extract" with ota.results[] and opentravel.hotels[] — done, format and reply.
  • If output contains "missing":["agoda"] or similar — Agoda has no cached URL template yet, so fall through to the discovery flow (Step 3 + 3.5) for the missing sites only, then save their templates so the NEXT search hits the fast path.

Step 2 — Try Booking cache first (sequential fallback path)

node bin/browse.js try-extract booking <locale> search-cards

Replace <locale> with the Booking locale matching the user's language (vi-vn for Vietnamese, en-gb for English, th-th for Thai, etc.).

  • If output {"status":"cache-hit", ...} — you have results, skip to Step 5.
  • If {"status":"cache-miss"} or {"status":"cache-stale"} — continue to Step 3 (discovery).
  • If {"error":"No daemon running ..."} — go to Step 3a first.

Step 3 — Booking discovery flow (only on cache miss)

Send the RULE 2 notice to the user NOW, before any browser commands. Then:

3a. Launch the browser daemon. Returns immediately after the daemon is listening on a local port:

node bin/browse.js launch

3b. Navigate to Booking homepage in the user's locale:

node bin/browse.js goto "https://www.booking.com/index.<locale>.html"

(index.vi.html for Vietnamese, index.en-gb.html for English, index.th.html for Thai, etc.)

3c. Take a snapshot. The output is a numbered list of interactive elements:

node bin/browse.js snapshot

Each line looks like [12] input aria-label="Where to?". The number in brackets is a ref you'll use to click/fill the element.

3d. Identify the search input. Look for an input with placeholder/aria-label about destination. Note its ref. Fill the city:

node bin/browse.js fill <ref> "<city>"

3e. Snapshot again. The autocomplete dropdown should have opened — look for list items whose text contains the city name:

node bin/browse.js snapshot

3f. Click the matching autocomplete row (critical step — typing alone doesn't commit the destination):

node bin/browse.js click <suggestion-ref>

3g. SKIP the calendar — submit with default dates. Calendar interaction is brittle (promo hotel links inside the calendar steal clicks). Instead, just click the "Search" button without touching dates. Booking will navigate to /searchresults with today's default dates. The URL that comes back contains the city's stable dest_id parameter — that's the only thing you actually need to discover. Subsequent searches use the URL template (with {checkIn}/{checkOut} placeholders) to request any user-supplied dates without UI interaction.

3h. Find the "Search" button (its text is localized — "Tìm" in Vietnamese, "Search" in English, etc.). Click it:

node bin/browse.js click <search-button-ref>

3i. Verify the URL changed to a results page:

node bin/browse.js current-url

URL should now contain searchresults.

3j. SCROLL FIRST. Booking lazy-loads prices — the first paint shows hotel cards with name+link but the price element is still empty (or absent) until you scroll. Always scroll before trying selectors:

node bin/browse.js scroll 4000

Then snapshot the results page and find the repeating hotel cards. Identify selectors for: card container, name, price, link. Prefer data-testid attributes over class names.

3k. Dry-run your selectors:

node bin/browse.js try-selectors '{"card":"[data-testid=...]","name":"[data-testid=...]","price":"[data-testid=...]","link":"[data-testid=...]"}'

The output shows {stats:{total,withPrice,withName,withLink}, sample:[...]}. Sanity check:

  • withPrice / total should be ≥ 70%. If withPrice = 0 despite withName ≈ total, the price selector is wrong (very common — Booking rotates price selectors).
  • Try these price selector candidates in order until one returns prices:
    1. [data-testid="price-and-discounted-price"] (current standard)
    2. [data-testid="availability-rate-text"]
    3. [data-testid="price-text"]
    4. span[aria-label*="price" i]
    5. Look at the snapshot output for refs whose text matches /\d{1,3}[.,]\d{3}/ (number with thousands separator) — read the entry's data-testid attribute and use that as the price selector.
  • If after 4 candidate selectors all return withPrice=0, scroll 8000 then re-snapshot — sometimes Booking loads prices via second-paint after a longer scroll.
  • Only after the dry-run returns healthy data (withPrice/total ≥ 70%) proceed to save-selectors.

3l. Save the working selectors so future searches skip discovery:

node bin/browse.js save-selectors booking <locale> search-cards '{"card":"...","name":"...","price":"...","link":"..."}'

3m. Full extraction:

node bin/browse.js try-extract booking <locale> search-cards

3n. Close the browser (silent — do NOT show the {"status":"closed"} output to the user):

node bin/browse.js close

Step 3.5 — Agoda flow (very similar to Booking, but with calendar popup)

Agoda's homepage at https://www.agoda.com/<locale>/ defaults to a non-hotel tab depending on the user's history, so always click the "Khách sạn" / "Hotels" tab first.

3.5a. Try Agoda cache first:

node bin/browse.js try-extract agoda <locale> search-cards

If cache-hit, skip to merge. Otherwise:

3.5b. Goto Agoda homepage:

node bin/browse.js goto "https://www.agoda.com/<locale>/"

3.5c. Snapshot, find the "Khách sạn" / "Hotels" tab (look for data-selenium="allRoomsTab"), click it.

3.5d. Re-snapshot. Find the destination input (data-selenium="textInput", placeholder mentions destination). Use type (not fill) so Agoda's autocomplete fires on real keystrokes:

node bin/browse.js type <input-ref> "<city>"

3.5e. Wait ~2 seconds, then snapshot. Agoda will surface autocomplete options with data-testid="autosuggest-item". Click the FIRST option whose text matches the user's city + country (e.g. "Mai Châu (Hòa Bình), Việt Nam · Thành phố"). Avoid hotel-specific entries unless the user named a specific property.

3.5f. Clicking the suggestion opens the calendar popup. Snapshot — you'll see month navigation buttons (data-selenium="calendar-next-month-button" and calendar-previous-month-button) and day cells with aria-label like "Fri Jun 05 2026". Click the next-month arrow until the requested month is in view, then click the check-in day, then the check-out day.

3.5g. SKIP calendar interaction. Same trick as Booking — clicking inside Agoda's calendar can hit a hotel promo banner instead of a date cell. After the city suggestion click commits the destination, press keyboard-press Escape to close any auto-opened calendar, then click the "TÌM" button directly. Agoda will navigate to a /search?city=<cityId>&checkIn=<today>&checkOut=<tomorrow>... URL. That URL contains the city's stable cityId; the URL template will replace {checkIn}/{checkOut} with the user's dates on future searches.

3.5g-bis. CRITICAL — Agoda may open results in a NEW TAB. Right after clicking TÌM the browser can be in either of two states:

  • State A (one tab): the active tab itself navigated to the results URL (agoda.com/<locale>/search?...).
  • State B (two tabs): the active tab redirected to /activities/search?cityId=... (wrong tab — that's the activities/airport-transfer page) AND a second tab opened with /search?...city=<cityId> (the real hotel results).

Handle both cases with one command:

node bin/browse.js switch-to-tab-matching "/search?" "/activities/"

That switches focus to the first tab whose URL contains /search? AND does NOT contain /activities/. Verify with current-url — it must contain /search? and city=<id>.

Then clean up the stray activities tab so subsequent searches don't reuse it:

node bin/browse.js close-tabs-matching "/activities/"

Do NOT close tabs by index — close-by-URL is safer because the active results tab is never closed (the command skips the currently focused tab).

3.5h. On the results page, scroll to surface lazy-loaded cards (the first paint usually shows only the first ~3 entries):

node bin/browse.js scroll 5000

Snapshot, then identify the card pattern. Agoda's stable selectors at the time of writing are li[data-selenium="hotel-item"] for cards, [data-selenium="hotel-name"] for the name link, [data-element-name="final-price"] for the price.

3.5i. try-selectors to dry-run, save-selectors agoda <locale> search-cards, then try-extract for the final extraction.

3.5j. Close the browser (silent — RULE 5).

Step 4 — If any browser step fails twice, stop and use only OpenTravel results

Show the user a brief, friendly message in their language ("Booking is slow right now, here are the direct-booking results I have") and return Step 1's records.

Step 5 — Merge and present

Combine OpenTravel hotels + Booking hotels. Sort by price ascending. Format (translate the field labels into the user's language):

🏨 <name>  (<city>, <stars>★)
   💰 <price>/night — <Direct booking | Booking.com>
   <if hotel has both sources:>
   🏷  Direct: <direct price>/night — <directLink>
       (saves <X> vs Booking)
   <end>
   🔗 <link>

Show 10–12 hotels (or all of them if fewer found). Always keep at least one direct-bookable entry visible at the top of the list. Do NOT trim to just 3–5 — users want a real spread of price options to compare.

User-facing wording when things go wrong

Translate these into the user's language before sending:

Internal issueWhat to tell the user
Snapshot timed out"The page is loading slowly, let me retry."
No autocomplete after fill"Booking couldn't find this location. Want to try a different name?"
Zero cards on result page"Booking isn't showing results for this area today. Here are the direct-booking options I have:"
Network error / browser crash"I lost the Booking connection. Sending you the direct-booking data I already have:"

Data and consent

Before kicking off the Booking flow, confirm with the user if they didn't explicitly ask for OTA comparison. Scraping Booking at scale may violate their Terms of Service and can trigger CAPTCHAs.

Limitations

  • First search per locale pays the discovery cost (2–4 minutes for Booking).
  • Subsequent searches reuse the cache and complete in ~30 seconds.
  • If two browser attempts fail, return only the direct-booking results — never block the user indefinitely.