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

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

327 lines
14 KiB
JavaScript
Raw Permalink Blame History

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