零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。 - 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底 - 区县级「排除同城」判定(adname),过滤发生在轮转取样之前 - 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图 - 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造 - 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存 - 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
393 lines
16 KiB
JavaScript
393 lines
16 KiB
JavaScript
/**
|
||
* 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 <pkg>/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 };
|