归属判定升级:DataV 官方行政区划表 + 同名县消歧

用阿里 DataV GeoAtlas(免密钥)替换 OSM Overpass 离线县表:
- 民政部 adcode 体系,县级前 4 位即所属地级码,2832 县 100% 可定位地级
  (OSM 表只有 1182/2362,且 admin_level 层级不可靠)
- 同名县(全国 30 组)保留全部候选,运行时按坐标消歧,
  修掉「柳州的城中区被归到西宁市」

归属判定改为「按名字精确查官方表」优先于「按中心点猜最近的县」:
窄长县按中心点会跨省错判(环江喀斯特实际在环江县,按中心点判到荔波县)
查表带距离校验,精确全称命中放宽到 150km(狭长县中心离边界可达 85km+)

修掉三个真实缺陷:
- Overpass 兜底把地级塞进 osmCounty、县名塞进 osmCity,
  与 attachPlaces「先找县级」的顺序相反,导致兜底路径永远丢下层县名
- Overpass 请求无硬超时,公共实例「连着不返回」能把一次分析拖成十几分钟;
  补三层限(单次超时 / 阶段总时限 / is_in 反查次数上限),13 分钟 → 48 秒
- Photon 的 osm.place.city 类目混着景点 POI,kind==='城市' 时无条件把名字
  当地名,导致「社更穿洞北门」「柳州之门」等景点自己成一组;
  改为必须同时是行政区名且能在官方表查到

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