SKILL DETAIL
pricewin-hotel-deal-finder
price-win/pricewin-skills-hub/pricewin-hotel-deal-finder
PriceWin Hotel Deal Finder is a hotel price comparison tool that queries live rates from Booking.com, Agoda, Google Hotels, and OpenTravel simultaneously, presenting results in USD. It returns ranked picks for best value, cheapest, and quality options, along with direct booking links. The skill works for any city and dates, requires no API keys, and only needs Node.js. Use cases include asking for the cheapest hotel in a city for specific dates, comparing prices across OTAs, or finding hotels under a budget. The skill automatically infers parameters like dates and guest count, and outputs formatted result cards with clickable hotel names and price comparisons. First-time searches for a city may take 2-4 minutes for discovery, while subsequent searches typically complete in 30-60 seconds.
Installation
npx skills add https://github.com/price-win/pricewin-skills-hub --skill pricewin-hotel-deal-finder
スキルファイル
SKILL.md
最終同期 · 2026/08/29
bin/browse.js›
#!/usr/bin/env node
// ----------------------------------------------------------------------------
// browse.js — thin HTTP client for the daemon.
//
// All real work happens in bin/daemon.js (a long-running process that owns
// the Patchright browser). This script just spawns the daemon on `launch`
// and POSTs to it for every other subcommand.
//
// Architecture rationale: Patchright's stealth patches only apply when
// commands are issued from the same Node process that called
// `chromium.launch()`. A detached chromium re-attached via CDP from a
// fresh process loses those patches, and Booking/Agoda flag the session
// as a bot. Hence: persistent daemon, ephemeral CLI clients.
// ----------------------------------------------------------------------------
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
saveState,
loadState,
clearState,
isProcessAlive,
} from '../lib/browser-state.js';
import {
getSelectors,
getEntry,
recordSuccess,
recordFailure,
recordDiscovery,
invalidate,
invalidateAll,
} from '../lib/selector-cache.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DAEMON_PATH = path.join(__dirname, 'daemon.js');
const OPENTRAVEL_DEFAULT_BASE = 'https://api.opentravel.one';
// --- helpers ----------------------------------------------------------------
function out(obj) {
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
}
function bail(msg, code = 1) {
process.stdout.write(JSON.stringify({ error: msg }) + '\n');
process.exit(code);
}
// Every daemon endpoint requires the per-run token from the 0600 state file.
// A state file without one belongs to a pre-1.1.3 daemon still running; the
// call will 401 and the user just needs `browse close` + `launch`.
function authHeaders(state) {
return state?.token ? { 'x-pricewin-token': state.token } : {};
}
async function daemonState() {
const state = await loadState();
if (!state) return null;
if (!isProcessAlive(state.pid)) return null;
// ping to confirm daemon is responsive
try {
const res = await fetch(`http://127.0.0.1:${state.port}/ping`, {
headers: authHeaders(state),
signal: AbortSignal.timeout(2000),
});
if (res.ok) return state;
} catch { /* dead */ }
return null;
}
async function call(endpoint, args = {}) {
const state = await daemonState();
if (!state) bail('No daemon running — call `launch` first.');
const res = await fetch(`http://127.0.0.1:${state.port}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(state) },
body: JSON.stringify(args),
signal: AbortSignal.timeout(120_000),
});
const json = await res.json();
if (!res.ok || json.error) bail(json.error || `HTTP ${res.status}`);
return json;
}
// --- subcommands ------------------------------------------------------------
async function cmdLaunch() {
const existing = await daemonState();
if (existing) {
out({ status: 'already-running', port: existing.port, pid: existing.pid });
return;
}
// Spawn daemon detached. It writes its own state file once the HTTP
// server is listening; we poll for that file appearing.
await clearState();
const child = spawn('node', [DAEMON_PATH], {
detached: true,
stdio: 'ignore',
});
child.unref();
const deadline = Date.now() + 30_000; // Patchright launch + Chromium boot
while (Date.now() < deadline) {
const state = await daemonState();
if (state) {
out({ status: 'launched', port: state.port, pid: state.pid });
return;
}
await new Promise((r) => setTimeout(r, 500));
}
bail('Daemon failed to come up within 30s — check stderr or ~/.cache/pricewin-hotel-deal-finder/');
}
async function cmdGoto(url) {
if (!url) bail('Usage: browse goto <url>');
out(await call('goto', { url }));
}
async function cmdSnapshot(opts) {
const r = await call('snapshot');
if (opts.json) out({ status: 'snapshot', ...r });
else {
process.stdout.write(r.text + '\n');
process.stdout.write(`# snapshot ok · ${r.elementCount} elements\n`);
}
}
async function cmdClick(ref) {
if (!ref) bail('Usage: browse click <ref>');
out(await call('click', { ref }));
}
async function cmdFill(ref, text) {
if (!ref || text === undefined) bail('Usage: browse fill <ref> <text>');
out(await call('fill', { ref, text }));
}
async function cmdType(ref, text) {
if (!ref || text === undefined) bail('Usage: browse type <ref> <text>');
out(await call('type', { ref, text }));
}
async function cmdPress(ref, key) {
if (!ref || !key) bail('Usage: browse press <ref> <key>');
out(await call('press', { ref, key }));
}
async function cmdWaitFor(selector, minCount, timeoutMs) {
if (!selector) bail('Usage: browse wait-for <selector> [minCount] [timeoutMs]');
out(await call('wait-for', {
selector,
minCount: minCount ? Number(minCount) : 1,
timeoutMs: timeoutMs ? Number(timeoutMs) : 15_000,
}));
}
async function cmdTrySelectors(jsonStr) {
if (!jsonStr) bail('Usage: browse try-selectors <json>');
let selectors;
try { selectors = JSON.parse(jsonStr); } catch (e) { bail(`Invalid JSON: ${e.message}`); }
out(await call('try-selectors', { selectors }));
}
// Like try-selectors but returns the FULL record set (try-selectors caps the
// sample at 3 for a discovery preview). Used for inline ad-hoc extraction.
async function cmdExtractAll(jsonStr) {
if (!jsonStr) bail('Usage: browse extract-all <json>');
let selectors;
try { selectors = JSON.parse(jsonStr); } catch (e) { bail(`Invalid JSON: ${e.message}`); }
out(await call('extract-all', { selectors }));
}
/**
* Replace concrete search params + locale segments in a URL with placeholders
* so the cache can rebuild fresh URLs for new (city, dates, adults, locale)
* combinations. Handles every well-known query-param name across
* Booking/Agoda/Traveloka plus the two common locale-segment conventions:
* - Booking file suffix: ".en-gb.html" → ".{locale-short}.html"
* - Agoda path segment: "/en-us/" → "/{locale}/"
* `locale` is the canonical cache-key locale (e.g. "en-us"); `{locale-short}`
* substitution uses just the language part ("en"). Pass `locale=null` to
* skip locale templating (rare — only when the captured URL has no locale).
*/
function templatize(url, locale) {
if (!url) return null;
let t = url
.replace(/(?<=[?&])(checkIn|checkin|check_in)=\d{4}-\d{2}-\d{2}/g, '$1={checkIn}')
.replace(/(?<=[?&])(checkOut|checkout|check_out)=\d{4}-\d{2}-\d{2}/g, '$1={checkOut}')
// Replace text-based city params with {city}.
// `city=` is only templated when the value is NOT purely numeric — Agoda stores
// a numeric cityId (e.g. city=3987) that is city-specific and must be kept
// as-is; the citySlug cache field handles city validation in multi-extract.
.replace(/(?<=[?&])(ss|q|destination|textToSearch|searchText)=[^&]+/g, '$1={city}')
.replace(/(?<=[?&])(city)=(?!\d+(?:&|$))([^&]+)/g, '$1={city}')
.replace(/(?<=[?&])(adults|group_adults)=\d+/g, '$1={adults}');
if (locale) {
const lang = locale.split('-')[0];
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Agoda-style: "/en-us/" → "/{locale}/"
t = t.replaceAll(`/${locale}/`, '/{locale}/');
// Booking-style: ".en-gb.html" or ".en-us.html" → ".{locale-short}.html" / ".{locale}.html"
t = t.replace(new RegExp(`\\.${esc(locale)}\\.html`, 'g'), '.{locale}.html');
if (lang && lang !== locale) {
t = t.replace(new RegExp(`\\.${esc(lang)}\\.html`, 'g'), '.{locale-short}.html');
}
}
return t;
}
/**
* Substitute the `{locale}` / `{locale-short}` placeholders that templatize()
* introduced. Called when rebuilding a concrete URL from a cached template.
*/
function applyLocale(template, locale) {
if (!template || !locale) return template;
const lang = locale.split('-')[0];
return template
.replaceAll('{locale}', locale)
.replaceAll('{locale-short}', lang);
}
async function cmdSaveSelectors(site, locale, task, jsonStr, cityOverride = null) {
if (!site || !locale || !task || !jsonStr) {
bail('Usage: browse save-selectors <site> <locale> <task> <json> [city]');
}
let selectors;
try { selectors = JSON.parse(jsonStr); } catch (e) { bail(`Invalid JSON: ${e.message}`); }
// Capture the current results URL so we can warm-replay it next time.
let urlTemplate = null;
let citySlug = null;
try {
const cur = await call('current-url');
urlTemplate = templatize(cur.url, locale);
// Extract citySlug from text-based city params in the original URL.
// Stored so multi-extract can validate city-specific templates (e.g. Agoda
// numeric cityId that wasn't templated).
try {
const urlObj = new URL(cur.url);
const cityParam = urlObj.searchParams.get('textToSearch')
|| urlObj.searchParams.get('ss')
|| urlObj.searchParams.get('q')
|| urlObj.searchParams.get('destination')
|| urlObj.searchParams.get('searchText');
if (cityParam) citySlug = cityParam.trim().toLowerCase().replace(/\s+/g, '-');
} catch { /* ignore */ }
} catch { /* daemon may not be running — fine, save selectors only */ }
// If caller supplies the city name explicitly (5th arg), it takes priority over
// the auto-extracted textToSearch. Use this when the URL uses a localized city name
// (e.g. an OTA may localize the city name) but the user searched in English.
if (cityOverride) citySlug = cityOverride.trim().toLowerCase().replace(/\s+/g, '-');
await recordDiscovery(site, locale, task, selectors, urlTemplate, citySlug);
out({ status: 'saved', site, locale, task, urlTemplate, citySlug });
}
async function cmdTryExtract(site, locale, task) {
if (!site || !locale || !task) bail('Usage: browse try-extract <site> <locale> <task>');
const selectors = await getSelectors(site, locale, task);
if (!selectors) { out({ status: 'cache-miss', site, locale, task }); return; }
const result = await call('extract-all', { selectors });
const records = result.records ?? [];
const stats = result.stats ?? {};
// "healthy" = at least 3 results with a price. Agoda commonly has 30-50% of
// cards without a price (sold-out / loading) so we use a loose threshold.
// IMPORTANT: always return records even when stale — partial data beats nothing.
const priceRatio = stats.withPrice / Math.max(stats.total, 1);
const healthy = records.length >= 3 && priceRatio >= 0.3;
if (healthy) {
await recordSuccess(site, locale, task);
out({ status: 'cache-hit', site, locale, task, records, stats });
} else if (records.length > 0) {
// partial results — include them so the agent can still present something
await recordFailure(site, locale, task);
out({ status: 'cache-partial', site, locale, task, records, stats });
} else {
await recordFailure(site, locale, task);
out({ status: 'cache-stale', site, locale, task, records: [], stats });
}
}
async function cmdCurrentUrl() {
out(await call('current-url'));
}
async function cmdMultiExtract(city, checkIn, checkOut, adults = '2', localeArg) {
if (!city || !checkIn || !checkOut || !localeArg) {
bail('Usage: browse multi-extract <city> <checkIn> <checkOut> <adults> <locale>\n' +
' <locale> is required — pass the user\'s locale code, e.g. en-us, th-th, ja-jp.');
}
const locale = localeArg;
// Google deliberately excluded — its destination layout varies per city
// (sponsored-overview vs hotel-list), and cards require aria-label parsing,
// not the textContent extraction multi-extract uses. search.js calls Google
// inline via searchGoogleHotels() after merging Booking/Agoda results.
const wantedSites = ['booking', 'agoda'];
const requests = [];
const missing = [];
const citySlugNorm = city.trim().toLowerCase().replace(/\s+/g, '-');
for (const site of wantedSites) {
const entry = await getEntry(site, locale, 'search-cards');
if (!entry?.urlTemplate || !entry?.selectors) {
missing.push(site);
continue;
}
// City-slug validation: enforce when the URL template contains a
// hardcoded numeric city ID (Agoda's city=1569). Booking templates use
// ss={city} placeholders → work for any city, skip check.
const hasNumericCityId = /(?:^|[?&])(?:city|cityId|dest_id|destinationId)=\d+/i
.test(entry.urlTemplate || '');
if (hasNumericCityId && entry.citySlug && entry.citySlug !== citySlugNorm) {
missing.push(site);
continue;
}
const url = applyLocale(entry.urlTemplate, locale)
.replaceAll('{city}', encodeURIComponent(city))
.replaceAll('{checkIn}', checkIn)
.replaceAll('{checkOut}', checkOut)
.replaceAll('{adults}', String(adults));
requests.push({ site, url, selectors: entry.selectors });
}
if (!requests.length) {
out({ status: 'no-cache', missing, message: 'No cached URL templates. Run discovery for at least one OTA.' });
return;
}
// Fire daemon multi-extract + the OpenTravel API call in parallel.
const [otaResp, otResp] = await Promise.allSettled([
call('multi-extract-urls', { requests }),
cmdOpentravelInternal(city, checkIn, checkOut, adults),
]);
out({
status: 'multi-extract',
missing,
ota: otaResp.status === 'fulfilled' ? otaResp.value : { error: otaResp.reason?.message },
opentravel: otResp.status === 'fulfilled' ? otResp.value : { error: otResp.reason?.message },
});
}
async function cmdOpentravelInternal(city, checkIn, checkOut, adults) {
const base = process.env.OPENTRAVEL_API_BASE_URL || OPENTRAVEL_DEFAULT_BASE;
const url = new URL('/api/v1/public/hotels/search', base);
url.searchParams.set('city', city);
url.searchParams.set('checkIn', checkIn);
url.searchParams.set('checkOut', checkOut);
url.searchParams.set('adults', String(adults));
url.searchParams.set('limit', '20');
const res = await fetch(url.toString(), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`OpenTravel API ${res.status}`);
const json = await res.json();
const payload = json?.data ?? json;
return {
hotels: payload.hotels || [],
indicativeHotels: payload.indicativeHotels || [],
meta: payload.meta || null,
};
}
async function cmdOpentravel(city, checkIn, checkOut, adults = '2') {
if (!city || !checkIn || !checkOut) {
bail('Usage: browse opentravel <city> <checkIn YYYY-MM-DD> <checkOut YYYY-MM-DD> [adults]');
}
const base = process.env.OPENTRAVEL_API_BASE_URL || OPENTRAVEL_DEFAULT_BASE;
const url = new URL('/api/v1/public/hotels/search', base);
url.searchParams.set('city', city);
url.searchParams.set('checkIn', checkIn);
url.searchParams.set('checkOut', checkOut);
url.searchParams.set('adults', String(adults));
url.searchParams.set('limit', '20');
try {
const res = await fetch(url.toString(), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) bail(`OpenTravel API ${res.status}`);
const json = await res.json();
const payload = json?.data ?? json;
out({
status: 'opentravel-ok',
hotels: payload.hotels || [],
indicativeHotels: payload.indicativeHotels || [],
meta: payload.meta || null,
});
} catch (e) {
bail(`OpenTravel fetch failed: ${e.message}`);
}
}
async function cmdCacheList() {
const fs = await import('node:fs/promises');
const path = await import('node:path');
const os = await import('node:os');
const cacheFile = path.join(os.homedir(), '.cache', 'pricewin-hotel-deal-finder', 'selectors.json');
try {
const raw = await fs.readFile(cacheFile, 'utf8');
const cache = JSON.parse(raw);
const entries = Object.entries(cache).map(([key, v]) => ({
key,
lastWorkedAt: v.lastWorkedAt,
discoveredAt: v.discoveredAt,
consecutiveFails: v.consecutiveFails || 0,
fields: Object.keys(v.selectors || {}),
}));
out({ status: 'cache-list', entries });
} catch (e) {
if (e.code === 'ENOENT') out({ status: 'cache-list', entries: [] });
else bail(`Cache read failed: ${e.message}`);
}
}
async function cmdRefreshCache(siteOrAll, locale, task) {
if (siteOrAll === '--all') {
await invalidateAll();
out({ status: 'cache-wiped' });
return;
}
if (!siteOrAll || !locale || !task) {
bail('Usage: browse refresh-cache <site> <locale> <task> | browse refresh-cache --all');
}
await invalidate(siteOrAll, locale, task);
out({ status: 'cache-evicted', site: siteOrAll, locale, task });
}
async function cmdClose() {
const state = await daemonState();
if (!state) { out({ status: 'no-session' }); return; }
try { await call('shutdown'); } catch { /* daemon may have exited mid-reply */ }
// give daemon a beat to actually exit
await new Promise((r) => setTimeout(r, 500));
await clearState();
out({ status: 'closed' });
}
// --- dispatcher -------------------------------------------------------------
const [, , cmd, ...args] = process.argv;
const router = {
launch: () => cmdLaunch(),
goto: () => cmdGoto(args[0]),
snapshot: () => cmdSnapshot({ json: args.includes('--json') }),
click: () => cmdClick(args[0]),
fill: () => cmdFill(args[0], args.slice(1).join(' ')),
type: () => cmdType(args[0], args.slice(1).join(' ')),
scroll: async () => out(await call('scroll', {
to: args[0] ? Number(args[0]) : 3000,
step: args[1] ? Number(args[1]) : 600,
delayMs: args[2] ? Number(args[2]) : 200,
})),
'keyboard-press': async () => out(await call('keyboard-press', { key: args[0] || 'Escape' })),
'list-pages': async () => out(await call('list-pages')),
'query-all': async () => out(await call('query-all', {
selector: args[0] || '',
limit: args[1] ? Number(args[1]) : 20,
textLimit: args[2] !== undefined ? Number(args[2]) : 80,
})),
'switch-to-newest-tab': async () => out(await call('switch-to-newest-tab')),
'switch-to-tab-matching': async () => out(await call('switch-to-tab-matching', { urlIncludes: args[0] || '', urlAvoids: args[1] || '' })),
'close-tabs-matching': async () => out(await call('close-tabs-matching', { urlIncludes: args[0] || '' })),
press: () => cmdPress(args[0], args[1]),
'wait-for': () => cmdWaitFor(args[0], args[1], args[2]),
'try-selectors': () => cmdTrySelectors(args[0]),
'extract-all': () => cmdExtractAll(args[0]),
'save-selectors': () => cmdSaveSelectors(args[0], args[1], args[2], args[3], args[4]),
'try-extract': () => cmdTryExtract(args[0], args[1], args[2]),
'current-url': () => cmdCurrentUrl(),
opentravel: () => cmdOpentravel(args[0], args[1], args[2], args[3]),
'multi-extract': () => cmdMultiExtract(args[0], args[1], args[2], args[3], args[4]),
'cache-list': () => cmdCacheList(),
'refresh-cache': () => cmdRefreshCache(args[0], args[1], args[2]),
close: () => cmdClose(),
};
if (!cmd || !router[cmd]) {
process.stderr.write(`Usage: browse <command> [args]\nCommands: ${Object.keys(router).join(', ')}\n`);
process.exit(2);
}
router[cmd]().catch((e) => bail(e.message || String(e)));
bin/daemon.js›
#!/usr/bin/env node
// ----------------------------------------------------------------------------
// daemon.js
//
// Long-running process that owns the Patchright Chromium browser and serves
// each agent action over a localhost HTTP endpoint. Critical: Patchright's
// stealth patches (CDP-level fingerprint masking) only apply when commands
// flow through the same Node process that called `chromium.launch()`. If we
// instead detached the browser and re-attached via `connectOverCDP` from a
// fresh process (the v0.2-rc.1 design), Booking/Agoda see plain headless
// Chrome and degrade the response. Hence: one daemon, many CLI clients.
//
// Lifecycle:
// - bin/browse.js spawns this with `detached: true` + `unref()` on launch.
// - State (port, pid, token) is written to
// ~/.cache/pricewin-hotel-deal-finder/session-default.json (0600)
// - SIGTERM (sent by `browse close`) → clean shutdown.
//
// Security: this endpoint drives a real browser and returns page content, so it
// is treated as privileged. It binds loopback only, requires a per-run bearer
// token that lives in the 0600 state file, and rejects non-loopback Host headers
// (DNS-rebinding defence — a web page cannot reach it even knowing the port).
// ----------------------------------------------------------------------------
import http from 'node:http';
import net from 'node:net';
import crypto from 'node:crypto';
import { chromium } from 'patchright';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { takeSnapshot } from '../lib/snapshot.js';
import { extractWithSelectors, isExtractionHealthy } from '../lib/dom-extract.js';
import { saveState, clearState } from '../lib/browser-state.js';
const CACHE_DIR = path.join(os.homedir(), '.cache', 'pricewin-hotel-deal-finder');
// Per-run bearer token. Generated here, handed to clients only through the 0600
// state file, so a process that cannot read that file cannot drive the browser.
const AUTH_HEADER = 'x-pricewin-token';
const AUTH_TOKEN = crypto.randomBytes(32).toString('hex');
function isAuthorized(req) {
const got = req.headers[AUTH_HEADER];
if (typeof got !== 'string') return false;
const a = Buffer.from(got);
const b = Buffer.from(AUTH_TOKEN);
// timingSafeEqual throws on length mismatch, so compare lengths first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Reject anything whose Host is not loopback. Without this, a page in any
// browser could POST to http://<attacker-domain>/ resolving to 127.0.0.1 and
// hit the daemon; with it, only a client that already knows to say "127.0.0.1"
// (and holds the token) gets through.
function isLoopbackHost(req) {
const host = String(req.headers.host || '');
const name = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
return name === '127.0.0.1' || name === 'localhost' || name === '::1';
}
let browser;
let context;
let page;
// In-memory map of ref → stable CSS selector, populated by each snapshot
// and consumed by click/type/fill/press so the agent doesn't fail when
// React/Vue re-renders strip our data-browse-ref attribute.
let lastSnapshotRefs = {};
// In-memory results cache for multi-extract-urls.
// Key: "<site>:<url>". Entries expire after RESULTS_CACHE_TTL_MS (10 min).
// Purpose: avoid re-scraping the same (site, city, dates, adults) within a
// single working session — prices don't change that fast, and the scroll
// pipeline (~10s per OTA) is expensive.
const RESULTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const resultsCache = new Map();
function getCachedResult(site, url) {
const key = `${site}:${url}`;
const entry = resultsCache.get(key);
if (!entry) return null;
if (Date.now() - entry.cachedAt > RESULTS_CACHE_TTL_MS) {
resultsCache.delete(key);
return null;
}
return entry;
}
function setCachedResult(site, url, records, stats) {
resultsCache.set(`${site}:${url}`, { records, stats, cachedAt: Date.now() });
}
function normalizeRef(ref) {
// Some LLMs hand us refs with prefixes ("@e16", "ref-12", "#27").
// Strip everything that isn't a digit so the lookup still hits the map.
const s = String(ref ?? '');
const m = s.match(/\d+/);
return m ? m[0] : s;
}
function refToEntry(ref) {
const key = normalizeRef(ref);
const entry = lastSnapshotRefs[key];
if (entry && typeof entry === 'object') return entry;
return { selector: `[data-browse-ref="${key}"]`, signature: null };
}
function refToSelector(ref) {
return refToEntry(ref).selector;
}
/**
* Resolve a ref to the live DOM element by trying:
* 1) stable CSS selector saved at snapshot time
* 2) data-browse-ref attribute (may have been stripped by React)
* 3) signature match (tag + kind + text + testid + ariaLabel + placeholder + href)
* Returns the matching CSS selector (possibly a fresh data-browse-ref the
* resolver wrote back onto the element) or throws.
*/
async function resolveRef(page, ref) {
const refKey = normalizeRef(ref);
const entry = refToEntry(refKey);
const sel = await page.evaluate(
({ ref, entry }) => {
const tryCount = (s) => {
try { return document.querySelectorAll(s).length; } catch { return 0; }
};
if (entry.selector && tryCount(entry.selector) === 1) return entry.selector;
const byRef = '[data-browse-ref="' + ref + '"]';
if (tryCount(byRef) === 1) return byRef;
const sig = entry.signature;
if (!sig) return null;
// Re-scan the DOM for an element matching the signature.
const candidates = Array.from(document.querySelectorAll(sig.tag || '*'));
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 160);
const targetText = norm(sig.text);
let best = null;
let bestScore = -1;
for (const el of candidates) {
let score = 0;
if (sig.testid && (el.getAttribute('data-testid') === sig.testid || el.getAttribute('data-selenium') === sig.testid)) score += 5;
if (sig.ariaLabel && el.getAttribute('aria-label') === sig.ariaLabel) score += 4;
if (sig.placeholder && el.getAttribute('placeholder') === sig.placeholder) score += 3;
if (sig.href && el.getAttribute('href') === sig.href) score += 4;
if (targetText && norm(el.innerText || el.value || '') === targetText) score += 2;
if (score > bestScore) { bestScore = score; best = el; }
}
if (!best || bestScore < 2) return null;
// Tag the winner with a fresh data-browse-ref so callers have a stable
// handle for follow-up operations.
best.setAttribute('data-browse-ref', String(ref));
return '[data-browse-ref="' + ref + '"]';
},
{ ref: refKey, entry },
);
if (!sel) throw new Error(`could not resolve ref ${ref} after re-scan`);
return sel;
}
// Chromium's own sandbox is the last line of defence between a hostile OTA page
// and the user's machine, so we keep it ON by default. It genuinely cannot run
// as root on Linux, and most container images lack the user namespaces it needs
// — those two cases (and only those) drop it, loudly.
function browserArgs() {
const args = [
'--disable-dev-shm-usage',
'--password-store=basic',
'--use-mock-keychain',
'--disable-blink-features=AutomationControlled',
];
const isLinuxRoot = process.platform === 'linux'
&& typeof process.getuid === 'function'
&& process.getuid() === 0;
if (process.env.PRICEWIN_NO_SANDBOX === '1' || isLinuxRoot) {
process.stderr.write(
`[daemon] WARNING: launching Chromium with --no-sandbox (${isLinuxRoot ? 'running as root on Linux' : 'PRICEWIN_NO_SANDBOX=1'})\n`,
);
args.unshift('--no-sandbox');
}
return args;
}
async function ensurePage() {
if (!browser) {
// Default headless. Override via PRICEWIN_HEADED=1 for local debugging
// (helps when Google Hotels triggers bot detection in headless mode).
const headless = process.env.PRICEWIN_HEADED !== '1';
browser = await chromium.launch({
headless,
args: browserArgs(),
});
process.stderr.write(`[daemon] browser launched (headless=${headless})\n`);
}
if (!context) {
context = await browser.newContext({
locale: 'en-US',
viewport: { width: 1440, height: 900 },
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
});
}
if (!page || page.isClosed()) {
const existing = context.pages()[0];
page = existing || (await context.newPage());
}
return page;
}
async function findFreePort() {
return new Promise((resolve, reject) => {
const s = net.createServer();
s.listen(0, '127.0.0.1', () => {
const p = s.address().port;
s.close(() => resolve(p));
});
s.on('error', reject);
});
}
// --- handlers ---------------------------------------------------------------
async function handle(req) {
const path = new URL(req.url, 'http://x').pathname.slice(1);
let body = '';
for await (const chunk of req) body += chunk;
const args = body ? JSON.parse(body) : {};
switch (path) {
case 'ping':
return { status: 'ok' };
case 'goto': {
const p = await ensurePage();
// Tight ceilings so every command stays under the 30s shell timeout.
await p.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
await p.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => {});
return { status: 'loaded', url: p.url(), title: await p.title() };
}
case 'snapshot': {
const p = await ensurePage();
// We deliberately do NOT scroll here — scrolling closes open
// autocomplete dropdowns (Agoda's symptom). The agent should call
// `scroll` explicitly when it needs to surface lazy-loaded content.
const snap = await Promise.race([
takeSnapshot(p),
new Promise((_, reject) => setTimeout(() => reject(new Error('snapshot timed out (page too heavy)')), 22_000)),
]);
lastSnapshotRefs = snap.refs;
return { text: snap.text, elementCount: Object.keys(snap.refs).length };
}
case 'list-pages': {
// Debug helper: list all tabs in the current context.
if (!context) return { pages: [] };
const pages = context.pages();
const info = [];
for (const pg of pages) {
info.push({ url: pg.url(), title: await pg.title().catch(() => '?') });
}
return { pages: info, count: pages.length, activeIndex: pages.indexOf(page) };
}
case 'switch-to-newest-tab': {
// After a click that opens target=_blank, switch focus to the new tab.
if (!context) throw new Error('no context');
const pages = context.pages();
if (pages.length < 2) return { status: 'no-other-tab', currentUrl: page?.url() };
page = pages[pages.length - 1];
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
return { status: 'switched', url: page.url(), title: await page.title() };
}
case 'switch-to-tab-matching': {
// Focus the first tab whose URL matches `urlIncludes`. Useful when
// a click might open results in a new tab (Agoda) but might also
// navigate the existing tab (Booking) — the agent just says
// "find me the /search? tab" and we handle both shapes.
if (!context) throw new Error('no context');
const pages = context.pages();
const needle = String(args.urlIncludes || '');
const avoid = String(args.urlAvoids || '');
const match = pages.find((pg) => {
const u = pg.url();
if (needle && !u.includes(needle)) return false;
if (avoid && u.includes(avoid)) return false;
return true;
});
if (!match) return { status: 'no-match', urlIncludes: needle, urlAvoids: avoid, currentUrl: page?.url(), tabCount: pages.length };
page = match;
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
return { status: 'switched', url: page.url(), title: await page.title() };
}
case 'close-tabs-matching': {
// Close every tab whose URL contains `urlIncludes`. Won't touch the
// currently active page even if it matches (so we never accidentally
// close the results tab we just switched to).
if (!context) throw new Error('no context');
const pages = context.pages();
const needle = String(args.urlIncludes || '');
let closed = 0;
for (const pg of pages) {
if (pg === page) continue;
if (needle && pg.url().includes(needle)) {
await pg.close().catch(() => {});
closed += 1;
}
}
return { status: 'closed-tabs', count: closed, remainingTabs: context.pages().length };
}
case 'query-all': {
// Debug + extraction helper: return innerText + attrs for all matches.
// `limit` defaults to 20 (debug-friendly); callers extracting full
// result sets (e.g. Google Hotels via aria-label parsing) can pass up
// to 100. `textLimit` controls per-match text truncation; 0 disables.
const p = await ensurePage();
const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const textLimit = args.textLimit === 0 ? 0 : (Number(args.textLimit) || 80);
const out = await p.evaluate(({ sel, lim, tlim }) => {
const els = Array.from(document.querySelectorAll(sel));
return els.slice(0, lim).map((el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const raw = (el.innerText || '').replace(/\s+/g, ' ').trim();
return {
tag: el.tagName,
text: tlim > 0 ? raw.slice(0, tlim) : raw,
testid: el.getAttribute('data-testid') || el.getAttribute('data-selenium') || null,
visible: rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden',
opacity: style.opacity,
display: style.display,
ariaLabel: el.getAttribute('aria-label') || null,
href: el.getAttribute('href') || null,
};
});
}, { sel: args.selector, lim: limit, tlim: textLimit });
return { selector: args.selector, count: out.length, matches: out };
}
case 'keyboard-press': {
// Page-level key press (no element ref needed). Useful for closing
// overlays via Escape, navigating with Tab, submitting with Enter.
const p = await ensurePage();
await p.keyboard.press(String(args.key || 'Escape'));
return { status: 'pressed', key: args.key };
}
case 'scroll': {
// Explicit scroll command. Used when the agent wants to surface
// lazy-loaded content (search results pagination, infinite scroll).
// Do NOT call before snapshot when a dropdown is open — scrolling
// closes them.
const p = await ensurePage();
const yTo = typeof args.to === 'number' ? args.to : 3000;
const step = typeof args.step === 'number' ? args.step : 600;
const delayMs = typeof args.delayMs === 'number' ? args.delayMs : 200;
await p.evaluate(
async ({ yTo, step, delayMs }) => {
for (let y = 0; y <= yTo; y += step) {
window.scrollTo(0, y);
await new Promise((r) => setTimeout(r, delayMs));
}
},
{ yTo, step, delayMs },
);
return { status: 'scrolled', to: yTo };
}
case 'click': {
const p = await ensurePage();
const sel = await resolveRef(p, args.ref);
const locator = p.locator(sel).first();
// Normal click first. If an overlay intercepts pointer events
// (common on Booking's autocomplete + tooltips), fall back to a
// direct DOM .click() via evaluate, which bypasses the overlay.
let mode = 'pointer';
try {
await locator.click({ timeout: 4_000 });
} catch (e) {
mode = 'dispatch';
await p.evaluate((s) => {
const el = document.querySelector(s);
if (!el) throw new Error('element not found');
el.click();
}, sel);
}
await p.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {});
return { status: 'clicked', ref: args.ref, mode, url: p.url() };
}
case 'fill': {
// Fast path: set the input value directly. Works for Booking and most
// sites whose autocomplete listens to the `input` event. Use `type`
// (below) when a site only fires its autocomplete on real keystrokes.
const p = await ensurePage();
const sel = await resolveRef(p, args.ref);
await p.locator(sel).first().fill(String(args.text ?? ''), { timeout: 10_000 });
return { status: 'filled', ref: args.ref };
}
case 'type': {
// Slow path: focus + clear + send keystrokes one at a time. Use this
// for SPAs whose autocomplete only fires on actual keydown events
// (Agoda is the canonical example). Bypasses overlay-intercepts by
// calling focus() via JS instead of relying on a pointer click.
const p = await ensurePage();
const sel = await resolveRef(p, args.ref);
await p.evaluate((s) => {
const el = document.querySelector(s);
if (!el) throw new Error('element not found');
el.focus();
if ('value' in el) el.value = '';
el.dispatchEvent(new Event('input', { bubbles: true }));
}, sel);
// Now type via keyboard at page level — works as long as the input
// is focused, regardless of any overlay.
await p.keyboard.type(String(args.text ?? ''), { delay: 80 });
return { status: 'typed', ref: args.ref };
}
case 'press': {
const p = await ensurePage();
const sel = await resolveRef(p, args.ref);
await p.locator(sel).first().press(args.key, { timeout: 10_000 });
return { status: 'pressed', ref: args.ref, key: args.key };
}
case 'wait-for': {
const p = await ensurePage();
// Wait until at least N elements match the given selector.
const { selector, minCount = 1, timeoutMs = 15_000 } = args;
const ok = await p
.waitForFunction(
({ s, n }) => document.querySelectorAll(s).length >= n,
{ s: selector, n: minCount },
{ timeout: timeoutMs },
)
.then(() => true)
.catch(() => false);
return { status: ok ? 'matched' : 'timeout', selector, minCount };
}
case 'try-selectors': {
const p = await ensurePage();
const result = await extractWithSelectors(p, args.selectors);
return {
healthy: isExtractionHealthy(result),
sampleCount: result.records.length,
stats: result.stats,
sample: result.records.slice(0, 3),
};
}
case 'extract-all': {
const p = await ensurePage();
const result = await extractWithSelectors(p, args.selectors);
return result;
}
case 'multi-extract-urls': {
// Parallel cache-warm extraction. Opens one new tab per OTA inside the
// SAME main browser context (shared cookies + stealth patches), navigates
// them concurrently, extracts, then closes each tab.
//
// We deliberately stay in the main context instead of creating ephemeral
// contexts: Booking.com and Agoda use session cookies that are not present
// in a cold context, causing bot-detection redirects with zero hotel cards.
if (!context) throw new Error('daemon not launched');
const requests = Array.isArray(args.requests) ? args.requests : [];
const results = await Promise.all(
requests.map(async (req) => {
// Check in-memory results cache (TTL = 10 min) before opening a tab.
const cached = getCachedResult(req.site, req.url);
if (cached) {
process.stderr.write(`[daemon] results cache hit: ${req.site} (${cached.records.length} records, age ${Math.round((Date.now() - cached.cachedAt) / 1000)}s)\n`);
return { site: req.site, healthy: true, records: cached.records, stats: cached.stats, fromCache: true };
}
const pg = await context.newPage();
try {
await pg.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
// Best-effort wait for the card selector to render — gives lazy
// SPA pages a chance to paint without blocking the extraction.
if (req.selectors?.card) {
await pg.waitForSelector(req.selectors.card, { timeout: 12_000 }).catch(() => {});
}
// Scroll progressively to trigger lazy-loaded prices (Agoda, Booking
// both load prices only when cards scroll into the viewport).
// 4 scroll steps × 3000px each covers the first ~25-40 hotels.
for (const y of [3000, 6000, 9000, 12000]) {
await pg.evaluate((yy) => window.scrollTo(0, yy), y).catch(() => {});
await pg.waitForTimeout(800).catch(() => {});
}
const r = await extractWithSelectors(pg, req.selectors);
// Populate results cache on successful extraction.
if (r.records.length > 0) {
setCachedResult(req.site, req.url, r.records, r.stats);
}
return { site: req.site, healthy: isExtractionHealthy(r), records: r.records, stats: r.stats };
} catch (e) {
return { site: req.site, error: e.message };
} finally {
await pg.close().catch(() => {});
}
}),
);
return { status: 'multi-extract-done', count: results.length, results };
}
case 'current-url': {
const p = await ensurePage();
return { url: p.url() };
}
case 'inspect-ref': {
const entry = refToEntry(args.ref);
const p = await ensurePage();
let resolved = null;
try { resolved = await resolveRef(p, args.ref); } catch (e) { resolved = `(failed: ${e.message})`; }
const match = await p.evaluate((s) => {
try { return document.querySelectorAll(s).length; } catch { return -1; }
}, entry.selector || '');
return { ref: args.ref, savedSelector: entry.selector, signature: entry.signature, resolvedSelector: resolved, savedSelectorMatchCount: match };
}
case 'shutdown': {
// Trigger graceful close after replying
setImmediate(async () => {
try { await browser?.close(); } catch {}
try { await clearState(); } catch {}
process.exit(0);
});
return { status: 'shutting-down' };
}
default:
throw new Error(`unknown endpoint: ${path}`);
}
}
// --- server boot ------------------------------------------------------------
async function main() {
await fs.mkdir(CACHE_DIR, { recursive: true });
// Pre-warm browser so first /goto is fast.
await ensurePage();
const port = await findFreePort();
const server = http.createServer(async (req, res) => {
// Gate every endpoint, /ping included — an unauthenticated liveness probe
// is still a way to fingerprint the daemon.
if (!isLoopbackHost(req)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'forbidden: non-loopback Host' }));
return;
}
if (!isAuthorized(req)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `unauthorized: missing or bad ${AUTH_HEADER}` }));
return;
}
try {
const result = await handle(req);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message || String(e) }));
}
});
server.listen(port, '127.0.0.1', async () => {
await saveState({ port, pid: process.pid, token: AUTH_TOKEN, createdAt: new Date().toISOString() });
process.stderr.write(`[daemon] ready on port ${port} (pid ${process.pid})\n`);
});
// Auto-shutdown if the cache state file is removed (boss can clean manually).
// Also handle SIGTERM/SIGINT cleanly.
const shutdown = async () => {
try { await browser?.close(); } catch {}
try { await clearState(); } catch {}
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
main().catch((e) => {
process.stderr.write(`[daemon] fatal: ${e.stack || e.message}\n`);
process.exit(1);
});
bin/search.js›
#!/usr/bin/env node
/**
* search.js — one-shot hotel search wrapper.
*
* Usage:
* node bin/search.js "<city>" <checkIn> <checkOut> <adults> [locale]
*
* Returns formatted tier-card result for Telegram.
* Handles: daemon launch, multi-extract (cache), Agoda discovery, formatting.
*/
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import readline from 'node:readline';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BROWSE = path.join(__dirname, 'browse.js');
const [,, city, checkIn, checkOut, adults = '2', locale = 'en-us'] = process.argv;
if (!city || !checkIn || !checkOut) {
console.error('Usage: node bin/search.js "<city>" <checkIn> <checkOut> [adults] [locale]');
process.exit(1);
}
function run(args, timeoutMs = 60000) {
return new Promise((resolve, reject) => {
const child = spawn('node', [BROWSE, ...args], { cwd: path.dirname(__dirname) });
let out = '';
child.stdout.on('data', d => out += d);
child.stderr.on('data', d => out += d);
const t = setTimeout(() => { child.kill(); reject(new Error(`Timeout after ${timeoutMs}ms: node ${BROWSE} ${args[0]}`)); }, timeoutMs);
child.on('close', code => { clearTimeout(t); resolve(out.trim()); });
});
}
function parse(raw) {
try { return JSON.parse(raw); } catch { return null; }
}
function fmt(n) { return '$' + Number(n).toLocaleString('en-US'); }
/**
* Neutralize untrusted third-party text before it reaches model-visible output.
*
* Hotel names come from OTA DOM (Booking title, Agoda hotel-name), Google
* aria-labels, and the OpenTravel API — none of it is trusted. sanitizeText
* defends on two fronts:
* 1. Indirect prompt injection — strips control/zero-width/bidi chars, defangs
* the markdown/link control set, and collapses whitespace so scraped text
* cannot smuggle directives or fake structure into the agent's context.
* 2. MarkdownV2 integrity — the same removed characters (brackets, parens,
* backticks, backslashes, angle/brace/pipe) are exactly what would corrupt
* the `[name](url)` link syntax, so a crafted or malformed name can't break
* the rendered output.
* Empty string ⇒ record is skipped upstream (treated as "no name").
*/
function sanitizeText(s, max = 80) {
if (typeof s !== 'string') return '';
let t = s
.normalize('NFC')
.replace(/[\u0000-\u001F\u007F-\u009F]/g, " ") // control chars
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\u2028\u2029\uFEFF]/g, "") // zero-width / bidi / line-sep
.replace(/[`[\]()<>{}\\|]/g, ' ') // markdown + link-breaking chars
.replace(/\s+/g, ' ')
.trim();
if (t.length > max) t = t.slice(0, max).trim() + '…';
return t;
}
// Prices are normalized to USD for display. Agoda, Google and OpenTravel
// geo-lock to VND by IP (so they need conversion); Booking honours USD. The
// per-record `currency` from extraction drives the conversion. Rate is fetched
// live with a sane fallback.
let VND_PER_USD = 25400;
async function loadFxRate() {
try {
const res = await fetch('https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout(8000) });
const j = await res.json();
const r = j?.rates?.VND;
if (Number.isFinite(r) && r > 1000) VND_PER_USD = r;
} catch { /* keep fallback */ }
}
function toUSD(price, currency) {
const n = Number(price);
if (!Number.isFinite(n) || n <= 0) return 0;
if (currency === 'VND') return Math.max(1, Math.round(n / VND_PER_USD));
return Math.max(1, Math.round(n)); // already USD (or assume USD)
}
// OTAs compared by this skill. OpenTravel is an independent provider, listed
// in the same tier as Booking/Agoda/Google (not a PriceWin "direct" source).
const OTAS = ['agoda', 'booking', 'google', 'opentravel'];
const OTA_LABEL = { agoda: 'Agoda', booking: 'Booking', google: 'Google', opentravel: 'OpenTravel' };
function label(site) { return OTA_LABEL[site] || (site.charAt(0).toUpperCase() + site.slice(1)); }
/**
* Normalize a raw link from extraction:
* - Resolve relative Agoda paths to absolute URLs
* - Strip session/tracking params that add noise and can break markdown parsers
* (e.g. flightSearchCriteria=[object Object] breaks MarkdownV2 link regex)
*/
function cleanLink(url, site) {
if (!url) return '';
// Agoda and Google both return relative hrefs — prepend their host
if (url.startsWith('/')) {
if (site === 'agoda') url = 'https://www.agoda.com' + url;
else if (site === 'google') url = 'https://www.google.com' + url;
}
// Leave other relative/unknown links blank (can't produce a clickable URL)
if (!url.startsWith('http')) return '';
try {
const u = new URL(url);
if (site === 'agoda') {
// Strip Agoda session + noise params (includes the [object Object] offender)
for (const p of [
'flightSearchCriteria', 'searchrequestid', 'isShowMobileAppPrice',
'finalPriceView', 'isCalendarCallout', 'missingChildAges',
'numberOfGuest', 'numberOfBedrooms', 'familyMode', 'maxRooms',
'showReviewSubmissionEntry', 'isFreeOccSearch', 'tspTypes', 'cid',
]) u.searchParams.delete(p);
}
if (site === 'booking') {
// Strip Booking tracking/session params
for (const p of [
'aid', 'label', 'ucfs', 'arphpl', 'srpvid', 'srepoch',
'all_sr_blocks', 'highlighted_blocks', 'matching_block_id',
'sr_pri_blocks', 'from', 'hapos', 'hpos', 'sr_order',
'nad_id', 'nad_cpc', 'nad_track', 'nad_placement',
'req_adults', 'req_children', 'group_children', 'no_rooms',
]) u.searchParams.delete(p);
}
if (site === 'google') {
// Strip Google's search-context noise. `qs=` identifies the hotel
// itself; ved/ts/ap/q are derived from the originating search.
for (const p of ['ved', 'ts', 'ap', 'q']) u.searchParams.delete(p);
}
return u.toString();
} catch {
return url;
}
}
async function main() {
// ── Step 0: ensure daemon ────────────────────────────────────────────────
process.stderr.write('[search] launching daemon...\n');
const launchRaw = await run(['launch'], 30000);
const launchR = parse(launchRaw);
if (!launchR || (launchR.status !== 'launched' && launchR.status !== 'already-running')) {
process.stderr.write(`[search] daemon error: ${launchRaw}\n`);
process.exit(1);
}
process.stderr.write(`[search] daemon ok (pid ${launchR.pid || '?'})\n`);
// FX rate for VND→USD display conversion (all sources price in VND).
await loadFxRate();
process.stderr.write(`[search] fx: 1 USD = ${VND_PER_USD} VND\n`);
// ── Step 1: OpenTravel API ──────────────────────────────────────────────
process.stderr.write('[search] opentravel API...\n');
const otRaw = await run(['opentravel', city, checkIn, checkOut, adults], 20000);
const otR = parse(otRaw) || {};
// ── Step 1.5: fast-path multi-extract (Booking + Agoda) ──────────────────
process.stderr.write('[search] multi-extract (booking + agoda)...\n');
const meRaw = await run(['multi-extract', city, checkIn, checkOut, adults, locale], 60000);
let meR = parse(meRaw);
// ── Step 3.5: discovery for missing Agoda (per-city numeric cityId) ──────
let missing = meR?.missing ?? [];
if (missing.includes('agoda')) {
process.stderr.write('[search] agoda not cached — running discovery...\n');
try {
await discoverAgoda(city, checkIn, checkOut, adults, locale);
process.stderr.write('[search] agoda discovery done, re-running multi-extract...\n');
const meRaw2 = await run(['multi-extract', city, checkIn, checkOut, adults, locale], 60000);
meR = parse(meRaw2) ?? meR;
} catch (e) {
process.stderr.write(`[search] agoda discovery failed: ${e.message}\n`);
}
}
// ── Step 5: merge Booking + Agoda + OpenTravel ─────────────────────────
const all = {};
for (const r of (meR?.ota?.results ?? [])) {
const site = r.site;
for (const h of (r.records ?? [])) {
const name = sanitizeText(h.name);
if (!name || !h.price) continue;
if (!all[name]) all[name] = { prices: {}, links: {} };
all[name].prices[site] = toUSD(h.price, h.currency);
all[name].links[site] = cleanLink(h.link ?? '', site);
}
}
// ── Step 5.5: Google Hotels via aria-label extraction ────────────────────
// No URL caching — Google's destination layout varies per city and the
// aria-label structure changes with locale. Always re-navigate.
process.stderr.write('[search] google search inline...\n');
try {
const googleRecords = await searchGoogleHotels(city, checkIn, checkOut, locale);
process.stderr.write(`[search] google returned ${googleRecords.length} records\n`);
for (const h of googleRecords) {
const name = sanitizeText(h.name);
if (!name || !h.price) continue;
if (!all[name]) all[name] = { prices: {}, links: {} };
all[name].prices.google = toUSD(h.price, h.currency);
all[name].links.google = cleanLink(h.link ?? '', 'google');
}
} catch (e) {
process.stderr.write(`[search] google search failed: ${e.message}\n`);
}
// ── Step 5.6: Booking.com via direct searchresults URL ───────────────────
// Booking honours `selected_currency=USD`, so its prices come back in USD.
process.stderr.write('[search] booking search inline...\n');
try {
const bookingRecords = await searchBookingHotels(city, checkIn, checkOut, adults);
process.stderr.write(`[search] booking returned ${bookingRecords.length} records\n`);
for (const h of bookingRecords) {
const name = sanitizeText(h.name);
if (!name || !h.price) continue;
if (!all[name]) all[name] = { prices: {}, links: {} };
all[name].prices.booking = toUSD(h.price, h.currency);
all[name].links.booking = cleanLink(h.link ?? '', 'booking');
}
} catch (e) {
process.stderr.write(`[search] booking search failed: ${e.message}\n`);
}
// OpenTravel — an independent OTA, same tier as Booking/Agoda/Google.
// Per-night price comes from `cheapestPrice`; the public API returns no
// booking URL, so the link stays empty (name renders as plain text).
for (const h of [...(otR.hotels ?? []), ...(otR.indicativeHotels ?? [])]) {
const name = sanitizeText(h.name);
const price = h.cheapestPrice ?? h.price ?? h.pricePerNight;
if (!name || !price) continue;
if (!all[name]) all[name] = { prices: {}, links: {} };
all[name].prices.opentravel = toUSD(price, h.currency ?? 'VND');
// Route the partner-API link through cleanLink too, so a non-http(s)
// value (e.g. javascript:) can never reach a rendered hyperlink.
all[name].links.opentravel = cleanLink(h.url ?? h.link ?? '', 'opentravel');
}
const sorted = Object.entries(all).sort((a, b) =>
Math.min(...Object.values(a[1].prices)) - Math.min(...Object.values(b[1].prices)));
if (!sorted.length) {
console.log(`❌ No hotels found for ${city} (${checkIn}→${checkOut}).`);
console.log(' No results from Booking · Agoda · Google · OpenTravel for this city/date.');
process.exit(0);
}
const nights = Math.round((new Date(checkOut) - new Date(checkIn)) / 86400000);
const d1 = checkIn.slice(5).replace('-', '/');
const d2 = checkOut.slice(5).replace('-', '/');
const lines = [];
lines.push(`🏨 ${sanitizeText(city, 60) || city} • ${d1}–${d2} • ${nights} nights • ${adults} guests`);
lines.push('━'.repeat(20));
const labels = ['🥇 BEST VALUE', '🥈 CHEAPEST', '🥉 QUALITY'];
for (let i = 0; i < Math.min(3, sorted.length); i++) {
const [name, { prices, links }] = sorted[i];
const priceEntries = Object.entries(prices).sort((a, b) => a[1] - b[1]);
const best = priceEntries[0];
const worst = priceEntries[priceEntries.length - 1];
lines.push('');
lines.push(labels[i]);
// Hotel name as Markdown link → cheapest OTA. transform_llm_output bypasses
// the model so [text](url) syntax survives intact through to Telegram's
// format_message which converts it to a MarkdownV2 hyperlink (clickable,
// no raw URL visible).
const cheapestUrl = links[best[0]];
lines.push(` ${cheapestUrl ? `[${name}](${cheapestUrl})` : name}`);
for (const [site, price] of priceEntries) {
const mark = site === best[0] ? '✅' : ' ';
lines.push(` ${mark} ${site.padEnd(10)} 💰 ${fmt(price)}/night`);
}
const diff = worst[1] - best[1];
if (priceEntries.length > 1 && diff > 3) {
lines.push(` → Save ${fmt(diff)} vs ${label(worst[0])}`);
}
}
// "More good deals" — balanced per-OTA picks instead of a flat
// cheapest-first list (which Google would dominate, since it returns the
// most records). For each OTA in order: take the 3 cheapest hotels on
// that OTA that aren't already in the top-3 tier cards above.
if (sorted.length > 3) {
const topNames = new Set(sorted.slice(0, 3).map(([n]) => n));
const picks = [];
const used = new Set();
for (const ota of OTAS) {
const onOta = Object.entries(all)
.filter(([n, d]) => d.prices[ota] != null && !topNames.has(n) && !used.has(n))
.sort((a, b) => a[1].prices[ota] - b[1].prices[ota])
.slice(0, 3);
for (const entry of onOta) {
picks.push({ ota, entry });
used.add(entry[0]);
}
}
if (picks.length) {
lines.push('');
lines.push('📋 More good deals');
let curOta = null;
for (const { ota, entry } of picks) {
if (ota !== curOta) {
lines.push(` — ${label(ota)} —`);
curOta = ota;
}
const [name, { prices, links }] = entry;
// Order prices so the section's own OTA is first; hyperlink hotel
// name to that OTA's URL so the click goes to the platform the row
// is grouped under.
const priceEntries = Object.entries(prices).sort((a, b) => {
if (a[0] === ota) return -1;
if (b[0] === ota) return 1;
return a[1] - b[1];
});
const sectionUrl = links[ota];
const truncName = name.slice(0, 45);
const nameLink = sectionUrl ? `[${truncName}](${sectionUrl})` : truncName;
const priceStr = priceEntries.map(([s, p]) => `${s}: ${fmt(p)}`).join(' | ');
lines.push(` • ${nameLink} — ${priceStr}`);
}
}
}
const [bestName, bestData] = sorted[0];
const bestSite = Object.entries(bestData.prices).sort((a,b)=>a[1]-b[1])[0][0];
const bestPrice = Math.min(...Object.values(bestData.prices));
lines.push('');
lines.push(`💡 Tip: ${bestName}`);
const bestLink = bestData.links[bestSite];
const bestSiteCap = label(bestSite);
const cta = bestLink ? `[Book on ${bestSiteCap}](${bestLink})` : `Book on ${bestSiteCap}`;
lines.push(` ${cta} — ${fmt(bestPrice)}/night`);
// Only credit sources that actually returned data this run.
const presentSites = OTAS.filter(s => sorted.some(([, d]) => d.prices[s] != null));
lines.push(`\n📊 ${sorted.length} hotels | ${presentSites.map(label).join(' · ')} • prices in USD`);
console.log(lines.join('\n'));
// ── Cleanup ────────────────────────────────────────────────────────────────
await run(['close'], 5000).catch(() => {});
}
async function discoverAgoda(city, checkIn, checkOut, adults, locale) {
async function r(args, t = 30000) { return parse(await run(args, t)); }
// Navigate to Agoda homepage
await r(['goto', `https://www.agoda.com/${locale}/`], 20000);
// Find and type in search box
const snap1Raw = await run(['snapshot'], 10000);
const inputRef = snap1Raw.match(/\[(\d+)\][^\n]*data-selenium="textInput"/)?.[1];
if (!inputRef) throw new Error('Agoda search input not found');
await r(['type', inputRef, city]);
await new Promise(res => setTimeout(res, 3000));
// Click first autocomplete suggestion
const snap2Raw = await run(['snapshot'], 10000);
const optRef = snap2Raw.match(/\[(\d+)\][^\n]*autosuggest-item/)?.[1];
if (!optRef) throw new Error('No autocomplete suggestion for: ' + city);
await r(['click', optRef]);
await new Promise(res => setTimeout(res, 1000));
await r(['keyboard-press', 'Escape']);
await new Promise(res => setTimeout(res, 500));
// Find and click search button. Agoda's en-us label is "SEARCH" (uppercase);
// match case-insensitively to tolerate locale/label variations.
const snap3Raw = await run(['snapshot'], 10000);
const btnRef = snap3Raw.match(/\[(\d+)\] button "SEARCH"/i)?.[1];
if (!btnRef) throw new Error('Search button not found');
await r(['click', btnRef]);
await new Promise(res => setTimeout(res, 4000));
// Switch to hotel results tab if needed. Agoda opens hotel results in a new
// tab at `agoda.com/search` (no locale segment), while the original tab may
// land on `/activities/`. Match the hotel-results URL, not a locale path.
const urlRaw = await r(['current-url'], 5000);
if (!urlRaw?.url?.includes('agoda.com/search')) {
await r(['switch-to-tab-matching', 'agoda.com/search', 'activities'], 10000);
await new Promise(res => setTimeout(res, 2000));
}
// Guard: only cache once we're actually on a hotel-results page. If Agoda
// redirected to the homepage/overview (anti-bot, or the search never went
// through), bail without caching a broken URL — the caller continues with
// Booking + Google, and the next run retries discovery cleanly.
const finalUrl = await r(['current-url'], 5000);
if (!finalUrl?.url?.includes('agoda.com/search')) {
throw new Error('Agoda did not reach a results page (homepage redirect / anti-bot)');
}
// Save selectors immediately with city as slug
await r(['save-selectors', 'agoda', locale, 'search-cards',
JSON.stringify({
card: 'li[data-selenium=hotel-item]',
name: '[data-selenium=hotel-name]',
price: '[data-element-name=final-price]',
link: '[data-selenium=hotel-name]',
}),
city,
], 10000);
// Wait for cards and extract
await new Promise(res => setTimeout(res, 3000));
await r(['try-extract', 'agoda', locale, 'search-cards'], 15000);
}
/**
* Search Google Hotels for a city and parse cards via aria-label.
*
* Google's destination layout doesn't fit our generic textContent extractor:
* - h2 inside a card can be the room description instead of the hotel name
* → unreliable for matching across OTAs
* - Price text concatenates discounts/totals → naive digit-parsing produces
* garbage
*
* The price link for each card has an aria-label of the form
* "Prices for <HotelName> start at <currency><price>"
* which contains both name and price in a single, parseable string.
*
* Returns array of { name, price, link }. Throws on navigation failure.
*/
async function searchGoogleHotels(city, checkIn, checkOut, locale) {
async function r(args, t = 30000) { return parse(await run(args, t)); }
const lang = (locale || 'en-us').split('-')[0];
const url = `https://www.google.com/travel/search`
+ `?q=${encodeURIComponent(city)}`
+ `&hl=${lang}&gl=us&curr=USD`
+ `&checkin=${checkIn}&checkout=${checkOut}`;
await r(['goto', url], 25000);
await new Promise(res => setTimeout(res, 6000));
// Per-card aria-label selector — the English Google Hotels price link reads
// "Prices starting from <price>, <HotelName>".
const selector = 'a[aria-label^="Prices starting from"]';
const probeRaw = await run(['query-all', selector, '50', '0'], 15000);
const probe = parse(probeRaw);
if (!probe?.matches?.length) {
throw new Error('Google Hotels: no price-link cards found (selector="' + selector + '")');
}
// Parse aria-label "Prices starting from <currency><price>, <HotelName>".
// Price comes first (skip the currency symbol/code before the digits), then
// the hotel name after the comma.
const enRe = /^Prices starting from\s*\D*?([\d.,]+),\s*(.+)$/;
const records = [];
for (const m of probe.matches) {
const aria = m.ariaLabel || '';
const match = aria.match(enRe);
if (!match) continue;
// Strip Google's promo suffix ("... GREAT DEAL 51% less than usual",
// "... DEAL 19% less than") so the name dedupes cleanly across OTAs.
const name = match[2].replace(/\s+(GREAT DEAL|DEAL)\b.*$/i, '').trim();
const priceDigits = match[1].replace(/[^\d]/g, '');
const price = Number(priceDigits);
if (!name || !Number.isFinite(price) || price <= 0) continue;
const currency = /US\$|\bUSD\b|^\$|\s\$/.test(aria) ? 'USD' : 'VND';
records.push({ name, price, link: m.href || '', currency });
}
return records;
}
/**
* Search Booking.com via its direct searchresults URL and extract cards with an
* ad-hoc selector recipe (no cache/discovery needed — the URL is built fresh
* each time). Booking honours `selected_currency=USD`, so prices come back in
* USD; the recipe also reports the detected currency per record.
*
* Returns array of { name, price, currency, link }.
*/
async function searchBookingHotels(city, checkIn, checkOut, adults) {
const url = 'https://www.booking.com/searchresults.html'
+ `?ss=${encodeURIComponent(city)}`
+ `&checkin=${checkIn}&checkout=${checkOut}`
+ `&group_adults=${adults}&no_rooms=1&group_children=0&selected_currency=USD`;
await run(['goto', url], 30000);
await new Promise(res => setTimeout(res, 6000));
// Booking lazy-loads property cards on scroll — scroll down to populate more.
for (let i = 0; i < 3; i++) {
await run(['scroll', '6000', '900', '250'], 15000);
await new Promise(res => setTimeout(res, 1200));
}
const recipe = JSON.stringify({
card: 'div[data-testid=property-card]',
name: 'div[data-testid=title]',
price: 'span[data-testid=price-and-discounted-price]',
link: 'a[data-testid=title-link]',
});
const raw = parse(await run(['extract-all', recipe], 15000));
const recs = raw?.records ?? [];
return recs
.filter(r => r?.name && r?.price)
.map(r => ({ name: r.name.trim(), price: r.price, currency: r.currency, link: r.link || '' }));
}
main().catch(e => {
console.error('[search] fatal:', e.message);
process.exit(1);
});
install.sh›
#!/bin/bash
# ----------------------------------------------------------------------------
# pricewin-hotel-deal-finder — first-run installer for the agentic version.
# Idempotent.
# ----------------------------------------------------------------------------
set -euo pipefail
cd "$(dirname "$0")"
echo "[pricewin-hotel-deal-finder] Installing Node deps (locked versions)..."
if [ -f package-lock.json ]; then
npm ci --omit=dev --no-audit --no-fund
else
npm install --omit=dev --no-audit --no-fund
fi
echo "[pricewin-hotel-deal-finder] Downloading Chromium for Patchright (one-time, ~200MB)..."
npx --yes patchright install chromium
echo "[pricewin-hotel-deal-finder] Done."
echo
echo "First-time usage: the agent will discover selectors live for each site"
echo "and locale (~2-3 minutes per source) and cache them at"
echo " ~/.cache/pricewin-hotel-deal-finder/selectors.json"
echo "Subsequent searches reuse the cache and complete in ~30 seconds."
lib/browser-state.js›
// ----------------------------------------------------------------------------
// browser-state.js
//
// Persists the running Chromium's CDP endpoint to a state file so that
// subsequent CLI invocations can re-attach to the same browser. Without this
// every CLI call would spawn a fresh browser and lose its tab + cookies.
//
// State layout (~/.cache/pricewin-hotel-deal-finder/session-<id>.json):
// {
// "port": 51234,
// "pid": 12345,
// "token": "<64-hex auth token for the daemon's localhost API>",
// "createdAt": "2026-05-18T10:00:00Z"
// }
//
// The file carries the daemon's auth token, so it is created 0600 inside a 0700
// directory: on a shared machine another local user must not be able to read it
// and drive the browser.
// ----------------------------------------------------------------------------
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { existsSync, mkdirSync, chmodSync } from 'node:fs';
const STATE_DIR = path.join(os.homedir(), '.cache', 'pricewin-hotel-deal-finder');
const DEFAULT_SESSION = 'default';
function ensureDir() {
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
// Tighten a directory an older version created 0755.
else chmodSync(STATE_DIR, 0o700);
}
function statePath(sessionId = DEFAULT_SESSION) {
return path.join(STATE_DIR, `session-${sessionId}.json`);
}
export async function saveState(state, sessionId = DEFAULT_SESSION) {
ensureDir();
const file = statePath(sessionId);
await fs.writeFile(file, JSON.stringify(state, null, 2), { mode: 0o600 });
// writeFile's `mode` only applies when it creates the file — chmod covers the
// case where a previous run left a world-readable state file behind.
await fs.chmod(file, 0o600).catch(() => {});
}
export async function loadState(sessionId = DEFAULT_SESSION) {
try {
const raw = await fs.readFile(statePath(sessionId), 'utf8');
return JSON.parse(raw);
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
export async function clearState(sessionId = DEFAULT_SESSION) {
await fs.unlink(statePath(sessionId)).catch(() => {});
}
export function isProcessAlive(pid) {
if (!pid) return false;
try {
// Signal 0 = test without actually signalling. Throws if process gone.
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
lib/dom-extract.js›
// ----------------------------------------------------------------------------
// dom-extract.js
//
// Given a selector recipe (an object with CSS selectors per field), pull
// records out of the current page. Used by both the cache fast-path
// (`try-extract`) and the agent's discovery dry-run (`try-selectors`).
//
// Recipe shape (whatever the agent discovers, but these field names are
// expected downstream by the merger):
// {
// "card": "[data-testid='property-card']", // required — the row container
// "name": "[data-testid='title']", // required — hotel name
// "price": "[data-testid='price-and-discounted-price']", // required
// "link": "a[data-testid='title-link']", // optional — booking URL
// "image": "img[data-testid='image']", // optional
// "rating":"[data-testid='review-score']", // optional
// "stars": "[data-testid='rating-stars']" // optional
// }
//
// `extractWithSelectors` returns:
// {
// records: [{name, price, currency, link, image?, rating?, stars?}, …],
// stats: { total, withPrice, withName, withLink }
// }
//
// The stats let the caller decide whether the recipe is healthy enough to
// keep cached. `isExtractionHealthy` codifies that decision.
// ----------------------------------------------------------------------------
const REQUIRED_FIELDS = ['name', 'price'];
/**
* Run a recipe against the live page. Card selector returns the list; the
* other selectors are scoped *inside* each card so we don't accidentally
* match the wrong row.
*/
export async function extractWithSelectors(page, selectors) {
if (!selectors?.card) throw new Error('Recipe missing required "card" selector');
for (const f of REQUIRED_FIELDS) {
if (!selectors[f]) throw new Error(`Recipe missing required "${f}" selector`);
}
const raw = await page.evaluate(
({ sel }) => {
const cleanText = (el) => (el?.textContent || '').replace(/\s+/g, ' ').trim();
const parsePrice = (txt) => {
if (!txt) return null;
const digits = txt.replace(/[^\d]/g, '');
const n = Number(digits);
return Number.isFinite(n) && n > 0 ? n : null;
};
const detectCurrency = (txt) => {
if (!txt) return null;
if (/USD|\$/.test(txt)) return 'USD';
if (/EUR|€/.test(txt)) return 'EUR';
if (/GBP|£/.test(txt)) return 'GBP';
if (/THB|฿/.test(txt)) return 'THB';
if (/JPY|¥/.test(txt)) return 'JPY';
if (/VND|₫/i.test(txt)) return 'VND';
return null;
};
const cards = Array.from(document.querySelectorAll(sel.card));
return cards.slice(0, 50).map((card) => {
const nameEl = card.querySelector(sel.name);
const priceEl = card.querySelector(sel.price);
// Resolve link element. Fallback chain so Google's pattern (the card
// itself is an <a> tag with no nested anchor) still produces a URL:
// 1. Try the explicit `link` selector
// 2. If missing, check if card itself is an <a> with href
// 3. Last resort: any nested <a href>
let linkEl = sel.link ? card.querySelector(sel.link) : null;
if (!linkEl && card.tagName === 'A' && card.hasAttribute('href')) {
linkEl = card;
}
if (!linkEl) linkEl = card.querySelector('a[href]');
const imageEl = sel.image ? card.querySelector(sel.image) : card.querySelector('img');
const ratingEl = sel.rating ? card.querySelector(sel.rating) : null;
const starsEl = sel.stars ? card.querySelector(sel.stars) : null;
const priceText = cleanText(priceEl);
return {
name: cleanText(nameEl) || null,
priceText,
price: parsePrice(priceText),
currency: detectCurrency(priceText),
link: linkEl?.getAttribute('href') || null,
image: imageEl?.getAttribute('src') || null,
rating: ratingEl ? cleanText(ratingEl) : null,
starsText: starsEl ? cleanText(starsEl) : null,
};
});
},
{ sel: selectors },
);
const records = raw.filter((r) => r.name && r.price);
const stats = {
total: raw.length,
withPrice: raw.filter((r) => r.price).length,
withName: raw.filter((r) => r.name).length,
withLink: raw.filter((r) => r.link).length,
};
return { records, stats };
}
/**
* Decide whether an extraction is good enough to keep the cached selectors
* alive. Used by the CLI to drive the fail counter in selector-cache.js.
*
* Healthy if: at least 5 records returned AND at least 80% have both a
* non-null price and link. Tunable.
*/
export function isExtractionHealthy({ records, stats }) {
if (records.length < 5) return false;
const ratio = stats.total === 0 ? 0 : stats.withPrice / stats.total;
if (ratio < 0.8) return false;
return true;
}
lib/selector-cache.js›
// ----------------------------------------------------------------------------
// selector-cache.js
//
// Self-healing selector cache. The agent's discovery loop is expensive
// (~3 min, ~$1 in tokens) so we cache the selectors it finds and reuse them
// indefinitely. A TTL would be wasted effort: if the site ever changes its
// markup the cached selectors will simply return incomplete data, which
// trips the fail counter and forces a fresh discovery anyway.
//
// Invalidation rules:
// - Extract returns no records, or records missing required fields, 3
// times in a row → entry is dropped, agent re-discovers.
// - User can manually wipe via `browse refresh-cache`.
//
// Cache file layout (~/.cache/pricewin-hotel-deal-finder/selectors.json):
// {
// "<site>:<locale>:<task>": {
// "selectors": { "card": "[...]", "name": "[...]", ... },
// "discoveredAt": ISO,
// "lastWorkedAt": ISO,
// "consecutiveFails": 0
// }
// }
//
// NOTE: We deliberately do NOT ship a seed file. Selectors and aria-labels
// vary per locale (en-us vs th-th vs ja-jp), per region, and per A/B test
// bucket — shipping one author's selectors would just cause cache misses
// for every user on a different bucket, defeating the whole point of the
// cache. First-time users pay the discovery cost (~3 min, ~$1) once per
// (site, locale) and amortise from there.
// ----------------------------------------------------------------------------
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { existsSync, mkdirSync } from 'node:fs';
const CACHE_DIR = path.join(os.homedir(), '.cache', 'pricewin-hotel-deal-finder');
const CACHE_FILE = path.join(CACHE_DIR, 'selectors.json');
const MAX_CONSECUTIVE_FAILS = 3;
function ensureCacheDir() {
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
}
function cacheKey(site, locale, task) {
return `${site}:${locale}:${task}`;
}
async function readCache() {
ensureCacheDir();
if (!existsSync(CACHE_FILE)) return {};
try {
return JSON.parse(await fs.readFile(CACHE_FILE, 'utf8'));
} catch {
return {};
}
}
async function writeCache(cache) {
ensureCacheDir();
await fs.writeFile(CACHE_FILE, JSON.stringify(cache, null, 2));
}
function isPoisoned(entry) {
return (entry?.consecutiveFails || 0) >= MAX_CONSECUTIVE_FAILS;
}
/**
* Get cached selectors. Returns `null` if there's nothing usable — the agent
* discovery loop should fire and `recordDiscovery` should save the result.
* No expiration: as long as the selectors keep producing usable data they
* stay forever.
*/
export async function getSelectors(site, locale, task) {
const cache = await readCache();
const entry = cache[cacheKey(site, locale, task)];
if (!entry) return null;
if (isPoisoned(entry)) return null;
return entry.selectors;
}
/**
* Mark cached selectors as having worked. Resets the fail counter and bumps
* `lastWorkedAt` so the entry stays warm.
*/
export async function recordSuccess(site, locale, task) {
const cache = await readCache();
const key = cacheKey(site, locale, task);
const entry = cache[key];
if (!entry) return;
entry.lastWorkedAt = new Date().toISOString();
entry.consecutiveFails = 0;
cache[key] = entry;
await writeCache(cache);
}
/**
* Mark cached selectors as having failed (returned no usable data). After
* `MAX_CONSECUTIVE_FAILS` in a row the entry is considered poisoned and
* `getSelectors` will return null on the next call, forcing re-discovery.
*/
export async function recordFailure(site, locale, task) {
const cache = await readCache();
const key = cacheKey(site, locale, task);
const entry = cache[key];
if (!entry) return;
entry.consecutiveFails = (entry.consecutiveFails || 0) + 1;
cache[key] = entry;
await writeCache(cache);
}
/**
* Save freshly discovered selectors (or overwrite stale ones).
*/
export async function recordDiscovery(site, locale, task, selectors, urlTemplate = null, citySlug = null) {
const cache = await readCache();
const key = cacheKey(site, locale, task);
cache[key] = {
selectors,
// Optional URL template. When the template contains a numeric site-specific
// city param (e.g. Agoda city=3987) that was NOT templatized, citySlug records
// which city this template is valid for. multi-extract uses citySlug to detect
// a city mismatch and fall through to discovery instead of returning wrong data.
urlTemplate,
...(citySlug ? { citySlug } : {}),
discoveredAt: new Date().toISOString(),
lastWorkedAt: new Date().toISOString(),
consecutiveFails: 0,
};
await writeCache(cache);
}
export async function getEntry(site, locale, task) {
const cache = await readCache();
return cache[cacheKey(site, locale, task)] || null;
}
/**
* Drop a single entry — exposed for manual cache busting via the CLI.
*/
export async function invalidate(site, locale, task) {
const cache = await readCache();
delete cache[cacheKey(site, locale, task)];
await writeCache(cache);
}
/**
* Drop everything. Useful for `browse refresh-cache --all`.
*/
export async function invalidateAll() {
await writeCache({});
}
export const _internals = { isPoisoned, MAX_CONSECUTIVE_FAILS };
lib/snapshot.js›
// ----------------------------------------------------------------------------
// snapshot.js
//
// Reads the loaded page and produces a compact text rendering for the LLM
// agent to reason over. Output is a numbered list of interactive elements
// and visible structural text. Each entry has a `[ref]` token; the same
// ref can be passed back to `click <ref>` / `fill <ref> <text>` because
// the snapshot writes its index into a `data-browse-ref` attribute on the
// live DOM, which `lib/dom-extract.js` (and the CLI) can then re-locate.
//
// Filtering rules:
// - Keep <a>, <button>, <input>, <select>, <textarea>
// - Keep elements with role=button/link/searchbox/checkbox/menuitem
// - Keep <h1>–<h6>
// - Keep visible text nodes that look like prices, hotel names, or
// review scores
// - Skip elements that are hidden (display:none, visibility:hidden,
// opacity:0, zero-size) or inside <script>/<style>
//
// Token-budget conscious: each entry is one line; the whole snapshot is
// typically 200–800 lines (~1–4k tokens) for a busy results page.
// ----------------------------------------------------------------------------
/**
* Public API. Call after page.goto / page.waitForLoadState.
* Returns:
* {
* text: "[1] input placeholder=\"Where to?\"\\n[2] button \"Search\"\\n…",
* refs: { 1: "<stable CSS selector>", 2: "<stable CSS selector>", … }
* }
*
* The daemon caches the `refs` map so subsequent click/type/fill commands
* can look up the stable selector even if React rerenders and wipes our
* `data-browse-ref` attribute.
*
* Stable selector strategy: prefer stable attributes (`data-testid`,
* `data-selenium`, `data-element-name`), then unique aria-label or href,
* then path-based nth-of-type chain. Always validated to be unique at
* snapshot time.
*/
export async function takeSnapshot(page) {
const result = await page.evaluate(() => {
const PRICE_RE = /[\d.,]{3,}\s*(VND|₫|\$|USD|THB|JPY|EUR|£)\b/i;
const isVisible = (el) => {
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || +style.opacity === 0) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const cleanText = (s) => (s || '').replace(/\s+/g, ' ').trim().slice(0, 160);
const kind = (el) => {
const tag = el.tagName.toLowerCase();
const role = (el.getAttribute('role') || '').toLowerCase();
if (tag === 'a' || role === 'link') return 'link';
if (tag === 'button' || role === 'button') return 'button';
if (tag === 'input') {
const t = (el.getAttribute('type') || 'text').toLowerCase();
return t === 'submit' ? 'button' : 'input';
}
if (tag === 'select' || tag === 'textarea') return 'input';
if (role === 'searchbox' || role === 'textbox' || role === 'combobox') return 'input';
if (role === 'checkbox') return 'checkbox';
if (role === 'menuitem' || role === 'option') return 'option';
if (role === 'tab' || role === 'switch' || role === 'radio') return 'button';
if (/^h[1-6]$/.test(tag)) return 'heading';
return null;
};
const stableSelectorFor = (el) => {
// 1) #id when id is css-safe
const id = el.id;
if (id && /^[A-Za-z][\w-]*$/.test(id) && document.querySelectorAll('#' + id).length === 1) {
return '#' + id;
}
// 2) stable data-* attrs when unique
for (const a of ['data-testid', 'data-selenium', 'data-element-name']) {
const v = el.getAttribute(a);
if (v) {
const sel = '[' + a + '=' + JSON.stringify(v) + ']';
if (document.querySelectorAll(sel).length === 1) return sel;
}
}
// 3) unique aria-label
const aria = el.getAttribute('aria-label');
if (aria) {
const sel = el.tagName.toLowerCase() + '[aria-label=' + JSON.stringify(aria) + ']';
if (document.querySelectorAll(sel).length === 1) return sel;
}
// 4) unique href on anchors
const href = el.getAttribute('href');
if (href && el.tagName === 'A') {
const sel = 'a[href=' + JSON.stringify(href) + ']';
if (document.querySelectorAll(sel).length === 1) return sel;
}
// 5) path-based nth-of-type chain
const parts = [];
let cur = el;
while (cur && cur !== document.body && cur.tagName) {
const sibs = Array.from((cur.parentElement && cur.parentElement.children) || []).filter((c) => c.tagName === cur.tagName);
const i = sibs.indexOf(cur);
parts.unshift(cur.tagName.toLowerCase() + (sibs.length > 1 ? ':nth-of-type(' + (i + 1) + ')' : ''));
cur = cur.parentElement;
}
return 'body > ' + parts.join(' > ');
};
// Clear any old refs from a prior snapshot.
document.querySelectorAll('[data-browse-ref]').forEach((el) => el.removeAttribute('data-browse-ref'));
let n = 0;
const entries = [];
const refMap = {};
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) {
const el = walker.currentNode;
if (!el || !el.tagName) continue;
if (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(el.tagName)) continue;
if (!isVisible(el)) continue;
const k = kind(el);
if (!k) continue;
n += 1;
el.setAttribute('data-browse-ref', String(n));
const attrs = {};
for (const name of ['data-testid', 'data-selenium', 'aria-label', 'placeholder', 'name', 'href', 'type', 'role', 'id']) {
const v = el.getAttribute(name);
if (v) attrs[name] = cleanText(v);
}
const entryText = cleanText(el.innerText || el.value || '');
entries.push({ ref: n, kind: k, text: entryText, attrs });
// Keep BOTH a CSS selector (fast path) and a signature (resilient
// fallback if React re-renders strips attrs after snapshot).
refMap[n] = {
selector: stableSelectorFor(el),
signature: {
tag: el.tagName.toLowerCase(),
kind: k,
text: entryText,
testid: el.getAttribute('data-testid') || el.getAttribute('data-selenium') || null,
ariaLabel: el.getAttribute('aria-label') || null,
placeholder: el.getAttribute('placeholder') || null,
href: el.getAttribute('href') || null,
},
};
}
// Capture standalone price text nodes (Booking pattern).
const priceWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
while (priceWalker.nextNode()) {
const t = priceWalker.currentNode;
const txt = cleanText(t.nodeValue);
if (!txt || !PRICE_RE.test(txt)) continue;
const parent = t.parentElement;
if (!parent || parent.hasAttribute('data-browse-ref')) continue;
if (!isVisible(parent)) continue;
n += 1;
parent.setAttribute('data-browse-ref', String(n));
entries.push({ ref: n, kind: 'price', text: txt, attrs: {} });
refMap[n] = {
selector: stableSelectorFor(parent),
signature: { tag: parent.tagName.toLowerCase(), kind: 'price', text: txt, testid: null, ariaLabel: null, placeholder: null, href: null },
};
}
return { entries, refMap };
});
const refs = {};
const lines = [];
for (const e of result.entries) {
refs[e.ref] = result.refMap[e.ref] || { selector: `[data-browse-ref="${e.ref}"]`, signature: null };
const parts = [`[${e.ref}]`, e.kind];
if (e.kind === 'price') {
parts.push(JSON.stringify(e.text));
} else if (e.kind === 'heading') {
parts.push(JSON.stringify(e.text));
} else {
// For links/buttons/inputs, show label + key attrs that help the LLM
// pick the right one (testid, href, placeholder).
if (e.text) parts.push(JSON.stringify(e.text));
for (const k of ['data-testid', 'data-selenium', 'aria-label', 'placeholder', 'href']) {
if (e.attrs[k]) parts.push(`${k}=${JSON.stringify(e.attrs[k])}`);
}
}
lines.push(parts.join(' '));
}
return { text: lines.join('\n'), refs };
}
package-lock.json›
{
"name": "pricewin-hotel-deal-finder",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pricewin-hotel-deal-finder",
"version": "1.1.1",
"hasInstallScript": true,
"dependencies": {
"patchright": "^1.55.2"
},
"bin": {
"browse": "bin/browse.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/patchright": {
"version": "1.59.4",
"resolved": "https://registry.npmjs.org/patchright/-/patchright-1.59.4.tgz",
"integrity": "sha512-RDZ40tBZHZtTAMoUoct/IpMA1wrozPZGU2RFk8NIDEndOEBbLxS0dH2fRLiwsNrgXgjZGA/3krrTlzK+uFGgoQ==",
"license": "Apache-2.0",
"dependencies": {
"patchright-core": "v1.59.4"
},
"bin": {
"patchright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/patchright-core": {
"version": "1.59.4",
"resolved": "https://registry.npmjs.org/patchright-core/-/patchright-core-1.59.4.tgz",
"integrity": "sha512-7/vyX0XK0cpGKlcnUD+Rhjv5o9rrmZQl4v/NI+EUBed+VaU5EORpkOF0Gdi+fP698fLhY0tXwacKBUqKE38jQA==",
"license": "Apache-2.0",
"bin": {
"patchright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
package.json›
{
"name": "pricewin-hotel-deal-finder",
"version": "1.1.3",
"type": "module",
"private": true,
"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.",
"author": "PriceWin",
"license": "MIT-0",
"homepage": "https://github.com/Price-Win/pricewin-skills-hub/tree/main/skills/pricewin-hotel-deal-finder#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/Price-Win/pricewin-skills-hub.git",
"directory": "skills/pricewin-hotel-deal-finder"
},
"bugs": "https://github.com/Price-Win/pricewin-skills-hub/issues",
"bin": {
"browse": "bin/browse.js"
},
"scripts": {
"postinstall": "echo 'Run install.sh next to download Chromium' >&2"
},
"dependencies": {
"patchright": "^1.55.2"
},
"engines": {
"node": ">=20"
}
}
SECURITY.md›
# Security & Data Handling — PriceWin Hotel Deal Finder
This skill drives a stealth browser (Patchright/Chromium) to read **public** hotel
listings and compare prices. It runs entirely on the user's machine, needs no API
keys, and collects no personal data. This document discloses exactly what it
executes, what it downloads, what leaves the machine, and how untrusted web
content is contained — so an auditing agent (or a human) can verify the behavior
instead of inferring it.
## What runs (command execution)
The skill's only entry point is one command:
```bash
node bin/search.js "<city>" <checkIn> <checkOut> <adults> [locale]
```
`search.js` spawns `bin/browse.js` / `bin/daemon.js` (both shipped in this repo)
to control a local browser. It runs **no** shell strings built from scraped
content, executes **no** downloaded code, and takes **no** command input from web
pages — the browser flow is fixed in this repo's source. `npm install` / `npx`
appear **only** at install time to fetch the `patchright` dependency (see below).
The skill's whole source is three files in `bin/` and four in `lib/`: no minified
blobs, no obfuscation, no `eval`, no post-install script that fetches code (the
`postinstall` hook only prints a reminder). Every source file is auditable in the
public repo
<https://github.com/Price-Win/pricewin-skills-hub>.
## Why a "stealth" browser
The word is doing narrow work here. Patchright masks the CDP-level automation
fingerprint so a **normal, logged-out page view** of a public listing is not
mistaken for a scraping bot and served an empty or blocked page. It exists to
read public prices reliably from bot-hardened cities, not to evade security
controls: the skill does not bypass authentication, solve CAPTCHAs, defeat
rate limits with proxy rotation, hide from the user, or persist beyond the
session (`browse close`, `SIGTERM`, or the state file disappearing all stop it).
Nothing about it is aimed at endpoint security software, and it makes no attempt
to conceal what it is doing on the user's own machine — the daemon logs to
stderr and its state file is in plain sight under `~/.cache/`.
## What it downloads
| Item | When | Source | Purpose |
|------|------|--------|---------|
| `patchright` npm package | install | npm registry | Stealth Playwright fork (browser driver) |
| Chromium | first run (`install.sh`) | Patchright's official host | The browser engine that renders OTA pages |
No other binaries or code are downloaded at runtime.
## What leaves the machine (network egress)
Egress is limited to a fixed, auditable set of hosts. **The only user-derived data
sent is the search query itself** — city, check-in/out dates, guest count. No
account data, credentials, cookies from other sites, files, or PII are transmitted.
| Host | Data sent | Why |
|------|-----------|-----|
| `booking.com`, `agoda.com`, `google.com/travel` | city + dates + guests (as normal search URL params) | Read public listing prices |
| `api.opentravel.one` (override via `OPENTRAVEL_API_BASE_URL`) | city + dates + guests | Partner inventory lookup |
| `open.er-api.com` | none (public `GET /latest/USD`) | Live VND→USD FX rate for price normalization |
There is no telemetry, analytics, or callback to PriceWin servers.
## The local daemon (`bin/daemon.js`)
`search.js` drives one long-running local process that owns the Chromium
instance and answers commands over HTTP. It is privileged — it can navigate and
read any page — so it is locked down on four axes:
| Control | Implementation |
|---|---|
| Loopback only | `server.listen(port, '127.0.0.1')` — never reachable from the LAN or internet |
| Authenticated | Every request (including `/ping`) must carry `x-pricewin-token`, a 32-byte random token minted per daemon run and compared with `crypto.timingSafeEqual` |
| 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 |
| 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 |
Ephemeral port, chosen at startup; no fixed port to scan for. The daemon exits on
`SIGTERM`/`SIGINT` and on `browse close`, clearing its state file.
**Chromium sandbox stays ON.** `--no-sandbox` is *not* passed by default. It is
added only when the sandbox provably cannot work — running as root on Linux, or
an explicit `PRICEWIN_NO_SANDBOX=1` — and the daemon prints a warning to stderr
when it does.
## Untrusted content containment (indirect prompt injection)
Hotel names, prices, and aria-labels scraped from OTA pages are **untrusted
third-party content**. Before any of it reaches the model-visible output,
`sanitizeText()` in `bin/search.js`:
- strips control, zero-width, and bidirectional-override characters (defeats
hidden-instruction and text-spoofing tricks);
- removes the markdown/link control set (`` ` `` `[ ] ( ) < > { } \ |`) so scraped
text cannot forge `[label](url)` structure or smuggle directives;
- collapses whitespace and caps length.
All booking links (including the OpenTravel partner API's) are passed through
`cleanLink()`, which **accepts only `http(s)` URLs** — a `javascript:` or other
scheme can never render as a clickable link. The skill also treats scraped data
as data only: it ranks and formats prices, and never executes or follows
instructions found inside scraped text.
## Guidance for the running agent
The skill instructs the agent to treat OTA output as reference data to present to
the user, not as commands. Partial results (a source blocked or empty) are normal
and are surfaced honestly rather than "fixed" by ad-hoc scraping.
## Reporting
Found an issue? Open a ticket at
<https://github.com/Price-Win/pricewin-skills-hub/issues>.
SKILL.md›
---
name: pricewin-hotel-deal-finder
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'."
version: 1.1.3
author: PriceWin
platforms: [linux, macos, windows]
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]
metadata:
openclaw:
requires:
bins: [node, npx]
envVars:
- name: OPENTRAVEL_API_BASE_URL
required: false
description: Override the OpenTravel API host (default https://api.opentravel.one).
emoji: "🏨"
homepage: https://github.com/Price-Win/pricewin-skills-hub
---
# 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 `<city>` for `<dates>`?"
- "Is Booking.com or Agoda cheaper for this hotel?"
- "Compare hotel prices for `<city>`, `<N>` guests."
- "Find me a hotel under $`<X>`/night in `<city>`."
- "Best-value place to stay near `<landmark>` on `<dates>`?"
Each returns the same one-command answer below — no clarifying round-trip needed.
**What you get from one command:**
- 🥇 Best value · 🥈 Cheapest · 🥉 Quality — picked side-by-side
- Real per-night prices from up to **4 sources**, normalized to **USD**
- **Clickable booking links** straight to the cheapest OTA for each hotel
- Works for **any city worldwide** — including bot-hardened ones (Shanghai, Hangzhou, Bangkok…) via a stealth Patchright daemon
- No API keys, no MCP server — `node`/`npx` is all you need
**Sample result:**
```
🏨 Tokyo • Aug 12–15 • 3 nights • 2 guests
━━━━━━━━━━━━━━━━━━━━
🥇 BEST VALUE
Shinjuku Granbell Hotel
✅ agoda 💰 $118/night
booking 💰 $131/night
→ Save $13 vs Booking
🥈 CHEAPEST
APA Hotel Shinjuku
✅ google 💰 $94/night
📊 18 hotels | agoda, booking, google, opentravel • prices in USD
```
**Install:**
```bash
npx skills add https://github.com/Price-Win/pricewin-skills-hub --skill pricewin-hotel-deal-finder
```
---
## How to use this skill
**One command does the whole job — you normally won't need to ask clarifying questions first. Infer the parameters (below) and run it:**
```bash
cd {baseDir} && node bin/search.js "<city>" <checkInYYYY-MM-DD> <checkOutYYYY-MM-DD> <adults> en-us
```
`{baseDir}` is this skill's install directory (auto-resolved by the runtime). If your runtime does not substitute it, `cd` into the folder that contains this `SKILL.md` (the one with `bin/search.js`). Avoid hardcoding a `~/.hermes/...` or `~/.openclaw/...` path — it differs per platform.
Example:
```bash
cd {baseDir} && node bin/search.js "Hangzhou" 2026-06-10 2026-06-13 2 en-us
```
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.
**Infer the parameters instead of asking** (ask only if the city or dates are genuinely ambiguous):
- **Year:** 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 `date +%Y-%m-%d` if unsure.)
- **"10-13/6"** → `<year>-06-10 <year>-06-13` — fill `<year>` from the rule above
- **"2 guests" / "2 people"** → `2` adults
- **Locale:** language/region code passed to the OTAs (controls site language + region). Default `en-us`. Prices are in USD (Google Hotels is requested with `gl=us&curr=USD`); other sources follow the locale you pass.
One `search.js` run is the whole workflow — no Python, curl, or ad-hoc scraping is needed on top of it.
---
## Operating rules — how to get reliable results
**RULE 0 — Drive the browser only through `search.js` (via your terminal/shell tool). The native browser tools don't work here.** This skill relies on a stealth Patchright daemon. The runtime's native tools — `browser_navigate` / `browser_open`, `browser_click`, `browser_type` / `browser_fill`, `browser_snapshot`, `browser_close`, any other `browser_*`, and subagent delegation (`delegate_task` / `spawn_agent`) — will fail on this task, so don't reach for them:
- 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 `search.js` launches survives bot-detection.
- 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.
The one path that works:
```
cd {baseDir} && node bin/search.js ...
```
**RULE 1 — Let `search.js` do the scraping; don't scrape an OTA yourself.** Avoid calling `browse.js` directly, doing `goto`/`click`/`type` in the browser, building Agoda/Booking/Google URLs by hand, calling the OpenTravel API separately, or launching the daemon yourself. `search.js` 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 `search.js` once and send its output. If a source looks "missing", see RULE 4 rather than fetching it by hand.
**RULE 2 — First-time city discovery takes 2–4 minutes.** If `search.js` output contains `"discovering"` or `"launching"` 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.
**RULE 3 — Send the output exactly.** `search.js` outputs formatted tier cards ready to send. Copy the output directly into your response. Do not reformat, summarize, or abbreviate it.
**RULE 3a — Preserve the markdown hyperlinks.** Every hotel name in the output is already wrapped as `[Hotel Name](https://booking-url...)` — a clickable hyperlink. Keep it intact: don't split the URL onto a separate `🔗 https://...` line, don't replace `[Hotel Name](url)` 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).
**RULE 3b — Hyperlink hotel names in your own commentary too.** If you add a suggestion or commentary section after the output, wrap every hotel name you mention as `[Hotel Name](url)` using the same URL the script printed for that hotel, rather than plain text.
**RULE 4 — Partial results are normal — send them as-is rather than fixing by hand.** 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 (`📊 N hotels | <sources> • prices in USD`) 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 `search.js` 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 `search.js` command again (anti-bot is often transient).
---
## Output Format Reference
`search.js` prints tier cards in this format — you send this directly to the user:
The hotel name is a Markdown link to its cheapest OTA. Price rows carry NO
links and the OTA key is shown lowercase (`agoda`/`booking`/`google`/`opentravel`).
There are no star ratings or area lines — the script does not have that data.
```
🏨 <city> • <d1>–<d2> • <N> nights • <adults> guests
━━━━━━━━━━━━━━━━━━━━
🥇 BEST VALUE
[<Hotel Name>](<cheapest_link>)
✅ agoda 💰 <price>/night
booking 💰 <price>/night
opentravel 💰 <price>/night
→ Save <diff> vs Booking
🥈 CHEAPEST
[<Hotel Name>](<cheapest_link>)
✅ google 💰 <price>/night
agoda 💰 <price>/night
🥉 QUALITY
[<Hotel Name>](<cheapest_link>)
✅ booking 💰 <price>/night
agoda 💰 <price>/night
📋 More good deals
— Agoda —
• [<Hotel>](<agoda_link>) — agoda: <price> | booking: <price>
— Booking —
• [<Hotel>](<booking_link>) — booking: <price>
— Google —
• [<Hotel>](<google_link>) — google: <price>
— OpenTravel —
• [<Hotel>](<opentravel_link>) — opentravel: <price>
💡 Tip: <best Hotel Name>
[Book on <OTA>](<link>) — <price>/night
📊 <N> hotels | <sources with data> • prices in USD
```
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.
---
## Limitations
- 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.
- Subsequent searches reuse the Agoda cache and complete in ~30–60 seconds.
## Security & data handling
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: `sanitizeText()` in `bin/search.js` strips control/zero-width/bidi
and markdown-control characters before any of it reaches model output, and booking
links are restricted to `http(s)`.
The local browser daemon is **loopback-only, token-authenticated** (a per-run
token in a `0600` state file), rejects non-loopback `Host` headers against DNS
rebinding, and keeps the **Chromium sandbox enabled** — `--no-sandbox` is used
only where it cannot work (root on Linux, or explicit `PRICEWIN_NO_SANDBOX=1`).
Full disclosure of commands run, downloads, and network egress is in
[`SECURITY.md`](./SECURITY.md).