归属判定升级: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
+125
View File
@@ -0,0 +1,125 @@
'use strict';
/**
* 生成内置地点库的归属表(一次性脚本,结果写入 lib/places-admin.js)。
*
* 背景:第二步要按「地名 → 景点」两级展示,所以每个候选都得知道它属于哪个地级市/州。
* 内置知名景区库(EXTRA_SCENIC)与小众库(NICHE_SPOTS)里只有坐标,没有归属;
* 高德 POI 自带 cityname/adname 但用户未必配了 Key。
*
* 办法:用 Photon 按名字搜,再用**坐标距离**在候选结果里挑最近的那个
* (光按名字会歧义:「灵山」在钦州、「百花山」在百色,都不是京郊那个)。
* 命中不了就留空,运行时再用「最近地级市」兜底。
*
* 用法:node tools/build-admin.mjs
*/
const fs = require('fs');
const path = require('path');
const cn = require('../lib/places-cn');
const niche = require('../lib/places-niche').NICHE_SPOTS;
const PHOTON = 'https://photon.komoot.io/api/';
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}
/** Photon 的层级:state=省, county=地级市/州, city=县/区, district=街道, locality=村 */
function pickArea(props) {
const clean = (s) => String(s || '').trim();
const state = clean(props.state);
const county = clean(props.county);
const city = clean(props.city);
const district = clean(props.district);
// 地级优先 county,其次 city(有些地方 county 缺失),最后 state
const area = county || city || state;
const detail = county && city && city !== county ? city : '';
return { area, detail, state, level: county ? 'prefecture' : (city ? 'county' : 'province') };
}
async function query(name, tries = 3) {
for (let i = 0; i < tries; i += 1) {
try {
const u = new URL(PHOTON);
u.searchParams.set('q', name);
u.searchParams.set('limit', '5');
u.searchParams.set('lang', 'default');
const res = await fetch(u.toString());
const text = await res.text();
if (!text.startsWith('{')) throw new Error('non-json: ' + text.slice(0, 40));
return JSON.parse(text).features || [];
} catch (err) {
if (i === tries - 1) return null;
await new Promise((r) => setTimeout(r, 1200 * (i + 1)));
}
}
return null;
}
async function main() {
const items = [];
for (const s of cn.EXTRA_SCENIC) items.push({ name: s[0], lat: s[1], lon: s[2], group: 'scenic' });
for (const s of niche) items.push({ name: s[0], lat: s[1], lon: s[2], group: 'niche', region: s[5] || '' });
const out = {};
const areas = new Map(); // 地级名 → [lat, lon]
let hit = 0;
for (let i = 0; i < items.length; i += 1) {
const it = items[i];
// 小众库自带「省 · 市」,先用它当线索,能省一次请求
const feats = await query(it.name);
let best = null;
if (feats) {
for (const f of feats) {
const co = f.geometry?.coordinates;
if (!co) continue;
const d = haversine(it.lat, it.lon, Number(co[1]), Number(co[0]));
if (!best || d < best.d) best = { d, props: f.properties || {}, co };
}
}
const area = best ? pickArea(best.props) : null;
// 名字搜到的点离得太远(>60km)基本是同名的另一个地方,不采信
const trust = best && best.d < 60;
if (trust && area && area.area) {
hit += 1;
out[`${it.group}:${it.name}`] = { area: area.area, detail: area.detail, state: area.state, level: area.level };
if (area.level === 'prefecture' || area.level === 'county') {
if (!areas.has(area.area)) areas.set(area.area, [it.lat, it.lon]);
}
} else if (it.region) {
// 小众库自带的「省 · 市」也可用,至少是市级
const seg = it.region.split('·').map((x) => x.trim()).filter(Boolean);
const area2 = seg.length >= 2 ? seg[seg.length - 1] : seg[0];
if (area2) out[`${it.group}:${it.name}`] = { area: area2, detail: '', state: seg[0] || '', level: 'prefecture' };
}
if ((i + 1) % 20 === 0) console.log(` ${i + 1}/${items.length} hit=${hit}`);
await new Promise((r) => setTimeout(r, 650));
}
const file = path.join(__dirname, '..', 'lib', 'places-admin.js');
const body = `'use strict';
/**
* 内置地点库的归属地名(自动生成,勿手改)。
*
* 生成方式:tools/build-admin.mjs —— 用 Photon 按名检索 + 坐标距离消歧,
* 取回 state/county/city 层级,存成「地级名 + 区县」。
*
* 字段:
* SPOT_AREA { 'scenic:荔波小七孔': { area, detail, state, level } }
* AREA_COORD { '荔波县': [lat, lon] } 各地级/县级名的代表坐标,运行时做最近市兜底
*/
const SPOT_AREA = ${JSON.stringify(out, null, 0)};
const AREA_COORD = ${JSON.stringify(Object.fromEntries(areas), null, 0)};
module.exports = { SPOT_AREA, AREA_COORD };
`;
fs.writeFileSync(file, body, 'utf8');
console.log('written', file, 'entries', Object.keys(out).length, 'hit', hit, 'areas', areas.size);
}
main();