Files
tripsense/tools/fetch-datav-admin.cjs
T
tamakyi 7ad86c6619 归属判定升级: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)
2026-11-06 02:05:24 +08:00

118 lines
4.0 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.
#!/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');
})();