PriceWin Hotel Deal Finder Compare live hotel prices across Booking.com, Agoda, Google Hotels & OpenTravel in one command — and get back ranked best-value, cheapest, and quality picks with direct booking links. Stop opening five OTA tabs to find the real cheapest rate. Ask your agent "find me a hotel in Tokyo for 12–15 Aug, 2 guests" and this skill returns a clean, ranked comparison in 30–60 seconds (cached cities). Invoke this skill for questions like: - "What's the cheapest hotel in for ?" - "Is Booking.com or Agoda cheaper for this hotel?" - "Compare hotel prices for , guests." - "Find me…

+ Number(n).toLocaleString('en-US'); }\n\n/**\n * Neutralize untrusted third-party text before it reaches model-visible output.\n *\n * Hotel names come from OTA DOM (Booking title, Agoda hotel-name), Google\n * aria-labels, and the OpenTravel API — none of it is trusted. sanitizeText\n * defends on two fronts:\n * 1. Indirect prompt injection — strips control/zero-width/bidi chars, defangs\n * the markdown/link control set, and collapses whitespace so scraped text\n * cannot smuggle directives or fake structure into the agent's context.\n * 2. MarkdownV2 integrity — the same removed characters (brackets, parens,\n * backticks, backslashes, angle/brace/pipe) are exactly what would corrupt\n * the `[name](url)` link syntax, so a crafted or malformed name can't break\n * the rendered output.\n * Empty string ⇒ record is skipped upstream (treated as \"no name\").\n */\nfunction sanitizeText(s, max = 80) {\n if (typeof s !== 'string') return '';\n let t = s\n .normalize('NFC')\n .replace(/[\\u0000-\\u001F\\u007F-\\u009F]/g, \" \") // control chars\n .replace(/[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\u2028\\u2029\\uFEFF]/g, \"\") // zero-width / bidi / line-sep\n .replace(/[`[\\]()\u003c>{}\\\\|]/g, ' ') // markdown + link-breaking chars\n .replace(/\\s+/g, ' ')\n .trim();\n if (t.length > max) t = t.slice(0, max).trim() + '…';\n return t;\n}\n\n// Prices are normalized to USD for display. Agoda, Google and OpenTravel\n// geo-lock to VND by IP (so they need conversion); Booking honours USD. The\n// per-record `currency` from extraction drives the conversion. Rate is fetched\n// live with a sane fallback.\nlet VND_PER_USD = 25400;\nasync function loadFxRate() {\n try {\n const res = await fetch('https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout(8000) });\n const j = await res.json();\n const r = j?.rates?.VND;\n if (Number.isFinite(r) && r > 1000) VND_PER_USD = r;\n } catch { /* keep fallback */ }\n}\nfunction toUSD(price, currency) {\n const n = Number(price);\n if (!Number.isFinite(n) || n \u003c= 0) return 0;\n if (currency === 'VND') return Math.max(1, Math.round(n / VND_PER_USD));\n return Math.max(1, Math.round(n)); // already USD (or assume USD)\n}\n\n// OTAs compared by this skill. OpenTravel is an independent provider, listed\n// in the same tier as Booking/Agoda/Google (not a PriceWin \"direct\" source).\nconst OTAS = ['agoda', 'booking', 'google', 'opentravel'];\nconst OTA_LABEL = { agoda: 'Agoda', booking: 'Booking', google: 'Google', opentravel: 'OpenTravel' };\nfunction label(site) { return OTA_LABEL[site] || (site.charAt(0).toUpperCase() + site.slice(1)); }\n\n/**\n * Normalize a raw link from extraction:\n * - Resolve relative Agoda paths to absolute URLs\n * - Strip session/tracking params that add noise and can break markdown parsers\n * (e.g. flightSearchCriteria=[object Object] breaks MarkdownV2 link regex)\n */\nfunction cleanLink(url, site) {\n if (!url) return '';\n // Agoda and Google both return relative hrefs — prepend their host\n if (url.startsWith('/')) {\n if (site === 'agoda') url = 'https://www.agoda.com' + url;\n else if (site === 'google') url = 'https://www.google.com' + url;\n }\n // Leave other relative/unknown links blank (can't produce a clickable URL)\n if (!url.startsWith('http')) return '';\n try {\n const u = new URL(url);\n if (site === 'agoda') {\n // Strip Agoda session + noise params (includes the [object Object] offender)\n for (const p of [\n 'flightSearchCriteria', 'searchrequestid', 'isShowMobileAppPrice',\n 'finalPriceView', 'isCalendarCallout', 'missingChildAges',\n 'numberOfGuest', 'numberOfBedrooms', 'familyMode', 'maxRooms',\n 'showReviewSubmissionEntry', 'isFreeOccSearch', 'tspTypes', 'cid',\n ]) u.searchParams.delete(p);\n }\n if (site === 'booking') {\n // Strip Booking tracking/session params\n for (const p of [\n 'aid', 'label', 'ucfs', 'arphpl', 'srpvid', 'srepoch',\n 'all_sr_blocks', 'highlighted_blocks', 'matching_block_id',\n 'sr_pri_blocks', 'from', 'hapos', 'hpos', 'sr_order',\n 'nad_id', 'nad_cpc', 'nad_track', 'nad_placement',\n 'req_adults', 'req_children', 'group_children', 'no_rooms',\n ]) u.searchParams.delete(p);\n }\n if (site === 'google') {\n // Strip Google's search-context noise. `qs=` identifies the hotel\n // itself; ved/ts/ap/q are derived from the originating search.\n for (const p of ['ved', 'ts', 'ap', 'q']) u.searchParams.delete(p);\n }\n return u.toString();\n } catch {\n return url;\n }\n}\n\nasync function main() {\n // ── Step 0: ensure daemon ────────────────────────────────────────────────\n process.stderr.write('[search] launching daemon...\\n');\n const launchRaw = await run(['launch'], 30000);\n const launchR = parse(launchRaw);\n if (!launchR || (launchR.status !== 'launched' && launchR.status !== 'already-running')) {\n process.stderr.write(`[search] daemon error: ${launchRaw}\\n`);\n process.exit(1);\n }\n process.stderr.write(`[search] daemon ok (pid ${launchR.pid || '?'})\\n`);\n\n // FX rate for VND→USD display conversion (all sources price in VND).\n await loadFxRate();\n process.stderr.write(`[search] fx: 1 USD = ${VND_PER_USD} VND\\n`);\n\n // ── Step 1: OpenTravel API ──────────────────────────────────────────────\n process.stderr.write('[search] opentravel API...\\n');\n const otRaw = await run(['opentravel', city, checkIn, checkOut, adults], 20000);\n const otR = parse(otRaw) || {};\n\n // ── Step 1.5: fast-path multi-extract (Booking + Agoda) ──────────────────\n process.stderr.write('[search] multi-extract (booking + agoda)...\\n');\n const meRaw = await run(['multi-extract', city, checkIn, checkOut, adults, locale], 60000);\n let meR = parse(meRaw);\n\n // ── Step 3.5: discovery for missing Agoda (per-city numeric cityId) ──────\n let missing = meR?.missing ?? [];\n if (missing.includes('agoda')) {\n process.stderr.write('[search] agoda not cached — running discovery...\\n');\n try {\n await discoverAgoda(city, checkIn, checkOut, adults, locale);\n process.stderr.write('[search] agoda discovery done, re-running multi-extract...\\n');\n const meRaw2 = await run(['multi-extract', city, checkIn, checkOut, adults, locale], 60000);\n meR = parse(meRaw2) ?? meR;\n } catch (e) {\n process.stderr.write(`[search] agoda discovery failed: ${e.message}\\n`);\n }\n }\n\n // ── Step 5: merge Booking + Agoda + OpenTravel ─────────────────────────\n const all = {};\n\n for (const r of (meR?.ota?.results ?? [])) {\n const site = r.site;\n for (const h of (r.records ?? [])) {\n const name = sanitizeText(h.name);\n if (!name || !h.price) continue;\n if (!all[name]) all[name] = { prices: {}, links: {} };\n all[name].prices[site] = toUSD(h.price, h.currency);\n all[name].links[site] = cleanLink(h.link ?? '', site);\n }\n }\n\n // ── Step 5.5: Google Hotels via aria-label extraction ────────────────────\n // No URL caching — Google's destination layout varies per city and the\n // aria-label structure changes with locale. Always re-navigate.\n process.stderr.write('[search] google search inline...\\n');\n try {\n const googleRecords = await searchGoogleHotels(city, checkIn, checkOut, locale);\n process.stderr.write(`[search] google returned ${googleRecords.length} records\\n`);\n for (const h of googleRecords) {\n const name = sanitizeText(h.name);\n if (!name || !h.price) continue;\n if (!all[name]) all[name] = { prices: {}, links: {} };\n all[name].prices.google = toUSD(h.price, h.currency);\n all[name].links.google = cleanLink(h.link ?? '', 'google');\n }\n } catch (e) {\n process.stderr.write(`[search] google search failed: ${e.message}\\n`);\n }\n\n // ── Step 5.6: Booking.com via direct searchresults URL ───────────────────\n // Booking honours `selected_currency=USD`, so its prices come back in USD.\n process.stderr.write('[search] booking search inline...\\n');\n try {\n const bookingRecords = await searchBookingHotels(city, checkIn, checkOut, adults);\n process.stderr.write(`[search] booking returned ${bookingRecords.length} records\\n`);\n for (const h of bookingRecords) {\n const name = sanitizeText(h.name);\n if (!name || !h.price) continue;\n if (!all[name]) all[name] = { prices: {}, links: {} };\n all[name].prices.booking = toUSD(h.price, h.currency);\n all[name].links.booking = cleanLink(h.link ?? '', 'booking');\n }\n } catch (e) {\n process.stderr.write(`[search] booking search failed: ${e.message}\\n`);\n }\n\n // OpenTravel — an independent OTA, same tier as Booking/Agoda/Google.\n // Per-night price comes from `cheapestPrice`; the public API returns no\n // booking URL, so the link stays empty (name renders as plain text).\n for (const h of [...(otR.hotels ?? []), ...(otR.indicativeHotels ?? [])]) {\n const name = sanitizeText(h.name);\n const price = h.cheapestPrice ?? h.price ?? h.pricePerNight;\n if (!name || !price) continue;\n if (!all[name]) all[name] = { prices: {}, links: {} };\n all[name].prices.opentravel = toUSD(price, h.currency ?? 'VND');\n // Route the partner-API link through cleanLink too, so a non-http(s)\n // value (e.g. javascript:) can never reach a rendered hyperlink.\n all[name].links.opentravel = cleanLink(h.url ?? h.link ?? '', 'opentravel');\n }\n\n const sorted = Object.entries(all).sort((a, b) =>\n Math.min(...Object.values(a[1].prices)) - Math.min(...Object.values(b[1].prices)));\n\n if (!sorted.length) {\n console.log(`❌ No hotels found for ${city} (${checkIn}→${checkOut}).`);\n console.log(' No results from Booking · Agoda · Google · OpenTravel for this city/date.');\n process.exit(0);\n }\n\n const nights = Math.round((new Date(checkOut) - new Date(checkIn)) / 86400000);\n const d1 = checkIn.slice(5).replace('-', '/');\n const d2 = checkOut.slice(5).replace('-', '/');\n\n const lines = [];\n lines.push(`🏨 ${sanitizeText(city, 60) || city} • ${d1}–${d2} • ${nights} nights • ${adults} guests`);\n lines.push('━'.repeat(20));\n\n const labels = ['🥇 BEST VALUE', '🥈 CHEAPEST', '🥉 QUALITY'];\n for (let i = 0; i \u003c Math.min(3, sorted.length); i++) {\n const [name, { prices, links }] = sorted[i];\n const priceEntries = Object.entries(prices).sort((a, b) => a[1] - b[1]);\n const best = priceEntries[0];\n const worst = priceEntries[priceEntries.length - 1];\n lines.push('');\n lines.push(labels[i]);\n // Hotel name as Markdown link → cheapest OTA. transform_llm_output bypasses\n // the model so [text](url) syntax survives intact through to Telegram's\n // format_message which converts it to a MarkdownV2 hyperlink (clickable,\n // no raw URL visible).\n const cheapestUrl = links[best[0]];\n lines.push(` ${cheapestUrl ? `[${name}](${cheapestUrl})` : name}`);\n for (const [site, price] of priceEntries) {\n const mark = site === best[0] ? '✅' : ' ';\n lines.push(` ${mark} ${site.padEnd(10)} 💰 ${fmt(price)}/night`);\n }\n const diff = worst[1] - best[1];\n if (priceEntries.length > 1 && diff > 3) {\n lines.push(` → Save ${fmt(diff)} vs ${label(worst[0])}`);\n }\n }\n\n // \"More good deals\" — balanced per-OTA picks instead of a flat\n // cheapest-first list (which Google would dominate, since it returns the\n // most records). For each OTA in order: take the 3 cheapest hotels on\n // that OTA that aren't already in the top-3 tier cards above.\n if (sorted.length > 3) {\n const topNames = new Set(sorted.slice(0, 3).map(([n]) => n));\n const picks = [];\n const used = new Set();\n for (const ota of OTAS) {\n const onOta = Object.entries(all)\n .filter(([n, d]) => d.prices[ota] != null && !topNames.has(n) && !used.has(n))\n .sort((a, b) => a[1].prices[ota] - b[1].prices[ota])\n .slice(0, 3);\n for (const entry of onOta) {\n picks.push({ ota, entry });\n used.add(entry[0]);\n }\n }\n if (picks.length) {\n lines.push('');\n lines.push('📋 More good deals');\n let curOta = null;\n for (const { ota, entry } of picks) {\n if (ota !== curOta) {\n lines.push(` — ${label(ota)} —`);\n curOta = ota;\n }\n const [name, { prices, links }] = entry;\n // Order prices so the section's own OTA is first; hyperlink hotel\n // name to that OTA's URL so the click goes to the platform the row\n // is grouped under.\n const priceEntries = Object.entries(prices).sort((a, b) => {\n if (a[0] === ota) return -1;\n if (b[0] === ota) return 1;\n return a[1] - b[1];\n });\n const sectionUrl = links[ota];\n const truncName = name.slice(0, 45);\n const nameLink = sectionUrl ? `[${truncName}](${sectionUrl})` : truncName;\n const priceStr = priceEntries.map(([s, p]) => `${s}: ${fmt(p)}`).join(' | ');\n lines.push(` • ${nameLink} — ${priceStr}`);\n }\n }\n }\n\n const [bestName, bestData] = sorted[0];\n const bestSite = Object.entries(bestData.prices).sort((a,b)=>a[1]-b[1])[0][0];\n const bestPrice = Math.min(...Object.values(bestData.prices));\n lines.push('');\n lines.push(`💡 Tip: ${bestName}`);\n const bestLink = bestData.links[bestSite];\n const bestSiteCap = label(bestSite);\n const cta = bestLink ? `[Book on ${bestSiteCap}](${bestLink})` : `Book on ${bestSiteCap}`;\n lines.push(` ${cta} — ${fmt(bestPrice)}/night`);\n // Only credit sources that actually returned data this run.\n const presentSites = OTAS.filter(s => sorted.some(([, d]) => d.prices[s] != null));\n lines.push(`\\n📊 ${sorted.length} hotels | ${presentSites.map(label).join(' · ')} • prices in USD`);\n\n console.log(lines.join('\\n'));\n\n // ── Cleanup ────────────────────────────────────────────────────────────────\n await run(['close'], 5000).catch(() => {});\n}\n\nasync function discoverAgoda(city, checkIn, checkOut, adults, locale) {\n async function r(args, t = 30000) { return parse(await run(args, t)); }\n\n // Navigate to Agoda homepage\n await r(['goto', `https://www.agoda.com/${locale}/`], 20000);\n\n // Find and type in search box\n const snap1Raw = await run(['snapshot'], 10000);\n const inputRef = snap1Raw.match(/\\[(\\d+)\\][^\\n]*data-selenium=\"textInput\"/)?.[1];\n if (!inputRef) throw new Error('Agoda search input not found');\n\n await r(['type', inputRef, city]);\n await new Promise(res => setTimeout(res, 3000));\n\n // Click first autocomplete suggestion\n const snap2Raw = await run(['snapshot'], 10000);\n const optRef = snap2Raw.match(/\\[(\\d+)\\][^\\n]*autosuggest-item/)?.[1];\n if (!optRef) throw new Error('No autocomplete suggestion for: ' + city);\n\n await r(['click', optRef]);\n await new Promise(res => setTimeout(res, 1000));\n await r(['keyboard-press', 'Escape']);\n await new Promise(res => setTimeout(res, 500));\n\n // Find and click search button. Agoda's en-us label is \"SEARCH\" (uppercase);\n // match case-insensitively to tolerate locale/label variations.\n const snap3Raw = await run(['snapshot'], 10000);\n const btnRef = snap3Raw.match(/\\[(\\d+)\\] button \"SEARCH\"/i)?.[1];\n if (!btnRef) throw new Error('Search button not found');\n\n await r(['click', btnRef]);\n await new Promise(res => setTimeout(res, 4000));\n\n // Switch to hotel results tab if needed. Agoda opens hotel results in a new\n // tab at `agoda.com/search` (no locale segment), while the original tab may\n // land on `/activities/`. Match the hotel-results URL, not a locale path.\n const urlRaw = await r(['current-url'], 5000);\n if (!urlRaw?.url?.includes('agoda.com/search')) {\n await r(['switch-to-tab-matching', 'agoda.com/search', 'activities'], 10000);\n await new Promise(res => setTimeout(res, 2000));\n }\n\n // Guard: only cache once we're actually on a hotel-results page. If Agoda\n // redirected to the homepage/overview (anti-bot, or the search never went\n // through), bail without caching a broken URL — the caller continues with\n // Booking + Google, and the next run retries discovery cleanly.\n const finalUrl = await r(['current-url'], 5000);\n if (!finalUrl?.url?.includes('agoda.com/search')) {\n throw new Error('Agoda did not reach a results page (homepage redirect / anti-bot)');\n }\n\n // Save selectors immediately with city as slug\n await r(['save-selectors', 'agoda', locale, 'search-cards',\n JSON.stringify({\n card: 'li[data-selenium=hotel-item]',\n name: '[data-selenium=hotel-name]',\n price: '[data-element-name=final-price]',\n link: '[data-selenium=hotel-name]',\n }),\n city,\n ], 10000);\n\n // Wait for cards and extract\n await new Promise(res => setTimeout(res, 3000));\n await r(['try-extract', 'agoda', locale, 'search-cards'], 15000);\n}\n\n/**\n * Search Google Hotels for a city and parse cards via aria-label.\n *\n * Google's destination layout doesn't fit our generic textContent extractor:\n * - h2 inside a card can be the room description instead of the hotel name\n * → unreliable for matching across OTAs\n * - Price text concatenates discounts/totals → naive digit-parsing produces\n * garbage\n *\n * The price link for each card has an aria-label of the form\n * \"Prices for \u003cHotelName> start at \u003ccurrency>\u003cprice>\"\n * which contains both name and price in a single, parseable string.\n *\n * Returns array of { name, price, link }. Throws on navigation failure.\n */\nasync function searchGoogleHotels(city, checkIn, checkOut, locale) {\n async function r(args, t = 30000) { return parse(await run(args, t)); }\n const lang = (locale || 'en-us').split('-')[0];\n\n const url = `https://www.google.com/travel/search`\n + `?q=${encodeURIComponent(city)}`\n + `&hl=${lang}&gl=us&curr=USD`\n + `&checkin=${checkIn}&checkout=${checkOut}`;\n await r(['goto', url], 25000);\n await new Promise(res => setTimeout(res, 6000));\n\n // Per-card aria-label selector — the English Google Hotels price link reads\n // \"Prices starting from \u003cprice>, \u003cHotelName>\".\n const selector = 'a[aria-label^=\"Prices starting from\"]';\n\n const probeRaw = await run(['query-all', selector, '50', '0'], 15000);\n const probe = parse(probeRaw);\n if (!probe?.matches?.length) {\n throw new Error('Google Hotels: no price-link cards found (selector=\"' + selector + '\")');\n }\n\n // Parse aria-label \"Prices starting from \u003ccurrency>\u003cprice>, \u003cHotelName>\".\n // Price comes first (skip the currency symbol/code before the digits), then\n // the hotel name after the comma.\n const enRe = /^Prices starting from\\s*\\D*?([\\d.,]+),\\s*(.+)$/;\n const records = [];\n for (const m of probe.matches) {\n const aria = m.ariaLabel || '';\n const match = aria.match(enRe);\n if (!match) continue;\n // Strip Google's promo suffix (\"... GREAT DEAL 51% less than usual\",\n // \"... DEAL 19% less than\") so the name dedupes cleanly across OTAs.\n const name = match[2].replace(/\\s+(GREAT DEAL|DEAL)\\b.*$/i, '').trim();\n const priceDigits = match[1].replace(/[^\\d]/g, '');\n const price = Number(priceDigits);\n if (!name || !Number.isFinite(price) || price \u003c= 0) continue;\n const currency = /US\\$|\\bUSD\\b|^\\$|\\s\\$/.test(aria) ? 'USD' : 'VND';\n records.push({ name, price, link: m.href || '', currency });\n }\n return records;\n}\n\n/**\n * Search Booking.com via its direct searchresults URL and extract cards with an\n * ad-hoc selector recipe (no cache/discovery needed — the URL is built fresh\n * each time). Booking honours `selected_currency=USD`, so prices come back in\n * USD; the recipe also reports the detected currency per record.\n *\n * Returns array of { name, price, currency, link }.\n */\nasync function searchBookingHotels(city, checkIn, checkOut, adults) {\n const url = 'https://www.booking.com/searchresults.html'\n + `?ss=${encodeURIComponent(city)}`\n + `&checkin=${checkIn}&checkout=${checkOut}`\n + `&group_adults=${adults}&no_rooms=1&group_children=0&selected_currency=USD`;\n await run(['goto', url], 30000);\n await new Promise(res => setTimeout(res, 6000));\n // Booking lazy-loads property cards on scroll — scroll down to populate more.\n for (let i = 0; i \u003c 3; i++) {\n await run(['scroll', '6000', '900', '250'], 15000);\n await new Promise(res => setTimeout(res, 1200));\n }\n\n const recipe = JSON.stringify({\n card: 'div[data-testid=property-card]',\n name: 'div[data-testid=title]',\n price: 'span[data-testid=price-and-discounted-price]',\n link: 'a[data-testid=title-link]',\n });\n const raw = parse(await run(['extract-all', recipe], 15000));\n const recs = raw?.records ?? [];\n return recs\n .filter(r => r?.name && r?.price)\n .map(r => ({ name: r.name.trim(), price: r.price, currency: r.currency, link: r.link || '' }));\n}\n\nmain().catch(e => {\n console.error('[search] fatal:', e.message);\n process.exit(1);\n});\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":22783,"content_sha256":"1acb74c70a2ab21e08a5f8543482f0aab02adf23bcb6a8e9fdf209b6041d4cee"},{"filename":"install.sh","content":"#!/bin/bash\n# ----------------------------------------------------------------------------\n# pricewin-hotel-deal-finder — first-run installer for the agentic version.\n# Idempotent.\n# ----------------------------------------------------------------------------\nset -euo pipefail\n\ncd \"$(dirname \"$0\")\"\n\necho \"[pricewin-hotel-deal-finder] Installing Node deps (locked versions)...\"\nif [ -f package-lock.json ]; then\n npm ci --omit=dev --no-audit --no-fund\nelse\n npm install --omit=dev --no-audit --no-fund\nfi\n\necho \"[pricewin-hotel-deal-finder] Downloading Chromium for Patchright (one-time, ~200MB)...\"\nnpx --yes patchright install chromium\n\necho \"[pricewin-hotel-deal-finder] Done.\"\necho\necho \"First-time usage: the agent will discover selectors live for each site\"\necho \"and locale (~2-3 minutes per source) and cache them at\"\necho \" ~/.cache/pricewin-hotel-deal-finder/selectors.json\"\necho \"Subsequent searches reuse the cache and complete in ~30 seconds.\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":963,"content_sha256":"ac7ed1e143b455edc65c38c19a8140b4332919c58180af217d311d4a5236ea7c"},{"filename":"lib/browser-state.js","content":"// ----------------------------------------------------------------------------\n// browser-state.js\n//\n// Persists the running Chromium's CDP endpoint to a state file so that\n// subsequent CLI invocations can re-attach to the same browser. Without this\n// every CLI call would spawn a fresh browser and lose its tab + cookies.\n//\n// State layout (~/.cache/pricewin-hotel-deal-finder/session-\u003cid>.json):\n// {\n// \"port\": 51234,\n// \"pid\": 12345,\n// \"token\": \"\u003c64-hex auth token for the daemon's localhost API>\",\n// \"createdAt\": \"2026-05-18T10:00:00Z\"\n// }\n//\n// The file carries the daemon's auth token, so it is created 0600 inside a 0700\n// directory: on a shared machine another local user must not be able to read it\n// and drive the browser.\n// ----------------------------------------------------------------------------\n\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport os from 'node:os';\nimport { existsSync, mkdirSync, chmodSync } from 'node:fs';\n\nconst STATE_DIR = path.join(os.homedir(), '.cache', 'pricewin-hotel-deal-finder');\nconst DEFAULT_SESSION = 'default';\n\nfunction ensureDir() {\n if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });\n // Tighten a directory an older version created 0755.\n else chmodSync(STATE_DIR, 0o700);\n}\n\nfunction statePath(sessionId = DEFAULT_SESSION) {\n return path.join(STATE_DIR, `session-${sessionId}.json`);\n}\n\nexport async function saveState(state, sessionId = DEFAULT_SESSION) {\n ensureDir();\n const file = statePath(sessionId);\n await fs.writeFile(file, JSON.stringify(state, null, 2), { mode: 0o600 });\n // writeFile's `mode` only applies when it creates the file — chmod covers the\n // case where a previous run left a world-readable state file behind.\n await fs.chmod(file, 0o600).catch(() => {});\n}\n\nexport async function loadState(sessionId = DEFAULT_SESSION) {\n try {\n const raw = await fs.readFile(statePath(sessionId), 'utf8');\n return JSON.parse(raw);\n } catch (err) {\n if (err.code === 'ENOENT') return null;\n throw err;\n }\n}\n\nexport async function clearState(sessionId = DEFAULT_SESSION) {\n await fs.unlink(statePath(sessionId)).catch(() => {});\n}\n\nexport function isProcessAlive(pid) {\n if (!pid) return false;\n try {\n // Signal 0 = test without actually signalling. Throws if process gone.\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2436,"content_sha256":"5dcddaa0965e643d3d6833fe83a9dacba59b1bf17c8b010cab6ed507b4a1e258"},{"filename":"lib/dom-extract.js","content":"// ----------------------------------------------------------------------------\n// dom-extract.js\n//\n// Given a selector recipe (an object with CSS selectors per field), pull\n// records out of the current page. Used by both the cache fast-path\n// (`try-extract`) and the agent's discovery dry-run (`try-selectors`).\n//\n// Recipe shape (whatever the agent discovers, but these field names are\n// expected downstream by the merger):\n// {\n// \"card\": \"[data-testid='property-card']\", // required — the row container\n// \"name\": \"[data-testid='title']\", // required — hotel name\n// \"price\": \"[data-testid='price-and-discounted-price']\", // required\n// \"link\": \"a[data-testid='title-link']\", // optional — booking URL\n// \"image\": \"img[data-testid='image']\", // optional\n// \"rating\":\"[data-testid='review-score']\", // optional\n// \"stars\": \"[data-testid='rating-stars']\" // optional\n// }\n//\n// `extractWithSelectors` returns:\n// {\n// records: [{name, price, currency, link, image?, rating?, stars?}, …],\n// stats: { total, withPrice, withName, withLink }\n// }\n//\n// The stats let the caller decide whether the recipe is healthy enough to\n// keep cached. `isExtractionHealthy` codifies that decision.\n// ----------------------------------------------------------------------------\n\nconst REQUIRED_FIELDS = ['name', 'price'];\n\n/**\n * Run a recipe against the live page. Card selector returns the list; the\n * other selectors are scoped *inside* each card so we don't accidentally\n * match the wrong row.\n */\nexport async function extractWithSelectors(page, selectors) {\n if (!selectors?.card) throw new Error('Recipe missing required \"card\" selector');\n for (const f of REQUIRED_FIELDS) {\n if (!selectors[f]) throw new Error(`Recipe missing required \"${f}\" selector`);\n }\n\n const raw = await page.evaluate(\n ({ sel }) => {\n const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();\n const parsePrice = (txt) => {\n if (!txt) return null;\n const digits = txt.replace(/[^\\d]/g, '');\n const n = Number(digits);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n const detectCurrency = (txt) => {\n if (!txt) return null;\n if (/USD|\\$/.test(txt)) return 'USD';\n if (/EUR|€/.test(txt)) return 'EUR';\n if (/GBP|£/.test(txt)) return 'GBP';\n if (/THB|฿/.test(txt)) return 'THB';\n if (/JPY|¥/.test(txt)) return 'JPY';\n if (/VND|₫/i.test(txt)) return 'VND';\n return null;\n };\n const cards = Array.from(document.querySelectorAll(sel.card));\n return cards.slice(0, 50).map((card) => {\n const nameEl = card.querySelector(sel.name);\n const priceEl = card.querySelector(sel.price);\n // Resolve link element. Fallback chain so Google's pattern (the card\n // itself is an \u003ca> tag with no nested anchor) still produces a URL:\n // 1. Try the explicit `link` selector\n // 2. If missing, check if card itself is an \u003ca> with href\n // 3. Last resort: any nested \u003ca href>\n let linkEl = sel.link ? card.querySelector(sel.link) : null;\n if (!linkEl && card.tagName === 'A' && card.hasAttribute('href')) {\n linkEl = card;\n }\n if (!linkEl) linkEl = card.querySelector('a[href]');\n const imageEl = sel.image ? card.querySelector(sel.image) : card.querySelector('img');\n const ratingEl = sel.rating ? card.querySelector(sel.rating) : null;\n const starsEl = sel.stars ? card.querySelector(sel.stars) : null;\n const priceText = cleanText(priceEl);\n return {\n name: cleanText(nameEl) || null,\n priceText,\n price: parsePrice(priceText),\n currency: detectCurrency(priceText),\n link: linkEl?.getAttribute('href') || null,\n image: imageEl?.getAttribute('src') || null,\n rating: ratingEl ? cleanText(ratingEl) : null,\n starsText: starsEl ? cleanText(starsEl) : null,\n };\n });\n },\n { sel: selectors },\n );\n\n const records = raw.filter((r) => r.name && r.price);\n const stats = {\n total: raw.length,\n withPrice: raw.filter((r) => r.price).length,\n withName: raw.filter((r) => r.name).length,\n withLink: raw.filter((r) => r.link).length,\n };\n return { records, stats };\n}\n\n/**\n * Decide whether an extraction is good enough to keep the cached selectors\n * alive. Used by the CLI to drive the fail counter in selector-cache.js.\n *\n * Healthy if: at least 5 records returned AND at least 80% have both a\n * non-null price and link. Tunable.\n */\nexport function isExtractionHealthy({ records, stats }) {\n if (records.length \u003c 5) return false;\n const ratio = stats.total === 0 ? 0 : stats.withPrice / stats.total;\n if (ratio \u003c 0.8) return false;\n return true;\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4890,"content_sha256":"904b0216fa5528bcea41b4b5ef503843b8b92ebe25ac0803f53da6c8f7d139f3"},{"filename":"lib/selector-cache.js","content":"// ----------------------------------------------------------------------------\n// selector-cache.js\n//\n// Self-healing selector cache. The agent's discovery loop is expensive\n// (~3 min, ~$1 in tokens) so we cache the selectors it finds and reuse them\n// indefinitely. A TTL would be wasted effort: if the site ever changes its\n// markup the cached selectors will simply return incomplete data, which\n// trips the fail counter and forces a fresh discovery anyway.\n//\n// Invalidation rules:\n// - Extract returns no records, or records missing required fields, 3\n// times in a row → entry is dropped, agent re-discovers.\n// - User can manually wipe via `browse refresh-cache`.\n//\n// Cache file layout (~/.cache/pricewin-hotel-deal-finder/selectors.json):\n// {\n// \"\u003csite>:\u003clocale>:\u003ctask>\": {\n// \"selectors\": { \"card\": \"[...]\", \"name\": \"[...]\", ... },\n// \"discoveredAt\": ISO,\n// \"lastWorkedAt\": ISO,\n// \"consecutiveFails\": 0\n// }\n// }\n//\n// NOTE: We deliberately do NOT ship a seed file. Selectors and aria-labels\n// vary per locale (en-us vs th-th vs ja-jp), per region, and per A/B test\n// bucket — shipping one author's selectors would just cause cache misses\n// for every user on a different bucket, defeating the whole point of the\n// cache. First-time users pay the discovery cost (~3 min, ~$1) once per\n// (site, locale) and amortise from there.\n// ----------------------------------------------------------------------------\n\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport os from 'node:os';\nimport { existsSync, mkdirSync } from 'node:fs';\n\nconst CACHE_DIR = path.join(os.homedir(), '.cache', 'pricewin-hotel-deal-finder');\nconst CACHE_FILE = path.join(CACHE_DIR, 'selectors.json');\n\nconst MAX_CONSECUTIVE_FAILS = 3;\n\nfunction ensureCacheDir() {\n if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });\n}\n\nfunction cacheKey(site, locale, task) {\n return `${site}:${locale}:${task}`;\n}\n\nasync function readCache() {\n ensureCacheDir();\n if (!existsSync(CACHE_FILE)) return {};\n try {\n return JSON.parse(await fs.readFile(CACHE_FILE, 'utf8'));\n } catch {\n return {};\n }\n}\n\nasync function writeCache(cache) {\n ensureCacheDir();\n await fs.writeFile(CACHE_FILE, JSON.stringify(cache, null, 2));\n}\n\nfunction isPoisoned(entry) {\n return (entry?.consecutiveFails || 0) >= MAX_CONSECUTIVE_FAILS;\n}\n\n/**\n * Get cached selectors. Returns `null` if there's nothing usable — the agent\n * discovery loop should fire and `recordDiscovery` should save the result.\n * No expiration: as long as the selectors keep producing usable data they\n * stay forever.\n */\nexport async function getSelectors(site, locale, task) {\n const cache = await readCache();\n const entry = cache[cacheKey(site, locale, task)];\n if (!entry) return null;\n if (isPoisoned(entry)) return null;\n return entry.selectors;\n}\n\n/**\n * Mark cached selectors as having worked. Resets the fail counter and bumps\n * `lastWorkedAt` so the entry stays warm.\n */\nexport async function recordSuccess(site, locale, task) {\n const cache = await readCache();\n const key = cacheKey(site, locale, task);\n const entry = cache[key];\n if (!entry) return;\n entry.lastWorkedAt = new Date().toISOString();\n entry.consecutiveFails = 0;\n cache[key] = entry;\n await writeCache(cache);\n}\n\n/**\n * Mark cached selectors as having failed (returned no usable data). After\n * `MAX_CONSECUTIVE_FAILS` in a row the entry is considered poisoned and\n * `getSelectors` will return null on the next call, forcing re-discovery.\n */\nexport async function recordFailure(site, locale, task) {\n const cache = await readCache();\n const key = cacheKey(site, locale, task);\n const entry = cache[key];\n if (!entry) return;\n entry.consecutiveFails = (entry.consecutiveFails || 0) + 1;\n cache[key] = entry;\n await writeCache(cache);\n}\n\n/**\n * Save freshly discovered selectors (or overwrite stale ones).\n */\nexport async function recordDiscovery(site, locale, task, selectors, urlTemplate = null, citySlug = null) {\n const cache = await readCache();\n const key = cacheKey(site, locale, task);\n cache[key] = {\n selectors,\n // Optional URL template. When the template contains a numeric site-specific\n // city param (e.g. Agoda city=3987) that was NOT templatized, citySlug records\n // which city this template is valid for. multi-extract uses citySlug to detect\n // a city mismatch and fall through to discovery instead of returning wrong data.\n urlTemplate,\n ...(citySlug ? { citySlug } : {}),\n discoveredAt: new Date().toISOString(),\n lastWorkedAt: new Date().toISOString(),\n consecutiveFails: 0,\n };\n await writeCache(cache);\n}\n\nexport async function getEntry(site, locale, task) {\n const cache = await readCache();\n return cache[cacheKey(site, locale, task)] || null;\n}\n\n/**\n * Drop a single entry — exposed for manual cache busting via the CLI.\n */\nexport async function invalidate(site, locale, task) {\n const cache = await readCache();\n delete cache[cacheKey(site, locale, task)];\n await writeCache(cache);\n}\n\n/**\n * Drop everything. Useful for `browse refresh-cache --all`.\n */\nexport async function invalidateAll() {\n await writeCache({});\n}\n\nexport const _internals = { isPoisoned, MAX_CONSECUTIVE_FAILS };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":5295,"content_sha256":"e4151a054ba28678aa38713149e9060655ee9702a9f500dc5187fd0bf648edd9"},{"filename":"lib/snapshot.js","content":"// ----------------------------------------------------------------------------\n// snapshot.js\n//\n// Reads the loaded page and produces a compact text rendering for the LLM\n// agent to reason over. Output is a numbered list of interactive elements\n// and visible structural text. Each entry has a `[ref]` token; the same\n// ref can be passed back to `click \u003cref>` / `fill \u003cref> \u003ctext>` because\n// the snapshot writes its index into a `data-browse-ref` attribute on the\n// live DOM, which `lib/dom-extract.js` (and the CLI) can then re-locate.\n//\n// Filtering rules:\n// - Keep \u003ca>, \u003cbutton>, \u003cinput>, \u003cselect>, \u003ctextarea>\n// - Keep elements with role=button/link/searchbox/checkbox/menuitem\n// - Keep \u003ch1>–\u003ch6>\n// - Keep visible text nodes that look like prices, hotel names, or\n// review scores\n// - Skip elements that are hidden (display:none, visibility:hidden,\n// opacity:0, zero-size) or inside \u003cscript>/\u003cstyle>\n//\n// Token-budget conscious: each entry is one line; the whole snapshot is\n// typically 200–800 lines (~1–4k tokens) for a busy results page.\n// ----------------------------------------------------------------------------\n\n/**\n * Public API. Call after page.goto / page.waitForLoadState.\n * Returns:\n * {\n * text: \"[1] input placeholder=\\\"Where to?\\\"\\\\n[2] button \\\"Search\\\"\\\\n…\",\n * refs: { 1: \"\u003cstable CSS selector>\", 2: \"\u003cstable CSS selector>\", … }\n * }\n *\n * The daemon caches the `refs` map so subsequent click/type/fill commands\n * can look up the stable selector even if React rerenders and wipes our\n * `data-browse-ref` attribute.\n *\n * Stable selector strategy: prefer stable attributes (`data-testid`,\n * `data-selenium`, `data-element-name`), then unique aria-label or href,\n * then path-based nth-of-type chain. Always validated to be unique at\n * snapshot time.\n */\nexport async function takeSnapshot(page) {\n const result = await page.evaluate(() => {\n const PRICE_RE = /[\\d.,]{3,}\\s*(VND|₫|\\$|USD|THB|JPY|EUR|£)\\b/i;\n const isVisible = (el) => {\n const style = getComputedStyle(el);\n if (style.display === 'none' || style.visibility === 'hidden' || +style.opacity === 0) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const cleanText = (s) => (s || '').replace(/\\s+/g, ' ').trim().slice(0, 160);\n const kind = (el) => {\n const tag = el.tagName.toLowerCase();\n const role = (el.getAttribute('role') || '').toLowerCase();\n if (tag === 'a' || role === 'link') return 'link';\n if (tag === 'button' || role === 'button') return 'button';\n if (tag === 'input') {\n const t = (el.getAttribute('type') || 'text').toLowerCase();\n return t === 'submit' ? 'button' : 'input';\n }\n if (tag === 'select' || tag === 'textarea') return 'input';\n if (role === 'searchbox' || role === 'textbox' || role === 'combobox') return 'input';\n if (role === 'checkbox') return 'checkbox';\n if (role === 'menuitem' || role === 'option') return 'option';\n if (role === 'tab' || role === 'switch' || role === 'radio') return 'button';\n if (/^h[1-6]$/.test(tag)) return 'heading';\n return null;\n };\n const stableSelectorFor = (el) => {\n // 1) #id when id is css-safe\n const id = el.id;\n if (id && /^[A-Za-z][\\w-]*$/.test(id) && document.querySelectorAll('#' + id).length === 1) {\n return '#' + id;\n }\n // 2) stable data-* attrs when unique\n for (const a of ['data-testid', 'data-selenium', 'data-element-name']) {\n const v = el.getAttribute(a);\n if (v) {\n const sel = '[' + a + '=' + JSON.stringify(v) + ']';\n if (document.querySelectorAll(sel).length === 1) return sel;\n }\n }\n // 3) unique aria-label\n const aria = el.getAttribute('aria-label');\n if (aria) {\n const sel = el.tagName.toLowerCase() + '[aria-label=' + JSON.stringify(aria) + ']';\n if (document.querySelectorAll(sel).length === 1) return sel;\n }\n // 4) unique href on anchors\n const href = el.getAttribute('href');\n if (href && el.tagName === 'A') {\n const sel = 'a[href=' + JSON.stringify(href) + ']';\n if (document.querySelectorAll(sel).length === 1) return sel;\n }\n // 5) path-based nth-of-type chain\n const parts = [];\n let cur = el;\n while (cur && cur !== document.body && cur.tagName) {\n const sibs = Array.from((cur.parentElement && cur.parentElement.children) || []).filter((c) => c.tagName === cur.tagName);\n const i = sibs.indexOf(cur);\n parts.unshift(cur.tagName.toLowerCase() + (sibs.length > 1 ? ':nth-of-type(' + (i + 1) + ')' : ''));\n cur = cur.parentElement;\n }\n return 'body > ' + parts.join(' > ');\n };\n // Clear any old refs from a prior snapshot.\n document.querySelectorAll('[data-browse-ref]').forEach((el) => el.removeAttribute('data-browse-ref'));\n let n = 0;\n const entries = [];\n const refMap = {};\n const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) {\n const el = walker.currentNode;\n if (!el || !el.tagName) continue;\n if (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(el.tagName)) continue;\n if (!isVisible(el)) continue;\n const k = kind(el);\n if (!k) continue;\n n += 1;\n el.setAttribute('data-browse-ref', String(n));\n const attrs = {};\n for (const name of ['data-testid', 'data-selenium', 'aria-label', 'placeholder', 'name', 'href', 'type', 'role', 'id']) {\n const v = el.getAttribute(name);\n if (v) attrs[name] = cleanText(v);\n }\n const entryText = cleanText(el.innerText || el.value || '');\n entries.push({ ref: n, kind: k, text: entryText, attrs });\n // Keep BOTH a CSS selector (fast path) and a signature (resilient\n // fallback if React re-renders strips attrs after snapshot).\n refMap[n] = {\n selector: stableSelectorFor(el),\n signature: {\n tag: el.tagName.toLowerCase(),\n kind: k,\n text: entryText,\n testid: el.getAttribute('data-testid') || el.getAttribute('data-selenium') || null,\n ariaLabel: el.getAttribute('aria-label') || null,\n placeholder: el.getAttribute('placeholder') || null,\n href: el.getAttribute('href') || null,\n },\n };\n }\n // Capture standalone price text nodes (Booking pattern).\n const priceWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);\n while (priceWalker.nextNode()) {\n const t = priceWalker.currentNode;\n const txt = cleanText(t.nodeValue);\n if (!txt || !PRICE_RE.test(txt)) continue;\n const parent = t.parentElement;\n if (!parent || parent.hasAttribute('data-browse-ref')) continue;\n if (!isVisible(parent)) continue;\n n += 1;\n parent.setAttribute('data-browse-ref', String(n));\n entries.push({ ref: n, kind: 'price', text: txt, attrs: {} });\n refMap[n] = {\n selector: stableSelectorFor(parent),\n signature: { tag: parent.tagName.toLowerCase(), kind: 'price', text: txt, testid: null, ariaLabel: null, placeholder: null, href: null },\n };\n }\n return { entries, refMap };\n });\n const refs = {};\n const lines = [];\n for (const e of result.entries) {\n refs[e.ref] = result.refMap[e.ref] || { selector: `[data-browse-ref=\"${e.ref}\"]`, signature: null };\n const parts = [`[${e.ref}]`, e.kind];\n if (e.kind === 'price') {\n parts.push(JSON.stringify(e.text));\n } else if (e.kind === 'heading') {\n parts.push(JSON.stringify(e.text));\n } else {\n // For links/buttons/inputs, show label + key attrs that help the LLM\n // pick the right one (testid, href, placeholder).\n if (e.text) parts.push(JSON.stringify(e.text));\n for (const k of ['data-testid', 'data-selenium', 'aria-label', 'placeholder', 'href']) {\n if (e.attrs[k]) parts.push(`${k}=${JSON.stringify(e.attrs[k])}`);\n }\n }\n lines.push(parts.join(' '));\n }\n return { text: lines.join('\\n'), refs };\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":8147,"content_sha256":"828bc283618da61881e622a818d425a3d8ce39fc031f89e428e425bee700f1af"},{"filename":"package.json","content":"{\n \"name\": \"pricewin-hotel-deal-finder\",\n \"version\": \"1.1.3\",\n \"type\": \"module\",\n \"private\": true,\n \"description\": \"Agent-driven browser primitives + OpenTravel API client. The agent reasons over text DOM snapshots and drives the browser action-by-action — no pre-coded selectors.\",\n \"author\": \"PriceWin\",\n \"license\": \"MIT-0\",\n \"homepage\": \"https://github.com/Price-Win/pricewin-skills-hub/tree/main/skills/pricewin-hotel-deal-finder#readme\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/Price-Win/pricewin-skills-hub.git\",\n \"directory\": \"skills/pricewin-hotel-deal-finder\"\n },\n \"bugs\": \"https://github.com/Price-Win/pricewin-skills-hub/issues\",\n \"bin\": {\n \"browse\": \"bin/browse.js\"\n },\n \"scripts\": {\n \"postinstall\": \"echo 'Run install.sh next to download Chromium' >&2\"\n },\n \"dependencies\": {\n \"patchright\": \"^1.55.2\"\n },\n \"engines\": {\n \"node\": \">=20\"\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":920,"content_sha256":"02f726d28d6c42551e0c8cebddbf5dca4c75435d8ce5ac74274fe978cde7bf4d"},{"filename":"SECURITY.md","content":"# Security & Data Handling — PriceWin Hotel Deal Finder\n\nThis skill drives a stealth browser (Patchright/Chromium) to read **public** hotel\nlistings and compare prices. It runs entirely on the user's machine, needs no API\nkeys, and collects no personal data. This document discloses exactly what it\nexecutes, what it downloads, what leaves the machine, and how untrusted web\ncontent is contained — so an auditing agent (or a human) can verify the behavior\ninstead of inferring it.\n\n## What runs (command execution)\n\nThe skill's only entry point is one command:\n\n```bash\nnode bin/search.js \"\u003ccity>\" \u003ccheckIn> \u003ccheckOut> \u003cadults> [locale]\n```\n\n`search.js` spawns `bin/browse.js` / `bin/daemon.js` (both shipped in this repo)\nto control a local browser. It runs **no** shell strings built from scraped\ncontent, executes **no** downloaded code, and takes **no** command input from web\npages — the browser flow is fixed in this repo's source. `npm install` / `npx`\nappear **only** at install time to fetch the `patchright` dependency (see below).\n\nThe skill's whole source is three files in `bin/` and four in `lib/`: no minified\nblobs, no obfuscation, no `eval`, no post-install script that fetches code (the\n`postinstall` hook only prints a reminder). Every source file is auditable in the\npublic repo\n\u003chttps://github.com/Price-Win/pricewin-skills-hub>.\n\n## Why a \"stealth\" browser\n\nThe word is doing narrow work here. Patchright masks the CDP-level automation\nfingerprint so a **normal, logged-out page view** of a public listing is not\nmistaken for a scraping bot and served an empty or blocked page. It exists to\nread public prices reliably from bot-hardened cities, not to evade security\ncontrols: the skill does not bypass authentication, solve CAPTCHAs, defeat\nrate limits with proxy rotation, hide from the user, or persist beyond the\nsession (`browse close`, `SIGTERM`, or the state file disappearing all stop it).\nNothing about it is aimed at endpoint security software, and it makes no attempt\nto conceal what it is doing on the user's own machine — the daemon logs to\nstderr and its state file is in plain sight under `~/.cache/`.\n\n## What it downloads\n\n| Item | When | Source | Purpose |\n|------|------|--------|---------|\n| `patchright` npm package | install | npm registry | Stealth Playwright fork (browser driver) |\n| Chromium | first run (`install.sh`) | Patchright's official host | The browser engine that renders OTA pages |\n\nNo other binaries or code are downloaded at runtime.\n\n## What leaves the machine (network egress)\n\nEgress is limited to a fixed, auditable set of hosts. **The only user-derived data\nsent is the search query itself** — city, check-in/out dates, guest count. No\naccount data, credentials, cookies from other sites, files, or PII are transmitted.\n\n| Host | Data sent | Why |\n|------|-----------|-----|\n| `booking.com`, `agoda.com`, `google.com/travel` | city + dates + guests (as normal search URL params) | Read public listing prices |\n| `api.opentravel.one` (override via `OPENTRAVEL_API_BASE_URL`) | city + dates + guests | Partner inventory lookup |\n| `open.er-api.com` | none (public `GET /latest/USD`) | Live VND→USD FX rate for price normalization |\n\nThere is no telemetry, analytics, or callback to PriceWin servers.\n\n## The local daemon (`bin/daemon.js`)\n\n`search.js` drives one long-running local process that owns the Chromium\ninstance and answers commands over HTTP. It is privileged — it can navigate and\nread any page — so it is locked down on four axes:\n\n| Control | Implementation |\n|---|---|\n| Loopback only | `server.listen(port, '127.0.0.1')` — never reachable from the LAN or internet |\n| Authenticated | Every request (including `/ping`) must carry `x-pricewin-token`, a 32-byte random token minted per daemon run and compared with `crypto.timingSafeEqual` |\n| Token not readable by other users | The token lives only in `~/.cache/pricewin-hotel-deal-finder/session-default.json`, written `0600` inside a `0700` directory |\n| Anti-DNS-rebinding | Requests whose `Host` header is not `127.0.0.1` / `localhost` / `::1` are rejected `403`, so a web page cannot reach the daemon even if it guesses the port |\n\nEphemeral port, chosen at startup; no fixed port to scan for. The daemon exits on\n`SIGTERM`/`SIGINT` and on `browse close`, clearing its state file.\n\n**Chromium sandbox stays ON.** `--no-sandbox` is *not* passed by default. It is\nadded only when the sandbox provably cannot work — running as root on Linux, or\nan explicit `PRICEWIN_NO_SANDBOX=1` — and the daemon prints a warning to stderr\nwhen it does.\n\n## Untrusted content containment (indirect prompt injection)\n\nHotel names, prices, and aria-labels scraped from OTA pages are **untrusted\nthird-party content**. Before any of it reaches the model-visible output,\n`sanitizeText()` in `bin/search.js`:\n\n- strips control, zero-width, and bidirectional-override characters (defeats\n hidden-instruction and text-spoofing tricks);\n- removes the markdown/link control set (`` ` `` `[ ] ( ) \u003c > { } \\ |`) so scraped\n text cannot forge `[label](url)` structure or smuggle directives;\n- collapses whitespace and caps length.\n\nAll booking links (including the OpenTravel partner API's) are passed through\n`cleanLink()`, which **accepts only `http(s)` URLs** — a `javascript:` or other\nscheme can never render as a clickable link. The skill also treats scraped data\nas data only: it ranks and formats prices, and never executes or follows\ninstructions found inside scraped text.\n\n## Guidance for the running agent\n\nThe skill instructs the agent to treat OTA output as reference data to present to\nthe user, not as commands. Partial results (a source blocked or empty) are normal\nand are surfaced honestly rather than \"fixed\" by ad-hoc scraping.\n\n## Reporting\n\nFound an issue? Open a ticket at\n\u003chttps://github.com/Price-Win/pricewin-skills-hub/issues>.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5887,"content_sha256":"e0209a38017a668d01d5e66ef603bd5850245aeb99a64ba5b6fa81077ae07631"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"PriceWin Hotel Deal Finder","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Compare live hotel prices across Booking.com, Agoda, Google Hotels & OpenTravel in one command","type":"text","marks":[{"type":"strong"}]},{"text":" — and get back ranked best-value, cheapest, and quality picks with direct booking links.","type":"text"}]}]},{"type":"paragraph","content":[{"text":"Stop opening five OTA tabs to find the real cheapest rate. Ask your agent ","type":"text"},{"text":"\"find me a hotel in Tokyo for 12–15 Aug, 2 guests\"","type":"text","marks":[{"type":"em"}]},{"text":" and this skill returns a clean, ranked comparison in ~30–60 seconds (cached cities).","type":"text"}]},{"type":"paragraph","content":[{"text":"Invoke this skill for questions like:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"What's the cheapest hotel in ","type":"text"},{"text":"\u003ccity>","type":"text","marks":[{"type":"code_inline"}]},{"text":" for ","type":"text"},{"text":"\u003cdates>","type":"text","marks":[{"type":"code_inline"}]},{"text":"?\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Is Booking.com or Agoda cheaper for this hotel?\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Compare hotel prices for ","type":"text"},{"text":"\u003ccity>","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"\u003cN>","type":"text","marks":[{"type":"code_inline"}]},{"text":" guests.\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Find me a hotel under $","type":"text"},{"text":"\u003cX>","type":"text","marks":[{"type":"code_inline"}]},{"text":"/night in ","type":"text"},{"text":"\u003ccity>","type":"text","marks":[{"type":"code_inline"}]},{"text":".\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Best-value place to stay near ","type":"text"},{"text":"\u003clandmark>","type":"text","marks":[{"type":"code_inline"}]},{"text":" on ","type":"text"},{"text":"\u003cdates>","type":"text","marks":[{"type":"code_inline"}]},{"text":"?\"","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Each returns the same one-command answer below — no clarifying round-trip needed.","type":"text"}]},{"type":"paragraph","content":[{"text":"What you get from one command:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"🥇 Best value · 🥈 Cheapest · 🥉 Quality — picked side-by-side","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Real per-night prices from up to ","type":"text"},{"text":"4 sources","type":"text","marks":[{"type":"strong"}]},{"text":", normalized to ","type":"text"},{"text":"USD","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Clickable booking links","type":"text","marks":[{"type":"strong"}]},{"text":" straight to the cheapest OTA for each hotel","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Works for ","type":"text"},{"text":"any city worldwide","type":"text","marks":[{"type":"strong"}]},{"text":" — including bot-hardened ones (Shanghai, Hangzhou, Bangkok…) via a stealth Patchright daemon","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No API keys, no MCP server — ","type":"text"},{"text":"node","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" is all you need","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Sample result:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"🏨 Tokyo • Aug 12–15 • 3 nights • 2 guests\n━━━━━━━━━━━━━━━━━━━━\n🥇 BEST VALUE\nShinjuku Granbell Hotel\n ✅ agoda 💰 $118/night\n booking 💰 $131/night\n → Save $13 vs Booking\n🥈 CHEAPEST\nAPA Hotel Shinjuku\n ✅ google 💰 $94/night\n📊 18 hotels | agoda, booking, google, opentravel • prices in USD","type":"text"}]},{"type":"paragraph","content":[{"text":"Install:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"npx skills add https://github.com/Price-Win/pricewin-skills-hub --skill pricewin-hotel-deal-finder","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"How to use this skill","type":"text"}]},{"type":"paragraph","content":[{"text":"One command does the whole job — you normally won't need to ask clarifying questions first. Infer the parameters (below) and run it:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"cd {baseDir} && node bin/search.js \"\u003ccity>\" \u003ccheckInYYYY-MM-DD> \u003ccheckOutYYYY-MM-DD> \u003cadults> en-us","type":"text"}]},{"type":"paragraph","content":[{"text":"{baseDir}","type":"text","marks":[{"type":"code_inline"}]},{"text":" is this skill's install directory (auto-resolved by the runtime). If your runtime does not substitute it, ","type":"text"},{"text":"cd","type":"text","marks":[{"type":"code_inline"}]},{"text":" into the folder that contains this ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the one with ","type":"text"},{"text":"bin/search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Avoid hardcoding a ","type":"text"},{"text":"~/.hermes/...","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"~/.openclaw/...","type":"text","marks":[{"type":"code_inline"}]},{"text":" path — it differs per platform.","type":"text"}]},{"type":"paragraph","content":[{"text":"Example:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"cd {baseDir} && node bin/search.js \"Hangzhou\" 2026-06-10 2026-06-13 2 en-us","type":"text"}]},{"type":"paragraph","content":[{"text":"The script handles everything automatically: daemon launch, Agoda cache lookup, Google + Booking inline search, OpenTravel API lookup (all cities), discovery for new cities, and formatted tier-card output. Run it and send the output to the user.","type":"text"}]},{"type":"paragraph","content":[{"text":"Infer the parameters instead of asking","type":"text","marks":[{"type":"strong"}]},{"text":" (ask only if the city or dates are genuinely ambiguous):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Year:","type":"text","marks":[{"type":"strong"}]},{"text":" use the current year from today's date unless the user states otherwise. If the requested day/month has already passed this year, assume next year. (Get today's date with ","type":"text"},{"text":"date +%Y-%m-%d","type":"text","marks":[{"type":"code_inline"}]},{"text":" if unsure.)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"10-13/6\"","type":"text","marks":[{"type":"strong"}]},{"text":" → ","type":"text"},{"text":"\u003cyear>-06-10 \u003cyear>-06-13","type":"text","marks":[{"type":"code_inline"}]},{"text":" — fill ","type":"text"},{"text":"\u003cyear>","type":"text","marks":[{"type":"code_inline"}]},{"text":" from the rule above","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"2 guests\" / \"2 people\"","type":"text","marks":[{"type":"strong"}]},{"text":" → ","type":"text"},{"text":"2","type":"text","marks":[{"type":"code_inline"}]},{"text":" adults","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Locale:","type":"text","marks":[{"type":"strong"}]},{"text":" language/region code passed to the OTAs (controls site language + region). Default ","type":"text"},{"text":"en-us","type":"text","marks":[{"type":"code_inline"}]},{"text":". Prices are in USD (Google Hotels is requested with ","type":"text"},{"text":"gl=us&curr=USD","type":"text","marks":[{"type":"code_inline"}]},{"text":"); other sources follow the locale you pass.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"One ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" run is the whole workflow — no Python, curl, or ad-hoc scraping is needed on top of it.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Operating rules — how to get reliable results","type":"text"}]},{"type":"paragraph","content":[{"text":"RULE 0 — Drive the browser only through ","type":"text","marks":[{"type":"strong"}]},{"text":"search.js","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (via your terminal/shell tool). The native browser tools don't work here.","type":"text","marks":[{"type":"strong"}]},{"text":" This skill relies on a stealth Patchright daemon. The runtime's native tools — ","type":"text"},{"text":"browser_navigate","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"browser_open","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"browser_click","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"browser_type","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"browser_fill","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"browser_snapshot","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"browser_close","type":"text","marks":[{"type":"code_inline"}]},{"text":", any other ","type":"text"},{"text":"browser_*","type":"text","marks":[{"type":"code_inline"}]},{"text":", and subagent delegation (","type":"text"},{"text":"delegate_task","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"spawn_agent","type":"text","marks":[{"type":"code_inline"}]},{"text":") — will fail on this task, so don't reach for them:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Those native tools spawn a vanilla Chromium with no stealth, so Booking.com and Agoda detect the bot within seconds; the requests hang until the runtime kills them (\"Command timed out after 30/60 seconds\"). That burns 5+ minutes and returns nothing. The Patchright daemon that ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" launches survives bot-detection.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Delegated subagents start with empty history and no skill context, so they fall back to Python/curl scraping that gets bot-blocked immediately. Run the skill in the current agent.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The one path that works:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"cd {baseDir} && node bin/search.js ...","type":"text"}]},{"type":"paragraph","content":[{"text":"RULE 1 — Let ","type":"text","marks":[{"type":"strong"}]},{"text":"search.js","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" do the scraping; don't scrape an OTA yourself.","type":"text","marks":[{"type":"strong"}]},{"text":" Avoid calling ","type":"text"},{"text":"browse.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" directly, doing ","type":"text"},{"text":"goto","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"click","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"type","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the browser, building Agoda/Booking/Google URLs by hand, calling the OpenTravel API separately, or launching the daemon yourself. ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" already drives the stealth daemon through a careful flow that survives bot-detection — it handles Agoda discovery internally for EVERY city (including Chinese cities like Shanghai, Hangzhou, etc.). Manually navigating an OTA is the #1 cause of failure: it trips Agoda/Booking anti-bot (\"detect automation\", \"redirect to homepage\", \"problem completing your search\") and gets the IP blocked. Run ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" once and send its output. If a source looks \"missing\", see RULE 4 rather than fetching it by hand.","type":"text"}]},{"type":"paragraph","content":[{"text":"RULE 2 — First-time city discovery takes 2–4 minutes.","type":"text","marks":[{"type":"strong"}]},{"text":" If ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" output contains ","type":"text"},{"text":"\"discovering\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"\"launching\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" messages, tell the user: \"First time searching this city — discovering selectors, this takes about 2–4 minutes...\" and wait for the result rather than retrying or aborting.","type":"text"}]},{"type":"paragraph","content":[{"text":"RULE 3 — Send the output exactly.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" outputs formatted tier cards ready to send. Copy the output directly into your response. Do not reformat, summarize, or abbreviate it.","type":"text"}]},{"type":"paragraph","content":[{"text":"RULE 3a — Preserve the markdown hyperlinks.","type":"text","marks":[{"type":"strong"}]},{"text":" Every hotel name in the output is already wrapped as ","type":"text"},{"text":"[Hotel Name](https://booking-url...)","type":"text","marks":[{"type":"code_inline"}]},{"text":" — a clickable hyperlink. Keep it intact: don't split the URL onto a separate ","type":"text"},{"text":"🔗 https://...","type":"text","marks":[{"type":"code_inline"}]},{"text":" line, don't replace ","type":"text"},{"text":"[Hotel Name](url)","type":"text","marks":[{"type":"code_inline"}]},{"text":" with plain text, keep OTA names lowercase (\"google\", not \"Google\"), and keep section titles as-is (\"📋 More good deals\"). The output is Telegram-MarkdownV2-ready; sending it verbatim gives the user clickable hotel names with hidden URLs (clean UI).","type":"text"}]},{"type":"paragraph","content":[{"text":"RULE 3b — Hyperlink hotel names in your own commentary too.","type":"text","marks":[{"type":"strong"}]},{"text":" If you add a suggestion or commentary section after the output, wrap every hotel name you mention as ","type":"text"},{"text":"[Hotel Name](url)","type":"text","marks":[{"type":"code_inline"}]},{"text":" using the same URL the script printed for that hotel, rather than plain text.","type":"text"}]},{"type":"paragraph","content":[{"text":"RULE 4 — Partial results are normal — send them as-is rather than fixing by hand.","type":"text","marks":[{"type":"strong"}]},{"text":" A source can be absent from a run (e.g. Agoda blocked this run, or OpenTravel has no inventory for the city). That's fine — send the tier cards with whatever sources are present; the footer (","type":"text"},{"text":"📊 N hotels | \u003csources> • prices in USD","type":"text","marks":[{"type":"code_inline"}]},{"text":") lists exactly what was found. Fetching the missing source via the browser or a direct URL tends to trip anti-bot and make things worse, so avoid it. If ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" errors out entirely, tell the user what failed in one line and show any partial output it printed above the error. For more coverage, the one reliable retry is running the same ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" command again (anti-bot is often transient).","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Output Format Reference","type":"text"}]},{"type":"paragraph","content":[{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" prints tier cards in this format — you send this directly to the user:","type":"text"}]},{"type":"paragraph","content":[{"text":"The hotel name is a Markdown link to its cheapest OTA. Price rows carry NO links and the OTA key is shown lowercase (","type":"text"},{"text":"agoda","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"booking","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"google","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"opentravel","type":"text","marks":[{"type":"code_inline"}]},{"text":"). There are no star ratings or area lines — the script does not have that data.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"🏨 \u003ccity> • \u003cd1>–\u003cd2> • \u003cN> nights • \u003cadults> guests\n━━━━━━━━━━━━━━━━━━━━\n\n🥇 BEST VALUE\n[\u003cHotel Name>](\u003ccheapest_link>)\n ✅ agoda 💰 \u003cprice>/night\n booking 💰 \u003cprice>/night\n opentravel 💰 \u003cprice>/night\n → Save \u003cdiff> vs Booking\n\n🥈 CHEAPEST\n[\u003cHotel Name>](\u003ccheapest_link>)\n ✅ google 💰 \u003cprice>/night\n agoda 💰 \u003cprice>/night\n\n🥉 QUALITY\n[\u003cHotel Name>](\u003ccheapest_link>)\n ✅ booking 💰 \u003cprice>/night\n agoda 💰 \u003cprice>/night\n\n📋 More good deals\n — Agoda —\n • [\u003cHotel>](\u003cagoda_link>) — agoda: \u003cprice> | booking: \u003cprice>\n — Booking —\n • [\u003cHotel>](\u003cbooking_link>) — booking: \u003cprice>\n — Google —\n • [\u003cHotel>](\u003cgoogle_link>) — google: \u003cprice>\n — OpenTravel —\n • [\u003cHotel>](\u003copentravel_link>) — opentravel: \u003cprice>\n\n💡 Tip: \u003cbest Hotel Name>\n [Book on \u003cOTA>](\u003clink>) — \u003cprice>/night\n\n📊 \u003cN> hotels | \u003csources with data> • prices in USD","type":"text"}]},{"type":"paragraph","content":[{"text":"All prices are shown in USD. Agoda, Google and OpenTravel geo-lock to VND by IP and are converted via a live FX rate; Booking returns USD natively. Only sources that actually returned data are listed in the footer.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Limitations","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"First search per city pays the Agoda discovery cost (2–4 minutes). Google and Booking are inline (no discovery); OpenTravel is a direct API call.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Subsequent searches reuse the Agoda cache and complete in ~30–60 seconds.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Security & data handling","type":"text"}]},{"type":"paragraph","content":[{"text":"Runs locally, needs no API keys, and collects no personal data — the only data sent out is the search query (city, dates, guests). Scraped hotel text is treated as untrusted: ","type":"text"},{"text":"sanitizeText()","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"bin/search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" strips control/zero-width/bidi and markdown-control characters before any of it reaches model output, and booking links are restricted to ","type":"text"},{"text":"http(s)","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"The local browser daemon is ","type":"text"},{"text":"loopback-only, token-authenticated","type":"text","marks":[{"type":"strong"}]},{"text":" (a per-run token in a ","type":"text"},{"text":"0600","type":"text","marks":[{"type":"code_inline"}]},{"text":" state file), rejects non-loopback ","type":"text"},{"text":"Host","type":"text","marks":[{"type":"code_inline"}]},{"text":" headers against DNS rebinding, and keeps the ","type":"text"},{"text":"Chromium sandbox enabled","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"--no-sandbox","type":"text","marks":[{"type":"code_inline"}]},{"text":" is used only where it cannot work (root on Linux, or explicit ","type":"text"},{"text":"PRICEWIN_NO_SANDBOX=1","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"paragraph","content":[{"text":"Full disclosure of commands run, downloads, and network egress is in ","type":"text"},{"text":"SECURITY.md","type":"text","marks":[{"type":"link","attrs":{"href":"./SECURITY.md","title":null}},{"type":"code_inline"}]},{"text":".","type":"text"}]}]},"metadata":{"date":"2026-11-02","name":"pricewin-hotel-deal-finder","tags":["hotel-price-comparison","compare-hotel-prices","cheapest-hotel","cheapest-hotels","hotel-deals","booking-vs-agoda","best-hotel-rates","best-rates","hotel-search","hotel-booking","price-comparison","booking-com","agoda","google-hotels","opentravel","ota","hotel","hotels","travel","travel-deals","trip-planning","accommodation","deals"],"author":"PriceWin","source":{"stars":2,"repo_name":"pricewin-skills-hub","origin_url":"https://github.com/Price-Win/pricewin-skills-hub/blob/HEAD/skills/pricewin-hotel-deal-finder/SKILL.md","repo_owner":"Price-Win","body_sha256":"8cc1e6ab867b64eb015a6ec8b45a2e4120d186da12dd4b32da9fc6236512f8de","cluster_key":"c6113a4f31c11747c995f4cd7b2e395e2b1010a4cb596f0e9185375b32d52154","clean_bundle":{"format":"clean-skill-bundle-v1","source":"Price-Win/pricewin-skills-hub/skills/pricewin-hotel-deal-finder/SKILL.md","text_bytes":95301,"source_commit":"ea022aa512aaf729d9d91ff4f4a0b9a965f6a521","skills_sh_rank":1509,"attachment_count":10,"text_attachments":10,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/pricewin-hotel-deal-finder/SKILL.md","import_metadata":{"date":"2026-11-02","author":"PriceWin","version":"1.1.3","category":"business-sales-marketing","category_label":"Business"},"exact_dupes_collapsed_into_this":0},"version":"1.1.3","category":"business-sales-marketing","metadata":{"openclaw":{"emoji":"🏨","envVars":[{"name":"OPENTRAVEL_API_BASE_URL","required":false,"description":"Override the OpenTravel API host (default https://api.opentravel.one)."}],"homepage":"https://github.com/Price-Win/pricewin-skills-hub","requires":{"bins":["node","npx"]}}},"platforms":["linux","macos","windows"],"import_tag":"skills-sh-top20k-2026-11","description":"Find the cheapest hotel deal by comparing live prices across Booking.com, Agoda, Google Hotels, and OpenTravel for any city worldwide and any travel dates — one command returns ranked best-value, cheapest, and quality picks with direct booking links, all normalized to USD. Use whenever someone asks for hotel prices, hotel deals, the cheapest room or rate, best hotel rates, a hotel price comparison, or which OTA is cheaper — e.g. 'is Booking or Agoda cheaper for Tokyo', 'find me a hotel in Bangkok under $100', 'compare hotel prices for 12–15 Aug', 'cheapest hotel near Shinjuku'."}},"renderedAt":1793672055569}

PriceWin Hotel Deal Finder Compare live hotel prices across Booking.com, Agoda, Google Hotels & OpenTravel in one command — and get back ranked best-value, cheapest, and quality picks with direct booking links. Stop opening five OTA tabs to find the real cheapest rate. Ask your agent "find me a hotel in Tokyo for 12–15 Aug, 2 guests" and this skill returns a clean, ranked comparison in 30–60 seconds (cached cities). Invoke this skill for questions like: - "What's the cheapest hotel in for ?" - "Is Booking.com or Agoda cheaper for this hotel?" - "Compare hotel prices for , guests." - "Find me…