TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。 - 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底 - 区县级「排除同城」判定(adname),过滤发生在轮转取样之前 - 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图 - 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造 - 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存 - 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
This commit is contained in:
+326
@@ -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 };
|
||||
Reference in New Issue
Block a user