'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'); const ADMIN = require('./places-admin'); const DV = require('./places-admin-dv'); // 数据源层缓存 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; // 纯外文的条目(如「Rice Terraces」)对中文用户没有辨识度, // 而且 OSM 上外国景点会混进来占名额。名称里至少要有一个汉字。 if (!/[\u4e00-\u9fa5]/.test(name)) 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), // OSM 中国的层级命名与直觉相反:county 是地级市/州,city 是县/区, // district 是街道,locality 是村。分组必须用 county,取 district 会分出「玉屏街道」这种组。 osmCounty: String(p.county || '').trim(), osmCity: String(p.city || '').trim(), osmDistrict: String(p.district || '').trim(), region: [p.county, p.city, p.district].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); } /** * Overpass 兜底检索(免密钥)。 * * 为什么需要:Photon 公共实例限流或临时挂掉时,候选池会掉到只剩内置库—— * 而内置库在广西、云南这类省的覆盖很薄(150km 内常常只有 1~3 个点), * 第二步就几乎没内容可分。Overpass 直接查 OSM 的原始标签,是同源数据但不同入口。 * * 代价:单次查询要 20~120 秒,所以**只在 Photon 全部失败时启用**,且走独立缓存。 */ const OVERPASS_TAGS = [ { sel: 'node["tourism"="attraction"]', kind: '景点', tags: ['人文历史', '拍照', '观光'] }, { sel: 'node["tourism"="viewpoint"]', kind: '观景地', tags: ['自然风光', '拍照', '徒步登山'] }, { sel: 'node["tourism"="museum"]', kind: '博物馆', tags: ['人文历史', '亲子', '室内避雨'] }, { sel: 'way["tourism"="attraction"]', kind: '景点', tags: ['人文历史', '拍照', '观光'] }, { sel: 'node["natural"="cave_entrance"]', kind: '溶洞', tags: ['自然风光', '探险', '避暑'] }, { sel: 'node["historic"="memorial"]', kind: '纪念地', tags: ['人文历史', '室内避雨'] }, { sel: 'node["leisure"="nature_reserve"]', kind: '自然保护地', tags: ['自然风光', '徒步登山'] }, { sel: 'node["tourism"="theme_park"]', kind: '主题乐园', tags: ['亲子', '主题乐园'] }, ]; async function overpassQuery(origin, radiusKm, cfg) { // bboxFromRadius 返回 [west, south, east, north](Photon 的顺序), // 而 Overpass 要 [south, west, north, east] —— 顺序错了直接 400。 const [w, s, e, n] = U.bboxFromRadius(origin.lat, origin.lon, radiusKm); const body = [s, w, n, e].map((x) => x.toFixed(4)).join(','); const sel = OVERPASS_TAGS.map((t) => `${t.sel}(${body});`).join(''); const q = `[out:json][timeout:50];(${sel});out center 120;`; // Overpass 公共实例对大 bbox + 多标签容易 502/504,换几个镜像重试 const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass]; const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000; // 单次查询的总时限:镜像可能有 5 个,逐个等超时最坏要 5×timeout。 // 给一份总预算,超了就别再试下一个镜像了。 const deadline = Date.now() + timeoutMs * 2; let lastErr = ''; for (const host of hosts) { if (Date.now() > deadline) { lastErr = lastErr || 'Overpass 超出总时限'; break; } try { const res = await fetch(host, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' }, body: 'data=' + encodeURIComponent(q), signal: AbortSignal.timeout(timeoutMs), }); const text = await res.text(); // 实例可能返回 200 + HTML 错误页(如 osm.ch 的 duplicate_query), // 只看 status 会把错误页当成功,判据必须是响应体是否以 '{' 开头。 if (!text.startsWith('{')) throw new Error(`${host} 返回 ${res.status}`); return JSON.parse(text).elements || []; } catch (e) { lastErr = e.message || String(e); console.warn('[overpass]', lastErr); } } throw new Error(lastErr || 'Overpass 全部镜像均失败'); } /** * Overpass 的兴趣点大多**没有** `addr:*` 标签,直接拿归属会得到空组名。 * * 解法:用 Overpass 的 `is_in` 反查**点落在哪些行政区里**。 * 一次请求就能拿到完整层级(实测 25.1,107.98 → L6 环江毛南族自治县 / L5 河池市 / L4 广西)。 * * 为什么不用「拉边界多边形再本地做点面判定」: * relation 的几何在 `members` 里,`out tags geom` / `out geom` 都拿不到 * relation 级的 geometry 数组;实测 `out tags geom` 返回的 1064 条元素 * 只有 type/id/bounds/tags,geometry 为 undefined。绕这一圈代价太高。 * * 层级:OSM 中国的 `admin_level` 是 **4=省 / 5=地级市 / 6=县**, * 不是直觉上的 4/6/8(实测荔波县是 6)。 */ async function overpassAdminAreas(origin, radiusKm, cfg) { // 保留旧签名:这里改为「一次拉 bbox 内所有行政区 + 打点标记」,不再逐点查 const [w, s, e, n] = U.bboxFromRadius(origin.lat, origin.lon, radiusKm); const body = [s, w, n, e].map((x) => x.toFixed(4)).join(','); const q = `[out:json][timeout:60];rel["admin_level"~"^[56]$"](${body});out center tags;`; const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass]; const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000; const deadline = Date.now() + timeoutMs * 2; let lastErr = ''; for (const host of hosts) { if (Date.now() > deadline) { lastErr = lastErr || 'Overpass 超出总时限'; break; } try { const res = await fetch(host, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' }, body: 'data=' + encodeURIComponent(q), signal: AbortSignal.timeout(timeoutMs), }); const text = await res.text(); if (!text.startsWith('{')) throw new Error(`${host} 返回 ${res.status}`); const els = JSON.parse(text).elements || []; const areas = []; for (const el of els) { const name = String(el.tags?.['name:zh'] || el.tags?.name || '').trim(); if (!name) continue; const c = el.center || {}; const lat = Number(c.lat); const lon = Number(c.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; areas.push({ name, level: String(el.tags.admin_level), lat, lon }); } return areas; } catch (e) { lastErr = e.message || String(e); console.warn('[overpass-admin]', lastErr); } } throw new Error(lastErr || 'Overpass 行政区查询失败'); } /** * 逐点反查所在行政区(Overpass `is_in`)。 * 只在 Overpass 兜底路径上、且候选缺少 addr:* 标签时用(一次约 1~3 秒)。 * @returns {{ area: string, detail: string } | null} */ async function overpassAdminAtPoint(lat, lon, cfg) { const q = `[out:json][timeout:60];is_in(${lat.toFixed(5)},${lon.toFixed(5)})->.a;.a out tags;`; const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass]; const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000; for (const host of hosts) { try { const res = await fetch(host, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' }, body: 'data=' + encodeURIComponent(q), signal: AbortSignal.timeout(timeoutMs), }); const text = await res.text(); if (!text.startsWith('{')) continue; const els = JSON.parse(text).elements || []; let prefecture = ''; let county = ''; for (const el of els) { const level = String(el.tags?.admin_level || ''); const name = String(el.tags?.['name:zh'] || el.tags?.name || '').trim(); if (!name) continue; if (level === '5') prefecture = name; else if (level === '6') county = name; } if (!prefecture && !county) continue; // 走统一查表:OSM 的名字可能是简称(「环江」)或全称(「环江毛南族自治县」), // 查表能补齐标准全称并带上所属地级;同名县还要按坐标消歧。 const cHit = county ? lookupAdminByName(county, lat, lon) : null; if (cHit && cHit.detail) return { area: cHit.area, detail: cHit.detail }; const pHit = prefecture ? lookupAdminByName(prefecture, lat, lon) : null; if (pHit) return { area: pHit.area, detail: pHit.detail || '' }; // 查表查不到就原样返回 OSM 名字,至少还有个能显示的地名 return { area: prefecture || county, detail: county && county !== prefecture ? county : '' }; } catch (e) { console.warn('[overpass-isin]', e.message || e); } } return null; } const isPrefectureSuffix = (n) => /(市|州|盟|地区)$/.test(String(n || '').trim()); const isCountySuffix = (n) => /(县|区|旗)$/.test(String(n || '').trim()); /** * 按坐标找所属行政区(Overpass 兜底路径专用)。 * * 输入是 bbox 内所有行政区 relation 的 **center 点**(不是多边形—— * relation 级 geometry 在 Overpass 里拿不到,见 overpassAdminAreas 注释)。 * 所以判定退化��「找最近的同层级行政区」,并用 admin_level 区分地级/县级: * L5=地级市、L6=县。 * * @returns {{ area: string, detail: string } | null} */ function areaByPoint(areas, lat, lon) { let prefecture = null; let county = null; for (const a of areas) { const d = U.haversine(lat, lon, a.lat, a.lon); if (a.level === '5' || isPrefectureSuffix(a.name)) { if (!prefecture || d < prefecture.d) prefecture = { name: a.name, d }; } else if (a.level === '6' || isCountySuffix(a.name)) { if (!county || d < county.d) county = { name: a.name, d }; } } // 近邻判定给的是 OSM 的名字(可能是简称),过一遍统一查表补全标准全称 if (county) { const hit = lookupAdminByName(county.name, lat, lon); if (hit && hit.detail) return { area: hit.area, detail: hit.detail }; } if (prefecture) { const hit = lookupAdminByName(prefecture.name, lat, lon); if (hit) return { area: hit.area, detail: hit.detail || (county ? county.name : '') }; } if (prefecture) return { area: prefecture.name, detail: county ? county.name : '' }; if (county) return { area: county.name, detail: '' }; return null; } function overpassCandidate(el, origin, cfg) { const t = el.tags || {}; const name = String(t['name:zh'] || t.name || '').trim(); if (!name) return null; const lat = Number(el.lat ?? el.center?.lat); const lon = Number(el.lon ?? el.center?.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; // 按标签找类别 const tag = OVERPASS_TAGS.find((x) => x.sel.includes(`"${t.tourism || t.natural || t.historic || t.leisure}"`)); const kind = tag?.kind || '景点'; const tags = tag?.tags || ['自然风光', '拍照']; const distanceKm = U.haversine(origin.lat, origin.lon, lat, lon); return { id: `overpass-${el.type}-${el.id}`, name, kind, tags: [...tags], lat: U.round(lat, 5), lon: U.round(lon, 5), osmCounty: String(t['addr:county'] || t['addr:city'] || t['addr:province'] || '').trim(), osmCity: String(t['addr:district'] || t['addr:city'] || '').trim(), region: [t['addr:province'], t['addr:city'], t['addr:district']].filter(Boolean).join(' · '), distanceKm, bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), source: 'OpenStreetMap / Overpass', hasExtent: false, featured: false, }; } /** * 检索周边目的地(多层来源,任一不可用都不影响出结果): * Photon 分类检索 → 高德周边 POI(可选) → 内置知名景区 → 内置城市库 → 内置小众地点库 */ /** * 候选的归属地名(第二步分组用)。 * * 问题:候选来自四个来源,归属信息粒度完全不一致—— * 高德 place/text → 有 cityname(地级市)与 adname(区县) * 内置小众库 → region 是「省 · 市」 * 内置知名库 → 没有任何归属信息 * Photon(OSM) → region 是街道名(「金城江街道」) * 直接把这些 region 铺在卡片上,用户看到的就是一堆平铺的名字, * 分不清「柳州」和「柳州古城」是两级还是并列。 * * 做法:统一反查成「地级市 + 区县」,作为分组键。 * 高德 regeo 的批量参数(batch=true)实测返回 INVALID_PARAMS, * 所以逐点反查 + 缓存(TTL.geo,坐标取整 3 位做指纹)。 */ async function amapPlaceOf(lat, lon, cfg) { if (!cfg.AMAP_KEY) return null; const [glng, glat] = U.wgs84ToGcj02(lon, lat); return C.memo(sk(`placeOf:${U.round(lat, 3)},${U.round(lon, 3)}`, cfg), async () => { 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 province = String(ac.province || '').trim(); const city = String(ac.city || '').trim(); const district = String(ac.district || '').trim(); if (!city && !district) return null; return { province, city: city || district, district: city && district ? district : '', label: district && district !== city ? `${city} · ${district}` : city }; }, TTL.geo); } /** * 分组键归一:去掉末尾的「市 / 地区 / 盟 / 自治州」等行政后缀。 * 「柳州」(内置小众库写法)与「柳州市」(高德 cityname)必须落到同一组, * 否则同一个地方会被拆成两堆。 * * 民族自治州要连民族名一起去掉:「黔南布依族苗族自治州」→「黔南」, * 「黔东南苗族侗族自治州」→「黔东南」。但**不能用贪婪正则**—— * `[一-龥]{1,3}族` 会把「黔南」的「南」也当成民族名吃掉(实测变成「黔」)。 * 所以这里用显式的民族名清单。 */ const ETHNIC_AUTONOMY = /(?:壮族|满族|回族|苗族|侗族|土家族|羌族|彝族|蒙古族|藏族|维吾尔族|哈萨克族|柯尔克孜族|布依族|朝鲜族|白族|傣族|黎族|傈僳族|佤族|畲族|高山族|拉祜族|水族|东乡族|纳西族|景颇族|土族|达斡尔族|仫佬族|羌族|布朗族|撒拉族|毛南族|仡佬族|锡伯族|阿昌族|普米族|塔吉克族|怒族|乌孜别克族|俄罗斯族|鄂温克族|德昂族|保安族|裕固族|京族|塔塔尔族|独龙族|鄂伦春族|赫哲族|门巴族|珞巴族|基诺族)+自治(?:州|县)/g; function placeKey(city) { return String(city || '').trim() .replace(ETHNIC_AUTONOMY, '') .replace(/(自治区|自治州|自治县|地区|盟|市)$/g, '') .trim(); } /** * 给候选补 place / placeKey / placeDetail: * place 分组显示名(地级市/州,如「黔南州」「柳州市」) * placeKey 分组键(归一后的地名,用于合并不同写法) * placeDetail 更细一级的区县说明 * * 分组名必须落在**地级**这一层,不能是街道或村名。 * 用户看到「玉屏街道」「乐依村」这种组名等于没分组——它是 OSM 的最细一级, * 同一个县下面的几十个点会被拆成几十组。 */ /** * 查内置地点库(知名景区 / 小众地点)的离线归属地名。 * 数据由 tools/build-admin*.cjs 生成:Photon 按名检索 + 坐标距离消歧 + 归一到地级。 * * detail 只保留**县级**(以「县/区/市/州/旗」结尾): * Photon 有时把村名(「摆别村」)塞在这一级,展示出来反而更乱。 */ function adminAreaOf(c) { const table = ADMIN.SPOT_AREA; if (!table) return null; const key = c.id && c.id.startsWith('curated-') ? `scenic:${c.id.slice('curated-'.length)}` : (c.id && c.id.startsWith('niche-') ? `niche:${c.id.slice('niche-'.length)}` : null); if (!key) return null; const hit = table[key]; if (!hit || !hit.area) return null; const detail = /(县|区|市|州|旗|盟)$/.test(String(hit.detail || '').trim()) ? hit.detail : ''; return { area: hit.area, detail }; } /** * 就近最近的地级市/州:全国地级坐标表 + 内置城市库 + 离线归属表里的地名坐标。 * 用 haversine 在本地算,不发请求(几十个点 × 几百个地名的规模完全够用)。 * * 只认**地级**名字(以「市/州/盟/地区」结尾)。 * 县级名(从江县、吉县)当组名会让同一个地级市被拆成好几组, * 也会出现「荔波小七孔归到隔壁的从江县」这种误导。 * * 全国表(lib/places-admin-dv.js,343 个地级)是主力;CN_CITIES 只有 146 个, * 桂黔边境(河池市、黔南州、黔东南州)根本不在里面,缺了它候选点会落进空组名。 */ function isPrefectureName(name) { return /(市|州|盟|地区)$/.test(String(name || '').trim()); } /** * 「下层地名」是否合格:必须是县级(县/区/旗/市)或地级,不能是村/街道/屯。 * Photon 的 city/district 字段层级不稳,会给出「乐依村」「双福」「陈家甫」这种 * 村名甚至门牌名——挂在分组标题下当区县完全没法看。 */ function isDistrictName(name) { return /(县|区|旗|盟|州|市)$/.test(String(name || '').trim()); } /** * 这个名字是不是**已知的地级行政区**。 * * 用于过滤 Photon `osm.place.city` 检索里的噪声:OSM 的 place=city 里 * 混着大量景点 POI(牌坊、寺庙、老街),它们的名字也带「市/州」后缀 * 会被误当行政区。查一下官方表就知道是不是真地名。 */ function isKnownPrefecture(name) { const raw = String(name || '').trim(); if (!raw) return false; if (DV.PREF_DV[raw]) return true; const alias = DV.PREF_ALIAS[raw]; return !!(alias && DV.PREF_DV[alias]); } function nearestArea(lat, lon) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; let best = null; const scan = (name, coord) => { if (!coord || !isPrefectureName(name)) return; const d = U.haversine(lat, lon, coord[0], coord[1]); if (!best || d < best.d) best = { area: name, d }; }; // DataV 官方地级表(343 个,含各族自治州简称)优先 for (const [name, coord] of Object.entries(DV.PREF_DV || {})) scan(name, coord); for (const [name, la, lo] of CN_CITIES) scan(name, [la, lo]); for (const [name, coord] of Object.entries(ADMIN.AREA_COORD || {})) scan(name, coord); // 最近的超过 120km 基本就不是「这一片」,宁可留空也不要给出误导性的地名 return best && best.d <= 120 ? { area: best.area } : null; } /** * 从离线表里取一个县的条目,同名县按距离选最近的。 * * DataV 官方表里同名县保留为**数组**(全国 30 组:「城中区」柳州/西宁都有、 * 「鼓楼区」南京/徐州/福州/开封都有、「铁西区」沈阳/鞍山/四平都有)。 * 只按名字取会把柳州的点归到西宁去,所以必须带坐标做消歧。 * * @param {string} name 县全称(表里的 key) * @param {number} lat 候选点纬度 * @param {number} lon 候选点经度 * @returns {[number, number, string, string] | null} [纬度, 经度, 所属地级, adcode] */ function pickCounty(name, lat, lon) { const v = DV.COUNTY_DV[name]; if (!v) return null; const list = (Array.isArray(v) && Array.isArray(v[0])) ? v : [v]; if (list.length === 1) return list[0]; let best = null; for (const c of list) { const d = U.haversine(lat, lon, c[0], c[1]); if (!best || d < best.d) best = { c, d }; } return best ? best.c : null; } /** * 就近最近的县级行政区(DataV 官方县级表)。 * 县比地级小得多,所以「最近县」比「最近地级」精确得多——桂黔边境 * 荔波县与河池市相距不到 40km,按地级中心点会分错,按县不会。 * * 注意:这是**兜底**路径,主力是按名字精确查(lookupAdminByName)。 * 按中心点距离在狭长县上仍会判错(环江县中心点远在贵州方向)。 */ function nearestCounty(lat, lon) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; let best = null; for (const [name, coord] of Object.entries(DV.COUNTY_DV || {})) { if (!coord) continue; // 同名县在表里是数组(每个候选各自有中心点),要展平后逐个比距离 const list = (Array.isArray(coord) && Array.isArray(coord[0])) ? coord : [coord]; for (const c of list) { if (!c) continue; const d = U.haversine(lat, lon, c[0], c[1]); if (!best || d < best.d) best = { county: name, d, prefecture: c[2] || '' }; } } // 60km 外基本就不是「这一片」了,县级行政区一般也就横跨 30~60km return best && best.d <= 60 ? best : null; } /** * 由「最近县」推导所属地级。 * 优先用 GB/T 2260 编码前缀(县级表第三项,已离线算好), * 没有编码时退回「按坐标找最近地级」。 */ function prefectureOfCounty(countyHit, lat, lon) { if (countyHit && countyHit.prefecture) return countyHit.prefecture; const near = nearestArea(lat, lon); return near ? near.area : ''; } /** * 按**名称**精确查行政区(不靠距离)。 * * 这是归属判定的主力路径:OSM 的 `addr:county` / Photon 的 `county` * 有时直接就是标准县名(「环江毛南族自治县」「大化瑶族自治县」), * 此时按名字查官方表是 100% 准确的,比「按中心点找最近的县」可靠得多 * (桂黔边境的狭长县,中心点距离会判到隔壁省去)。 * * **必须带距离校验**:全国有同名县(「城中区」柳州西宁都有、 * 「鼓楼区」福州南京都有),只看名字会把柳州的点归到西宁去。 * 查到的县离候选点太远(>80km)就判定为不可信,退回按坐标近邻。 * * @param {number} lat 候选点纬度 * @param {number} lon 候选点经度 * @returns {{ area: string, detail: string } | null} */ function lookupAdminByName(name, lat, lon) { const raw = String(name || '').trim(); if (!raw) return null; // 县级:全称 → 别名 const isExact = !!DV.COUNTY_DV[raw]; const full = isExact ? raw : (DV.COUNTY_ALIAS[raw] || null); if (full && DV.COUNTY_DV[full]) { // 同名县在表里是数组,按坐标取最近的那个(否则柳州的「城中区」会落到西宁) const c = pickCounty(full, lat, lon); if (!c) return null; // 距离阈值分两档: // 精确全称命中(OSM 明确写了这个县名)→ 可信度高,放宽到 150km。 // 狭长县的中心点离边界可达 80km 以上(大化县中心在西北角, // 东南角的响水关离中心 85km),按 80km 一刀切会把正确的县名丢掉。 // 别名/简称命中 → 只说明大致是这片地方,严格一点(80km)。 const limitKm = (full === raw) ? 150 : 80; if (tooFar(lat, lon, c, limitKm)) return null; // 同名县但离得远 → 不可信 // 同名县组(如「城中区」有 2 个候选)必须选**距离最近**的那个: // pickCounty 已经按距离挑了,但还要确认它明显比同组其他候选更近, // 否则说明 OSM 这个名字可能是别名/误填。 const list = DV.COUNTY_DV[full]; if (Array.isArray(list) && Array.isArray(list[0])) { const sorted = list .map((x) => ({ x, d: U.haversine(lat, lon, x[0], x[1]) })) .sort((a, b) => a.d - b.d); // 最近的这个比第二名远 1.5 倍以上 → 判为误匹配 if (sorted[1] && sorted[0].d > sorted[1].d * 1.5 && sorted[0].d > 30) return null; } return { area: c[2], detail: full }; } // 名字本身是地级(OSM 常常把地级填进 county 字段) const pref = DV.PREF_DV[raw] ? raw : (DV.PREF_ALIAS[raw] || null); if (pref && DV.PREF_DV[pref]) { const p = DV.PREF_DV[pref]; // 地级可以很大(新疆的市跨几百公里),阈值放宽 if (!tooFar(lat, lon, p, 200)) return { area: pref, detail: '' }; return null; } return null; } /** * 查到的行政区离候选点是否太远(超过 limitKm 判为同名误匹配)。 * 县一般横跨 30~60km,80km 是上限;地级市可以很大,用 200km。 */ function tooFar(lat, lon, coord, limitKm) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; if (!coord) return false; return U.haversine(lat, lon, coord[0], coord[1]) > limitKm; } async function attachPlaces(candidates, cfg) { const need = candidates.filter((c) => !c.city && c.kind !== '城市'); if (need.length && cfg.AMAP_KEY) { // 串行 + 间隔:高德 regeo 并发 3 就会限流,失败的候选会退回街道名当分组名 // (出现过「坡进屯」「内塘屯」这种没法看的一组)。实测串行 20 个约 730ms。 for (const c of need) { try { const p = await amapPlaceOf(c.lat, c.lon, cfg); if (p) { c.city = p.city; c.district = p.district || c.district || ''; c.province = p.province || ''; } } catch (err) { console.warn('[attachPlaces]', c.name, err.message); } await new Promise((r) => setTimeout(r, 60)); } } for (const c of candidates) { // 内置城市库:名字本身就是地名(柳州市),它是这一组的地名节点。 // // 但「城市」这个 kind 也可能来自 Photon 的 `osm.place.city` 检索, // 而 OSM 的 place=city 里混着大量**景点 POI**(牌坊、寺庙、老街)。 // 那些名字不是行政区,不能拿来当组名——否则会出现 // 「社更穿洞北门」自己成一组、「柳州之门」自己成一组这种荒唐结果。 // 所以必须先校验名字确实是行政区名,否则走下面的正常归属流程。 if (c.kind === '城市' && isPrefectureName(c.name) && isKnownPrefecture(c.name)) { c.place = c.name; c.placeKey = placeKey(c.name) || c.name; c.placeDetail = ''; continue; } const city = String(c.city || '').trim(); const district = String(c.district || '').trim(); if (city) { c.place = city; c.placeKey = placeKey(city) || city; c.placeDetail = district && district !== city && isDistrictName(district) ? district : ''; continue; } // 内置库(知名景区 / 小众地点):查离线归属表,最准,优先用 const hit = adminAreaOf(c); if (hit) { c.place = hit.area; c.placeKey = placeKey(hit.area) || hit.area; c.placeDetail = hit.detail || ''; continue; } // OSM 系(Photon / Overpass):优先用 OSM 自带的归属字段**按名字精确查**。 // // 为什么不能一律「按坐标找最近的县」:桂黔边境的县界狭长, // 「环江喀斯特世界自然遗产」实际在环江县(河池市),但按中心点距离 // 会判到隔壁的荔波县(黔南州)——差 45km 完全够跨省。 // OSM 的 addr:county 在多数条目上直接写了标准县名,按名字查是准的。 // // OSM 的层级字段本身不可靠(county 有时填县名、有时填地级, // city 有时填村名),所以三个字段都试一遍,取第一个查得到的。 if (c.osmCounty || c.osmCity || c.osmDistrict) { // 先在所有字段里找**县级**(下层地名),找不到再退到地级。 // OSM 常常把地级填进 county、把县名填进 city(「黔南州」+「荔波县」), // 若只看第一个字段就定案,下层地名会丢。 let countyHitName = null; let areaHit = null; for (const raw of [c.osmCounty, c.osmCity, c.osmDistrict]) { if (!raw) continue; const hit = lookupAdminByName(raw, c.lat, c.lon); if (!hit) continue; if (hit.detail) { countyHitName = hit.detail; areaHit = hit.area; break; } if (!areaHit) areaHit = hit.area; } if (areaHit) { c.place = areaHit; c.placeKey = placeKey(areaHit) || areaHit; c.placeDetail = countyHitName && countyHitName !== areaHit ? countyHitName : ''; continue; } // 名字查不到:退回按坐标找最近的县 const countyHit = nearestCounty(c.lat, c.lon); const area = countyHit ? prefectureOfCounty(countyHit, c.lat, c.lon) : ''; if (area) { c.place = area; c.placeKey = placeKey(area) || area; const detail = countyHit ? countyHit.county : (isDistrictName(c.osmCity) ? c.osmCity : ''); c.placeDetail = detail && detail !== area ? detail : ''; continue; } // 县级表没覆盖到:退回 OSM 的 county 字段,但必须校验它是地级 const area2 = isPrefectureName(c.osmCounty) ? c.osmCounty : ''; if (area2) { c.place = area2; c.placeKey = placeKey(area2) || area2; c.placeDetail = c.osmCity && c.osmCity !== area2 && isDistrictName(c.osmCity) ? c.osmCity : ''; continue; } if (c.osmCity) { const lift = nearestArea(c.lat, c.lon); c.place = lift ? lift.area : c.osmCity; c.placeKey = placeKey(c.place) || c.place; c.placeDetail = lift && isDistrictName(c.osmCity) ? c.osmCity : ''; continue; } } // 最后兜底:按坐标找最近的地级市(全国地级表 + 内置城市库) const near = nearestArea(c.lat, c.lon); if (near) { c.place = near.area; c.placeKey = placeKey(near.area) || near.area; c.placeDetail = ''; continue; } // 实在查不到:退回内置库的 region,至少不丢信息 const seg = String(c.region || '').split('·').map((s) => s.trim()).filter(Boolean); c.place = seg.length ? seg[seg.length - 1] : ''; c.placeKey = placeKey(c.place) || c.place; c.placeDetail = seg.length > 1 ? seg[0] : ''; } return candidates; } 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); // Overpass 兜底:只在 Photon 全部失败时才启用(单次查询要 20~120 秒) let overpassCount = 0; let overpassError = ''; if (photonOk === 0 && !(amapPois || []).length) { // 兜底路径的总时限预算。公共实例经常「连着不返回」, // 没有总预算的话一次分析能被拖成十几分钟(实测踩过)。 // 超时后直接放弃兜底,清单退回内置地名库(+ dataNotes 里说明实时检索不可用)。 const budgetMs = Math.max(10000, (Number(cfg.SEARCH?.overpassTimeoutMs) || 20000) * 4); const deadline = Date.now() + budgetMs; try { const els = await overpassQuery(origin, radiusKm, cfg); // 兴趣点大多没有 addr:* 标签,要靠行政区反查归属 let areas = null; try { areas = await overpassAdminAreas(origin, radiusKm, cfg); } catch (e2) { console.warn('[overpass] 行政区边界拿不到,归属将退化', e2.message); } // 逐点 is_in 反查是**最慢**的一环(每个点每个镜像都要等一次超时), // 必须限量:超过上限后不再精确反查,剩下的点靠 bbox 行政区中心点近邻判定 // (近似,但一次请求覆盖全部候选,慢不到一个数量级)。 const isInMax = Number(cfg.SEARCH?.overpassIsInMax) || 8; let isInUsed = 0; for (const el of els) { const c = overpassCandidate(el, origin, cfg); if (!c) continue; if (JUNK_NAME.test(c.name) || POOR_NAME.test(c.name) || LOW_VALUE_NAME.test(c.name)) continue; if (c.distanceKm > radiusKm * 1.05) continue; if (!c.osmCounty) { // 先用 bbox 内的行政区中心点做近邻判定(一次请求覆盖全部) let hit = areas ? areaByPoint(areas, c.lat, c.lon) : null; // 近邻判定拿不到(中心点离边界太远)时,逐点用 is_in 精确反查 if (!hit && isInUsed < isInMax) { isInUsed += 1; // 超预算就停止精确反查,但**候选照常入池**: // 归属还有离线官方表兜底(attachPlaces 会按坐标找最近的县), // 不能因为反查超时就丢掉整个候选。 if (Date.now() > deadline) { console.warn('[overpass] 兜底路径超出时限预算,停止精确反查'); isInUsed = isInMax; } else { try { hit = await overpassAdminAtPoint(c.lat, c.lon, cfg); } catch (e3) { console.warn('[overpass-isin]', c.name, e3.message); } } } if (hit) { // 字段摆位要和 attachPlaces 的读取顺序对齐: // attachPlaces 是「先在 county/city/district 里找**县级**,找不到再退地级」, // 所以县级名必须放 osmCounty、地级名放 osmCity。 // 反过来(地级放 county)会让兜底路径永远丢掉下层县名。 if (hit.detail) { c.osmCounty = hit.detail; c.osmCity = hit.area || ''; } else { c.osmCity = hit.area || ''; } } } pool.push(c); overpassCount += 1; } } catch (e) { overpassError = e.message || String(e); console.warn('[overpass]', overpassError); } } // 内置知名目的地:既做兜底,也常年补入若干高辨识度地点 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; } } // 分组:给每个候选补上「归属地名」,第二步按地名分组展示 await attachPlaces(selected, cfg); return { candidates: selected.map((c, i) => ({ ...c, index: i })), stats: { photonOk, categoryHits, curatedCount, cityCount, nicheCount, overpassCount, overpassError: overpassError || '', 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, attachPlaces, placeKey, };