Files
tripsense/lib/hotels-rollinggo.js
T
tamakyi c5c2ffae68 TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

- 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底
- 区县级「排除同城」判定(adname),过滤发生在轮转取样之前
- 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图
- 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造
- 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存
- 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
2026-11-05 20:25:03 +08:00

393 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };