Files
tripsense/lib/sources.js
T
tamakyi c5c2ffae68 TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

- 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底
- 区县级「排除同城」判定(adname),过滤发生在轮转取样之前
- 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图
- 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造
- 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存
- 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
2026-11-05 20:25:03 +08:00

1297 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 数据层:地理编码 / 周边候选 / 天气 / 空气质量 / 公路 / 铁路
*
* 设计原则:
* - 全部接口免密钥即可跑通(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,
};