零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。 - 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底 - 区县级「排除同城」判定(adname),过滤发生在轮转取样之前 - 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图 - 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造 - 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存 - 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
1297 lines
56 KiB
JavaScript
1297 lines
56 KiB
JavaScript
'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,
|
||
};
|