'use strict'; /** * 数据层:地理编码 / 周边候选 / 天气 / 空气质量 / 公路 / 铁路 * * 设计原则: * - 全部接口免密钥即可跑通(Open-Meteo、Photon、OSRM); * - 每个外部依赖都有降级路径,任何单点失败不会让整页白屏; * - 所有「估算」结果都带 source 标记,最终会原样传给 AI,避免模型把估算当事实。 */ const U = require('./util'); const C = require('./cache'); const { JUNK_NAME, POOR_NAME, LOW_VALUE_NAME, CURATED, CN_CITIES, NICHE_SPOTS, wmo } = require('./data'); // 数据源层缓存 TTL(秒级语义): // 地理编码 7 天(行政区划不会变)/高德 POI 6 小时(商家会变) // 天气 & 空气质量 3 小时(Open-Meteo 按小时刷新)/路况 3 小时 const TTL = { geo: 7 * 24 * 3600 * 1000, poi: 6 * 3600 * 1000, weather: 3 * 3600 * 1000, road: 3 * 3600 * 1000, }; /** * 数据源层缓存键:高德 Key 后 8 位必须参与——换 Key 会改变 POI 与路网结果, * 不能把 A Key 的结果当成 B Key 的结果复用。 */ function sk(key, cfg) { const k = cfg && cfg.AMAP_KEY ? String(cfg.AMAP_KEY).slice(-8) : ''; return `${key}|amap:${k}`; } // Photon 公共实例易限流,只取覆盖度最高的几类;失败会快速降级到内置地名库 const PHOTON_JOBS = [ { include: 'osm.tourism.attraction', kind: '景点', tags: ['人文历史', '拍照', '观光'] }, { include: 'osm.tourism.museum', kind: '博物馆', tags: ['人文历史', '亲子', '室内避雨'] }, { include: 'osm.tourism.viewpoint', kind: '观景地', tags: ['自然风光', '拍照', '徒步登山'] }, { include: 'osm.tourism.theme_park', kind: '主题乐园', tags: ['亲子', '主题乐园'] }, { include: 'osm.tourism.zoo', kind: '动物园', tags: ['亲子', '自然风光'] }, { include: 'osm.leisure.park', kind: '公园', tags: ['自然风光', '休闲', '亲子'] }, { include: 'osm.leisure.nature_reserve', kind: '自然保护地', tags: ['自然风光', '徒步登山'] }, { include: 'osm.natural.peak', kind: '山峰', tags: ['自然风光', '徒步登山', '拍照'] }, { include: 'osm.boundary.national_park', kind: '风景区', tags: ['自然风光', '徒步登山', '拍照'] }, { include: 'osm.place.city', kind: '城市', tags: ['休闲', '人文历史', '美食'], isCity: true }, ]; // ================================================================ 地理编码 async function geocode(name, cfg, count = 8) { return C.memo(sk(`geo:${String(name).trim()}:${count}`, cfg), async () => { const u = new URL(cfg.ENDPOINTS.geocoding); u.searchParams.set('name', String(name).trim()); u.searchParams.set('count', String(count)); u.searchParams.set('language', 'zh'); u.searchParams.set('format', 'json'); const data = await U.fetchJSON(u.toString(), {}, 12000); return (data.results || []).map((r) => ({ name: r.name, label: [r.name, r.admin1 && r.admin1 !== r.name ? r.admin1 : '', r.country && r.country !== '中国' ? r.country : ''] .filter(Boolean).join(' · '), lat: r.latitude, lon: r.longitude, admin1: r.admin1 || '', country: r.country || '', timezone: r.timezone || 'auto', population: r.population || 0, })); }, TTL.geo); } async function reverseGeocode(lat, lon, cfg) { const key = sk(`rev:${U.round(lat, 3)},${U.round(lon, 3)}`, cfg); const hit = C.peek(key); if (hit) return hit.result; const res = await reverseGeocodeRaw(lat, lon, cfg); // 「当前位置」是反向地理编码彻底失败后的占位假地名,绝不能进缓存: // 缓存了它,后续所有依赖地名的查询(酒店/POI)都会拿着假地名去查,必然落空。 if (res && res.name && res.name !== '当前位置') C.set(key, res, TTL.geo); return res; } /** * 高德反向地理编码:坐标 → 省/市/区县/街道。 * BigDataCloud 与 Photon reverse 在国内经常返回空或被限流, * 高德 regeo 能稳定拿到「柳州市 / 城中区」——这是住宿「城市兜底」能生效的前提。 */ async function amapReverse(lat, lon, cfg) { if (!cfg.AMAP_KEY) return null; const [glng, glat] = U.wgs84ToGcj02(lon, lat); const u = new URL(cfg.ENDPOINTS.amapRegeo); u.searchParams.set('location', `${U.round(glng, 6)},${U.round(glat, 6)}`); u.searchParams.set('extensions', 'base'); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 8000, 1); if (d.status !== '1' || !d.regeocode) return null; const ac = d.regeocode.addressComponent || {}; const city = String(ac.city || ac.district || '').trim(); const district = String(ac.district || '').trim(); const province = String(ac.province || '').trim(); if (!city && !district) return null; // 高德在直辖市/县级市会把 city 留空,用 province 兜 const label = [province, city === province ? '' : city, district === city ? '' : district] .filter(Boolean).join(' · '); return { name: city || district || province, label: label || city || district, lat, lon, admin1: province, country: '中国', timezone: 'Asia/Shanghai', source: '高德反向地理编码', }; } async function reverseGeocodeRaw(lat, lon, cfg) { // 高德优先:国内地址结构最准,且能给到城市名(酒店兜底要用) if (cfg.AMAP_KEY) { try { const am = await amapReverse(lat, lon, cfg); if (am && am.name) return am; } catch (err) { console.warn('[reverse] amap:', err.message); } } const u = new URL(cfg.ENDPOINTS.reverse); u.searchParams.set('latitude', String(lat)); u.searchParams.set('longitude', String(lon)); u.searchParams.set('localityLanguage', 'zh'); try { const d = await U.fetchJSON(u.toString(), {}, 10000); const city = d.city || d.principalSubdivision || d.locality || ''; const region = d.principalSubdivision || ''; return { name: city || d.locality || '当前位置', label: [city, region && region !== city ? region : '', d.countryName].filter(Boolean).join(' · '), lat, lon, admin1: region, country: d.countryName || '', timezone: 'auto', }; } catch (err) { // 兜底:Photon reverse try { const u2 = new URL(cfg.ENDPOINTS.photon.replace(/\/api$/, '/reverse')); u2.searchParams.set('lat', String(lat)); u2.searchParams.set('lon', String(lon)); u2.searchParams.set('limit', '1'); const d2 = await U.fetchJSON(u2.toString(), { headers: { 'Accept-Language': 'zh-CN,zh' } }, 10000); const p = d2.features?.[0]?.properties || {}; return { name: p.city || p.name || '当前位置', label: [p.city || p.name, p.state].filter(Boolean).join(' · '), lat, lon, admin1: p.state || '', country: p.country || '', timezone: 'auto', }; } catch { return { name: '当前位置', label: `当前位置 (${U.round(lat, 3)}, ${U.round(lon, 3)})`, lat, lon, admin1: '', country: '', timezone: 'auto' }; } } } function photonCandidate(feature, cat, origin) { const p = feature.properties || {}; const coord = feature.geometry?.coordinates || []; const lon = Number(coord[0]); const lat = Number(coord[1]); const name = String(p.name || '').trim(); if (!name || !Number.isFinite(lat) || !Number.isFinite(lon)) return null; if (JUNK_NAME.test(name) || POOR_NAME.test(name) || LOW_VALUE_NAME.test(name)) return null; if (/^\d+$/.test(name)) return null; if (name.length < 2 || name.length > 30) return null; const distanceKm = U.haversine(origin.lat, origin.lon, lat, lon); return { id: `osm-${p.osm_type || 'N'}-${p.osm_id || Math.round(lat * 1e5)}`, name, kind: cat.kind, tags: [...cat.tags], lat: U.round(lat, 5), lon: U.round(lon, 5), region: [p.city, p.district, p.locality].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).slice(0, 2).join(' · '), distanceKm, bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), source: 'OpenStreetMap / Photon', hasExtent: Boolean(p.extent), featured: false, }; } async function photonQuery(job, bboxArr, origin, cfg) { // Photon 公共实例限流严格,分类检索的 bbox + 分类组合是稳定键,直接缓存 const pkey = `photon:${job.include}:${job.isCity ? 'c' : 'a'}:${bboxArr.map((n) => U.round(n, 2)).join(',')}:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}`; return C.memo(sk(pkey, cfg), async () => { const u = new URL(cfg.ENDPOINTS.photon); u.searchParams.set('limit', String(job.isCity ? 20 : cfg.SEARCH.photonPerCategory)); u.searchParams.set('bbox', bboxArr.join(',')); u.searchParams.set('lat', String(origin.lat)); u.searchParams.set('lon', String(origin.lon)); u.searchParams.set('location_bias_scale', job.isCity ? '0.9' : '0.15'); u.searchParams.set('include', job.include); u.searchParams.set('dedupe', '1'); const data = await U.fetchJSON(u.toString(), { headers: { 'Accept-Language': 'zh-CN,zh;q=0.9' }, }, cfg.SEARCH.photonTimeoutMs, cfg.SEARCH.photonRetries); return data.features || []; }, TTL.poi); } /** * 高德地理编码:中文区县 / 乡镇覆盖远好于 Open-Meteo。 * Open-Meteo 走 GeoNames,连「河池」「金城江」这类地级市/区县都查不到, * 而高德能直接定位到区县中心。坐标会从 GCJ-02 转回 WGS-84。 * 先走 geocode/geo(行政区),查不到再用 place/text(POI)。 */ async function amapGeocode(name, cfg) { if (!cfg.AMAP_KEY) return []; return C.memo(sk(`amapGeo:${String(name).trim()}`, cfg), async () => { const out = []; let lastErr = ''; const push = (obj) => { if (!out.some((x) => Math.abs(x.lat - obj.lat) < 0.01 && Math.abs(x.lon - obj.lon) < 0.01)) { out.push(obj); } }; try { const u = new URL(cfg.ENDPOINTS.amapGeocode); u.searchParams.set('address', name); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 6000, 1); if (d.status === '1') { for (const g of d.geocodes || []) { const [lng, lat] = String(g.location || '').split(',').map(Number); if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue; const [wlon, wlat] = U.gcj02ToWgs84(lng, lat); const city = Array.isArray(g.city) ? '' : (g.city || ''); const district = Array.isArray(g.district) ? '' : (g.district || ''); const province = Array.isArray(g.province) ? '' : (g.province || ''); const parts = [...new Set([province, city === province ? '' : city, district].filter(Boolean))]; push({ name: parts[parts.length - 1] || name, label: parts.join(' · '), lat: wlat, lon: wlon, admin1: province, country: '中国', timezone: 'Asia/Shanghai', level: g.level || '', source: '高德地理编码', }); } } else { lastErr = `高德地理编码:${d.info || d.infocode || d.status}`; } } catch (e) { console.warn('[amapGeocode] geo:', e.message); lastErr = e.message; } if (!out.length) { try { const u = new URL(cfg.ENDPOINTS.amapPlaceText); u.searchParams.set('keywords', name); u.searchParams.set('offset', '6'); u.searchParams.set('page', '1'); u.searchParams.set('extensions', 'base'); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 6000, 1); if (d.status === '1') { for (const p of d.pois || []) { const [lng, lat] = String(p.location || '').split(',').map(Number); if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue; const [wlon, wlat] = U.gcj02ToWgs84(lng, lat); const city = Array.isArray(p.cityname) ? '' : (p.cityname || ''); const ad = Array.isArray(p.adname) ? '' : (p.adname || ''); const label = [p.name, ad, city].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).slice(0, 3); push({ name: String(p.name || name), label: label.join(' · '), lat: wlat, lon: wlon, admin1: Array.isArray(p.pname) ? '' : (p.pname || ''), country: '中国', timezone: 'Asia/Shanghai', level: 'POI', source: '高德 POI', }); if (out.length >= 6) break; } } else if (!lastErr) { lastErr = `高德 POI:${d.info || d.infocode || d.status}`; } } catch (e) { console.warn('[amapGeocode] text:', e.message); if (!lastErr) lastErr = e.message; } } // 有 Key 但一条都没取到、且确实报错了 → 抛给上层,让用户看到 Key 无效之类的原因, // 而不是静默变成"查不到这个地名" if (!out.length && lastErr) { const err = new Error(lastErr); err.amapFatal = /INVALID_USER_KEY|USERKEY_PLAT_NOMATCH|DAILY_QUERY_OVER_LIMIT|10001|10002|10003|10009/i.test(lastErr); throw err; } return out; }, TTL.geo); } /** * 周边住宿参考:高德 POI「住宿服务」(types=100000)。 * 只用于给行程规划提供"住哪一片"的参考,房态与实时房价不存在于 POI 里, * 所以前端与提示词都必须标注"仅供参考、需自行核实"。 */ async function amapHotels(lat, lon, radiusKm, cfg) { if (!cfg.AMAP_KEY) return { hotels: [] }; return C.memo(sk(`amapHotel:${U.round(lat, 3)},${U.round(lon, 3)}:${Math.round(radiusKm)}`, cfg), async () => { const [glng, glat] = U.wgs84ToGcj02(lon, lat); const u = new URL(cfg.ENDPOINTS.amapAround); u.searchParams.set('location', `${U.round(glng, 6)},${U.round(glat, 6)}`); u.searchParams.set('types', '100000'); u.searchParams.set('radius', String(Math.min(Math.round(radiusKm * 1000), 20000))); u.searchParams.set('offset', '15'); u.searchParams.set('page', '1'); u.searchParams.set('extensions', 'all'); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 8000, 1); if (d.status !== '1') throw new Error(`高德住宿检索:${d.info || d.infocode || d.status}`); const hotels = (d.pois || []).map((p) => { const [plng, plat] = String(p.location || '').split(',').map(Number); if (!Number.isFinite(plng) || !Number.isFinite(plat)) return null; const [wlon, wlat] = U.gcj02ToWgs84(plng, plat); const biz = p.biz_ext || {}; const rating = Number(biz.rating); const cost = Number(biz.cost); return { name: String(p.name || '').slice(0, 40), address: String(p.address || '').slice(0, 60), lat: wlat, lon: wlon, distanceKm: U.round(U.haversine(lat, lon, wlat, wlon), 1), rating: Number.isFinite(rating) && rating > 0 ? rating : null, cost: Number.isFinite(cost) && cost > 0 ? cost : null, type: String(p.type || '').split(';').pop() || '住宿', }; }).filter((h) => h && h.name) .sort((a, b) => (b.rating || 0) - (a.rating || 0) || a.distanceKm - b.distanceKm) .slice(0, 8); return { hotels }; }, TTL.poi); } /** * 住宿价格的补充来源(可选)。 * 高德 POI 的 biz_ext.cost 覆盖很差,多数酒店干脆没有价格字段—— * 这不是解析问题,是数据源本身的限制。所以留一个可插拔的价格接口: * 配置 HOTEL_PRICE_API_URL 后按酒店名逐条查询并合并,取不到就保持 null, * 绝不猜价。模板占位符:{name} {lat} {lon}。 */ async function mergeHotelPrices(hotels, cfg) { const tpl = cfg.HOTEL_PRICE_API_URL; if (!tpl || !hotels.length) return { hotels, priceSource: '' }; const out = hotels.map((h) => ({ ...h })); const headers = cfg.HOTEL_PRICE_API_KEY ? { Authorization: `Bearer ${cfg.HOTEL_PRICE_API_KEY}` } : {}; let hit = 0; await U.mapLimit(out, 3, async (h) => { if (h.cost != null) return; try { const url = tpl .replace('{name}', encodeURIComponent(h.name || '')) .replace('{lat}', String(h.lat ?? '')) .replace('{lon}', String(h.lon ?? '')); const d = await U.fetchJSON(url, { headers }, 6000, 0); const list = Array.isArray(d) ? d : (Array.isArray(d?.items) ? d.items : []); let best = null; for (const it of list) { const price = Number(it.price ?? it.cost); if (!Number.isFinite(price) || price <= 0) continue; const nm = String(it.name || ''); // 名称要真的对得上,避免把别家价格套过来 if (!nm || (!nm.includes(h.name) && !h.name.includes(nm))) continue; best = price; break; } if (best != null) { h.cost = Math.round(best); hit += 1; } } catch { /* 价格源失败不影响主流程 */ } }); return { hotels: out, priceSource: hit ? '第三方价格源' : '' }; } /** * 计算静态地图的取景参数(中心点 / zoom / 尺寸 / GCJ-02 坐标)。 * 前端要用它把经纬度投影成像素,才能在图上叠加编号标记与连线。 */ function staticMapMeta(points, cfg, opt = {}) { const width = U.clamp(Number(opt.width) || 640, 320, 1024); const height = U.clamp(Number(opt.height) || 360, 200, 1024); const pts = (points || []).slice(0, 9).map((p) => { const [lng, lat] = U.wgs84ToGcj02(p.lon, p.lat); return { lng, lat }; }); if (!pts.length) return null; const lats = pts.map((p) => p.lat); const lngs = pts.map((p) => p.lng); const cLat = (Math.min(...lats) + Math.max(...lats)) / 2; const cLng = (Math.min(...lngs) + Math.max(...lngs)) / 2; const cosLat = Math.cos((cLat * Math.PI) / 180) || 1; // 横纵分别算,再取较"远"的那个:用同一个跨度套两个方向会明显过远 const spanNS = Math.max((Math.max(...lats) - Math.min(...lats)) * 110540, 300); const spanEW = Math.max((Math.max(...lngs) - Math.min(...lngs)) * 111320 * cosLat, 300); // 下限 6 m/px:行程点往往集中在城市几公里内,若按真实跨度算, // metersPerPx 会小到 2m,zoom 直接顶到 16 级,几十个点全叠成一坨, // 反而看不出线路。下限保证图上至少有约 4km 的视野。 const metersPerPx = Math.max(spanEW / (width * 0.72), spanNS / (height * 0.68), 6); const zoom = U.clamp( Math.round(Math.log2((156543.03392 * cosLat) / metersPerPx)), 4, 16, ); return { width, height, center: { lng: U.round(cLng, 6), lat: U.round(cLat, 6) }, zoom, points: pts.map((p) => [U.round(p.lng, 6), U.round(p.lat, 6)]), }; } /** * 高德静态地图 URL:只取底图。 * * 为什么不用 markers/paths: * - markers 的 size 只接受 small/mid/large(写 medium 直接 20003); * - 多组 markers(用 | 分隔)实测只渲染第一组,多目的地时图上只有一个点; * - paths 的 weight/transparency 容错也差。 * 所以这里只请求纯底图(参数最少、最稳),标记与连线由前端按 staticMapMeta 的 * 中心点/zoom 投影后叠加,编号、名称、顺序完全可控。 */ function staticMapUrl(points, cfg, opt = {}) { if (!cfg.AMAP_KEY) return null; const meta = staticMapMeta(points, cfg, opt); if (!meta) return null; // 固定 scale=1:scale=2 会改变像素与地理范围的对应关系,前端投影会算错。 // 用裸逗号拼:参数全是数字,且高德对 %2C 的处理不稳定。 return `${cfg.ENDPOINTS.amapStaticMap}?location=${meta.center.lng},${meta.center.lat}` + `&zoom=${meta.zoom}&size=${meta.width}*${meta.height}&scale=1` + `&key=${encodeURIComponent(cfg.AMAP_KEY)}`; } /** * 目的地周边的「可点名地点」:餐饮 + 景点 + 博物馆。 * 规划里不能只写"某某片区",得能点到具体店名/馆名,所以这里专门取 POI。 * 高德 place/around 支持多类型用 | 连接,一次请求拿三类。 */ async function amapNearbyPois(lat, lon, radiusKm, cfg, limit = 12) { if (!cfg.AMAP_KEY) return { food: [], sights: [], available: false, reason: '未配置高德 Key' }; return C.memo(sk(`amapNear:${U.round(lat, 3)},${U.round(lon, 3)}:${Math.round(radiusKm)}:${limit}`, cfg), async () => { const [glng, glat] = U.wgs84ToGcj02(lon, lat); const u = new URL(cfg.ENDPOINTS.amapAround); u.searchParams.set('location', `${U.round(glng, 6)},${U.round(glat, 6)}`); u.searchParams.set('radius', String(Math.min(Math.round(radiusKm * 1000), 20000))); u.searchParams.set('types', '050000|110000|140100'); u.searchParams.set('offset', '25'); u.searchParams.set('sortrule', 'weight'); u.searchParams.set('extensions', 'all'); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 10000, 1); if (d.status !== '1') throw new Error(`高德周边检索:${d.info || d.infocode || d.status}`); const food = []; const sights = []; for (const p of d.pois || []) { const [plng, plat] = String(p.location || '').split(',').map(Number); if (!Number.isFinite(plng) || !Number.isFinite(plat)) continue; const [wlon, wlat] = U.gcj02ToWgs84(plng, plat); const type = String(p.type || ''); const biz = p.biz_ext || {}; const rating = Number(biz.rating); const cost = Number(biz.cost); const item = { name: String(p.name || '').slice(0, 40), type: type.split(';').pop() || '', distanceKm: U.round(U.haversine(lat, lon, wlat, wlon), 1), rating: Number.isFinite(rating) && rating > 0 ? rating : null, cost: Number.isFinite(cost) && cost > 0 ? Math.round(cost) : null, address: String(p.address || p.adname || '').slice(0, 50), // 坐标必须留着:路线图要把方案里点名的地点落到真实位置上 lat: U.round(wlat, 5), lon: U.round(wlon, 5), city: String(p.cityname || '').trim(), district: String(p.adname || '').trim(), }; if (!item.name) continue; if (/餐饮|餐厅|美食|小吃|咖啡|茶/.test(type)) food.push(item); else sights.push(item); } const dedupe = (arr) => { const seen = new Set(); return arr .filter((x) => (seen.has(x.name) ? false : (seen.add(x.name), true))) .sort((a, b) => a.distanceKm - b.distanceKm) .slice(0, limit); }; return { food: dedupe(food), sights: dedupe(sights), available: true }; }, TTL.poi); } /** 可选:高德周边 POI(风景名胜类),需 AMAP_KEY */ /** * 高德周边 POI 检索 —— **类型词跨城检索**。 * * 为什么不能只查出发地一圈:高德 place/around 的 radius 上限 50km,返回按距离 * 排序、offset 只有 25 条。以柳州为圆心连查 6 页,150 条结果**全是柳州市区**的 * 游园、亭子、码头——50km 外一个都搜不到。这不是参数问题,是接口的覆盖边界。 * * 改为:走高德 place/text,用一组**目的地类型词**(风景区 / 古镇 / 森林公园…) * 在省级范围内检索,再按直线距离过滤。实测能拿到阳朔漓江、遇龙河、黄姚古镇、 * 德天瀑布、乐业天坑、三门海等真正的周边目的地,且每条都带 city / adname, * 正好可以按区县做「排除同城」判定。 */ /** 目的地类型词:覆盖不同类型的周边目的地,命中面广又不至于太泛 */ const SIGHT_KEYWORDS = [ '风景区', '古镇', '森林公园', '地质公园', '湿地公园', '瀑布', '溶洞', '峡谷', '梯田', '温泉', '博物馆', '古村落', '山水景区', '草原', '湖', ]; /** 高德 place/text:按类型词在给定城市范围内检索景点 */ async function amapTextSearch(keyword, cityName, cfg, offset = 12) { const u = new URL(cfg.ENDPOINTS.amapPlaceText); u.searchParams.set('keywords', keyword); if (cityName) u.searchParams.set('city', cityName); u.searchParams.set('citylimit', 'false'); u.searchParams.set('types', '110200|110100|140100'); u.searchParams.set('offset', String(offset)); u.searchParams.set('page', '1'); u.searchParams.set('extensions', 'base'); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 9000, 1); if (d.status !== '1') return []; return Array.isArray(d.pois) ? d.pois : []; } /** * 按名称定位一个地点(路线图用)。 * * 方案正文里点名的地点(如「马鞍山马尾坡野生红花林场」)不一定在候选清单里, * 但要画到地图上就必须有坐标。这里用高德 place/text 按名字检索: * - city 传目的地所在市/县,citylimit=false,跨县也能命中; * - 不限定 types,否则会漏掉餐饮、酒店、场馆等非景点类别; * - 命中多个时,优先取「名字包含查询词」且离参考点最近的,避免定位到同名的外地门店。 */ async function amapResolvePlace(name, cityName, cfg, near) { if (!cfg.AMAP_KEY) return null; const q = String(name || '').trim(); if (!q) return null; // 缓存键必须含参考点坐标:距离护栏与城市加分都依赖它, // 否则换一个目的地会复用上一个目的地的定位结果 const nearKey = near && Number.isFinite(Number(near.lat)) && Number.isFinite(Number(near.lon)) ? `${Number(near.lat).toFixed(2)},${Number(near.lon).toFixed(2)}` : ''; return C.memo(sk(`amapResolve:${q}:${cityName || ''}:${nearKey}`, cfg), async () => { const u = new URL(cfg.ENDPOINTS.amapPlaceText); u.searchParams.set('keywords', q); if (cityName) u.searchParams.set('city', cityName); u.searchParams.set('citylimit', 'false'); u.searchParams.set('offset', '8'); u.searchParams.set('page', '1'); u.searchParams.set('extensions', 'base'); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 8000, 1); if (d.status !== '1') return null; const pois = Array.isArray(d.pois) ? d.pois : []; if (!pois.length) return null; const scored = pois.map((p) => { const [lng, lat] = String(p.location || '').split(',').map(Number); if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null; const [wlon, wlat] = U.gcj02ToWgs84(lng, lat); const pn = String(p.name || ''); // 名字完全相等 > 包含查询词 > 其余 let score = pn === q ? 100 : (pn.includes(q) || q.includes(pn)) ? 60 : 20; // 城市匹配加分:查询「柳州马鞍山马尾坡」时, // 命中「柳州市」的那条才是对的,高德默认可能返回「马鞍山市」的同名 POI const pcity = String(p.cityname || '').trim(); if (cityName && pcity && (pcity === cityName || cityName.includes(pcity) || pcity.includes(cityName))) { score += 40; } let dKm = null; if (near && Number.isFinite(Number(near.lat)) && Number.isFinite(Number(near.lon))) { dKm = U.haversine(Number(near.lat), Number(near.lon), wlat, wlon); score += Math.max(0, 30 - dKm); // 同名时离参考点近的胜出 } return { name: pn, lat: U.round(wlat, 5), lon: U.round(wlon, 5), score, distanceKm: dKm == null ? null : U.round(dKm, 1), city: String(p.cityname || '').trim(), district: String(p.adname || '').trim(), address: String(p.address || '').slice(0, 60), source: '高德 POI', }; }).filter(Boolean); if (!scored.length) return null; scored.sort((a, b) => b.score - a.score); let best = scored[0]; // 距离护栏:名称里带地名(如「马鞍山马尾坡野生红花林场」)会让高德匹配到 // 同名的外地 POI,实测被定位到 1211km 外的安徽马鞍山市。 // 参考点存在时,超过 150km 一律不认——宁可返回 null 让前端标注「未能定位」, // 也不能在地图上画一个完全错误的位置。 if (near && best.distanceKm != null && best.distanceKm > 150) { const within = scored.find((x) => x.distanceKm != null && x.distanceKm <= 150); if (!within) return null; best = within; } return best; }, TTL.geo); } /** 以单点为圆心查高德 POI(补充出发地近处的点位) */ async function amapAroundOne(lng, lat, radiusM, types, offset, cfg) { const u = new URL(cfg.ENDPOINTS.amapAround); u.searchParams.set('location', `${U.round(lng, 6)},${U.round(lat, 6)}`); u.searchParams.set('radius', String(Math.min(radiusM, 50000))); u.searchParams.set('types', types); u.searchParams.set('offset', String(offset)); u.searchParams.set('page', '1'); u.searchParams.set('sortrule', 'weight'); u.searchParams.set('extensions', 'base'); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 10000, 1); if (d.status !== '1') { // 不静默吞掉:用户刚填的 Key 如果是错的,必须能在界面上看到原因 const reason = d.info || d.infocode || `status=${d.status}`; const err = new Error(`高德接口拒绝请求:${reason}`); err.amapInvalidKey = /INVALID_USER_KEY|USERKEY_PLAT_NOMATCH|DAILY_QUERY_OVER_LIMIT|10001|10002|10003|10009/.test(String(reason)); throw err; } return Array.isArray(d.pois) ? d.pois : []; } /** 高德 POI → 候选对象 */ function amapPoiToCandidate(p, origin, cfg) { const [plng, plat] = String(p.location || '').split(',').map(Number); if (!Number.isFinite(plng) || !Number.isFinite(plat)) return null; const [wlon, wlat] = U.gcj02ToWgs84(plng, plat); const name = String(p.name || '').trim(); if (!name) return null; // typecode 前 3 位决定大类,用它做 kind,比一律写「景点」准确得多 const tc = String(p.typecode || ''); const kind = tc.startsWith('1401') ? '博物馆' : tc.startsWith('1101') ? '公园' : tc.startsWith('1103') ? '自然景观' : tc.startsWith('1102') ? '风景区' : '景点'; return { id: `amap-${p.id}`, name, kind, tags: kind === '博物馆' ? ['人文历史', '室内避雨'] : kind === '公园' ? ['自然风光', '休闲', '亲子'] : kind === '自然景观' ? ['自然风光', '徒步登山', '拍照'] : ['人文历史', '拍照', '观光'], lat: U.round(wlat, 5), lon: U.round(wlon, 5), // 城市与区县分开存:排除同城要按**区县**判定, // 因为融水、三江都是「柳州市」下辖的县,它们是目的地不是同城市区 city: String(p.cityname || '').trim(), district: String(p.adname || '').trim(), region: [p.cityname, p.adname].filter(Boolean).join(' · '), distanceKm: U.haversine(origin.lat, origin.lon, wlat, wlon), bearingDeg: U.bearing(origin.lat, origin.lon, wlat, wlon), source: '高德周边检索', hasExtent: false, featured: false, }; } async function amapAround(origin, radiusKm, cfg) { if (!cfg.AMAP_KEY) return []; return C.memo(sk(`amapAround:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}:${Math.round(radiusKm)}`, cfg), async () => { // 检索范围用「省」:出发地所在省内的目的地才是「周边出行」, // 跨省的通常已超出合理半径,会被距离过滤掉 const province = cfg.AMAP_PROVINCE || ''; const raw = []; for (const kw of SIGHT_KEYWORDS) { try { const pois = await amapTextSearch(kw, province, cfg); raw.push(...pois); // 高德 QPS 限制:串行 + 间隔(实测并发 5 就会 CUQPS_HAS_EXCEEDED_THE_LIMIT) await new Promise((r) => setTimeout(r, 180)); } catch (e) { console.warn('[amapAround] keyword', kw, e.message); } } // 再补一圈出发地近处的 POI:类型词检索偏热门,近处小众点位靠这一圈补 try { const [lng, lat] = U.wgs84ToGcj02(origin.lon, origin.lat); raw.push(...await amapAroundOne(lng, lat, Math.min(Math.round(radiusKm * 1000), 50000), '110200|110101|140100|110300', 25, cfg)); } catch (e) { console.warn('[amapAround] home ring:', e.message); } const out = []; const seen = new Set(); for (const p of raw) { const c = amapPoiToCandidate(p, origin, cfg); if (!c) continue; if (JUNK_NAME.test(c.name) || POOR_NAME.test(c.name) || LOW_VALUE_NAME.test(c.name)) continue; // 「某园/某亭/某角/某店」这类是所在地的附属物,不是独立目的地 if (/(园|亭|角|门|巷|坊|社|所|部|局|铺|店|档|摊|桥|碑|塔)$/.test(c.name)) continue; // 主题乐园/动物世界这类「XX世界」多为小体量场馆,不是值得跑一趟的目的地 if (/(蛇世界|欢乐世界|动物大世界|共享农场|奇幻世界|梦幻世界|水上世界|欢乐谷|游乐园)$/.test(c.name)) continue; if (c.distanceKm > radiusKm * 1.05) continue; const gk = `${Math.round(c.lat * 100)}_${Math.round(c.lon * 100)}_${c.name}`; if (seen.has(gk)) continue; seen.add(gk); out.push(c); } return out; }, TTL.poi); } /** * 检索周边目的地(多层来源,任一不可用都不影响出结果): * Photon 分类检索 → 高德周边 POI(可选) → 内置知名景区 → 内置城市库 → 内置小众地点库 */ async function discoverCandidates(origin, radiusKm, cfg, opts = {}) { // 候选发现缓存键:起点 + 半径 + 排除同城设置。Photon 限流严重,重复分析时必须复用。 const dkey = `disc:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}:${Math.round(radiusKm)}:${opts.excludeLocal !== false}:${opts.localKm || ''}:${opts.homeDistrict || ''}:${opts.homeCity || ''}`; return C.memo(sk(dkey, cfg), async () => { const bbox = U.bboxFromRadius(origin.lat, origin.lon, radiusKm); const cityBbox = U.bboxFromRadius(origin.lat, origin.lon, Math.min(radiusKm * 1.7, cfg.SEARCH.maxRadiusKm)); let amapError = ''; const [photonResults, amapPois] = await Promise.all([ U.mapLimit(PHOTON_JOBS, cfg.SEARCH.photonConcurrency, async (job) => { const features = await photonQuery(job, job.isCity ? cityBbox : bbox, origin, cfg); return { job, features }; }), (async () => { if (!cfg.AMAP_KEY) return []; try { return await amapAround(origin, radiusKm, cfg); } catch (e) { amapError = e.message || String(e); console.warn('[amapAround]', amapError); return []; } })(), ]); const pool = []; let photonOk = 0; let categoryHits = 0; for (const r of photonResults) { if (!r) continue; photonOk += 1; if (r.features.length) categoryHits += 1; for (const f of r.features) { const c = photonCandidate(f, r.job, origin); if (!c) continue; if (r.job.isCity) { // 城市级候选:排除出发地自身的市辖区,只保留一定距离外的城市 if (c.distanceKm < 35 || /[区县乡镇街道村]$/.test(c.name)) continue; c.source = 'OpenStreetMap / Photon(城市)'; } if (c.distanceKm > radiusKm * 1.05) continue; pool.push(c); } } for (const c of amapPois || []) pool.push(c); // 内置知名目的地:既做兜底,也常年补入若干高辨识度地点 let curatedCount = 0; for (const [name, lat, lon, kind, tagStr] of CURATED) { const d = U.haversine(origin.lat, origin.lon, lat, lon); if (d > radiusKm) continue; pool.push({ id: `curated-${name}`, name, kind, tags: tagStr.split(','), lat, lon, region: '', distanceKm: d, bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), source: '内置知名目的地库', hasExtent: true, featured: true, }); curatedCount += 1; } // 内置城市库:保证全国范围内都有城市级候选 let cityCount = 0; for (const [name, lat, lon] of CN_CITIES) { const d = U.haversine(origin.lat, origin.lon, lat, lon); if (d < 30 || d > radiusKm) continue; pool.push({ id: `city-${name}`, name, kind: '城市', tags: ['休闲', '人文历史', '美食'], lat, lon, region: '', distanceKm: d, bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), source: '内置城市库', hasExtent: false, featured: false, }); cityCount += 1; } // 内置小众地点库:古村、遗址、湿地、地质景观、小馆与徒步线 // 统一带 niche 标记与「小众秘境」标签,供前端筛选与兴趣匹配 let nicheCount = 0; for (const [name, lat, lon, kind, tagStr, region] of NICHE_SPOTS) { const d = U.haversine(origin.lat, origin.lon, lat, lon); if (d > radiusKm) continue; pool.push({ id: `niche-${name}`, name, kind, tags: tagStr.split(','), region: region || '', lat, lon, distanceKm: d, bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), source: '内置小众地点库', hasExtent: false, featured: false, niche: true, }); nicheCount += 1; } // 去重(同名 / 同网格 ~1km) const seenName = new Set(); const seenGrid = new Set(); const unique = []; for (const c of pool) { const nk = U.normalizeName(c.name); const gk = `${Math.round(c.lat * 100)}_${Math.round(c.lon * 100)}`; if (seenName.has(nk) || seenGrid.has(gk)) continue; seenName.add(nk); seenGrid.add(gk); unique.push(c); } // 排除同城:填了高德 Key 后 POI 里会混入大量本市城区点位。 // 必须在轮转取样前剔除,否则它们会先占满名额,把真正「周边」的地方挤出去。 let localExcluded = 0; let filtered = unique; if (opts.excludeLocal) { const localKm = Number(opts.localKm) || 30; // 「排除同城」的语义:**排除出发地所在的城市/城区本身**, // 而不是砍掉出发地周围的整个半径。 // // 原因:柳州市下辖的融水、三江、鹿寨都在 30–100km,是最该推荐的周边目的地; // 如果按距离砍,它们会被误杀,清单里只剩市区的游园和亭子。 // 所以优先按**区县**判定——同区县(市中心城区)才排除,跨县保留。 const homeDistrict = String(opts.homeDistrict || '').trim(); const homeCity = String(opts.homeCity || '').trim(); filtered = unique.filter((c) => { // 太近的(<8km)一定是城郊/市区内部,不管区县都排除—— // 「周边出行」不该包含步行 10 分钟就能到的地方 if (c.distanceKm < 8) { localExcluded += 1; return false; } // 有区县信息:同区县 → 排除;同市不同县 → 保留(那正是周边目的地) if (homeDistrict && c.district) { if (c.district === homeDistrict) { localExcluded += 1; return false; } return true; } // 没有区县信息(内置库/Photon):退回距离判定 if (c.distanceKm < localKm) { localExcluded += 1; return false; } return true; }); } // 同类内按「知名目的地优先 → 有范围轮廓优先 → 距离近优先」排序, // 再按类别轮转取样,保证清单既多样又不被低质量 OSM 点位占满 const groups = new Map(); for (const c of filtered) { if (!groups.has(c.kind)) groups.set(c.kind, []); groups.get(c.kind).push(c); } const quality = (c) => (c.featured ? 0 : 1000) + (c.hasExtent ? 0 : 100) + c.distanceKm; for (const list of groups.values()) list.sort((a, b) => quality(a) - quality(b)); const cap = cfg.SEARCH.maxCandidates; const selected = []; let round = 0; while (selected.length < cap) { let added = false; for (const list of groups.values()) { if (list[round]) { selected.push(list[round]); added = true; if (selected.length >= cap) break; } } if (!added) break; round += 1; } // 小众地点配额:轮转取样容易让知名景区占满名额,这里保证至少约三成是冷门目的地 const nichePool = filtered.filter((c) => c.niche); const minNiche = Math.min(Math.max(6, Math.round(cap * 0.3)), nichePool.length); let nicheSelected = selected.filter((c) => c.niche).length; if (nicheSelected < minNiche) { const chosen = new Set(selected.map((c) => c.id)); const extras = nichePool.filter((c) => !chosen.has(c.id)); for (let i = selected.length - 1; i >= 0 && nicheSelected < minNiche && extras.length; i -= 1) { if (selected[i].niche) continue; selected[i] = extras.shift(); nicheSelected += 1; } } return { candidates: selected.map((c, i) => ({ ...c, index: i })), stats: { photonOk, categoryHits, curatedCount, cityCount, nicheCount, amapCount: (amapPois || []).length, amapError: amapError || '', poolSize: unique.length, localExcluded, localKm: opts.excludeLocal ? (Number(opts.localKm) || 30) : 0, requested: PHOTON_JOBS.length, fallbackOnly: photonOk === 0 && !(amapPois || []).length, }, }; }, TTL.poi); } // ================================================================== 天气 const DAILY_FIELDS = 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max'; function asArray(data) { return Array.isArray(data) ? data : [data]; } function buildDaily(one, startISO, endISO) { const d = one?.daily; if (!d || !Array.isArray(d.time)) return []; const out = []; for (let i = 0; i < d.time.length; i += 1) { const date = d.time[i]; if (date < startISO || date > endISO) continue; const code = Number(d.weather_code?.[i] ?? 0); const w = wmo(code); out.push({ date, weekday: U.weekdayCN(date), code, label: w.label, icon: w.icon, severity: w.severity, tmax: U.round(d.temperature_2m_max?.[i] ?? 0, 1), tmin: U.round(d.temperature_2m_min?.[i] ?? 0, 1), precip: U.round(d.precipitation_sum?.[i] ?? 0, 1), precipProb: U.round(d.precipitation_probability_max?.[i] ?? 0, 0), wind: U.round(d.wind_speed_10m_max?.[i] ?? 0, 1), }); } return out; } function summarizeWeather(daily) { if (!daily.length) return null; const mean = (key) => U.round(daily.reduce((s, x) => s + x[key], 0) / daily.length, 1); const max = (key) => U.round(Math.max(...daily.map((x) => x[key])), 1); return { avgHigh: mean('tmax'), avgLow: mean('tmin'), totalPrecip: U.round(daily.reduce((s, x) => s + x.precip, 0), 1), maxPrecipProb: max('precipProb'), maxWind: max('wind'), worstSeverity: Math.max(...daily.map((x) => x.severity)), dominant: daily.reduce((best, x) => (x.severity > best.severity ? x : best), daily[0]), badDays: daily.filter((x) => x.severity >= 3 || x.precipProb >= 70 || x.wind >= 40).length, days: daily.length, }; } /** * 批量获取多个坐标在日期区间内的逐日天气。 * - 16 天内:预报 API(可带 past_days 回溯) * - 超出 16 天:历史天气 API 取去年同期,标记「历史气候参考」 * - 很久以前:历史天气 API 取真实日期 */ async function fetchWeather(locations, startISO, endISO, cfg) { // 天气缓存键:所有候选点坐标 + 起止日期(预报模式与日期强相关) const wkey = `wx:${locations.map((c) => `${U.round(c.lat, 3)},${U.round(c.lon, 3)}`).join(';')}:${startISO}:${endISO}`; return C.memo(sk(wkey, cfg), async () => { const today = U.todayISO(); const endOffset = U.diffDays(today, endISO); const startOffset = U.diffDays(today, startISO); let mode = 'forecast'; let reqStart = startISO; let reqEnd = endISO; let note = 'Open-Meteo 实时预报'; if (endOffset > 15) { mode = 'climatology'; reqStart = U.addYearsISO(startISO, -1); reqEnd = U.addYearsISO(endISO, -1); note = `所选日期超出 16 天预报范围,改用去年同期(${reqStart} ~ ${reqEnd})历史气候作参考,非实时预报`; } else if (startOffset < -92) { mode = 'archive'; note = '所选日期较早,使用 Open-Meteo 历史再分析数据'; } else if (startOffset < 0) { mode = 'forecast-past'; note = '包含已过去日期,使用 Open-Meteo 预报回溯(past_days)数据'; } const lats = locations.map((c) => U.round(c.lat, 4)).join(','); const lons = locations.map((c) => U.round(c.lon, 4)).join(','); const chunks = U.chunk(locations.map((_, i) => i), 25); const series = new Array(locations.length).fill(null); for (const idxChunk of chunks) { const subLats = idxChunk.map((i) => U.round(locations[i].lat, 4)).join(','); const subLons = idxChunk.map((i) => U.round(locations[i].lon, 4)).join(','); let u; if (mode === 'climatology' || mode === 'archive') { u = new URL(cfg.ENDPOINTS.archive); u.searchParams.set('start_date', reqStart); u.searchParams.set('end_date', reqEnd); u.searchParams.set('daily', DAILY_FIELDS.replace(',precipitation_probability_max', '')); u.searchParams.set('timezone', 'auto'); } else { u = new URL(cfg.ENDPOINTS.forecast); u.searchParams.set('daily', DAILY_FIELDS); u.searchParams.set('timezone', 'auto'); u.searchParams.set('forecast_days', '16'); if (startOffset < 0) u.searchParams.set('past_days', String(Math.min(92, -startOffset))); } u.searchParams.set('latitude', subLats); u.searchParams.set('longitude', subLons); let data; try { data = await U.fetchJSON(u.toString(), {}, cfg.SEARCH.requestTimeoutMs); } catch (err) { console.warn('[weather] batch failed:', err.message); continue; } const arr = asArray(data); // 预报/历史 API 对单坐标返回对象、对多坐标返回数组;这里按 chunk 顺序对齐 idxChunk.forEach((globalIdx, localIdx) => { const one = arr.length === idxChunk.length ? arr[localIdx] : arr[0]; const daily = buildDaily(one, mode === 'climatology' ? reqStart : startISO, mode === 'climatology' ? reqEnd : endISO); if (mode === 'climatology') { // 把去年日期映射回用户所选日期 const wanted = U.eachDate(startISO, endISO); daily.forEach((d, i) => { if (wanted[i]) { d.date = wanted[i]; d.weekday = U.weekdayCN(wanted[i]); } }); } series[globalIdx] = { daily, summary: summarizeWeather(daily) }; }); } return { series, mode, note, lats, lons }; }, TTL.weather); } /** 空气质量(美标 AQI),best-effort,失败返回 null */ async function fetchAQI(locations, startISO, endISO, cfg) { const akey = `aqi:${locations.map((c) => `${U.round(c.lat, 3)},${U.round(c.lon, 3)}`).join(';')}:${startISO}:${endISO}`; return C.memo(sk(akey, cfg), async () => { const today = U.todayISO(); if (U.diffDays(today, endISO) > 6 || U.diffDays(today, startISO) < -6) { return { values: locations.map(() => null), note: '所选日期超出空气质量预报范围,AQI 不可用' }; } const values = new Array(locations.length).fill(null); const chunks = U.chunk(locations.map((_, i) => i), 25); for (const idxChunk of chunks) { const u = new URL(cfg.ENDPOINTS.airQuality); u.searchParams.set('latitude', idxChunk.map((i) => U.round(locations[i].lat, 4)).join(',')); u.searchParams.set('longitude', idxChunk.map((i) => U.round(locations[i].lon, 4)).join(',')); u.searchParams.set('hourly', 'us_aqi'); u.searchParams.set('timezone', 'auto'); u.searchParams.set('forecast_days', '7'); try { const data = await U.fetchJSON(u.toString(), {}, 12000); const arr = asArray(data); idxChunk.forEach((gi, li) => { const one = arr.length === idxChunk.length ? arr[li] : arr[0]; const times = one?.hourly?.time || []; const aqis = one?.hourly?.us_aqi || []; const picks = []; times.forEach((t, k) => { const date = String(t).slice(0, 10); if (date >= startISO && date <= endISO && Number.isFinite(aqis[k])) picks.push(aqis[k]); }); if (picks.length) values[gi] = U.round(Math.max(...picks), 0); }); } catch (err) { console.warn('[aqi]', err.message); } } return { values, note: 'Open-Meteo 空气质量预报(美标 AQI,取区间内最高值)' }; }, TTL.weather); } function aqiLabel(v) { if (v == null) return null; if (v <= 50) return { label: '优', level: 'good' }; if (v <= 100) return { label: '良', level: 'ok' }; if (v <= 150) return { label: '轻度污染', level: 'warn' }; if (v <= 200) return { label: '中度污染', level: 'warn' }; if (v <= 300) return { label: '重度污染', level: 'bad' }; return { label: '严重污染', level: 'bad' }; } // ================================================================== 公路 function estimateRoad(origin, dest) { const straight = U.haversine(origin.lat, origin.lon, dest.lat, dest.lon); const distanceKm = U.round(straight * 1.35, 1); const durationMinutes = U.round((distanceKm / 62) * 60 + 12, 0); return { durationMinutes, distanceKm, source: '估算', note: '按直线距离×1.35、均速 62km/h 估算,路网服务不可用时兜底' }; } async function amapRoad(origin, dest, cfg) { const [olng, olat] = U.wgs84ToGcj02(origin.lon, origin.lat); const [dlng, dlat] = U.wgs84ToGcj02(dest.lon, dest.lat); const u = new URL(cfg.ENDPOINTS.amapDriving); u.searchParams.set('origin', `${U.round(olng, 6)},${U.round(olat, 6)}`); u.searchParams.set('destination', `${U.round(dlng, 6)},${U.round(dlat, 6)}`); u.searchParams.set('extensions', 'base'); u.searchParams.set('output', 'json'); u.searchParams.set('key', cfg.AMAP_KEY); const d = await U.fetchJSON(u.toString(), {}, 10000); if (d.status !== '1' || !d.route?.paths?.[0]) throw new Error(d.info || '高德无结果'); const p = d.route.paths[0]; return { durationMinutes: U.round(Number(p.duration) / 60, 0), distanceKm: U.round(Number(p.distance) / 1000, 1), source: '高德实时路网', note: '高德 Web 服务驾车路径规划(已做 WGS-84→GCJ-02 坐标转换)', }; } async function roadMatrix(origin, destinations, cfg) { // 路网缓存键:起点 + 全部终点坐标。路况按小时更新,3 小时 TTL。 const rkey = `road:${U.round(origin.lat, 4)},${U.round(origin.lon, 4)}:${destinations.map((d) => `${U.round(d.lat, 4)},${U.round(d.lon, 4)}`).join(';')}`; return C.memo(sk(rkey, cfg), async () => { const out = new Array(destinations.length).fill(null); if (cfg.AMAP_KEY) { const res = await U.mapLimit(destinations, 5, (d) => U.attempt('amapRoad', () => amapRoad(origin, d, cfg), null)); res.forEach((r, i) => { out[i] = r; }); } // 没配高德、或高德部分/全部失败时,用 OSRM 补齐缺口—— // 而不是因为"配了一个无效 Key"就直接退化成纯估算 const missing = []; out.forEach((r, i) => { if (!r) missing.push(i); }); if (missing.length) { const targets = missing.map((i) => destinations[i]); const coords = [origin, ...targets] .map((c) => `${U.round(c.lon, 5)},${U.round(c.lat, 5)}`) .join(';'); const u = `${cfg.ENDPOINTS.osrmTable}/${coords}?sources=0&annotations=duration,distance`; try { const d = await U.fetchJSON(u, {}, cfg.SEARCH.osrmTimeoutMs); if (d.code !== 'Ok') throw new Error(`OSRM ${d.code}`); const durations = d.durations?.[0] || []; const distances = d.distances?.[0] || []; missing.forEach((idx, k) => { const sec = durations[k + 1]; const meter = distances[k + 1]; if (Number.isFinite(sec) && Number.isFinite(meter)) { out[idx] = { durationMinutes: U.round(sec / 60, 0), distanceKm: U.round(meter / 1000, 1), source: 'OSRM 路网', note: 'OSRM 公开路网服务计算的驾车时长与里程', }; } }); } catch (err) { console.warn('[road] OSRM failed:', err.message); } } for (let i = 0; i < out.length; i += 1) { if (!out[i]) out[i] = estimateRoad(origin, destinations[i]); } // provider 必须反映「实际拿到的数据」,不能只看有没有配 Key const hasAmap = out.some((r) => /高德/.test(r.source)); const hasOsrm = out.some((r) => r.source === 'OSRM 路网'); return { matrix: out, provider: hasAmap ? '高德' : (hasOsrm ? 'OSRM' : '估算'), }; }, TTL.road); } // ================================================================== 铁路 function estimateRail(origin, dest) { const straight = U.haversine(origin.lat, origin.lon, dest.lat, dest.lon); if (straight < 25) { return { available: false, durationMinutes: null, distanceKm: U.round(straight, 1), fareCny: null, source: '估算', note: '距离过近,通常无城际铁路直达,建议自驾/市内公共交通;末端接驳未计入', }; } const railKm = U.round(straight * 1.18, 1); const speed = straight > 400 ? 250 : straight > 150 ? 200 : straight > 70 ? 120 : 75; const overhead = straight > 150 ? 55 : 40; const durationMinutes = U.round((railKm / speed) * 60 + overhead, 0); return { available: true, durationMinutes, distanceKm: railKm, fareCny: U.round(railKm * 0.46, 0), source: '估算', note: '按里程×1.18、分档均速 + 进出站候车时间估算,非真实车次;车次/余票请在 12306 核对,景区末端接驳另计', }; } async function railFor(origin, dest, dateISO, cfg) { if (!cfg.RAIL_API_URL) return estimateRail(origin, dest); const url = cfg.RAIL_API_URL .replace('{fromLat}', origin.lat).replace('{fromLon}', origin.lon) .replace('{toLat}', dest.lat).replace('{toLon}', dest.lon) .replace('{date}', dateISO) .replace('{fromName}', encodeURIComponent(origin.name || '')) .replace('{toName}', encodeURIComponent(dest.name || '')); try { const d = await U.fetchJSON(url, { headers: cfg.RAIL_API_KEY ? { Authorization: `Bearer ${cfg.RAIL_API_KEY}`, 'X-Api-Key': cfg.RAIL_API_KEY } : {}, }, 10000); if (typeof d.durationMinutes !== 'number') throw new Error('铁路接口返回缺少 durationMinutes'); return { available: d.available !== false, durationMinutes: U.round(d.durationMinutes, 0), distanceKm: U.round(d.distanceKm ?? 0, 1), fareCny: d.fareCny == null ? null : U.round(d.fareCny, 0), source: '铁路接口', note: d.note || '来自配置的铁路接口', }; } catch (err) { console.warn('[rail] custom API failed:', err.message); return { ...estimateRail(origin, dest), note: `铁路接口调用失败,已降级为估算:${err.message}` }; } } async function railMatrix(origin, destinations, dateISO, cfg) { const key = `rail:${U.round(origin.lat, 4)},${U.round(origin.lon, 4)}:${destinations.map((d) => `${U.round(d.lat, 4)},${U.round(d.lon, 4)}`).join(';')}:${dateISO}`; return C.memo(sk(key, cfg), async () => { const res = await U.mapLimit(destinations, 6, (d) => railFor(origin, d, dateISO, cfg)); return res.map((r, i) => r || estimateRail(origin, destinations[i])); }, TTL.road); } /** 在 16 天预报里找一段天气最舒服的连续窗口,用于给表单推荐默认日期 */ async function bestWeatherWindow(lat, lon, days, cfg) { const u = new URL(cfg.ENDPOINTS.forecast); u.searchParams.set('latitude', String(U.round(lat, 4))); u.searchParams.set('longitude', String(U.round(lon, 4))); u.searchParams.set('daily', 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max'); u.searchParams.set('timezone', 'auto'); u.searchParams.set('forecast_days', '16'); const d = await U.fetchJSON(u.toString(), {}, 10000); const daily = d.daily; if (!daily?.time?.length) return null; const n = Math.min(Math.max(1, days), 5, daily.time.length); let best = null; for (let i = 0; i + n <= daily.time.length; i += 1) { let score = 0; const labels = []; for (let k = 0; k < n; k += 1) { const w = wmo(daily.weather_code?.[i + k] ?? 0); const prob = daily.precipitation_probability_max?.[i + k] ?? 0; const wind = daily.wind_speed_10m_max?.[i + k] ?? 0; const tmax = daily.temperature_2m_max?.[i + k] ?? 20; score -= prob * 0.45 + w.severity * 9 + Math.max(0, wind - 30) * 0.35 + Math.max(0, tmax - 30) * 1.6 + Math.max(0, 8 - tmax) * 1.1; const wd = new Date(`${daily.time[i + k]}T00:00:00Z`).getUTCDay(); if (wd === 0 || wd === 6) score += 7; // 周末轻微加成 labels.push(w.label); } if (!best || score > best.score) { best = { score: U.round(score, 1), start: daily.time[i], end: daily.time[i + n - 1], labels }; } } if (!best) return null; return { ...best, note: `未来 16 天里天气最平稳的 ${n} 天窗口(${[...new Set(best.labels)].join('、')}),可点击应用`, }; } module.exports = { geocode, amapGeocode, amapHotels, mergeHotelPrices, staticMapUrl, staticMapMeta, amapNearbyPois, reverseGeocode, amapReverse, discoverCandidates, bestWeatherWindow, fetchWeather, fetchAQI, aqiLabel, roadMatrix, railMatrix, estimateRoad, estimateRail, amapResolvePlace, };