Files
tripsense/lib/analyze.js
T
tamakyi 7ad86c6619 归属判定升级:DataV 官方行政区划表 + 同名县消歧
用阿里 DataV GeoAtlas(免密钥)替换 OSM Overpass 离线县表:
- 民政部 adcode 体系,县级前 4 位即所属地级码,2832 县 100% 可定位地级
  (OSM 表只有 1182/2362,且 admin_level 层级不可靠)
- 同名县(全国 30 组)保留全部候选,运行时按坐标消歧,
  修掉「柳州的城中区被归到西宁市」

归属判定改为「按名字精确查官方表」优先于「按中心点猜最近的县」:
窄长县按中心点会跨省错判(环江喀斯特实际在环江县,按中心点判到荔波县)
查表带距离校验,精确全称命中放宽到 150km(狭长县中心离边界可达 85km+)

修掉三个真实缺陷:
- Overpass 兜底把地级塞进 osmCounty、县名塞进 osmCity,
  与 attachPlaces「先找县级」的顺序相反,导致兜底路径永远丢下层县名
- Overpass 请求无硬超时,公共实例「连着不返回」能把一次分析拖成十几分钟;
  补三层限(单次超时 / 阶段总时限 / is_in 反查次数上限),13 分钟 → 48 秒
- Photon 的 osm.place.city 类目混着景点 POI,kind==='城市' 时无条件把名字
  当地名,导致「社更穿洞北门」「柳州之门」等景点自己成一组;
  改为必须同时是行政区名且能在官方表查到

新增离线回归测试(都不打外部网络,Photon/Overpass 挂掉也能回归):
- test-admin-lookup:同名县消歧、已知边界点、狭长县远端点(19 项)
- test-overpass-fallback:本地假 Overpass,验兜底路径、简称归一、200+HTML 错误页
- test-grouping-regression:本地假 Photon,20 候选完整分组
- verify-ui-full:端到端 UI 验收(真实无头 Chrome 读真实 DOM)
2026-11-06 02:05:24 +08:00

329 lines
15 KiB
JavaScript
Raw 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.overpassCount ? ` + Overpass 兜底 ${stats.overpassCount} 条` : ''}` +
` + 内置知名景区 ${stats.curatedCount} 个 + 内置城市 ${stats.cityCount} 个 + 内置小众地点 ${stats.nicheCount} 个` +
`${stats.fallbackOnly ? ';⚠️ 实时 POI 服务不可用,本次清单完全来自内置地名库(坐标为近似值)' : ''}` +
`${stats.overpassError ? `;Overpass 兜底也失败(${stats.overpassError})` : ''}` +
`${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 };