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
+326
View File
@@ -0,0 +1,326 @@
'use strict';
/**
* 分析编排:候选发现 → 天气 → 空气质量 → 公路 → 铁路 → 综合评分 → 备选清单
* progress(stage, pct, text) 用于 SSE 进度推送。
*/
const U = require('./util');
const S = require('./sources');
const { aqiLabel } = S;
/** 小时数 → 人类可读时长(0.07h 读起来毫无意义,转成 4分钟) */
function humanHours(h) {
if (h == null || !Number.isFinite(h)) return '—';
const total = Math.round(h * 60);
const hh = Math.floor(total / 60);
const mm = total % 60;
return hh ? `${hh}小时${mm ? mm + '分' : ''}` : `${mm}分钟`;
}
function validate(input) {
const start = String(input.startDate || '');
const end = String(input.endDate || '');
if (!U.isValidISO(start) || !U.isValidISO(end)) throw new Error('请提供合法的出行起止日期(YYYY-MM-DD)');
const days = U.diffDays(start, end) + 1;
if (days < 1) throw new Error('结束日期不能早于开始日期');
if (days > 31) throw new Error('单次分析最多支持 31 天,请缩小时间范围');
const maxOneWayHours = U.clamp(Number(input.maxOneWayHours) || 2.5, 0.5, 8);
const radiusKm = U.clamp(Number(input.radiusKm) || 150, 20, 300);
// 排除同城:填了高德 Key 后 POI 会带回大量本市城区点位,和「周边出行」的定位冲突。
// 默认开启;localKm 不超过搜索半径的六成,避免小半径时把清单清空。
const excludeLocal = input.excludeLocal !== false;
const localKm = Math.min(
U.clamp(Number(input.localKm) || 30, 10, 80),
Math.max(8, Math.round(radiusKm * 0.5)),
);
const o = input.origin || {};
if (!o.lat || !o.lon) {
if (!String(o.name || '').trim()) throw new Error('请填写出发地或允许网页定位');
}
return {
start, end, days, maxOneWayHours, radiusKm, excludeLocal, localKm,
transportPrefs: Array.isArray(input.transportPrefs) ? input.transportPrefs : [],
interests: Array.isArray(input.interests) ? input.interests : [],
origin: {
name: o.name || '当前位置',
label: o.label || o.name || '当前位置',
lat: Number(o.lat), lon: Number(o.lon),
admin1: o.admin1 || '', country: o.country || '',
},
};
}
function travelOptions(c, road, rail, prefs) {
const allowAll = prefs.length === 0 || prefs.includes('不限');
const allowRoad = allowAll || prefs.includes('自驾');
const allowRail = allowAll || prefs.some((p) => /铁路|高铁|火车/.test(p));
const allowTransit = allowAll || prefs.includes('公共交通');
const options = [];
if (allowRoad && Number.isFinite(road?.durationMinutes)) {
options.push({ mode: '自驾', hours: U.round(road.durationMinutes / 60, 2), source: road.source, estimate: road.source === '估算' });
}
if (allowRail && rail?.available && Number.isFinite(rail.durationMinutes)) {
options.push({ mode: '铁路/高铁', hours: U.round(rail.durationMinutes / 60, 2), source: rail.source, estimate: rail.source === '估算' });
}
if (allowTransit && Number.isFinite(road?.durationMinutes)) {
options.push({ mode: '公共交通', hours: U.round((road.durationMinutes * 1.28) / 60, 2), source: '估算', estimate: true });
}
options.sort((a, b) => a.hours - b.hours);
const best = options[0] || null;
return { options, bestHours: best ? best.hours : null, bestMode: best ? best.mode : null };
}
function scoreOne(c, ctx) {
const { weather, aqi, road, rail, constraints, days } = ctx;
const s = weather?.summary || null;
const travel = travelOptions(c, road, rail, constraints.transportPrefs);
// 1) 天气舒适度 0~40
let weatherScore = 20;
if (s) {
const tempPenalty = U.clamp(Math.max(0, 12 - s.avgLow) * 0.9 + Math.max(0, s.avgHigh - 28) * 1.1, 0, 16);
const precipPenalty = U.clamp(s.totalPrecip * 1.1 + s.maxPrecipProb * 0.09, 0, 16);
const windPenalty = U.clamp(Math.max(0, s.maxWind - 25) * 0.22, 0, 7);
const severePenalty = U.clamp(s.badDays * 3, 0, 10);
weatherScore = U.clamp(40 - tempPenalty - precipPenalty - windPenalty - severePenalty, 0, 40);
}
// 2) 时间适配 0~26
let timeScore = 2;
let overLimit = false;
if (travel.bestHours != null) {
if (travel.bestHours > constraints.maxOneWayHours) {
timeScore = 4;
overLimit = true;
} else {
timeScore = U.clamp(
26 - (travel.bestHours / constraints.maxOneWayHours) * 15 - (travel.bestHours > 3 ? 4 : 0) + (days >= 2 && travel.bestHours <= 1.5 ? 2 : 0),
0, 26,
);
}
}
// 3) 距离 0~7(不鼓励「越近越好」,只做轻微惩罚)
const distanceScore = U.clamp(7 - (c.distanceKm / constraints.radiusKm) * 4.5, 2.5, 7);
// 4) 兴趣匹配 0~14
let interestScore = 8;
const matched = constraints.interests.filter((i) => c.tags.includes(i));
if (constraints.interests.length) interestScore = U.clamp(4 + matched.length * 4, 0, 14);
// 5) 空气质量 0~6
let aqiScore = 3;
if (aqi != null) aqiScore = aqi <= 50 ? 6 : aqi <= 100 ? 4 : aqi <= 150 ? 2 : 0;
// 6) 目的地辨识度 0~8(知名景区 > 小众精选 > OSM 无名点位)
const qualityScore = U.clamp(
(c.featured ? 6 : 0)
+ (c.hasExtent ? 1 : 0)
+ (/高德/.test(c.source || '') ? 1 : 0)
+ (c.niche ? 1 : 0),
0, 8,
);
const score = U.round(weatherScore + timeScore + distanceScore + interestScore + aqiScore + qualityScore, 0);
const breakdown = {
weather: U.round(weatherScore, 1), time: U.round(timeScore, 1),
distance: U.round(distanceScore, 1), interest: U.round(interestScore, 1),
aqi: U.round(aqiScore, 1), quality: U.round(qualityScore, 1),
};
let verdict = score >= 74 ? '强烈推荐' : score >= 60 ? '推荐' : score >= 45 ? '可考虑' : '不推荐';
// 天气硬门槛:整段行程都是坏天气时,不因交通/距离优势给出「推荐」
if (s && s.badDays >= s.days && verdict === '强烈推荐') verdict = '推荐';
if (s && s.badDays >= s.days && verdict === '推荐') verdict = '可考虑';
return { score, breakdown, verdict, travel, overLimit, matched };
}
function buildReasons(c, ctx, scored) {
const { weather, aqi, road, rail, constraints, days, weatherMode } = ctx;
const s = weather?.summary;
const reasons = [];
const warnings = [];
if (s) {
if (s.badDays === 0 && s.maxPrecipProb < 40) {
reasons.push(`${s.days} 天以${s.dominant.label}为主,平均 ${s.avgLow}~${s.avgHigh}℃,降水概率最高 ${s.maxPrecipProb}%,天气窗口友好`);
} else {
reasons.push(`${s.days} 天内有 ${s.badDays} 天需注意天气(最差:${s.dominant.label}),累计降水 ${s.totalPrecip}mm、最大风速 ${s.maxWind}km/h`);
warnings.push('期间有明显降水/大风,建议准备室内备选');
}
if (s.avgHigh >= 30) warnings.push('高温,注意防晒补水并避开正午户外');
if (s.avgLow <= 3) warnings.push('气温偏低,需保暖并留意路面结冰');
} else {
warnings.push('未取到该地天气数据,天气维度按中性计分');
}
const optionsText = scored.travel.options
.slice(0, 2)
.map((o) => `${o.mode} ${humanHours(o.hours)}${o.estimate ? '(估算)' : ''}`)
.join(' / ');
if (optionsText) reasons.push(`${optionsText};公路里程约 ${road.distanceKm}km`);
if (scored.travel.bestHours == null) warnings.push('按所选交通方式无可达方案');
else if (scored.overLimit) warnings.push(`最快单程 ${humanHours(scored.travel.bestHours)},超出你设定的 ${constraints.maxOneWayHours} 小时`);
else reasons.push(`最快单程约 ${humanHours(scored.travel.bestHours)}(${scored.travel.bestMode}),${days} 天行程节奏${days >= 2 && scored.travel.bestHours <= 1.5 ? '宽松' : '适中'}`);
if (scored.matched.length) reasons.push(`匹配你的兴趣:${scored.matched.join('、')}`);
if (aqi != null) {
const a = aqiLabel(aqi);
reasons.push(`空气质量${a.label}(AQI ${aqi})`);
if (a.level === 'bad') warnings.push('空气质量较差,敏感人群减少户外停留');
}
if (road.source === '估算') warnings.push('公路耗时为路网兜底估算,请出发前用地图复核');
if (rail.source === '估算') warnings.push('铁路耗时/票价为估算,车次与余票需在 12306 核对');
if (weatherMode === 'climatology') warnings.push('超出 16 天预报范围,天气为去年同期历史气候参考,非实时预报');
return { reasons: reasons.slice(0, 5), warnings: warnings.slice(0, 4) };
}
async function analyze(input, cfg, progress = () => {}) {
const c = validate(input);
const t0 = Date.now();
// ---- 0. 解析出发地
progress('origin', 6, '解析出发地坐标');
let origin = { ...c.origin };
if (!Number.isFinite(origin.lat) || !Number.isFinite(origin.lon)) {
const hits = await S.geocode(origin.name, cfg, 1);
if (!hits.length) throw new Error(`没找到「${origin.name}」,换个地名试试`);
origin = { ...hits[0], name: origin.name, label: hits[0].label };
}
origin.lat = U.round(origin.lat, 5);
origin.lon = U.round(origin.lon, 5);
// 出发地的行政区信息(省/市/区县)。
// 用途:① 高德 place/text 检索时限定在同一个省;② 「排除同城」按**区县**判定——
// 同市下辖的其它县(融水、三江、鹿寨…)是真正的周边目的地,不该被排除。
if (!origin.district || !origin.province) {
try {
const rev = await S.reverseGeocode(origin.lat, origin.lon, cfg);
origin.province = origin.province || (rev.admin1 || '');
origin.city = origin.city || rev.name || '';
origin.district = origin.district || (rev.label || '').split(' · ').pop() || '';
} catch { /* 拿不到就退回距离判定 */ }
}
// 高德检索限定在同一个省(周边出行通常不出省)
const searchCfg = origin.province ? { ...cfg, AMAP_PROVINCE: origin.province.replace(/省$/, '') } : cfg;
// ---- 1. 周边候选
progress('discover', 18, `检索 ${c.radiusKm}km 内的景点、公园、博物馆与邻近城市`);
const { candidates, stats } = await S.discoverCandidates(origin, c.radiusKm, searchCfg, {
excludeLocal: c.excludeLocal,
localKm: c.localKm,
homeDistrict: origin.district || '',
homeCity: origin.city || '',
});
if (!candidates.length) {
throw new Error('这个范围内没有检索到可用目的地,试试调大搜索半径');
}
// ---- 2. 天气 + 空气质量(并行)
progress('weather', 42, `获取 ${candidates.length} 个候选地的逐日天气与空气质量`);
const [weatherRes, aqiRes] = await Promise.all([
S.fetchWeather(candidates, c.start, c.end, cfg),
S.fetchAQI(candidates, c.start, c.end, cfg),
]);
// ---- 3. 公路 + 铁路(并行)
progress('traffic', 66, '计算公路路网耗时与铁路可达性');
const [roadRes, railRes] = await Promise.all([
S.roadMatrix(origin, candidates, cfg),
S.railMatrix(origin, candidates, c.start, cfg),
]);
// ---- 4. 评分
progress('score', 86, '综合天气、交通、距离与偏好评分');
const ctxBase = { constraints: c, days: c.days, weatherMode: weatherRes.mode };
const scored = candidates.map((cd, i) => {
const ctx = {
...ctxBase,
weather: weatherRes.series[i],
aqi: aqiRes.values[i],
road: roadRes.matrix[i],
rail: railRes[i],
};
const s = scoreOne(cd, ctx);
const { reasons, warnings } = buildReasons(cd, ctx, s);
return {
...cd,
score: s.score,
breakdown: s.breakdown,
verdict: s.verdict,
travel: s.travel,
weather: ctx.weather ? {
summary: ctx.weather.summary,
daily: ctx.weather.daily,
} : null,
aqi: ctx.aqi,
aqiInfo: aqiLabel(ctx.aqi),
road: ctx.road,
rail: ctx.rail,
reasons,
warnings,
};
});
scored.sort((a, b) => b.score - a.score || a.distanceKm - b.distanceKm);
scored.forEach((x, i) => { x.rank = i + 1; });
// ---- 5. 汇总
const withWeather = scored.filter((x) => x.weather?.summary);
const avgHigh = withWeather.length
? U.round(withWeather.reduce((s, x) => s + x.weather.summary.avgHigh, 0) / withWeather.length, 1) : null;
const rainy = withWeather.filter((x) => x.weather.summary.badDays > 0).length;
const weatherAlert = withWeather.length > 0 && rainy / withWeather.length >= 0.7
? '所选时间窗口内多数候选地有降水/大风等不利天气,建议关注逐日天气并准备室内备选'
: null;
const dataNotes = [
`周边目的地:Photon / OpenStreetMap(成功 ${stats.categoryHits}/${stats.requested} 类)` +
`${stats.amapCount ? ` + 高德周边 ${stats.amapCount} 条` : ''}` +
` + 内置知名景区 ${stats.curatedCount} 个 + 内置城市 ${stats.cityCount} 个 + 内置小众地点 ${stats.nicheCount} 个` +
`${stats.fallbackOnly ? ';⚠️ 实时 POI 服务不可用,本次清单完全来自内置地名库(坐标为近似值)' : ''}` +
`${c.excludeLocal && stats.localExcluded ? `;已排除出发地所在城区的同城点位 ${stats.localExcluded} 个(同市下辖的其它县保留为周边目的地)` : ''}`,
`天气:${weatherRes.note}`,
`空气质量:${aqiRes.note}`,
`公路:${roadRes.provider === '估算' ? '路网服务不可用,全部为里程-速度估算' : roadRes.provider === '高德' ? '高德 Web 服务驾车路径(WGS-84→GCJ-02)' : 'OSRM 公开路网矩阵'}`,
`铁路:${cfg.RAIL_API_URL ? '优先调用配置的铁路接口,失败时降级估算' : '未配置铁路接口,高铁/城际耗时与票价均为估算'}`,
'所有标注「估算」的数据不得视为实时信息;车次、票价、门票、营业时间需临近出发前在官方渠道核对',
];
const result = {
origin,
timeRange: {
start: c.start, end: c.end, days: c.days,
weekdayHint: `${U.weekdayCN(c.start)} ~ ${U.weekdayCN(c.end)}`,
},
constraints: {
maxOneWayHours: c.maxOneWayHours,
radiusKm: c.radiusKm,
excludeLocal: c.excludeLocal,
localKm: c.localKm,
transportPrefs: c.transportPrefs,
interests: c.interests,
},
summary: {
total: scored.length,
recommended: scored.filter((x) => x.score >= 60).length,
avgScore: U.round(scored.reduce((s, x) => s + x.score, 0) / scored.length, 1),
avgHigh,
rainyCount: rainy,
weatherAlert,
weatherMode: weatherRes.mode,
weatherNote: weatherRes.note,
roadProvider: roadRes.provider,
elapsedMs: Date.now() - t0,
},
candidates: scored,
dataNotes,
stats,
};
progress('done', 100, '分析完成');
return result;
}
module.exports = { analyze, validate };
+119
View File
@@ -0,0 +1,119 @@
'use strict';
const U = require('./util');
/**
* 分析结果缓存(服务端内存)
*
* 为什么需要:一次 analyze 要打 Photon / Open-Meteo / AQI / OSRM / 高德
* 十几次外部请求,耗时 6–15 秒。同样的出发地 + 日期 + 半径 + 兴趣,
* 用户往往会反复点「分析」(换个兴趣再点、看到结果想再刷一次),
* 而这些数据在几十分钟内基本不变(天气预报按小时更新)。
*
* 设计要点:
* 1. 键 = 规范化后的请求指纹(origin 坐标取整到 3 位 ≈ 100m,其余字段精确)。
* 高德 Key 也参与指纹——换 Key 会改变 POI 结果,不能串味。
* 2. 缓存的是**完整分析结果**,命中时直接复用,不重打外部请求。
* 3. TTL 默认 30 分钟:超过就重算。天气/路况本来就按小时更新。
* 4. 带 LRU 上限(默认 40 条),避免长时间运行把内存吃满。
* 5. 命中时在结果里写 cacheHit / cachedAt,前端要如实告诉用户"这是缓存"。
*/
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const DEFAULT_MAX = 300;
const store = new Map(); // key -> { at, result }
/** 把请求归一化成稳定指纹 */
function fingerprint(body, cfg, extra = {}) {
const o = body.origin || {};
const lat = Number(o.lat);
const lon = Number(o.lon);
const key = {
// 坐标取整到 3 位小数(约 100 米),避免浮点尾数造成每次都不命中
lat: Number.isFinite(lat) ? Math.round(lat * 1000) / 1000 : null,
lon: Number.isFinite(lon) ? Math.round(lon * 1000) / 1000 : null,
name: String(o.name || '').trim().slice(0, 40),
start: String(body.startDate || ''),
end: String(body.endDate || ''),
maxHours: body.maxOneWayHours,
radiusKm: body.radiusKm,
excludeLocal: body.excludeLocal !== false,
localKm: body.localKm,
transport: (body.transportPrefs || []).slice().sort().join(','),
interests: (body.interests || []).slice().sort().join(','),
// 高德 Key 参与指纹:换了 Key,POI 与路网结果会变,不能复用
amap: cfg && cfg.AMAP_KEY ? String(cfg.AMAP_KEY).slice(-8) : '',
...extra,
};
return JSON.stringify(key);
}
function get(key) {
const hit = store.get(key);
if (!hit) return null;
const ttl = hit.ttlMs || DEFAULT_TTL_MS;
if (Date.now() - hit.at > ttl) {
store.delete(key);
return null;
}
// LRU:命中后移到末尾
store.delete(key);
store.set(key, hit);
return { result: hit.result, cachedAt: hit.at, ageMs: Date.now() - hit.at };
}
function set(key, result, ttlMs) {
if (store.size >= DEFAULT_MAX) {
// 删最旧的一条
const oldest = store.keys().next().value;
if (oldest !== undefined) store.delete(oldest);
}
store.set(key, { at: Date.now(), result, ttlMs: ttlMs || DEFAULT_TTL_MS });
}
/**
* 通用包装:命中缓存直接返回,否则执行 fn 并写回。
* @param {string} key 缓存键
* @param {Function} fn 实际取数逻辑
* @param {object} opt { ttlMs, force }
*/
async function wrap(key, fn, opt = {}) {
if (!opt.force) {
const hit = get(key);
if (hit) return { ...hit.result, cache: { hit: true, ageMs: hit.ageMs, cachedAt: hit.cachedAt } };
}
const result = await fn();
set(key, result, opt.ttlMs);
return { ...result, cache: { hit: false } };
}
/**
* 裸缓存:命中直接返回原对象,**不注入 cache 字段**。
*
* 用于数据源层(lib/sources.js)。这些函数的返回值会被原样塞进 AI 上下文,
* 多一个 cache 字段既污染上下文又白白吃 token,所以这里保持对象干净。
* 「是否命中」由调用方按需通过 peek() 判断,或者让上层接口自己汇报。
*/
async function memo(key, fn, ttlMs) {
const hit = get(key);
if (hit) return hit.result;
const result = await fn();
set(key, result, ttlMs);
return result;
}
/** 只看不取:用于判断某条底层数据是否已有缓存(不触发网络请求) */
function peek(key) {
return get(key);
}
function clear() {
store.clear();
}
function stats() {
return { entries: store.size, max: DEFAULT_MAX, ttlMs: DEFAULT_TTL_MS };
}
module.exports = { fingerprint, get, set, wrap, memo, peek, clear, stats, DEFAULT_TTL_MS };
+152
View File
@@ -0,0 +1,152 @@
'use strict';
const { EXTRA_SCENIC, CN_CITIES } = require('./places-cn');
const { NICHE_SPOTS } = require('./places-niche');
// ------------------------------------------------------- WMO 天气现象编码表
// code -> { label, icon, severity } severity: 0晴好 1一般 2小雨/雾 3中雨以上 4雪 5雷暴
const WMO = {
0: { label: '晴', icon: '☀️', severity: 0 },
1: { label: '晴间多云', icon: '🌤️', severity: 0 },
2: { label: '多云', icon: '⛅', severity: 1 },
3: { label: '阴', icon: '☁️', severity: 1 },
45: { label: '有雾', icon: '🌫️', severity: 2 },
48: { label: '冻雾', icon: '🌫️', severity: 2 },
51: { label: '轻微毛毛雨', icon: '🌦️', severity: 2 },
53: { label: '毛毛雨', icon: '🌦️', severity: 2 },
55: { label: '较强毛毛雨', icon: '🌧️', severity: 3 },
56: { label: '冻毛毛雨', icon: '🌧️', severity: 4 },
57: { label: '强冻毛毛雨', icon: '🌧️', severity: 4 },
61: { label: '小雨', icon: '🌦️', severity: 2 },
63: { label: '中雨', icon: '🌧️', severity: 3 },
65: { label: '大雨', icon: '🌧️', severity: 3 },
66: { label: '冻雨', icon: '🌧️', severity: 4 },
67: { label: '强冻雨', icon: '🌧️', severity: 4 },
71: { label: '小雪', icon: '🌨️', severity: 4 },
73: { label: '中雪', icon: '🌨️', severity: 4 },
75: { label: '大雪', icon: '❄️', severity: 4 },
77: { label: '米雪', icon: '🌨️', severity: 4 },
80: { label: '阵雨', icon: '🌦️', severity: 2 },
81: { label: '较强阵雨', icon: '🌧️', severity: 3 },
82: { label: '强阵雨', icon: '⛈️', severity: 4 },
85: { label: '阵雪', icon: '🌨️', severity: 4 },
86: { label: '强阵雪', icon: '❄️', severity: 4 },
95: { label: '雷阵雨', icon: '⛈️', severity: 5 },
96: { label: '雷暴伴冰雹', icon: '⛈️', severity: 5 },
99: { label: '强雷暴伴冰雹', icon: '⛈️', severity: 5 },
};
function wmo(code) {
return WMO[Number(code)] || { label: '未知', icon: '🌡️', severity: 1 };
}
// --------------------------------------------- Photon 类别 -> 目的地类型/标签
// 每一项对应一次 include=osm.<key>.<value> 查询(不需要 q)
const CATEGORIES = [
{ include: 'osm.tourism.attraction', kind: '景点', tags: ['人文历史', '拍照', '观光'] },
{ include: 'osm.tourism.museum', kind: '博物馆', tags: ['人文历史', '亲子', '室内避雨'] },
{ include: 'osm.tourism.gallery', kind: '展馆', tags: ['人文历史', '拍照', '室内避雨'] },
{ include: 'osm.tourism.viewpoint', kind: '观景地', tags: ['自然风光', '拍照', '徒步登山'] },
{ include: 'osm.tourism.theme_park', kind: '主题乐园', tags: ['亲子', '主题乐园'] },
{ include: 'osm.tourism.zoo', kind: '动物园', tags: ['亲子', '自然风光'] },
{ include: 'osm.leisure.park', kind: '公园', tags: ['自然风光', '休闲', '亲子'] },
{ include: 'osm.leisure.garden', kind: '园林', tags: ['人文历史', '休闲', '拍照'] },
{ include: 'osm.leisure.nature_reserve', kind: '自然保护地', tags: ['自然风光', '徒步登山'] },
{ include: 'osm.natural.peak', kind: '山峰', tags: ['自然风光', '徒步登山', '拍照'] },
{ include: 'osm.natural.beach', kind: '海滨', tags: ['亲水海滨', '自然风光', '拍照'] },
{ include: 'osm.historic.castle', kind: '古迹', tags: ['人文历史', '古镇', '拍照'] },
{ include: 'osm.amenity.place_of_worship', kind: '寺观', tags: ['人文历史', '静心', '拍照'] },
{ include: 'osm.boundary.national_park', kind: '风景区', tags: ['自然风光', '徒步登山', '拍照'] },
];
const INTERESTS = [
'自然风光', '人文历史', '亲子', '拍照', '美食',
'徒步登山', '室内避雨', '亲水海滨', '古镇', '主题乐园', '休闲', '小众秘境',
];
// 明显不是「目的地」的设施名,检索结果里剔除
const JUNK_NAME = /(停车场|厕所|卫生间|售票|游客中心|管理处|管理中心|出入口|入口|出口|公交站|地铁站|加油站|派出所|医院|学校|办公|宿舍|仓库|变电站|公厕|ATM|自动售货|候车|服务区|收费站|bridge|Bridge)/;
// OSM 里常见的无辨识度地名(土堆、观景台、未命名山峰等),不适合作为推荐目的地
const POOR_NAME = /^(土堆|土丘|山丘|小土包|无名|未命名|无名称|观景台|瞭望台|望台|平台|山顶|山峰|山头|碑|石碑|界碑|标记|标记点|点|路口|桥|桥头|河|沟|渠|村|庄|地|块|遗址|旧址|某处|unknown|unnamed|viewpoint|peak|summit|mountain)$/i;
// 「名字像景点、实际不是独立出行目的地」的点位。
// OSM 里这类条目辨识度很高(有名字、有范围),会把真正值得去的地方挤下去,
// 例如燕京八景中的几处只是小公园或一块碑,外国使馆旧址多为办公建筑。
// 仅用于实时检索来源(OSM / 高德),不影响内置景区与内置小众地点库。
const LOW_VALUE_NAME = /(金台夕照|蓟门烟树|卢沟晓月|太液秋风|琼岛春阴|玉泉趵突|西山晴雪|居庸叠翠|使馆旧址|使馆$|墓和祠|纪念碑$|纪念塔$|界桩$|上马石$|下马石$)/;
// ------------------------------------------ 兜底数据:国内知名目的地(经纬度)
// 用途:① Photon 不可用时保证仍有结果;② 始终补入若干知名目的地,提升清单可读性。
// 字段:名称, 纬度, 经度, 类型, 标签
const CURATED_BASE = [
['故宫博物院', 39.9163, 116.3972, '人文历史', '人文历史,拍照,室内避雨'],
['天坛公园', 39.8822, 116.4066, '公园', '人文历史,拍照,休闲'],
['颐和园', 39.9999, 116.2755, '园林', '人文历史,拍照,休闲'],
['八达岭长城', 40.3564, 116.0169, '古迹', '人文历史,徒步登山,拍照'],
['慕田峪长城', 40.4319, 116.5695, '古迹', '人文历史,徒步登山,拍照'],
['香山公园', 39.9960, 116.1896, '自然风光', '自然风光,徒步登山,拍照'],
['北京环球度假区', 39.8508, 116.6767, '主题乐园', '亲子,主题乐园'],
['798艺术区', 39.9828, 116.4905, '展馆', '人文历史,拍照,室内避雨'],
['中国国家博物馆', 39.9037, 116.4018, '博物馆', '人文历史,亲子,室内避雨'],
['古北水镇', 40.6883, 117.2764, '古镇', '古镇,人文历史,拍照'],
['十渡风景区', 39.6472, 115.5883, '风景区', '自然风光,亲水海滨,徒步登山'],
['五大道', 39.1167, 117.2000, '人文历史', '人文历史,拍照,休闲'],
['盘山', 40.0889, 117.2500, '山峰', '自然风光,徒步登山,拍照'],
['承德避暑山庄', 40.9975, 117.9364, '园林', '人文历史,休闲,拍照'],
['北戴河海滨', 39.8167, 119.4833, '海滨', '亲水海滨,自然风光,休闲'],
['山海关', 40.0089, 119.7536, '古迹', '人文历史,拍照'],
['白洋淀', 38.8500, 115.9833, '风景区', '自然风光,亲水海滨,休闲'],
['野三坡', 39.6833, 115.4333, '风景区', '自然风光,徒步登山'],
['正定古城', 38.1458, 114.5750, '古镇', '古镇,人文历史,拍照'],
['平遥古城', 37.2011, 112.1761, '古镇', '古镇,人文历史,拍照'],
['云冈石窟', 40.1106, 113.1222, '古迹', '人文历史,拍照'],
['五台山', 38.9833, 113.5833, '山峰', '自然风光,徒步登山,人文历史'],
['泰山', 36.2558, 117.1008, '山峰', '自然风光,徒步登山,拍照'],
['趵突泉', 36.6608, 117.0142, '公园', '自然风光,休闲,拍照'],
['曲阜三孔', 35.5917, 116.9886, '古迹', '人文历史,拍照'],
['青岛栈桥', 36.0597, 120.3128, '海滨', '亲水海滨,拍照,休闲'],
['崂山', 36.1500, 120.6333, '山峰', '自然风光,徒步登山,亲水海滨'],
['龙门石窟', 34.5589, 112.4714, '古迹', '人文历史,拍照'],
['少林寺', 34.5086, 113.0031, '寺观', '人文历史,拍照'],
['清明上河园', 34.8000, 114.3500, '主题乐园', '人文历史,亲子,主题乐园'],
['秦始皇兵马俑', 34.3853, 109.2785, '古迹', '人文历史,亲子,室内避雨'],
['华山', 34.4833, 110.0833, '山峰', '自然风光,徒步登山,拍照'],
['中山陵', 32.0617, 118.8486, '古迹', '人文历史,休闲'],
['拙政园', 31.4900, 120.6294, '园林', '人文历史,拍照,休闲'],
['周庄古镇', 31.1236, 120.8500, '古镇', '古镇,人文历史,拍照'],
['鼋头渚', 31.5333, 120.2333, '风景区', '自然风光,亲水海滨,拍照'],
['上海外滩', 31.2400, 121.4900, '人文历史', '人文历史,拍照,休闲'],
['上海迪士尼', 31.1434, 121.6570, '主题乐园', '亲子,主题乐园'],
['杭州西湖', 30.2424, 120.1481, '风景区', '自然风光,拍照,休闲'],
['乌镇', 30.7461, 120.4889, '古镇', '古镇,人文历史,拍照'],
['千岛湖', 29.6000, 118.9833, '风景区', '自然风光,亲水海滨,休闲'],
['普陀山', 30.0000, 122.3833, '寺观', '人文历史,亲水海滨,自然风光'],
['黄山', 30.1333, 118.1667, '山峰', '自然风光,徒步登山,拍照'],
['宏村', 29.9000, 117.9833, '古镇', '古镇,人文历史,拍照'],
['鼓浪屿', 24.4489, 118.0672, '海滨', '亲水海滨,人文历史,拍照'],
['武夷山', 27.7167, 117.9833, '山峰', '自然风光,徒步登山,亲水海滨'],
['黄鹤楼', 30.5450, 114.3030, '古迹', '人文历史,拍照'],
['武当山', 32.4000, 111.0000, '山峰', '自然风光,徒步登山,人文历史'],
['张家界国家森林公园', 29.3167, 110.4833, '风景区', '自然风光,徒步登山,拍照'],
['九寨沟', 33.2500, 103.9167, '风景区', '自然风光,拍照,亲水海滨'],
['都江堰', 31.0000, 103.6167, '古迹', '人文历史,自然风光'],
['青城山', 30.9000, 103.5667, '山峰', '自然风光,徒步登山,人文历史'],
['洪崖洞', 29.5630, 106.5830, '人文历史', '人文历史,拍照,美食'],
['白云山', 23.1833, 113.3000, '山峰', '自然风光,徒步登山,休闲'],
['丹霞山', 25.0333, 113.7500, '风景区', '自然风光,徒步登山,拍照'],
['阳朔漓江', 24.7833, 110.4833, '风景区', '自然风光,亲水海滨,拍照'],
['大理古城', 25.6911, 100.1600, '古镇', '古镇,人文历史,休闲'],
['洱海', 25.7500, 100.1833, '风景区', '自然风光,亲水海滨,拍照'],
['丽江古城', 26.8667, 100.2333, '古镇', '古镇,人文历史,拍照'],
['黄果树瀑布', 25.9833, 105.6667, '风景区', '自然风光,亲水海滨,拍照'],
['亚龙湾', 18.2167, 109.6500, '海滨', '亲水海滨,自然风光,休闲'],
['沈阳故宫', 41.7997, 123.4539, '古迹', '人文历史,拍照'],
['长白山天池', 42.0000, 128.0667, '山峰', '自然风光,徒步登山,拍照'],
['青海湖', 36.8833, 100.1833, '风景区', '自然风光,亲水海滨,拍照'],
['莫高窟', 40.0417, 94.8000, '古迹', '人文历史,室内避雨,拍照'],
['布达拉宫', 29.6578, 91.1175, '古迹', '人文历史,拍照'],
];
const CURATED = [...CURATED_BASE, ...EXTRA_SCENIC];
module.exports = { WMO, wmo, CATEGORIES, INTERESTS, JUNK_NAME, POOR_NAME, LOW_VALUE_NAME, CURATED, CN_CITIES, NICHE_SPOTS };
+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 };
+219
View File
@@ -0,0 +1,219 @@
'use strict';
/**
* 国内兜底地名库
*
* 用途:Photon / 高德 POI 服务不可用或结果不足时,仍能提供一份可用的周边清单。
* 坐标为近似值(WGS-84),用于天气与路网估算足够;结果里会明确标注数据来源,
* 不会伪装成实时检索结果。用户配置了高德 Key 或 Photon 可用时,以此为基础做补充。
*/
// 格式:[名称, 纬度, 经度, 类型, 标签(逗号分隔)]
const EXTRA_SCENIC = [
['金山岭长城', 40.6790, 117.2290, '古迹', '人文历史,徒步登山,拍照'],
['司马台长城', 40.6370, 117.3350, '古迹', '人文历史,徒步登山,拍照'],
['龙庆峡', 40.5330, 116.0080, '风景区', '自然风光,亲水海滨,休闲'],
['百里画廊', 40.5670, 116.2830, '风景区', '自然风光,拍照,休闲'],
['百花山', 39.8330, 115.6000, '山峰', '自然风光,徒步登山,拍照'],
['灵山', 39.9830, 115.4330, '山峰', '自然风光,徒步登山'],
['雾灵山', 40.6000, 117.4830, '山峰', '自然风光,徒步登山,拍照'],
['京东大峡谷', 40.1830, 117.1670, '风景区', '自然风光,徒步登山,亲水海滨'],
['石林峡', 40.1830, 117.2000, '风景区', '自然风光,徒步登山,拍照'],
['坡峰岭', 39.7170, 115.7330, '自然风光', '自然风光,徒步登山,拍照'],
['乐谷银滩', 39.6330, 115.5670, '风景区', '自然风光,亲水海滨'],
['清东陵', 40.1830, 117.6330, '古迹', '人文历史,拍照'],
['黄崖关长城', 40.2330, 117.4000, '古迹', '人文历史,徒步登山,拍照'],
['天津之眼', 39.1536, 117.1742, '人文历史', '人文历史,拍照,休闲'],
['意式风情区', 39.1431, 117.2008, '人文历史', '人文历史,拍照,休闲'],
['瓷房子', 39.1250, 117.2000, '展馆', '人文历史,拍照,室内避雨'],
['西柏坡纪念地', 38.2833, 113.9500, '古迹', '人文历史,休闲'],
['嶂石岩', 37.6000, 114.2330, '风景区', '自然风光,徒步登山,拍照'],
['苍岩山', 37.7830, 114.1330, '山峰', '自然风光,徒步登山,人文历史'],
['天生桥瀑布', 38.7500, 113.8330, '风景区', '自然风光,亲水海滨,拍照'],
['壶口瀑布', 36.1500, 110.4330, '风景区', '自然风光,亲水海滨,拍照'],
['乔家大院', 37.4000, 112.4330, '古镇', '古镇,人文历史,拍照'],
['王家大院', 36.8500, 111.8830, '古镇', '古镇,人文历史,拍照'],
['悬空寺', 39.7330, 113.7000, '寺观', '人文历史,拍照'],
['应县木塔', 39.5670, 113.1830, '古迹', '人文历史,拍照'],
['雁门关', 39.2000, 112.8500, '古迹', '人文历史,徒步登山'],
['绵山', 36.8830, 111.9830, '山峰', '自然风光,徒步登山,人文历史'],
['云台山', 35.4330, 113.4000, '风景区', '自然风光,徒步登山,亲水海滨'],
['老君山', 33.7500, 111.6330, '山峰', '自然风光,徒步登山,拍照'],
['白云山洛阳', 33.6500, 111.8500, '山峰', '自然风光,徒步登山'],
['殷墟', 36.1330, 114.3170, '古迹', '人文历史,室内避雨'],
['清明上河园', 34.8000, 114.3500, '主题乐园', '人文历史,亲子,主题乐园'],
['夫子庙秦淮河', 32.0217, 118.7881, '人文历史', '人文历史,美食,拍照'],
['牛首山', 31.9000, 118.7500, '山峰', '自然风光,人文历史,休闲'],
['栖霞山', 32.1500, 118.9670, '山峰', '自然风光,徒步登山,拍照'],
['灵山大佛', 31.4330, 120.1000, '寺观', '人文历史,休闲,拍照'],
['拈花湾', 31.4170, 120.0830, '古镇', '古镇,人文历史,拍照'],
['同里古镇', 31.1600, 120.7170, '古镇', '古镇,人文历史,拍照'],
['西塘古镇', 30.9400, 120.8900, '古镇', '古镇,人文历史,拍照'],
['南浔古镇', 30.8830, 120.4170, '古镇', '古镇,人文历史,拍照'],
['天目湖', 31.3000, 119.4170, '风景区', '自然风光,亲水海滨,休闲'],
['莫干山', 30.6000, 119.9000, '山峰', '自然风光,徒步登山,休闲'],
['普陀山', 30.0000, 122.3833, '寺观', '人文历史,亲水海滨,拍照'],
['雁荡山', 28.3667, 121.0500, '山峰', '自然风光,徒步登山,拍照'],
['楠溪江', 28.3000, 120.6500, '风景区', '自然风光,亲水海滨,休闲'],
['江郎山', 28.5330, 118.5670, '山峰', '自然风光,徒步登山,拍照'],
['根宫佛国', 29.1330, 118.4000, '展馆', '人文历史,拍照,室内避雨'],
['西递', 29.8830, 117.9830, '古镇', '古镇,人文历史,拍照'],
['呈坎', 29.9170, 118.2670, '古镇', '古镇,人文历史,拍照'],
['三清山', 28.9000, 118.0670, '山峰', '自然风光,徒步登山,拍照'],
['龙虎山', 28.1000, 116.9830, '山峰', '自然风光,徒步登山,人文历史'],
['庐山', 29.5830, 115.9830, '山峰', '自然风光,徒步登山,休闲'],
['婺源篁岭', 29.3830, 117.9330, '古镇', '古镇,自然风光,拍照'],
['滕王阁', 28.6830, 115.8830, '古迹', '人文历史,拍照'],
['鼓山', 26.0830, 119.3830, '山峰', '自然风光,徒步登山'],
['福建土楼', 24.6830, 117.1330, '古镇', '古镇,人文历史,拍照'],
['清源山', 24.9330, 118.5830, '山峰', '自然风光,徒步登山,人文历史'],
['泰山日出', 36.2558, 117.1008, '山峰', '自然风光,徒步登山,拍照'],
['蓬莱阁', 37.8170, 120.7500, '古迹', '人文历史,亲水海滨,拍照'],
['刘公岛', 37.5000, 122.1830, '海滨', '亲水海滨,人文历史,拍照'],
['台儿庄古城', 34.5670, 117.7330, '古镇', '古镇,人文历史,拍照'],
['青州古城', 36.6830, 118.4830, '古镇', '古镇,人文历史,拍照'],
['黄鹤楼', 30.5450, 114.3030, '古迹', '人文历史,拍照'],
['东湖绿道', 30.5500, 114.4000, '公园', '自然风光,休闲,拍照'],
['三峡大坝', 30.8330, 111.0000, '风景区', '自然风光,人文历史'],
['恩施大峡谷', 30.5670, 109.2670, '风景区', '自然风光,徒步登山,拍照'],
['神农架', 31.7500, 110.3330, '风景区', '自然风光,徒步登山'],
['武陵源', 29.3167, 110.4833, '风景区', '自然风光,徒步登山,拍照'],
['凤凰古城', 27.9500, 109.6000, '古镇', '古镇,人文历史,拍照'],
['岳麓山', 28.1830, 112.9330, '山峰', '自然风光,徒步登山,人文历史'],
['橘子洲', 28.1830, 112.9500, '公园', '自然风光,休闲,拍照'],
['丹霞山', 25.0333, 113.7500, '风景区', '自然风光,徒步登山,拍照'],
['广州塔', 23.1066, 113.3245, '人文历史', '人文历史,拍照,美食'],
['沙面', 23.1080, 113.2400, '人文历史', '人文历史,拍照,休闲'],
['开平碉楼', 22.3667, 112.7000, '古镇', '古镇,人文历史,拍照'],
['惠州西湖', 23.0830, 114.4000, '风景区', '自然风光,休闲,拍照'],
['桂林象鼻山', 25.2700, 110.3000, '风景区', '自然风光,拍照,亲水海滨'],
['龙脊梯田', 25.7500, 110.1330, '自然风光', '自然风光,徒步登山,拍照'],
['涠洲岛', 21.0330, 109.1170, '海滨', '亲水海滨,自然风光,拍照'],
['德天瀑布', 22.8500, 106.7170, '风景区', '自然风光,亲水海滨,拍照'],
['天涯海角', 18.2500, 109.3500, '海滨', '亲水海滨,拍照,休闲'],
['南山文化旅游区', 18.3000, 109.2000, '寺观', '人文历史,亲水海滨,休闲'],
['宽窄巷子', 30.6690, 104.0550, '人文历史', '人文历史,美食,拍照'],
['武侯祠', 30.6470, 104.0480, '古迹', '人文历史,拍照'],
['杜甫草堂', 30.6630, 103.9930, '园林', '人文历史,拍照,休闲'],
['大熊猫繁育基地', 30.7330, 104.1500, '动物园', '亲子,自然风光'],
['峨眉山', 29.5330, 103.3500, '山峰', '自然风光,徒步登山,人文历史'],
['乐山大佛', 29.5500, 103.7670, '古迹', '人文历史,拍照,亲水海滨'],
['稻城亚丁', 28.4330, 100.3000, '风景区', '自然风光,徒步登山,拍照'],
['四姑娘山', 31.1000, 102.9000, '山峰', '自然风光,徒步登山,拍照'],
['若尔盖花湖', 33.9000, 102.8330, '风景区', '自然风光,拍照,亲水海滨'],
['黄龙', 32.7500, 103.8330, '风景区', '自然风光,徒步登山,亲水海滨'],
['磁器口古镇', 29.5800, 106.4500, '古镇', '古镇,美食,人文历史'],
['大足石刻', 29.7000, 105.7000, '古迹', '人文历史,拍照'],
['武隆天生三桥', 29.4330, 107.8000, '风景区', '自然风光,徒步登山,拍照'],
['青岩古镇', 26.4170, 106.6830, '古镇', '古镇,人文历史,美食'],
['荔波小七孔', 25.3000, 107.7000, '风景区', '自然风光,亲水海滨,拍照'],
['镇远古镇', 27.0500, 108.4330, '古镇', '古镇,人文历史,拍照'],
['西江千户苗寨', 26.4830, 108.1830, '古镇', '古镇,人文历史,拍照'],
['石林风景区', 24.8167, 103.3333, '风景区', '自然风光,拍照,徒步登山'],
['玉龙雪山', 27.1000, 100.1830, '山峰', '自然风光,徒步登山,拍照'],
['束河古镇', 26.9000, 100.2170, '古镇', '古镇,人文历史,休闲'],
['泸沽湖', 27.7000, 100.7830, '风景区', '自然风光,亲水海滨,拍照'],
['西双版纳植物园', 21.9170, 101.2670, '园林', '自然风光,亲子,拍照'],
['崇圣寺三塔', 25.7000, 100.1500, '寺观', '人文历史,拍照'],
['兵马俑', 34.3853, 109.2785, '古迹', '人文历史,亲子,室内避雨'],
['华清宫', 34.3670, 109.2170, '古迹', '人文历史,拍照'],
['大唐芙蓉园', 34.2170, 108.9670, '园林', '人文历史,拍照,亲子'],
['法门寺', 34.4330, 107.9000, '寺观', '人文历史,室内避雨'],
['太白山', 34.0830, 107.7670, '山峰', '自然风光,徒步登山'],
['麦积山石窟', 34.3500, 106.0000, '古迹', '人文历史,拍照'],
['张掖七彩丹霞', 38.9000, 100.1330, '风景区', '自然风光,拍照,徒步登山'],
['嘉峪关关城', 39.8000, 98.2200, '古迹', '人文历史,拍照'],
['鸣沙山月牙泉', 40.0830, 94.6670, '风景区', '自然风光,拍照,亲子'],
['塔尔寺', 36.4830, 101.5670, '寺观', '人文历史,拍照'],
['茶卡盐湖', 36.7500, 99.0830, '风景区', '自然风光,拍照,亲水海滨'],
['沙坡头', 37.4500, 105.0000, '风景区', '自然风光,亲子,亲水海滨'],
['镇北堡西部影城', 38.5000, 106.0830, '主题乐园', '人文历史,拍照,亲子'],
['伊犁那拉提', 43.3000, 84.0000, '风景区', '自然风光,拍照,徒步登山'],
['喀纳斯', 48.7330, 87.0330, '风景区', '自然风光,拍照,亲水海滨'],
['赛里木湖', 44.6000, 81.2000, '风景区', '自然风光,拍照,亲水海滨'],
['沈阳故宫', 41.7997, 123.4539, '古迹', '人文历史,拍照'],
['大连星海广场', 38.8767, 121.5733, '海滨', '亲水海滨,休闲,拍照'],
['长白山天池', 42.0000, 128.0667, '山峰', '自然风光,徒步登山,拍照'],
['伪满皇宫', 43.8830, 125.3500, '古迹', '人文历史,室内避雨'],
['中央大街', 45.7750, 126.6167, '人文历史', '人文历史,美食,拍照'],
['北极村', 53.4670, 122.3500, '古镇', '自然风光,拍照,休闲'],
];
// 主要城市:[名称, 纬度, 经度],用作「城市级」周边目的地
const CN_CITIES = [
['北京市', 39.9042, 116.4074], ['天津市', 39.3434, 117.3616],
['石家庄市', 38.0428, 114.5149], ['唐山市', 39.6304, 118.1804],
['秦皇岛市', 39.9354, 119.6005], ['邯郸市', 36.6256, 114.5391],
['保定市', 38.8740, 115.4646], ['张家口市', 40.7687, 114.8865],
['承德市', 40.9544, 117.9634], ['廊坊市', 39.5377, 116.6833],
['沧州市', 38.3037, 116.8388], ['衡水市', 37.7392, 115.6690],
['邢台市', 37.0682, 114.5048], ['太原市', 37.8706, 112.5489],
['大同市', 40.0903, 113.3001], ['长治市', 36.1954, 113.1163],
['晋中市', 37.6870, 112.7529], ['运城市', 35.0263, 111.0038],
['临汾市', 36.0880, 111.5190], ['呼和浩特市', 40.8414, 111.7519],
['包头市', 40.6574, 109.8404], ['赤峰市', 42.2586, 118.9568],
['鄂尔多斯市', 39.6086, 109.7816], ['沈阳市', 41.8057, 123.4315],
['大连市', 38.9140, 121.6147], ['丹东市', 40.1243, 124.3939],
['锦州市', 41.0955, 121.1257], ['营口市', 40.6674, 122.2352],
['长春市', 43.8171, 125.3235], ['吉林市', 43.8379, 126.5496],
['延吉市', 42.9048, 129.5091], ['哈尔滨市', 45.8038, 126.5349],
['齐齐哈尔市', 47.3427, 123.9177], ['牡丹江市', 44.5527, 129.6333],
['上海市', 31.2304, 121.4737], ['南京市', 32.0603, 118.7969],
['无锡市', 31.4912, 120.3119], ['徐州市', 34.2058, 117.2848],
['常州市', 31.8107, 119.9730], ['苏州市', 31.2989, 120.5853],
['南通市', 31.9802, 120.8943], ['连云港市', 34.5967, 119.2216],
['扬州市', 32.3936, 119.4128], ['镇江市', 32.1878, 119.4253],
['杭州市', 30.2741, 120.1551], ['宁波市', 29.8683, 121.5440],
['温州市', 27.9938, 120.6993], ['嘉兴市', 30.7461, 120.7555],
['湖州市', 30.8927, 120.0868], ['绍兴市', 30.0303, 120.5802],
['金华市', 29.0784, 119.6474], ['舟山市', 29.9853, 122.2073],
['台州市', 28.6560, 121.4207], ['合肥市', 31.8206, 117.2272],
['芜湖市', 31.3529, 118.4331], ['蚌埠市', 32.9168, 117.3838],
['安庆市', 30.5259, 117.0520], ['黄山市', 29.7147, 118.3376],
['福州市', 26.0745, 119.2965], ['厦门市', 24.4798, 118.0894],
['泉州市', 24.8741, 118.6757], ['漳州市', 24.5125, 117.6471],
['南平市', 26.6416, 118.1781], ['南昌市', 28.6820, 115.8579],
['景德镇市', 29.2689, 117.1783], ['九江市', 29.7120, 116.0010],
['赣州市', 25.8310, 114.9350], ['上饶市', 28.4549, 117.9434],
['济南市', 36.6512, 117.1201], ['青岛市', 36.0671, 120.3826],
['淄博市', 36.8134, 118.0548], ['烟台市', 37.4638, 121.4479],
['潍坊市', 36.7069, 119.1618], ['济宁市', 35.4145, 116.5872],
['泰安市', 36.1944, 117.1280], ['威海市', 37.5128, 122.1204],
['日照市', 35.4166, 119.5269], ['临沂市', 35.1045, 118.3563],
['郑州市', 34.7466, 113.6254], ['开封市', 34.7973, 114.3077],
['洛阳市', 34.6197, 112.4539], ['安阳市', 36.0990, 114.3920],
['新乡市', 35.3028, 113.9268], ['南阳市', 32.9908, 112.5287],
['武汉市', 30.5928, 114.3055], ['宜昌市', 30.6919, 111.2865],
['襄阳市', 32.0090, 112.1226], ['荆州市', 30.3350, 112.2390],
['恩施市', 30.2720, 109.4880], ['长沙市', 28.2282, 112.9388],
['株洲市', 27.8275, 113.1339], ['衡阳市', 26.8932, 112.5719],
['岳阳市', 29.3570, 113.1288], ['张家界市', 29.1171, 110.4794],
['郴州市', 25.7707, 113.0117], ['广州市', 23.1291, 113.2644],
['深圳市', 22.5431, 114.0579], ['珠海市', 22.2710, 113.5767],
['佛山市', 23.0218, 113.1218], ['汕头市', 23.3535, 116.6808],
['湛江市', 21.2707, 110.3594], ['肇庆市', 23.0471, 112.4720],
['惠州市', 23.1110, 114.4160], ['东莞市', 23.0207, 113.7518],
['中山市', 22.5208, 113.3820], ['南宁市', 22.8170, 108.3665],
['柳州市', 24.3264, 109.4280], ['桂林市', 25.2736, 110.2994],
['北海市', 21.4813, 109.1200], ['海口市', 20.0442, 110.1999],
['三亚市', 18.2528, 109.5119], ['重庆市', 29.5630, 106.5516],
['成都市', 30.5728, 104.0668], ['绵阳市', 31.4670, 104.6790],
['乐山市', 29.5520, 103.7620], ['宜宾市', 28.7660, 104.6170],
['南充市', 30.8370, 106.1100], ['西昌市', 27.8860, 102.2580],
['贵阳市', 26.6470, 106.6302], ['遵义市', 27.7250, 106.9270],
['安顺市', 26.2530, 105.9470], ['昆明市', 25.0389, 102.7183],
['曲靖市', 25.4900, 103.7970], ['玉溪市', 24.3500, 102.5460],
['丽江市', 26.8720, 100.2320], ['大理市', 25.6060, 100.2670],
['景洪市', 22.0070, 100.7980], ['拉萨市', 29.6525, 91.1721],
['日喀则市', 29.2670, 88.8810], ['林芝市', 29.6490, 94.3620],
['西安市', 34.3416, 108.9398], ['宝鸡市', 34.3620, 107.2370],
['咸阳市', 34.3290, 108.7090], ['延安市', 36.5860, 109.4900],
['汉中市', 33.0670, 107.0230], ['榆林市', 38.2860, 109.7350],
['兰州市', 36.0611, 103.8343], ['天水市', 34.5800, 105.7250],
['张掖市', 38.9260, 100.4500], ['敦煌市', 40.1420, 94.6610],
['西宁市', 36.6171, 101.7782], ['银川市', 38.4872, 106.2309],
['乌鲁木齐市', 43.8256, 87.6168], ['喀什市', 39.4700, 75.9890],
['伊宁市', 43.9160, 81.3240], ['阿勒泰市', 47.8480, 88.1390],
];
module.exports = { EXTRA_SCENIC, CN_CITIES };
+240
View File
@@ -0,0 +1,240 @@
'use strict';
/**
* 小众 / 冷门目的地库
*
* 与 places-cn.js 的「知名景区」互补:这里收录的是真实存在、但游客相对较少的
* 古村、遗址、湿地、地质景观、小馆与徒步线,用于让清单不再千篇一律。
*
* 格式:[名称, 纬度, 经度, 类型, 标签(逗号分隔), 所在区域]
* 坐标为近似 WGS-84,仅用于天气与路网估算;结果会标注「内置小众地点库」,
* 不会伪装成实时检索。所有条目统一带「小众秘境」标签,便于前端筛选与兴趣匹配。
*/
const NICHE_SPOTS = [
// ---------------------------------------------------------------- 北京 · 门头沟 / 海淀 / 昌平
['爨底下村', 39.8990, 115.6350, '古村', '小众秘境,古镇,人文历史,拍照', '北京 · 门头沟'],
['灵水举人村', 39.9330, 115.5820, '古村', '小众秘境,古镇,人文历史,休闲', '北京 · 门头沟'],
['双龙峡', 39.9060, 115.5560, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '北京 · 门头沟'],
['黄草梁', 39.8520, 115.4520, '山峰', '小众秘境,自然风光,徒步登山,拍照', '北京 · 门头沟'],
['珍珠湖', 39.8830, 115.6010, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '北京 · 门头沟'],
['妙峰山', 39.9680, 116.0730, '山峰', '小众秘境,自然风光,徒步登山,人文历史', '北京 · 门头沟'],
['法海寺', 39.9510, 116.1520, '寺观', '小众秘境,人文历史,室内避雨,拍照', '北京 · 石景山'],
['模式口历史文化街', 39.9480, 116.1480, '古村', '小众秘境,古镇,人文历史,美食', '北京 · 石景山'],
['承恩寺', 39.9530, 116.1660, '寺观', '小众秘境,人文历史,室内避雨', '北京 · 石景山'],
['田义墓', 39.9400, 116.1610, '古迹', '小众秘境,人文历史,拍照', '北京 · 石景山'],
['北宫国家森林公园', 39.8820, 116.1300, '自然保护地', '小众秘境,自然风光,徒步登山,休闲', '北京 · 丰台'],
['千灵山', 39.8510, 116.0980, '山峰', '小众秘境,自然风光,徒步登山,人文历史', '北京 · 丰台'],
['大觉寺', 40.0520, 116.1020, '寺观', '小众秘境,人文历史,休闲,拍照', '北京 · 海淀'],
['鹫峰', 40.0670, 116.0770, '山峰', '小众秘境,自然风光,徒步登山', '北京 · 海淀'],
['阳台山', 40.0830, 116.0820, '山峰', '小众秘境,自然风光,徒步登山,拍照', '北京 · 海淀'],
['凤凰岭', 40.1020, 116.1010, '风景区', '小众秘境,自然风光,徒步登山,人文历史', '北京 · 海淀'],
['贝家花园', 40.0480, 116.1100, '遗址', '小众秘境,人文历史,拍照,休闲', '北京 · 海淀'],
['五塔寺', 39.9420, 116.3210, '古迹', '小众秘境,人文历史,室内避雨,拍照', '北京 · 海淀'],
['智化寺', 39.9180, 116.4310, '寺观', '小众秘境,人文历史,室内避雨', '北京 · 东城'],
['史家胡同博物馆', 39.9260, 116.4380, '展馆', '小众秘境,人文历史,室内避雨,亲子', '北京 · 东城'],
['银山塔林', 40.3330, 116.3350, '古迹', '小众秘境,人文历史,徒步登山,拍照', '北京 · 昌平'],
['沟崖自然风景区', 40.2830, 116.2820, '峡谷', '小众秘境,自然风光,徒步登山', '北京 · 昌平'],
['巩华城遗址', 40.1330, 116.2830, '遗址', '小众秘境,人文历史,拍照', '北京 · 昌平'],
['大杨山森林公园', 40.3020, 116.4180, '自然保护地', '小众秘境,自然风光,徒步登山', '北京 · 昌平'],
['黄花城水长城', 40.4160, 116.2830, '古迹', '小众秘境,人文历史,徒步登山,亲水海滨', '北京 · 怀柔'],
['箭扣长城', 40.4520, 116.5200, '古迹', '小众秘境,人文历史,徒步登山,拍照', '北京 · 怀柔'],
['喇叭沟门原始森林', 40.9020, 116.4020, '自然保护地', '小众秘境,自然风光,徒步登山,拍照', '北京 · 怀柔'],
['云蒙山', 40.5520, 116.7320, '山峰', '小众秘境,自然风光,徒步登山', '北京 · 密云'],
['白河峡谷', 40.6030, 116.6010, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '北京 · 密云'],
['黑龙潭', 40.5520, 116.7520, '风景区', '小众秘境,自然风光,亲水海滨,徒步登山', '北京 · 密云'],
['桃源仙谷', 40.5720, 116.7710, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '北京 · 密云'],
['京都第一瀑', 40.5830, 116.7820, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '北京 · 密云'],
['古崖居', 40.5520, 116.0210, '遗址', '小众秘境,人文历史,拍照,徒步登山', '北京 · 延庆'],
['玉渡山', 40.5820, 115.8820, '自然保护地', '小众秘境,自然风光,徒步登山,拍照', '北京 · 延庆'],
['野鸭湖湿地', 40.4210, 115.8510, '湿地', '小众秘境,自然风光,拍照,亲子', '北京 · 延庆'],
['硅化木国家地质公园', 40.6830, 116.3320, '地质公园', '小众秘境,自然风光,亲子,拍照', '北京 · 延庆'],
['滴水壶', 40.6520, 116.4210, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '北京 · 延庆'],
['乌龙峡谷', 40.6210, 116.7520, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '北京 · 延庆'],
['海坨山', 40.6210, 115.8510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '北京 · 延庆'],
['云居寺', 39.6170, 115.7680, '寺观', '小众秘境,人文历史,室内避雨,拍照', '北京 · 房山'],
['石经山', 39.6210, 115.7810, '古迹', '小众秘境,人文历史,徒步登山', '北京 · 房山'],
['上方山国家森林公园', 39.6810, 115.8210, '自然保护地', '小众秘境,自然风光,徒步登山', '北京 · 房山'],
['圣莲山', 39.7510, 115.7210, '山峰', '小众秘境,自然风光,徒步登山,人文历史', '北京 · 房山'],
['白草畔', 39.8020, 115.5510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '北京 · 房山'],
['石花洞', 39.7810, 115.9310, '洞穴', '小众秘境,自然风光,室内避雨,亲子', '北京 · 房山'],
['银狐洞', 39.7010, 115.8510, '洞穴', '小众秘境,自然风光,室内避雨', '北京 · 房山'],
['仙栖洞', 39.6510, 115.6010, '洞穴', '小众秘境,自然风光,室内避雨', '北京 · 房山'],
['金陵遗址', 39.8010, 115.9510, '遗址', '小众秘境,人文历史,拍照', '北京 · 房山'],
['琉璃河遗址', 39.6010, 116.0310, '遗址', '小众秘境,人文历史,亲子', '北京 · 房山'],
['团河行宫遗址', 39.7620, 116.4210, '遗址', '小众秘境,人文历史,休闲,拍照', '北京 · 大兴'],
['中国铁道博物馆东郊馆', 39.9910, 116.5210, '展馆', '小众秘境,人文历史,室内避雨,亲子', '北京 · 朝阳'],
['观复博物馆', 39.9820, 116.5210, '展馆', '小众秘境,人文历史,室内避雨,拍照', '北京 · 朝阳'],
['红砖美术馆', 40.0210, 116.5020, '美术馆', '小众秘境,人文历史,室内避雨,拍照', '北京 · 朝阳'],
['松美术馆', 40.0520, 116.5520, '美术馆', '小众秘境,人文历史,室内避雨,拍照', '北京 · 顺义'],
['罗红摄影艺术馆', 40.0010, 116.5520, '美术馆', '小众秘境,拍照,室内避雨,休闲', '北京 · 顺义'],
['张家湾古镇', 39.8510, 116.7020, '古镇', '小众秘境,古镇,人文历史,拍照', '北京 · 通州'],
['燃灯佛舍利塔', 39.9110, 116.6610, '古迹', '小众秘境,人文历史,拍照', '北京 · 通州'],
['路县故城遗址公园', 39.9020, 116.6810, '遗址', '小众秘境,人文历史,休闲,亲子', '北京 · 通州'],
// ---------------------------------------------------------------- 天津 / 河北
['鸡鸣驿', 40.4710, 115.3010, '古村', '小众秘境,古镇,人文历史,拍照', '河北 · 张家口'],
['天漠', 40.3810, 115.5510, '地质公园', '小众秘境,自然风光,拍照,徒步登山', '河北 · 张家口'],
['后城赤壁', 40.7510, 115.9010, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '河北 · 张家口'],
['暖泉古镇', 39.9410, 114.5010, '古镇', '小众秘境,古镇,人文历史,拍照', '河北 · 张家口'],
['开阳堡', 40.0610, 114.4310, '古村', '小众秘境,古镇,人文历史,拍照', '河北 · 张家口'],
['小五台山', 39.9010, 115.2010, '山峰', '小众秘境,自然风光,徒步登山', '河北 · 张家口'],
['易水湖', 39.2010, 115.1010, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '河北 · 保定'],
['白石山玻璃栈道', 39.1010, 114.7010, '山峰', '小众秘境,自然风光,徒步登山,拍照', '河北 · 保定'],
['狼牙山', 39.1510, 115.1810, '山峰', '小众秘境,自然风光,徒步登山,人文历史', '河北 · 保定'],
['窦王岭', 37.9010, 114.2510, '风景区', '小众秘境,自然风光,徒步登山', '河北 · 石家庄'],
['于家石头村', 37.8510, 114.0510, '古村', '小众秘境,古镇,人文历史,拍照', '河北 · 石家庄'],
['正定隆兴寺', 38.1510, 114.5810, '寺观', '小众秘境,人文历史,室内避雨,拍照', '河北 · 石家庄'],
['广府古城', 36.7010, 114.7310, '古镇', '小众秘境,古镇,人文历史,亲水海滨', '河北 · 邯郸'],
['京娘湖', 36.7510, 113.9510, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '河北 · 邯郸'],
['杨柳青古镇', 39.1410, 116.9910, '古镇', '小众秘境,古镇,人文历史,拍照', '天津 · 西青'],
['蓟州独乐寺', 40.0410, 117.4010, '寺观', '小众秘境,人文历史,室内避雨,拍照', '天津 · 蓟州'],
['蓟州梨木台', 40.2010, 117.4510, '自然保护地', '小众秘境,自然风光,徒步登山,亲水海滨', '天津 · 蓟州'],
// ---------------------------------------------------------------- 山西 / 内蒙古
['得胜堡', 40.3510, 113.2510, '古村', '小众秘境,古镇,人文历史,拍照', '山西 · 大同'],
['悬空村', 38.8510, 112.0510, '古村', '小众秘境,古镇,自然风光,徒步登山', '山西 · 忻州'],
['芦芽山', 38.7510, 111.9010, '山峰', '小众秘境,自然风光,徒步登山', '山西 · 忻州'],
['万年冰洞', 38.8010, 111.9510, '洞穴', '小众秘境,自然风光,室内避雨,拍照', '山西 · 忻州'],
['李家山村', 37.6010, 110.8010, '古村', '小众秘境,古镇,人文历史,拍照', '山西 · 吕梁'],
['郭峪古城', 35.5010, 112.4510, '古镇', '小众秘境,古镇,人文历史,拍照', '山西 · 晋城'],
['湘峪古堡', 35.4810, 112.4010, '古村', '小众秘境,古镇,人文历史,拍照', '山西 · 晋城'],
['王莽岭', 35.6010, 113.4510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '山西 · 晋城'],
['锡林郭勒石条山', 42.1010, 115.2010, '地质公园', '小众秘境,自然风光,拍照,徒步登山', '内蒙古 · 锡林郭勒'],
['阿尔山森林公园', 47.1810, 119.9510, '自然保护地', '小众秘境,自然风光,徒步登山,拍照', '内蒙古 · 兴安盟'],
['恩和俄罗斯族民族乡', 50.7510, 119.5510, '古村', '小众秘境,古镇,人文历史,拍照', '内蒙古 · 呼伦贝尔'],
// ---------------------------------------------------------------- 东北
['绿江村', 40.5510, 125.3510, '古村', '小众秘境,自然风光,拍照,休闲', '辽宁 · 丹东'],
['本溪关门山', 41.2510, 124.1510, '风景区', '小众秘境,自然风光,徒步登山,拍照', '辽宁 · 本溪'],
['盘锦红海滩', 40.9510, 121.8510, '湿地', '小众秘境,自然风光,拍照,亲水海滨', '辽宁 · 盘锦'],
['长白山望天鹅', 41.4510, 127.7510, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '吉林 · 白山'],
['防川一眼望三国', 42.4410, 130.6110, '观景地', '小众秘境,自然风光,拍照,人文历史', '吉林 · 延边'],
['横道河子镇', 44.5510, 129.6510, '古镇', '小众秘境,古镇,人文历史,拍照', '黑龙江 · 牡丹江'],
['五大连池', 48.7210, 126.1510, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '黑龙江 · 黑河'],
// ---------------------------------------------------------------- 江浙沪皖
['石塘古镇', 28.3510, 121.6010, '古镇', '小众秘境,古镇,亲水海滨,拍照', '浙江 · 台州'],
['括苍山', 28.7510, 120.9510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '浙江 · 台州'],
['公盂村', 28.7510, 120.5510, '古村', '小众秘境,自然风光,徒步登山,拍照', '浙江 · 台州'],
['前童古镇', 29.2510, 121.3510, '古镇', '小众秘境,古镇,人文历史,拍照', '浙江 · 宁波'],
['许家山石头村', 29.3510, 121.4510, '古村', '小众秘境,古镇,人文历史,拍照', '浙江 · 宁波'],
['覆卮山', 29.8510, 120.8510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '浙江 · 绍兴'],
['安昌古镇', 30.1510, 120.5010, '古镇', '小众秘境,古镇,美食,拍照', '浙江 · 绍兴'],
['廿八都古镇', 28.3510, 118.4510, '古镇', '小众秘境,古镇,人文历史,拍照', '浙江 · 衢州'],
['江郎山霞客古道', 28.5310, 118.5610, '古道', '小众秘境,自然风光,徒步登山', '浙江 · 衢州'],
['松阳杨家堂村', 28.4510, 119.4810, '古村', '小众秘境,古镇,人文历史,拍照', '浙江 · 丽水'],
['云和梯田', 28.1010, 119.5810, '风景区', '小众秘境,自然风光,拍照,徒步登山', '浙江 · 丽水'],
['遂昌南尖岩', 28.3510, 119.1510, '山峰', '小众秘境,自然风光,拍照,徒步登山', '浙江 · 丽水'],
['芹川古村', 29.5510, 118.8510, '古村', '小众秘境,古镇,人文历史,休闲', '浙江 · 杭州'],
['指南村', 30.2510, 119.6510, '古村', '小众秘境,自然风光,拍照,休闲', '浙江 · 杭州'],
['径山寺', 30.3510, 119.8810, '寺观', '小众秘境,人文历史,休闲,拍照', '浙江 · 杭州'],
['良渚古城遗址', 30.3910, 119.9910, '遗址', '小众秘境,人文历史,亲子,拍照', '浙江 · 杭州'],
['锦溪古镇', 31.1810, 120.8910, '古镇', '小众秘境,古镇,人文历史,休闲', '江苏 · 苏州'],
['甪直古镇', 31.2710, 120.8810, '古镇', '小众秘境,古镇,人文历史,美食', '江苏 · 苏州'],
['东山陆巷古村', 31.1010, 120.4010, '古村', '小众秘境,古镇,人文历史,拍照', '江苏 · 苏州'],
['窑湾古镇', 34.2510, 118.0510, '古镇', '小众秘境,古镇,人文历史,美食', '江苏 · 徐州'],
['高淳老街', 31.3310, 118.8810, '古镇', '小众秘境,古镇,美食,拍照', '江苏 · 南京'],
['石塘竹海', 31.5510, 119.0510, '自然保护地', '小众秘境,自然风光,徒步登山,休闲', '江苏 · 南京'],
['朱家角古镇', 31.1110, 121.0510, '古镇', '小众秘境,古镇,人文历史,美食', '上海 · 青浦'],
['广富林遗址', 31.0510, 121.2210, '遗址', '小众秘境,人文历史,亲子,拍照', '上海 · 松江'],
['武康路历史街区', 31.2110, 121.4310, '历史街区', '小众秘境,人文历史,拍照,美食', '上海 · 徐汇'],
['木渎古镇', 31.2510, 120.5110, '古镇', '小众秘境,古镇,人文历史,休闲', '江苏 · 苏州'],
['查济古村', 30.5510, 118.1510, '古村', '小众秘境,古镇,人文历史,拍照', '安徽 · 宣城'],
['桃花潭', 30.5510, 118.0510, '风景区', '小众秘境,自然风光,人文历史,亲水海滨', '安徽 · 宣城'],
['徽杭古道', 30.1510, 118.7510, '古道', '小众秘境,自然风光,徒步登山', '安徽 · 宣城'],
['阳产土楼', 29.8510, 118.7510, '古村', '小众秘境,古镇,人文历史,拍照', '安徽 · 黄山'],
['木梨硔', 29.6510, 118.1510, '古村', '小众秘境,自然风光,拍照,徒步登山', '安徽 · 黄山'],
['唐模古村', 29.9010, 118.2210, '古村', '小众秘境,古镇,人文历史,休闲', '安徽 · 黄山'],
['石潭村', 29.8510, 118.6510, '古村', '小众秘境,自然风光,拍照,休闲', '安徽 · 黄山'],
// ---------------------------------------------------------------- 福建 / 江西 / 山东 / 河南
['四礵列岛', 26.7510, 120.3510, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '福建 · 宁德'],
['霞浦滩涂', 26.8810, 120.0010, '湿地', '小众秘境,亲水海滨,自然风光,拍照', '福建 · 宁德'],
['屏南龙潭里', 26.8510, 119.1510, '古村', '小众秘境,古镇,人文历史,休闲', '福建 · 宁德'],
['培田古民居', 25.6510, 116.6510, '古村', '小众秘境,古镇,人文历史,拍照', '福建 · 龙岩'],
['樟脚村', 25.1510, 118.8510, '古村', '小众秘境,古镇,人文历史,拍照', '福建 · 泉州'],
['蟳埔村', 24.8810, 118.6510, '古村', '小众秘境,古镇,拍照,美食', '福建 · 泉州'],
['瑶里古镇', 29.5510, 117.5510, '古镇', '小众秘境,古镇,人文历史,自然风光', '江西 · 景德镇'],
['晓起古村', 29.4210, 117.9810, '古村', '小众秘境,古镇,人文历史,拍照', '江西 · 上饶'],
['武功山', 27.4510, 114.1810, '山峰', '小众秘境,自然风光,徒步登山,拍照', '江西 · 萍乡'],
['铅山鹅湖书院', 28.2510, 117.7510, '古迹', '小众秘境,人文历史,室内避雨,休闲', '江西 · 上饶'],
['青州井塘古村', 36.6510, 118.3510, '古村', '小众秘境,古镇,人文历史,拍照', '山东 · 潍坊'],
['长岛九丈崖', 37.9510, 120.7510, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '山东 · 烟台'],
['荣成成山头', 37.4010, 122.7010, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '山东 · 威海'],
['沂蒙山龟蒙', 35.5510, 117.8510, '山峰', '小众秘境,自然风光,徒步登山', '山东 · 临沂'],
['郭亮村挂壁公路', 35.7510, 113.6010, '古村', '小众秘境,古镇,自然风光,徒步登山', '河南 · 新乡'],
['南太行八里沟', 35.6510, 113.5510, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '河南 · 新乡'],
['巩义康百万庄园', 34.7510, 112.9810, '古迹', '小众秘境,人文历史,室内避雨,拍照', '河南 · 郑州'],
['登封观星台', 34.4010, 113.1510, '古迹', '小众秘境,人文历史,亲子,拍照', '河南 · 郑州'],
// ---------------------------------------------------------------- 湖北 / 湖南 / 广东 / 广西 / 海南
['鹿院坪', 30.3510, 109.7510, '峡谷', '小众秘境,自然风光,徒步登山,拍照', '湖北 · 恩施'],
['屏山峡谷', 29.8510, 109.8510, '峡谷', '小众秘境,自然风光,亲水海滨,拍照', '湖北 · 恩施'],
['地心谷', 30.5510, 109.9510, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '湖北 · 恩施'],
['羊楼洞古镇', 29.7510, 113.8510, '古镇', '小众秘境,古镇,人文历史,美食', '湖北 · 咸宁'],
['高椅古村', 26.8510, 109.7510, '古村', '小众秘境,古镇,人文历史,拍照', '湖南 · 怀化'],
['紫鹊界梯田', 27.6510, 111.0510, '风景区', '小众秘境,自然风光,拍照,徒步登山', '湖南 · 娄底'],
['崀山', 26.3510, 110.8510, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '湖南 · 邵阳'],
['边城茶峒', 28.5510, 109.2510, '古镇', '小众秘境,古镇,人文历史,休闲', '湖南 · 湘西'],
['连南千年瑶寨', 24.6510, 112.2510, '古村', '小众秘境,古镇,人文历史,拍照', '广东 · 清远'],
['南澳岛', 23.4210, 117.0510, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '广东 · 汕头'],
['盐洲岛', 22.7510, 114.9510, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '广东 · 惠州'],
['英西峰林', 24.2510, 112.9510, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '广东 · 清远'],
['石塘古村', 25.1010, 113.3510, '古村', '小众秘境,古镇,人文历史,拍照', '广东 · 韶关'],
['斜阳岛', 20.9510, 109.2010, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '广西 · 北海'],
['程阳八寨', 25.9010, 109.6510, '古村', '小众秘境,古镇,人文历史,拍照', '广西 · 柳州'],
['会仙湿地', 25.1510, 110.2010, '湿地', '小众秘境,自然风光,亲水海滨,拍照', '广西 · 桂林'],
['加榜梯田', 25.7510, 108.4510, '风景区', '小众秘境,自然风光,拍照,徒步登山', '贵州 · 黔东南'],
['肇兴侗寨', 25.7510, 109.1510, '古村', '小众秘境,古镇,人文历史,拍照', '贵州 · 黔东南'],
['镇远青龙洞', 27.0510, 108.4510, '古迹', '小众秘境,人文历史,拍照,室内避雨', '贵州 · 黔东南'],
['万峰林', 25.0510, 104.9510, '地质公园', '小众秘境,自然风光,拍照,休闲', '贵州 · 黔西南'],
['旧县古村', 24.8510, 110.4510, '古村', '小众秘境,古镇,人文历史,拍照', '广西 · 桂林'],
// ---------------------------------------------------------------- 川渝 / 云南 / 西藏
['党岭葫芦海', 30.8510, 101.5510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '四川 · 甘孜'],
['莫斯卡村', 30.7510, 101.8510, '古村', '小众秘境,古镇,自然风光,徒步登山', '四川 · 甘孜'],
['中路藏寨', 30.8510, 101.8510, '古村', '小众秘境,古镇,人文历史,拍照', '四川 · 甘孜'],
['亚青寺', 31.1510, 99.5510, '寺观', '小众秘境,人文历史,拍照,自然风光', '四川 · 甘孜'],
['措普沟', 30.4510, 99.5510, '自然保护地', '小众秘境,自然风光,徒步登山,亲水海滨', '四川 · 甘孜'],
['七藏沟', 32.7510, 103.7510, '峡谷', '小众秘境,自然风光,徒步登山,拍照', '四川 · 阿坝'],
['神座村', 32.5510, 102.0510, '古村', '小众秘境,古镇,自然风光,休闲', '四川 · 阿坝'],
['龚滩古镇', 28.7510, 108.4510, '古镇', '小众秘境,古镇,人文历史,亲水海滨', '重庆 · 酉阳'],
['石柱刀背梁', 30.0010, 108.2510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '重庆 · 石柱'],
['偏岩古镇', 29.9510, 106.5510, '古镇', '小众秘境,古镇,人文历史,休闲', '重庆 · 北碚'],
['诺邓古村', 25.9510, 99.3510, '古村', '小众秘境,古镇,人文历史,美食', '云南 · 大理'],
['沙溪古镇', 26.3210, 99.8510, '古镇', '小众秘境,古镇,人文历史,休闲', '云南 · 大理'],
['雨崩村', 28.3510, 98.7510, '古村', '小众秘境,自然风光,徒步登山,拍照', '云南 · 迪庆'],
['丙中洛', 28.0510, 98.6510, '峡谷', '小众秘境,自然风光,徒步登山,人文历史', '云南 · 怒江'],
['元阳箐口民俗村', 23.1510, 102.7510, '古村', '小众秘境,古镇,自然风光,拍照', '云南 · 红河'],
['建水团山民居', 23.6010, 102.7510, '古村', '小众秘境,古镇,人文历史,拍照', '云南 · 红河'],
['坝美村', 23.4510, 105.0510, '古村', '小众秘境,古镇,自然风光,亲水海滨', '云南 · 文山'],
['普者黑', 24.0510, 104.1510, '湿地', '小众秘境,自然风光,亲水海滨,拍照', '云南 · 文山'],
['萨普神山', 30.8510, 93.5510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '西藏 · 那曲'],
['札达土林', 31.5010, 79.7510, '地质公园', '小众秘境,自然风光,人文历史,拍照', '西藏 · 阿里'],
['来古冰川', 29.2510, 96.7510, '冰川', '小众秘境,自然风光,徒步登山,拍照', '西藏 · 昌都'],
['索松村', 29.5510, 94.9510, '古村', '小众秘境,古镇,自然风光,拍照', '西藏 · 林芝'],
// ---------------------------------------------------------------- 西北
['波浪谷', 37.5510, 108.8510, '地质公园', '小众秘境,自然风光,拍照,徒步登山', '陕西 · 榆林'],
['米脂窑洞古城', 37.7510, 110.1710, '古村', '小众秘境,古镇,人文历史,拍照', '陕西 · 榆林'],
['青木川古镇', 32.8510, 105.5510, '古镇', '小众秘境,古镇,人文历史,休闲', '陕西 · 汉中'],
['留坝高江路', 33.6010, 106.8510, '古道', '小众秘境,自然风光,拍照,休闲', '陕西 · 汉中'],
['扎尕那', 34.2510, 103.1510, '古村', '小众秘境,古镇,自然风光,徒步登山', '甘肃 · 甘南'],
['郎木寺', 34.0510, 102.6510, '寺观', '小众秘境,人文历史,自然风光,拍照', '甘肃 · 甘南'],
['炳灵寺石窟', 35.8010, 103.0510, '古迹', '小众秘境,人文历史,室内避雨,亲水海滨', '甘肃 · 临夏'],
['石包城遗址', 39.5510, 95.1510, '遗址', '小众秘境,人文历史,拍照,自然风光', '甘肃 · 酒泉'],
['翡翠湖', 37.4510, 95.1510, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '青海 · 海西'],
['水上雅丹', 37.7510, 93.3510, '地质公园', '小众秘境,自然风光,亲水海滨,拍照', '青海 · 海西'],
['贵德黄河清', 36.0410, 101.4310, '风景区', '小众秘境,自然风光,亲水海滨,休闲', '青海 · 海南'],
['坎布拉', 36.0510, 101.7510, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '青海 · 黄南'],
['西夏王陵', 38.2510, 105.9510, '遗址', '小众秘境,人文历史,拍照,亲子', '宁夏 · 银川'],
['水洞沟', 38.3510, 106.5510, '遗址', '小众秘境,人文历史,亲子,自然风光', '宁夏 · 银川'],
['大海道', 42.0510, 93.7510, '地质公园', '小众秘境,自然风光,拍照,徒步登山', '新疆 · 哈密'],
['夏塔古道', 43.1510, 80.7510, '古道', '小众秘境,自然风光,徒步登山,拍照', '新疆 · 伊犁'],
['琼库什台', 43.1510, 82.3510, '古村', '小众秘境,古镇,自然风光,徒步登山', '新疆 · 伊犁'],
['禾木村', 48.5510, 87.4510, '古村', '小众秘境,古镇,自然风光,拍照', '新疆 · 阿勒泰'],
['白哈巴村', 48.6510, 86.7510, '古村', '小众秘境,古镇,自然风光,拍照', '新疆 · 阿勒泰'],
];
module.exports = { NICHE_SPOTS };
+587
View File
@@ -0,0 +1,587 @@
'use strict';
/**
* AI 规划层
* - 使用标准 OpenAI 兼容 /chat/completions 协议(流式 SSE)
* - 无 API Key 时自动进入「演示模式」:用本地规则引擎生成同结构 Markdown,
* 保证整个网站不配密钥也能端到端跑通
*/
const U = require('./util');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function chunkString(str, size = 28) {
const out = [];
for (let i = 0; i < str.length; i += size) out.push(str.slice(i, i + size));
return out;
}
/** 构造发给模型的结构化上下文。字段刻意精简:多传一个字段就多一次 token。 */
function buildPayload(body) {
const days = Math.min(7, Math.max(1, Number(body.timeRange?.days) || 2));
const shortlist = (Array.isArray(body.shortlist) ? body.shortlist : []).slice(0, 5).map((c, i) => {
// 逐日天气是最大的 token 头:每个候选都带一串逐日数据。
// 只给第 1 名(主目的地)完整逐日天气——模型排日程主要围绕它,
// 其余候选只留区间摘要,需要时用 summary 判断"要不要换目的地"。
const withDaily = i === 0;
return {
rank: i + 1,
name: String(c.name || '').slice(0, 40),
kind: c.kind || '',
tags: (c.tags || []).slice(0, 3),
distanceKm: c.distanceKm,
direction: c.direction || '',
score: c.score,
verdict: String(c.verdict || '').slice(0, 30),
// 只要最优一档,别把 3 种交通方式全塞进去。
// road/rail 只给第 1 名(模型写交通与预算只针对主目的地),其余候选留空。
travel: c.travel ? {
bestHours: c.travel.bestHours,
bestMode: c.travel.bestMode,
road: (i === 0 && c.road) ? { distanceKm: c.road.distanceKm, durationMinutes: c.road.durationMinutes, source: c.road.source } : null,
rail: (i === 0 && c.rail) ? { available: c.rail.available, durationMinutes: c.rail.durationMinutes, fareCny: c.rail.fareCny, source: c.rail.source, note: c.rail.note } : null,
} : null,
weather: c.weather ? {
// 第 1 名给完整摘要;其余只给"够判断要不要换目的地"的极简摘要
summary: withDaily ? (c.weather.summary || null) : (c.weather.summary ? {
avgLow: c.weather.summary.avgLow,
avgHigh: c.weather.summary.avgHigh,
maxPrecipProb: c.weather.summary.maxPrecipProb,
badDays: c.weather.summary.badDays,
} : null),
// 只带行程覆盖的那几天:原来一次带 31 天,纯浪费
daily: withDaily ? (c.weather.daily || []).slice(0, days).map((d) => ({
date: d.date, weekday: d.weekday, label: d.label, tmax: d.tmax, tmin: d.tmin, precip: d.precip, precipProb: d.precipProb,
})) : [],
} : null,
aqi: c.aqi ?? null,
};
});
return {
origin: body.origin ? { name: body.origin.name || body.origin.label || '', label: body.origin.label || body.origin.name || '' } : {},
timeRange: body.timeRange || {},
constraints: body.constraints ? {
maxOneWayHours: body.constraints.maxOneWayHours,
interests: (body.constraints.interests || []).slice(0, 6),
} : {},
shortlist,
userNeeds: String(body.userNeeds || '').slice(0, 500) || '(未填,按清单与约束做通用安排)',
// 数据口径只留最关键的几条,全文在页面上有,不必喂给模型
dataNotes: (Array.isArray(body.dataNotes) ? body.dataNotes : []).slice(0, 3)
.map((n) => String(n).slice(0, 60)),
// 周边住宿:只作为"住哪一片"的参考,POI 里没有房态和实时房价
hotels: Array.isArray(body.hotels) ? body.hotels.slice(0, 5).map((h) => ({
name: String(h.name || '').slice(0, 30),
distanceKm: h.distanceKm,
rating: h.rating,
cost: h.cost,
})) : [],
// 目的地周边的可点名地点:没有这部分,模型只能写「某某片区」再让你自行核实
nearby: (() => {
const n = body.nearby;
if (!n || typeof n !== 'object') return null;
const pick = (arr) => (Array.isArray(arr) ? arr.slice(0, 5) : []).map((x) => ({
name: String(x.name || '').slice(0, 20),
type: String(x.type || '').slice(0, 10),
distanceKm: x.distanceKm,
rating: x.rating,
cost: x.cost,
})).filter((x) => x.name);
const food = pick(n.food);
const sights = pick(n.sights);
if (!food.length && !sights.length) return null;
return {
area: String(n.area || '').slice(0, 40),
radiusKm: Number(n.radiusKm) || 8,
food,
sights,
};
})(),
// 用户选的住宿价位:既约束推荐,也用于预算表
lodging: body.lodging && typeof body.lodging === 'object' ? {
label: String(body.lodging.label || '').slice(0, 24),
min: Number.isFinite(body.lodging.min) ? body.lodging.min : null,
max: Number.isFinite(body.lodging.max) ? body.lodging.max : null,
} : null,
};
}
/**
* 构造对话。
* 第一轮:system + 完整上下文 JSON + 用户需求。
* 追加轮:再塞入「上一版方案(assistant)」+「这次要怎么改(user)」,
* 让模型在原方案上迭代,而不是从零重来——否则用户会觉得"又给我一份新的"。
*/
function buildMessages(payload, systemPrompt, extra = {}) {
// 上下文用紧凑 JSON:缩进和换行本身也要算 token,白白多花几百
const json = JSON.stringify(payload);
const msgs = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content:
'以下是本站分析得到的上下文(JSON)。请据此与我的具体需求生成行程规划,严格按系统提示要求的 Markdown 结构输出。\n\n' +
'```json\n' + json + '\n```\n\n' +
`我的具体需求:${payload.userNeeds}`,
},
];
if (extra.previous) {
msgs.push({ role: 'assistant', content: extra.previous });
msgs.push({
role: 'user',
content:
`在上一版方案的基础上做如下调整:${extra.followUp || '请自行提升可执行性'}\n\n` +
'要求:输出调整后的**完整**方案,保持原有 Markdown 结构(方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选)。' +
'不要只输出改动片段,也不要写「已按要求修改」之类的交代;与上一版冲突的地方直接改掉,并在「风险与备选」里说明取舍。',
});
}
return msgs;
}
// ------------------------------------------------------------ 演示模式规划
function fmtHours(min) {
if (min == null) return '—';
const h = Math.floor(min / 60);
const m = Math.round(min % 60);
return h ? `${h}小时${m ? m + '分' : ''}` : `${m}分钟`;
}
function demoPlan(p) {
const list = p.shortlist || [];
const days = p.timeRange?.days || 1;
const dates = U.eachDate(p.timeRange?.start || U.todayISO(), p.timeRange?.end || U.todayISO(), 31);
const main = list[0];
const backup = list[1];
const maxH = p.constraints?.maxOneWayHours || 2.5;
const prefs = (p.constraints?.transportPrefs || []).join('、') || '不限';
const interests = (p.constraints?.interests || []).join('、') || '未指定';
const road = main?.travel?.road || main?.road || null;
const rail = main?.travel?.rail || main?.rail || null;
const w = main?.weather?.summary;
const daily = main?.weather?.daily || [];
const allNotes = (p.dataNotes || []).map((n) => `- ${n}`).join('\n');
const L = [];
L.push('> ⚠️ **演示模式**:当前未配置 AI 接口密钥,以下为本地规则引擎依据同一份数据生成的示例方案。配置任意 OpenAI 兼容接口后,将改由大模型结合你的需求生成更贴合的规划。');
L.push('');
L.push('## 方案总览');
L.push('');
if (!main) {
L.push('尚未选择目的地。请返回上一步,在备选清单中勾选至少一个目的地后再生成规划。');
return L.join('\n');
}
L.push(`以 **${main.name}** 为主目的地(适配分 ${main.score ?? '—'},${main.verdict || ''}),` +
`出行 ${days} 天(${p.timeRange?.start || ''} ~ ${p.timeRange?.end || ''},${p.timeRange?.weekdayHint || ''})。` +
`出发地 ${p.origin?.name || '—'},直线距离约 ${U.round(main.distanceKm ?? 0, 1)}km,` +
`最快单程约 ${fmtHours((main.travel?.bestHours ?? (road?.durationMinutes ?? 0)) * 60)}(${main.travel?.bestMode || '自驾'}),` +
`往返约 ${fmtHours(((main.travel?.bestHours ?? 0) * 2) * 60)}。`);
L.push('');
if (backup) L.push(`备选 **${backup.name}**(${backup.score ?? '—'} 分):${backup.reasons?.[0] || '综合条件次优'};不首选的原因是适配分低于主目的地,适合作为天气突变或主目的地拥挤时的替换。`);
else L.push('清单中暂无第二备选,建议放宽半径或时间范围后再分析一次。');
L.push('');
L.push(`约束:可接受单程 ≤ ${maxH} 小时;交通偏好 ${prefs};兴趣 ${interests}。节奏按「先扣往返、再排游玩」安排${days >= 2 ? ',每天留出午餐与 1 小时机动时间' : ',当日往返不安排过夜项目'}。`);
L.push('');
L.push(`> **需求备忘**:${p.userNeeds}。演示模式只做通用排布;配置 AI 接口后,模型会据此细化同行人、预算、体力与偏好,并指出与数据的冲突。`);
L.push('');
L.push('## 逐日安排');
L.push('');
dates.forEach((date, i) => {
const d = daily[i] || daily[Math.min(i, daily.length - 1)] || null;
const wd = U.weekdayCN(date);
L.push(`### D${i + 1}(${date} ${wd})`);
L.push('');
L.push('| 时段 | 安排 | 交通与耗时 | 天气应对 |');
L.push('| --- | --- | --- | --- |');
const weatherText = d ? `${d.label} ${d.tmin}~${d.tmax}℃,降水概率 ${d.precipProb}%` : '天气数据缺失';
const rainPlan = d && (d.precipProb >= 60 || d.precip >= 5)
? '改为室内:博物馆 / 展馆 / 商圈,备雨具'
: '按户外安排,备防晒与饮水';
if (i === 0) {
const arriveH = 8 + Math.ceil(main.travel?.bestHours ?? 1);
L.push(`| 08:00–${String(Math.min(arriveH, 23)).padStart(2, '0')}:00 | 从${p.origin?.name || '出发地'}出发前往${main.name} | ${main.travel?.bestMode || '自驾'} 约 ${fmtHours((main.travel?.bestHours ?? 0) * 60)}${main.travel?.bestMode === '铁路/高铁' ? '(估算)' : ''} | 出发前再看一次当日预报 |`);
L.push(`| 上午 | 抵达后先游览核心区域,轻量开场 | 步行 / 景区接驳 | ${weatherText};${rainPlan} |`);
L.push(`| 12:00–13:30 | 午餐与休息(不安排赶场) | — | ${d && d.tmax >= 30 ? '避开正午高温,选室内就餐' : '按体感调整'} |`);
L.push(`| 下午 | ${main.kind || '目的地'}主线游览${backup && days === 1 ? `,视体力可选顺路短停 ${backup.name}` : ''} | 景区内交通 | ${weatherText};${rainPlan} |`);
if (days === 1) {
L.push(`| 16:30 前 | 返程,预留 1 小时缓冲 | ${fmtHours((main.travel?.bestHours ?? 0) * 60)} | 天黑前结束山路/水域活动 |`);
} else {
L.push('| 傍晚 | 入住 / 晚餐,整理次日装备 | — | 复核次日天气与车次 |');
}
} else if (i === dates.length - 1 && days > 1) {
L.push('| 上午 | 补充昨日未尽项目或周边轻量点位 | 步行 / 短途接驳 | ' + weatherText + ';' + rainPlan + ' |');
L.push('| 12:00–13:30 | 午餐、退房 | — | — |');
L.push(`| 14:00 前 | 返程,预留 1 小时缓冲 | ${fmtHours((main.travel?.bestHours ?? 0) * 60)} | 避免夜间长途 |`);
} else {
L.push(`| 上午 | ${backup ? backup.name + ' 或 ' : ''}主线深度游 | 当地交通(估算) | ${weatherText};${rainPlan} |`);
L.push('| 12:00–13:30 | 午餐与休息 | — | 高温/降雨时延长室内休息 |');
L.push('| 下午 | 次级点位,留 1 小时机动 | 当地交通(估算) | ' + rainPlan + ' |');
}
L.push('');
});
L.push('## 天气与装备');
L.push('');
if (w) {
L.push(`区间平均 **${w.avgLow ?? '—'}~${w.avgHigh ?? '—'}℃**,累计降水 ${w.totalPrecip ?? 0}mm,最大降水概率 ${w.maxPrecipProb ?? 0}%,最大风速 ${w.maxWind ?? '—'}km/h,需留意天数 ${w.badDays ?? 0} 天,最差天气为${w.dominant?.label || '—'}。`);
} else {
L.push('未取到逐日天气,以下为通用建议。');
}
L.push('');
L.push('- 穿衣:按日温差分层,外层防风;' + (w && w.avgHigh >= 28 ? '高温天备速干衣物与遮阳帽。' : '低温天备保暖层。'));
L.push('- 雨具/防晒:' + (w && w.maxPrecipProb >= 50 ? '折叠伞或轻便雨衣必带,鞋子防滑。' : '备一把折叠伞与防晒霜即可。'));
L.push(`- 空气质量:${main.aqi != null ? `AQI ${main.aqi}(${main.aqi <= 50 ? '优' : main.aqi <= 100 ? '良' : '一般'}),敏感人群酌情减少户外停留。` : '本次未取到 AQI 数据,敏感人群请临近出发前查询。'}`);
L.push('- **若遇降雨或大风**:把户外主线替换为清单中标注「室内避雨」的候选地,或缩短停留、提前返程;山区/水域项目一律取消。');
L.push('');
L.push('## 交通方案');
L.push('');
const noDrive = (p.constraints?.transportPrefs || []).some((x) => /不限|公共交通|铁路|高铁/.test(x)) && !(p.constraints?.transportPrefs || []).includes('自驾');
if (!noDrive && road) {
L.push(`**自驾**:${p.origin?.name || '出发地'} → ${main.name},约 ${road.distanceKm}km / ${fmtHours(road.durationMinutes)}(${road.source}${road.source === '估算' ? ',非实时路网' : ''})。建议 08:00 前出发避开拥堵;确认停车场位置与收费、景区是否限行/需预约,长途每 2 小时休息一次。`);
L.push('');
}
if (rail) {
L.push(rail.available
? `**铁路 / 公共交通**:估算约 ${fmtHours(rail.durationMinutes)},参考票价 ¥${rail.fareCny ?? '—'}(${rail.source})。${rail.note || ''} 建议提前 30–45 分钟到站,回程留意末班车。`
: `**铁路**:${rail.note || '距离过近或暂无直达城际铁路'},建议改用自驾或市内公共交通。`);
L.push('');
}
L.push('> 车次、余票、实时路况与限行政策请在 12306 / 地图 App / 景区官方渠道临近出发前核对;本方案中的估算值不作为购票依据。');
L.push('');
L.push('## 预算粗估');
L.push('');
L.push('| 项目 | 区间(¥) | 说明 |');
L.push('| --- | --- | --- |');
L.push(`| 往返交通 | ${Math.round((road?.distanceKm || 60) * 1.2)} – ${Math.round((road?.distanceKm || 60) * 2.4)} | 油费/过路费或车票,按 ${road?.distanceKm || 60}km 里程粗估 |`);
L.push('| 门票 / 预约 | 0 – 200 | 是否收费需查景区官方公告,本站未提供门票数据 |');
L.push(`| 餐饮 | ${60 * days} – ${120 * days} | 按每人每天 60–120 元 × ${days} 天 |`);
L.push(days > 1 ? `| 住宿 | ${200 * (days - 1)} – ${500 * (days - 1)} | 按 ${days - 1} 晚、每间 200–500 元粗估 |` : '| 住宿 | 0 | 当日往返,不安排过夜 |');
L.push('| 其他(停车/接驳/保险) | 30 – 150 | 机动 |');
L.push('| **合计** | **粗估,不含购物** | 以实际发生为准,所有金额均为估算 |');
L.push('');
L.push('## 出发前清单');
L.push('');
L.push('- 复核:出行日前 1–2 天再看一次天气预报(本方案天气可能来自预报或历史气候参考)、景区开放与预约状态、车次余票。');
L.push('- 证件与支付:身份证、优惠证件、手机电量与充电宝、少量现金。');
L.push('- 装备:按「天气与装备」一节准备;山区加防滑鞋与登山杖,带儿童/老人加常用药与保暖备用。');
L.push('- 把返程时间与末班交通设成手机提醒,预留至少 1 小时缓冲。');
L.push('');
L.push('## 风险与备选');
L.push('');
L.push('1. **降雨/大风** → 触发条件:当日降水概率 ≥60% 或风速 ≥40km/h;替换动作:启用「室内避雨」候选地,取消山区、水域与高处观景。');
L.push(`2. **交通延误** → 触发条件:单程实际耗时超过 ${maxH} 小时;替换动作:压缩下午项目${backup ? `,或改去更近的 ${backup.name}` : ''},不硬赶返程。`);
L.push('3. **体力不支 / 同行人不适** → 触发条件:老人、儿童或同行者提出休息;替换动作:只保留上午核心项目,午后直接返程。');
L.push('4. **拥挤排队** → 触发条件:节假日或现场排队超预期;替换动作:改走次级点位,把热门项目放到次日早场。');
L.push('');
L.push('---');
L.push('');
L.push('**数据来源说明**');
L.push('');
L.push(allNotes || '- 见页面底部数据源声明');
L.push('');
L.push(`> 本方案由${p.site || 'TripSense'}基于候选清单数据生成,未提供的票价、班次、营业时间均未编造;凡标注「估算」处请自行核实。`);
return L.join('\n');
}
// ------------------------------------------------- OpenAI 兼容流式调用
async function* planEvents(body, ai, cfg) {
const payload = buildPayload(body);
// 追加轮:带上上一版方案与这次的新要求,让模型在原文上改
const rawPrev = String(body.previous || '');
const previous = rawPrev.length > 3500
? `${rawPrev.slice(0, 3500)}\n\n(上一版过长,已截断,请以本次要求为准重构)`
: rawPrev;
const followUp = String(body.followUp || '').slice(0, 500);
const useDemo = !ai.apiKey;
// 粗估输入 token:中文按 ~1.6 字/token,JSON 里的英文/数字约 3 字/token。
// 只用于给用户一个"上下文有多大"的直观数字,不做计费依据。
const ctxChars = JSON.stringify(payload).length;
const approxTokens = Math.round(ctxChars / 2.2);
yield {
type: 'meta',
mode: useDemo ? 'demo' : 'live',
model: useDemo ? '本地规则引擎' : ai.model,
baseUrl: ai.baseUrl,
count: payload.shortlist.length,
isFollowUp: Boolean(previous),
ctxChars,
approxTokens,
};
if (useDemo) {
let md = demoPlan(payload);
if (previous) {
md = '> ℹ️ 当前是**演示模式**:本地规则引擎无法真正理解增量需求,下面是重新生成的通用方案。到「设置」里填好 AI 接口后,就能基于上一版做针对性调整。\n\n' + md;
}
for (const piece of chunkString(md, 26)) {
yield { type: 'delta', text: piece };
await sleep(10);
}
yield { type: 'done' };
return;
}
const url = `${String(ai.baseUrl).replace(/\/+$/, '')}/chat/completions`;
const messages = buildMessages(payload, cfg.SYSTEM_PROMPT, { previous, followUp });
const timeoutMs = ai.timeoutMs || cfg.AI.TIMEOUT_MS;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${ai.apiKey}`,
Accept: 'text/event-stream, application/json',
};
// 只带必需参数的版本:新版推理模型会拒绝 temperature / max_tokens
const minimalPayload = { model: ai.model, messages, stream: true };
const baseMaxTokens = ai.maxTokens ?? cfg.AI.MAX_TOKENS;
// 推理模型会把 max_tokens 大量花在思考上,正文可能一个字都不剩
// (实测 deepseek 系模型 3000 的额度全被思考吃光)。第一次只拿到思考时,
// 自动放大额度重跑一次,省得用户自己去改配置。
let maxTokens = baseMaxTokens;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const out = yield* runOnce({ url, headers, payload: minimalPayload, timeoutMs, maxTokens, allowParamFallback: true });
if (out.emitted > 0) { yield { type: 'done' }; return; }
if (out.fatal) { yield { type: 'error', message: out.errorMsg }; yield { type: 'done' }; return; }
if (out.reasoningLen > 0 && attempt === 1) {
const next = Math.min(Math.max(baseMaxTokens * 3, 8192), 16384);
if (next > maxTokens) {
yield { type: 'notice', message: `模型把 token 预算花在思考上了(${out.reasoningLen} 字),已把上限 ${maxTokens} → ${next} 自动重试` };
maxTokens = next;
continue;
}
}
let msg;
if (out.reasoningLen) {
msg = `模型只输出了 ${out.reasoningLen} 字思考过程,没有正文;已把 max_tokens 提到 ${maxTokens} 仍不够。建议换非推理模型,或手动调大 AI_MAX_TOKENS。`;
} else if (out.finishReason === 'content_filter') {
msg = '模型触发了内容安全策略,输出被拦截(finish_reason=content_filter)。';
} else if (out.eventCount) {
msg = `接口返回了流但没有正文(收到 ${out.eventCount} 个事件,finish_reason=${out.finishReason || '空'})。片段:${(out.sample || []).join(' | ') || '(空)'}`;
} else {
msg = out.errorMsg || '接口返回 200,但流里没有任何数据事件。请确认地址是 OpenAI 兼容的 /chat/completions(常见坑:少了 /v1 后缀)。';
}
yield { type: 'error', message: msg };
yield { type: 'done' };
return;
}
}
/**
* 单次请求:发出去、读流,只负责 yield delta / reasoning。
* 结果(有没有正文、思考了多少字、是否已致命)通过 return 值交回,
* 外层才能判断"要不要换更大的 max_tokens 再试一次"。
*/
async function* runOnce({ url, headers, payload, timeoutMs, maxTokens, allowParamFallback }) {
const body = { ...payload, max_tokens: maxTokens };
let ctrl = new AbortController();
let timer = setTimeout(() => ctrl.abort(), timeoutMs);
let res;
try {
res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal });
if (!res.ok) {
const txt = await res.text().catch(() => '');
const paramIssue = res.status === 400
&& /unsupported|not support|unknown parameter|too large|temperature|max_tokens|max_completion_tokens/i.test(txt);
if (paramIssue && allowParamFallback) {
// 额度超限时先降到 8192(DeepSeek 系等模型的上限)再试,
// 仍不行才退回最小参数,避免"重试反而把请求撑爆"
clearTimeout(timer);
const tooLarge = /too large|too_large|exceed|out of range/i.test(txt);
const tries = [];
if (tooLarge && maxTokens > 8192) tries.push({ ...payload, max_tokens: 8192 });
tries.push(payload);
let lastTxt = txt;
for (const b of tries) {
ctrl = new AbortController();
timer = setTimeout(() => ctrl.abort(), timeoutMs);
res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(b), signal: ctrl.signal });
if (res.ok) break;
lastTxt = await res.text().catch(() => '');
}
if (!res.ok) {
clearTimeout(timer);
return { fatal: true, errorMsg: `AI 接口返回 HTTP ${res.status}:${lastTxt.slice(0, 300)}`, emitted: 0, reasoningLen: 0 };
}
} else {
clearTimeout(timer);
return { fatal: true, errorMsg: `AI 接口返回 HTTP ${res.status}:${txt.slice(0, 300)}`, emitted: 0, reasoningLen: 0 };
}
}
} catch (err) {
clearTimeout(timer);
return { fatal: true, errorMsg: `连接 AI 接口失败:${err.message}`, emitted: 0, reasoningLen: 0 };
}
const ct = res.headers.get('content-type') || '';
if (!ct.includes('text/event-stream')) {
// 部分兼容服务忽略 stream 参数,直接返回完整 JSON
clearTimeout(timer);
try {
const d = await res.json();
const text = d.choices?.[0]?.message?.content || d.choices?.[0]?.text || '';
const reasoning = d.choices?.[0]?.message?.reasoning_content || '';
if (text) {
yield { type: 'delta', text };
return { emitted: String(text).length, reasoningLen: 0, finishReason: '' };
}
return {
emitted: 0,
reasoningLen: String(reasoning).length,
finishReason: d.choices?.[0]?.finish_reason || '',
};
} catch (err) {
return { fatal: true, errorMsg: `AI 接口返回的不是 JSON:${err.message}`, emitted: 0, reasoningLen: 0 };
}
}
let emitted = 0;
let reasoningLen = 0;
let finishReason = '';
let eventCount = 0;
const sample = [];
try {
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() || '';
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
eventCount += 1;
if (sample.length < 2) sample.push(data.slice(0, 150));
try {
const j = JSON.parse(data);
const choice = j.choices?.[0];
if (choice?.finish_reason) finishReason = choice.finish_reason;
const delta = choice?.delta?.content ?? choice?.message?.content ?? '';
const reasoning = choice?.delta?.reasoning_content || choice?.message?.reasoning_content;
if (delta) { emitted += String(delta).length; yield { type: 'delta', text: delta }; }
if (reasoning) { reasoningLen += String(reasoning).length; yield { type: 'reasoning', text: reasoning }; }
// 个别兼容服务把正文放在 delta.text
if (!delta && choice?.delta?.text) {
emitted += String(choice.delta.text).length;
yield { type: 'delta', text: choice.delta.text };
}
} catch { /* 忽略心跳/非法片段 */ }
}
}
} catch (err) {
clearTimeout(timer);
return { fatal: false, errorMsg: `读取 AI 流失败:${err.message}`, emitted, reasoningLen, finishReason };
} finally {
clearTimeout(timer);
}
return { emitted, reasoningLen, finishReason, eventCount, sample };
}
/**
* 连通性自检:发一个最小的非流式请求,把失败定位到具体环节
* (没 Key / 连不上 / HTTP 报错 / 返回不是 JSON / 参数不被支持 / 通了但没内容)。
* 正因为它用非流式,才能直接读到上游的错误 JSON,比在流里猜要准。
*/
async function testAI(ai) {
if (!ai.apiKey) return { ok: false, stage: 'config', message: '没有可用的 API Key' };
if (!ai.model) return { ok: false, stage: 'config', message: '没有填写模型名称' };
const url = `${String(ai.baseUrl).replace(/\/+$/, '')}/chat/completions`;
const t0 = Date.now();
const messages = [{ role: 'user', content: '回复两个字:可用' }];
const timeout = Math.min(ai.timeoutMs || 30000, 30000);
const once = async (payload) => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeout);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ai.apiKey}` },
body: JSON.stringify(payload),
signal: ctrl.signal,
});
const txt = await res.text();
return { res, txt };
} finally { clearTimeout(timer); }
};
try {
let note = '';
let { res, txt } = await once({ model: ai.model, messages, stream: false, max_tokens: 32, temperature: 0.2 });
if (!res.ok && res.status === 400
&& /unsupported|not support|unknown parameter|temperature|max_tokens|max_completion_tokens/i.test(txt)) {
note = '该模型不接受 temperature / max_tokens,已按最小参数重试';
({ res, txt } = await once({ model: ai.model, messages, stream: false }));
}
const ms = Date.now() - t0;
if (!res.ok) {
return { ok: false, stage: 'http', status: res.status, ms, note, message: `HTTP ${res.status}:${txt.slice(0, 400)}` };
}
let data;
try { data = JSON.parse(txt); } catch {
return { ok: false, stage: 'parse', status: res.status, ms, note, message: `返回的不是 JSON(前 200 字):${txt.slice(0, 200)}` };
}
if (data.error) {
return { ok: false, stage: 'api', status: res.status, ms, note, message: `接口报错:${JSON.stringify(data.error).slice(0, 300)}` };
}
const choice = data.choices?.[0];
const content = choice?.message?.content ?? choice?.text ?? '';
const reasoning = choice?.message?.reasoning_content || '';
const finish = choice?.finish_reason || '';
if (!content && !reasoning) {
return { ok: false, stage: 'empty', status: res.status, ms, note, finish, message: `接口通了但没返回内容(finish_reason=${finish || '空'})。可能是 max_tokens 太小或命中内容策略。` };
}
return {
ok: true, status: res.status, ms, note,
model: data.model || ai.model,
sample: String(content || reasoning).replace(/\s+/g, ' ').slice(0, 60),
reasoningOnly: !content && Boolean(reasoning),
finish,
};
} catch (err) {
const ms = Date.now() - t0;
const isTimeout = err.name === 'AbortError';
return {
ok: false, stage: 'network', ms,
message: isTimeout
? `请求超时(${Math.round(timeout / 1000)}s 内无响应)`
: `无法连接:${err.message}。检查地址能否访问、是否需要代理,或是否少了 /v1。`,
};
}
}
/** 合并服务端配置与前端覆盖头 */
function resolveAI(headers, cfg) {
const h = (k) => {
const v = headers[k];
return Array.isArray(v) ? v[0] : v;
};
const overrideKey = h('x-ai-api-key');
let apiKey = cfg.AI.API_KEY;
if (overrideKey === '__demo__') apiKey = '';
else if (overrideKey && overrideKey !== '__server__') apiKey = overrideKey;
return {
baseUrl: (h('x-ai-base-url') || cfg.AI.BASE_URL).replace(/\/+$/, ''),
apiKey,
model: h('x-ai-model') || cfg.AI.MODEL,
temperature: cfg.AI.TEMPERATURE,
maxTokens: cfg.AI.MAX_TOKENS,
timeoutMs: cfg.AI.TIMEOUT_MS,
};
}
module.exports = { planEvents, resolveAI, buildPayload, demoPlan, testAI };
+1296
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
'use strict';
// ------------------------------------------------------------------ 通用工具
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const round = (v, n = 0) => {
const p = 10 ** n;
return Math.round((Number(v) || 0) * p) / p;
};
/** 带超时的 fetch,返回原始 Response */
async function fetchWithTimeout(url, opts = {}, timeoutMs = 15000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
return await fetch(url, { ...opts, signal: ctrl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJSON(url, opts = {}, timeoutMs = 15000, retries = 2) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const res = await fetchWithTimeout(url, opts, timeoutMs);
if (!res.ok) {
const text = await res.text().catch(() => '');
// 4xx 是确定性错误,不必重试
if (res.status >= 400 && res.status < 500) {
throw new Error(`HTTP ${res.status} ${res.statusText} ${text.slice(0, 160)}`);
}
throw new Error(`HTTP ${res.status} ${res.statusText} ${text.slice(0, 160)}`);
}
return await res.json();
} catch (err) {
lastErr = err;
if (attempt < retries) await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}
throw lastErr;
}
/** 并发限制的 map,失败不中断整体 */
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const i = cursor++;
if (i >= items.length) return;
try {
out[i] = await fn(items[i], i);
} catch (err) {
out[i] = null;
console.warn(`[mapLimit] ${i} failed: ${err.message}`);
}
}
});
await Promise.all(workers);
return out;
}
/** 安全地尝试,失败返回 fallback */
async function attempt(label, fn, fallback) {
try {
return await fn();
} catch (err) {
console.warn(`[${label}] ${err.message}`);
return typeof fallback === 'function' ? fallback(err) : fallback;
}
}
// ------------------------------------------------------------------ 地理计算
const R_EARTH = 6371;
const toRad = (d) => (d * Math.PI) / 180;
function haversine(lat1, lon1, lat2, lon2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * R_EARTH * Math.asin(Math.sqrt(a));
}
/** 方位角:0 = 正北,顺时针递增 */
function bearing(lat1, lon1, lat2, lon2) {
const p1 = toRad(lat1);
const p2 = toRad(lat2);
const dl = toRad(lon2 - lon1);
const y = Math.sin(dl) * Math.cos(p2);
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return (Math.atan2(y, x) * 180) / Math.PI;
}
const COMPASS_8 = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
function compassLabel(deg) {
return COMPASS_8[Math.round((((deg % 360) + 360) % 360) / 45) % 8];
}
/** WGS-84 -> GCJ-02(国内高德/腾讯地图需要火星坐标) */
function wgs84ToGcj02(lng, lat) {
const PI = Math.PI;
const A = 6378245.0;
const EE = 0.00669342162296594323;
const outOfChina = (l, b) => l < 73.66 || l > 135.05 || b < 3.86 || b > 53.55;
if (outOfChina(lng, lat)) return [lng, lat];
const transformLat = (x, y) => {
let r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
r += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
r += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
return r;
};
const transformLon = (x, y) => {
let r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
r += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
r += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
return r;
};
let dLat = transformLat(lng - 105, lat - 35);
let dLon = transformLon(lng - 105, lat - 35);
const radLat = (lat / 180) * PI;
let magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
dLon = (dLon * 180) / (A / sqrtMagic * Math.cos(radLat) * PI);
return [lng + dLon, lat + dLat];
}
/** GCJ-02 -> WGS-84(迭代逼近,误差 < 1m),用于把高德结果转回通用坐标 */
function gcj02ToWgs84(lng, lat) {
let wLng = lng;
let wLat = lat;
for (let i = 0; i < 5; i += 1) {
const [gLng, gLat] = wgs84ToGcj02(wLng, wLat);
wLng += lng - gLng;
wLat += lat - gLat;
}
return [wLng, wLat];
}
/** 由中心点与公里半径得到 bbox:minLon,minLat,maxLon,maxLat */
function bboxFromRadius(lat, lon, radiusKm) {
const dLat = radiusKm / 111.32;
const cos = Math.max(0.2, Math.cos(toRad(lat)));
const dLon = radiusKm / (111.32 * cos);
return [lon - dLon, lat - dLat, lon + dLon, lat + dLat].map((v) => round(v, 4));
}
// -------------------------------------------------------------- 日期工具
function todayISO() {
const d = new Date();
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
function isoToUTC(iso) {
const [y, m, d] = iso.split('-').map(Number);
return Date.UTC(y, m - 1, d);
}
function utcToISO(ms) {
return new Date(ms).toISOString().slice(0, 10);
}
function addDaysISO(iso, n) {
return utcToISO(isoToUTC(iso) + n * 86400000);
}
function addYearsISO(iso, n) {
const [y, m, d] = iso.split('-').map(Number);
// 处理 2/29
const day = m === 2 && d === 29 ? 28 : d;
return `${y + n}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function diffDays(aISO, bISO) {
return Math.round((isoToUTC(bISO) - isoToUTC(aISO)) / 86400000);
}
function eachDate(startISO, endISO, cap = 31) {
const out = [];
const n = Math.min(cap, Math.max(0, diffDays(startISO, endISO)) + 1);
for (let i = 0; i < n; i += 1) out.push(addDaysISO(startISO, i));
return out;
}
function weekdayCN(iso) {
return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date(isoToUTC(iso)).getUTCDay()];
}
function isValidISO(s) {
return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(isoToUTC(s));
}
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
// ------------------------------------------------------------------- 其它
function normalizeName(s) {
return String(s || '').replace(/[\s·・()()\[\]【】"'"']/g, '').toLowerCase();
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
module.exports = {
clamp, round, fetchWithTimeout, fetchJSON, mapLimit, attempt,
haversine, bearing, compassLabel, bboxFromRadius, wgs84ToGcj02, gcj02ToWgs84,
todayISO, isoToUTC, utcToISO, addDaysISO, addYearsISO, diffDays, eachDate,
weekdayCN, isValidISO, chunk, normalizeName, esc,
};