归属判定升级: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
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* 拉取全国行政区划边界(阿里 DataV GeoAtlas,免密钥)。
*
* 为什么不用 Overpass / OSM:
* 1) Overpass 公共实例限流极狠,实测连续大查询后连 1KB 小查询都 502/504;
* 2) OSM 中国的 admin_level 层级与直觉相反、归属字段不可靠;
* 3) DataV 用的是国家民政部 adcode 体系,层级清晰(省 → 市 → 县),
* 且**自带县级边界多边形**,能做精确点面判定。
*
* 流程:100000_full.json 拿省 → 省文件拿地级市 → 市文件拿县。
* 直辖市 / 省直辖县级单位由省文件直接给出(level != 'city')。
*
* 用法:node tools/fetch-datav-admin.cjs C:/tmp/datav-admin.json
*/
const fs = require('fs');
const BASE = 'https://geo.datav.aliyun.com/areas_v3/bound';
const UA = { 'User-Agent': 'Mozilla/5.0 (TripSense build script)' };
async function getJSON(adcode) {
const url = `${BASE}/${adcode}_full.json`;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(url, { headers: UA });
if (!res.ok) throw new Error(`${res.status}`);
const text = await res.text();
return JSON.parse(text);
} catch (e) {
if (attempt === 3) throw new Error(`${adcode}: ${e.message}`);
await new Promise((r) => setTimeout(r, 800 * (attempt + 1)));
}
}
return null;
}
function countPts(coords) {
let n = 0;
const walk = (c) => {
if (typeof c[0] === 'number') { n++; return; }
for (const x of c) walk(x);
};
walk(coords);
return n;
}
async function mapLimit(list, limit, fn) {
const out = [];
let idx = 0;
const workers = Array.from({ length: limit }, async () => {
while (idx < list.length) {
const i = idx++;
try { out[i] = await fn(list[i], i); } catch (e) { out[i] = null; console.error(' !', list[i], e.message); }
}
});
await Promise.all(workers);
return out;
}
function pack(f, extra) {
const g = f.geometry || { type: '', coordinates: [] };
return {
adcode: String(f.properties.adcode),
name: f.properties.name,
level: f.properties.level,
centroid: f.properties.centroid || f.properties.center || null,
center: f.properties.center || null,
geomType: g.type,
coordinates: g.coordinates,
pts: countPts(g.coordinates),
...extra,
};
}
(async () => {
const outFile = process.argv[2] || 'C:/tmp/datav-admin.json';
console.error('拉取省级…');
const provDoc = await getJSON('100000');
const provinces = provDoc.features.map((f) => ({
adcode: String(f.properties.adcode),
name: f.properties.name,
level: f.properties.level,
}));
console.error(`省 ${provinces.length} 个`);
// 第一层:每个省的直接下级(地级市,或直辖市的区)
console.error('拉取省市层…');
const firstLevel = await mapLimit(provinces, 5, async (p) => {
const doc = await getJSON(p.adcode);
return (doc.features || []).map((f) => pack(f, {
province: p.name,
provinceAdcode: p.adcode,
childrenNum: Number(f.properties.childrenNum || 0),
}));
});
const cities = firstLevel.flat().filter(Boolean);
const direct = cities.filter((c) => c.level !== 'city');
const cityList = cities.filter((c) => c.level === 'city' && c.childrenNum > 0);
console.error(`地级市 ${cityList.length} 个,直辖县级 ${direct.length} 个,拉县级…`);
const countyDocs = await mapLimit(cityList, 6, async (c) => {
const doc = await getJSON(c.adcode);
return (doc.features || []).map((f) => pack(f, {
province: c.province,
provinceAdcode: c.provinceAdcode,
city: c.name,
cityAdcode: c.adcode,
}));
});
const counties = countyDocs.flat().filter(Boolean);
console.error(`县级 ${counties.length} 个,总顶点 ${counties.reduce((s, c) => s + c.pts, 0)}`);
fs.writeFileSync(outFile, JSON.stringify({ provinces, cities, counties }));
console.error('写入', outFile, (fs.statSync(outFile).size / 1024 / 1024).toFixed(1) + 'MB');
})();