/** * RollingGo 酒店查询适配器(只读) * * 说明:https://github.com/RollingGo-AI/rollinggo-hotel-skill-CN 提供的是 * 「Claude Skill + 本机 CLI」而不是 MCP Server。底层 npm 包 @rollinggo/hotel, * 可执行文件是 rgh,认证走 OAuth 2.1(rgh login),没有 API Key。 * * 因此这里通过子进程调用本机 rgh,只使用两个只读子命令: * status —— 判断是否已登录 * search-hotels —— 按地点/日期/价位搜索,返回最低参考价 * hotel-detail —— 查房型与退改政策(按需) * * ⚠️ 安全红线:ALLOWED_SUBCOMMANDS 之外的一律拒绝。 * price-confirm / book / pre-cancel / confirm-cancel 会触发真实资金操作, * 本适配器永不调用,也不对外暴露。 */ const fs = require('fs'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); const ALLOWED_SUBCOMMANDS = new Set(['status', 'search-hotels', 'hotel-detail']); // 参数值里只有「地点名 / 查询语」是自由文本,其余由服务端生成。 // Windows 下 .cmd 需要 shell 才能 spawn,所以对自由文本做严格白名单,杜绝命令注入。 const SAFE_TEXT = /^[\u4e00-\u9fa5A-Za-z0-9\u3000-\u303f\s·\-—()()]{1,48}$/; function safeText(v, fallback = '') { const s = String(v || '').trim(); if (!s) return fallback; return SAFE_TEXT.test(s) ? s : fallback; } function argSafe(v) { // 兜底:任何值都不允许出现 shell 元字符 return !/[&|;`$<>"'\r\n]/.test(String(v)); } /** * WSL 会把 Windows 的 PATH 混进 Linux 环境,`which rgh` 可能命中 /mnt/c/... 的 Windows 版, * 那是 shell 脚本、无法在 Linux 正常运行,必须排除。 */ function isForeignMount(p) { return process.platform !== 'win32' && /^\/mnt\/[a-z]\//i.test(String(p || '')); } /** 找到真正的可执行文件:绝对路径直接用,裸命令名先查 PATH,再查常见全局安装位置 */ function resolveBin(cfg) { const raw = String(cfg.RHG_BIN || 'rgh').trim(); if (!raw) return ''; if (/[\\/]/.test(raw)) return (fs.existsSync(raw) && !isForeignMount(raw)) ? raw : ''; try { const probe = process.platform === 'win32' ? 'where' : 'which'; const r = spawnSync(probe, [raw], { encoding: 'utf8', windowsHide: true, timeout: 5000 }); if (r.status === 0 && r.stdout) { const list = String(r.stdout).split(/\r?\n/).map((s) => s.trim()).filter(Boolean); // Windows 下 `where rgh` 会先给一个无扩展名的 sh 脚本,那个 spawn 不起来; // 按 .exe > .cmd > .bat > 其他 排序后再取 const rank = (p) => (/\.exe$/i.test(p) ? 0 : (/\.cmd$/i.test(p) ? 1 : (/\.bat$/i.test(p) ? 2 : 3))); list.sort((a, b) => rank(a) - rank(b)); const hit = list.find((p) => fs.existsSync(p) && !isForeignMount(p)); if (hit) return hit; } } catch { /* 继续找 */ } // 服务进程的 PATH 未必包含 npm 全局目录(尤其桌面端启动的开发服务), // 这里补一遍常见位置,否则用户装了 rgh 也永远检测不到。 const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : ['']; const dirs = []; if (process.platform === 'win32') { if (process.env.APPDATA) dirs.push(path.join(process.env.APPDATA, 'npm')); if (process.env.ProgramFiles) dirs.push(path.join(process.env.ProgramFiles, 'nodejs')); if (process.env.LOCALAPPDATA) dirs.push(path.join(process.env.LOCALAPPDATA, 'Programs', 'nodejs')); } else { dirs.push('/usr/local/bin', '/opt/homebrew/bin', path.join(require('os').homedir(), '.local', 'bin')); } for (const dir of dirs) { for (const ext of exts) { const p = path.join(dir, raw + ext); try { if (fs.existsSync(p) && !isForeignMount(p)) return p; } catch { /* 忽略 */ } } } return ''; } /** * 从 npm 的 bin 脚本位置推断真实 JS 入口。 * Windows 全局安装拿到的 rgh.cmd 会经 cmd.exe 转发,中文参数有编码风险, * 直接 `node /dist/index.js` 既避开编码问题,也顺带杜绝了命令注入。 */ function jsEntryNear(binPath) { try { const dir = path.dirname(binPath); const candidates = [ path.join(dir, 'node_modules', '@rollinggo', 'hotel', 'dist', 'index.js'), path.resolve(dir, '..', 'lib', 'node_modules', '@rollinggo', 'hotel', 'dist', 'index.js'), // 局部安装时 bin 在 node_modules/.bin 下,包体在上一层的 @rollinggo 里 path.resolve(dir, '..', '@rollinggo', 'hotel', 'dist', 'index.js'), ]; for (const p of candidates) if (fs.existsSync(p)) return p; } catch { /* 忽略 */ } return ''; } function planInvocation(cfg) { const bin = resolveBin(cfg); if (!bin) return null; if (/\.js$/i.test(bin)) return { cmd: process.execPath, argv: [bin], bin }; const near = jsEntryNear(bin); if (near) return { cmd: process.execPath, argv: [near], bin }; if (process.platform !== 'win32' || /\.exe$/i.test(bin)) return { cmd: bin, argv: [], bin }; // .cmd 且找不到 JS 入口:只能借 shell(参数已做严格白名单过滤) return { cmd: bin, argv: [], bin, shell: true }; } function runRaw(cfg, args, timeoutMs = 20000) { return new Promise((resolve) => { const inv = planInvocation(cfg); if (!inv) return resolve({ ok: false, code: -1, out: '', err: 'ENOENT', missing: true }); let child; try { child = spawn(inv.cmd, [...inv.argv, ...args], { shell: Boolean(inv.shell), windowsHide: true, timeout: timeoutMs, env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', // rgh 用 dotenvx 加载配置,会往 stdout 打启动日志;能关就关,关不掉靠 extractJSON 兜 DOTENV_CONFIG_QUIET: 'true', DOTENVX_SILENT: '1', }, }); } catch (err) { return resolve({ ok: false, code: -1, out: '', err: err.message }); } let out = ''; let err = ''; const cap = 2 * 1024 * 1024; child.stdout.on('data', (d) => { if (out.length < cap) out += d; }); child.stderr.on('data', (d) => { if (err.length < cap) err += d; }); child.on('error', (e) => resolve({ ok: false, code: -1, out, err: e.message })); child.on('close', (code) => { const clean = (s) => String(s || '').replace(/\u001b\[[0-9;]*m/g, '').trim(); resolve({ ok: code === 0, code, out: clean(out), err: clean(err) }); }); }); } /** 从 start 开始按括号配对切出完整的一段(跳过字符串内的括号) */ function sliceBalanced(s, start) { const open = s[start]; const close = open === '{' ? '}' : ']'; let depth = 0; let inStr = false; let esc = false; for (let i = start; i < s.length; i += 1) { const ch = s[i]; if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; } if (ch === '"') { inStr = true; continue; } if (ch === open) depth += 1; else if (ch === close) { depth -= 1; if (depth === 0) return s.slice(start, i + 1); } } return ''; } /** * 从 CLI 输出里捞出 JSON 主体。 * * 实测坑:rgh 启动时 dotenvx 会往 stdout 打一行 * ◇ injected env (1) from ... // tip: ... { path: '/custom/path/.env' } * 这行本身就带花括号,所以「第一个 { 到最后一个 }」会把日志和 JSON 粘成一坨。 * 这里先从业务字段的位置回推所属对象,再按括号配对精确切分。 */ function extractJSON(text) { const s = String(text || ''); if (!s.trim()) return null; try { return JSON.parse(s); } catch (_) { /* 有前缀日志,继续找 */ } const hints = ['"success"', '"hotelInformationList"', '"roomRatePlans"', '"orderInfoList"', '"hotelId"']; const starts = []; for (const h of hints) { const idx = s.indexOf(h); if (idx < 0) continue; const b = s.lastIndexOf('{', idx); if (b >= 0) starts.push(b); } for (let i = 0; i < s.length; i += 1) { const ch = s[i]; if (ch === '{' || ch === '[') starts.push(i); } const seen = new Set(); for (const st of starts) { if (seen.has(st)) continue; seen.add(st); const frag = sliceBalanced(s, st); if (!frag) continue; try { const obj = JSON.parse(frag); if (obj && typeof obj === 'object') return obj; } catch (_) { /* 试下一个起点 */ } } return null; } let statusCache = { at: 0, ready: false, reason: '未检测' }; const STATUS_TTL = 60000; /** 探测:rgh 是否存在、是否已登录。结果缓存 60s,避免每次查住宿都起一个进程 */ async function availability(cfg, force = false) { if (!force && Date.now() - statusCache.at < STATUS_TTL) return statusCache; const r = await runRaw(cfg, ['status'], 12000); if (r.missing) { statusCache = { at: Date.now(), installed: false, ready: false, reason: '未安装 rgh(npm i -g @rollinggo/hotel)' }; return statusCache; } const text = `${r.out} ${r.err}`.trim(); const loggedOut = /未登录|请先.{0,8}login|not logged in|unauthenticated/i.test(text); statusCache = { at: Date.now(), installed: true, ready: r.ok && !loggedOut, reason: loggedOut ? 'rgh 已安装但未登录(执行 rgh login)' : (r.ok ? '' : (text.slice(0, 120) || '状态未知')), }; return statusCache; } const PLACE_TYPES = new Set(['城市', '机场', '景点', '火车站', '地铁站', '酒店', '区/县', '详细地址']); /** * 按名字推断地点类型。 * 「柳州市」是城市不是景点——用错类型会直接查空,然后被误判成「这家数据源没数据」。 */ function guessPlaceType(name) { const s = String(name || '').trim(); if (/(市|地区|自治州|盟)$/.test(s)) return '城市'; if (/(区|县|旗|镇|乡|街道)$/.test(s)) return '区/县'; return '景点'; } /** * 单次搜索(placeType 由调用方决定)。 * @param {object} q { name, checkIn, nights, maxPricePerNight, distanceMeters, size, adults, lat, lon } */ async function searchOnce(q, cfg, placeType) { const place = safeText(q.name); if (!place || !argSafe(place)) { return { hotels: [], error: '地点名包含不可用字符,跳过 RollingGo 查询' }; } const size = Math.min(20, Math.max(1, Number(q.size) || 8)); const nights = Math.min(30, Math.max(1, Number(q.nights) || 1)); const adults = Math.min(10, Math.max(1, Number(q.adults) || 2)); const dist = Math.min(20000, Math.max(500, Number(q.distanceMeters) || 10000)); const args = [ 'search-hotels', '--origin-query', safeText(`${place}周边适合出行的酒店`, `${place} 酒店`), '--place', place, '--place-type', placeType, '--size', String(size), '--stay-nights', String(nights), '--adult-count', String(adults), '--distance-in-meter', String(dist), ]; if (/^\d{4}-\d{2}-\d{2}$/.test(String(q.checkIn || ''))) { args.push('--check-in-date', String(q.checkIn)); } const maxPrice = Number(q.maxPricePerNight); if (Number.isFinite(maxPrice) && maxPrice > 0) { args.push('--max-price-per-night', String(Math.round(maxPrice))); } const sub = args[0]; if (!ALLOWED_SUBCOMMANDS.has(sub)) return { hotels: [], error: '子命令不在白名单内' }; if (!args.every((a) => argSafe(a))) return { hotels: [], error: '参数包含不可用字符' }; const r = await runRaw(cfg, args, Number(cfg.ROLLINGGO_TIMEOUT_MS) || 25000); const data = extractJSON(r.out) || extractJSON(r.err); if (!data) { const why = (r.out || r.err || '').slice(0, 160) || `exit ${r.code}`; return { hotels: [], error: `RollingGo 返回无法解析:${why}` }; } const list = data.hotelInformationList || data.hotels || []; if (!Array.isArray(list) || !list.length) { return { hotels: [], error: '', empty: true, message: String(data.message || '').slice(0, 80) }; } const anchorLat = Number(q.lat); const anchorLon = Number(q.lon); const hasAnchor = Number.isFinite(anchorLat) && Number.isFinite(anchorLon); // 城市类查询时 --distance-in-meter 不生效,服务端可能返回全城甚至外地的酒店, // 所以这里按坐标自己算距离并过滤,否则会出现「去柳州却推荐重庆酒店」。 const radiusKm = Math.min(50, Math.max(1, Number(q.distanceMeters) / 1000 || 10)); const limitKm = placeType === '城市' ? Math.max(radiusKm, 20) : radiusKm; const hotels = []; let droppedFar = 0; for (const h of list) { const lat = Number(h.latitude); const lon = Number(h.longitude); const price = h.price && Number(h.price.lowestPrice); // 距离优先自己算:数据源给的 distanceInMeters 实测是错的 // (柳州城中区的酒店被标成 64527 米,实际约 2.8km) let distanceKm = null; if (hasAnchor && Number.isFinite(lat) && Number.isFinite(lon)) { const R = 6371; const dLat = ((lat - anchorLat) * Math.PI) / 180; const dLon = ((lon - anchorLon) * Math.PI) / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos((anchorLat * Math.PI) / 180) * Math.cos((lat * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; distanceKm = 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } else if (Number.isFinite(Number(h.distanceInMeters))) { distanceKm = Number(h.distanceInMeters) / 1000; } if (distanceKm != null && distanceKm > limitKm) { droppedFar += 1; continue; } const star = Number(h.starRating); hotels.push({ name: String(h.name || '').slice(0, 60), address: String(h.address || '').slice(0, 80), lat: Number.isFinite(lat) ? lat : null, lon: Number.isFinite(lon) ? lon : null, distanceKm: distanceKm != null ? Math.round(distanceKm * 10) / 10 : null, rating: Number.isFinite(star) && star > 0 ? star : null, cost: Number.isFinite(price) && price > 0 ? Math.round(price) : null, currency: (h.price && h.price.currency) || 'CNY', type: '住宿', tags: Array.isArray(h.tags) ? h.tags.slice(0, 4) : [], bookingUrl: String(h.bookingUrl || ''), source: 'RollingGo', }); } hotels.sort((a, b) => (a.distanceKm ?? 999) - (b.distanceKm ?? 999)); return { hotels: hotels.filter((h) => h.name), droppedFar, limitKm: Math.round(limitKm), }; } /** * 搜索目的地周边酒店:先按名字推断地点类型,查空就换一种类型再试一次。 * 实测:「柳州市」用「景点」查返回 0 家,「城市」查才有结果; * 反过来把「坡峰岭」当城市查也会落空。两次都空才认定没数据。 */ async function searchHotels(q, cfg) { const first = PLACE_TYPES.has(q.placeType) ? q.placeType : guessPlaceType(q.name); const r1 = await searchOnce(q, cfg, first); if (r1.hotels.length || r1.error) return { ...r1, placeType: first }; const second = first === '城市' ? '景点' : '城市'; const r2 = await searchOnce(q, cfg, second); if (r2.hotels.length) return { ...r2, placeType: second, retried: true }; return { ...r1, placeType: first, retried: true }; } /** 查单个酒店的房型与退改政策(可选增强,失败不影响主流程) */ async function hotelDetail(q, cfg) { const name = safeText(q.name); if (!name) return { rooms: [], error: '缺少酒店名' }; const args = ['hotel-detail', '--name', name, '--room-count', '1', '--adult-count', '2']; if (/^\d{4}-\d{2}-\d{2}$/.test(String(q.checkIn || ''))) args.push('--check-in-date', String(q.checkIn)); if (/^\d{4}-\d{2}-\d{2}$/.test(String(q.checkOut || ''))) args.push('--check-out-date', String(q.checkOut)); if (!args.every((a) => argSafe(a))) return { rooms: [], error: '参数包含不可用字符' }; const r = await runRaw(cfg, args, Number(cfg.ROLLINGGO_TIMEOUT_MS) || 25000); const data = extractJSON(r.out) || extractJSON(r.err); if (!data) return { rooms: [], error: '详情返回无法解析' }; const plans = data.roomRatePlans || []; const rooms = plans.slice(0, 5).map((p) => ({ roomName: p.roomNameCn || p.roomName || '', bedType: p.bedTypeDescription || '', totalPrice: Number(p.totalPrice) || null, avgPrice: Number(p.totalSalesRate) || null, freeCancel: Array.isArray(p.cancellationPolicies) && p.cancellationPolicies.some((c) => Number(c.amount) === 0), cancelText: (Array.isArray(p.cancellationPolicies) && p.cancellationPolicies[0]?.description) || '', })); return { rooms }; } module.exports = { availability, searchHotels, hotelDetail, extractJSON, ALLOWED_SUBCOMMANDS };