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 };