TripSense 行知 · 周边出行顾问:首版

零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

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