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