Files
tripsense/tools/build-admin-dv.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

157 lines
7.3 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 行政区划原始数据生成离线归属表。
*
* 产出 lib/places-admin-dv.js:
* COUNTY_DV 县级 → [纬度, 经度, 所属地级名, adcode]
* PREF_DV 地级 → [纬度, 经度, adcode]
*
* 为什么不用 OSM 那套:OSM 中国的 admin_level 层级不可靠、编码只有 50% 覆盖,
* 只能靠「按中心点猜」;DataV 用的是国家民政部 adcode 体系,
* 县级前 4 位就是所属地级码,**100% 准确**,不需要猜。
*
* 用法:node tools/build-admin-dv.cjs C:/tmp/datav-admin.json
*/
const fs = require('fs');
const path = require('path');
const { shortPrefecture } = require('./lib-short-name.cjs');
const src = process.argv[2] || 'C:/tmp/datav-admin.json';
const out = path.join(__dirname, '..', 'lib', 'places-admin-dv.js');
const d = JSON.parse(fs.readFileSync(src, 'utf8'));
// 地级:level==city 且 adcode 以 00 结尾的条目。
// 键统一用简称(「黔南布依族苗族自治州」→「黔南州」),与运行时 placeKey 一致。
//
// **关键区分**:DataV 把「省直辖县级单位」也标成 level=city
// (海南的东方市/昌江黎族自治县、新疆兵团的石河子市、湖北的仙桃市…),
// 但它们的 adcode 不以 00 结尾(46**9001**、65**9001**、42**9001**)。
// 行政区划代码里「地级市用 xx00、省直辖县级用 9xxx 段」是固定规则,
// 不按这个分就会把县级市当成地级市,归属会整整差一级。
const prefecture = {};
const prefectureFull = {};
for (const c of d.cities) {
if (c.level !== 'city') continue;
if (!c.centroid) continue;
if (!/00$/.test(c.adcode)) continue;
const short = shortPrefecture(c.name);
prefecture[short] = [c.centroid[1], c.centroid[0], c.adcode];
prefectureFull[short] = c.name;
}
// 省级直辖县级单位(海南的东方市、新疆兵团的石河子市…):行政上是「县」,
// 上级直接是省,没有地级市这一层 —— 所以它们的归属地级就是省名。
const directCounties = [];
for (const c of d.cities) {
if (!c.centroid) continue;
const isCityLevel = c.level === 'city';
if (isCityLevel && /00$/.test(c.adcode)) continue; // 真正的地级市,上面已处理
directCounties.push(c);
}
// 县级 → 地级
//
// **同名县必须保留全部候选**:全国有 22 组同名县(「城中区」柳州/西宁都有、
// 「鼓楼区」南京/徐州/福州/开封都有、「铁西区」沈阳/鞍山/四平都有)。
// 只留一个会把柳州的点归到西宁去。
// 所以同名时值是**数组**,运行时按坐标距离选最近的那个。
const county = {};
// 注意:county[name] **永远**是数组(同名县有多个候选)。
// 不能写成「单值,冲突时升级成数组」——单值本身也是数组,
// Array.isArray 会把「第一次写入的条目」误判成「同名组」,
// 结果第二个同名县被 push 进第一个县的四元组里,表直接被污染。
function addCounty(name, entry) {
if (!county[name]) county[name] = [];
// 同 adcode 视为同一条(例如直辖县级既在 cities 又在 counties 里出现)
if (county[name].some((e) => e[3] === entry[3])) return;
county[name].push(entry);
}
for (const c of d.counties) {
if (!c.centroid) continue;
// 地级名统一简称化(「黔南布依族苗族自治州」→「黔南州」)
const pref = shortPrefecture(c.city || c.province || '');
addCounty(c.name, [c.centroid[1], c.centroid[0], pref, c.adcode]);
}
// 直辖县级(北京/上海/重庆/港澳台等)由省级文件直接给出
for (const c of directCounties) {
const pref = shortPrefecture(c.province || '');
addCounty(c.name, [c.centroid[1], c.centroid[0], pref, c.adcode]);
if (!prefecture[pref]) {
prefecture[pref] = [c.centroid[1], c.centroid[0], c.provinceAdcode];
prefectureFull[pref] = pref;
}
}
// 只有一组的压回单值,减少运行时分支;多组保持数组
for (const k of Object.keys(county)) {
if (county[k].length === 1) county[k] = county[k][0];
}
// 名称索引:允许用「短名/别名」查
// DataV 只给全称,但 OSM/高德常写「环江县」「荔波县」「罗城」这类简称或去族名写法,
// 统一在这里做归一,避免运行时各写一套。
// 分成两张表:县级别名 → 县全称;地级别名 → 地级简称。
// 混在一张表里会因为「同名县/市」互相覆盖而查错。
const countyAlias = {};
const prefAlias = {};
function regAlias(table, key, full) {
if (!full || !key || key === full) return;
if (!table[key]) table[key] = full;
}
/**
* 剥掉地名里的民族名块。
* 不能用 `[一-龥]{1,3}族` 这种贪婪正则——「环江毛南族自治县」会把
* 「江毛南族」整段吃掉变成「环自治县」(实测)。必须用**已知的民族名清单**。
* 顺序很重要:先去掉「自治县/自治州」尾巴,民族名块才会露在末尾。
*/
const ETHNIC_LIST = require('./lib-short-name.cjs').ETHNIC;
function stripEthnic(name) {
let s = name.replace(/自治(县|旗|州|区)$/, '');
let changed = true;
while (changed) {
changed = false;
for (const eth of ETHNIC_LIST) {
if (s.endsWith(eth)) { s = s.slice(0, -eth.length); changed = true; }
}
}
return s.replace(/(?:[\u4e00-\u9fa5]{1,3}族)+$/, '');
}
for (const name of Object.keys(county)) {
const noEthnic = stripEthnic(name); // 环江毛南族自治县 → 环江
regAlias(countyAlias, noEthnic, name);
regAlias(countyAlias, noEthnic + '县', name); // → 环江县
regAlias(countyAlias, noEthnic + '旗', name);
regAlias(countyAlias, name.replace(/自治(县|旗)$/, (m, g) => g), name); // 环江毛南族县
regAlias(countyAlias, name.replace(/自治(县|旗)$/, ''), name); // 环江毛南族
}
for (const [name, full] of Object.entries(prefectureFull)) {
// 值统一是地级**简称**(PREF_DV 的键),否则运行时查 PREF_DV 会落空
regAlias(prefAlias, full, name); // 黔南布依族苗族自治州 → 黔南州
regAlias(prefAlias, stripEthnic(full), name);
regAlias(prefAlias, name.replace(/(市|地区|盟)$/, ''), name); // 河池市 → 河池
}
const js = `/**
* 行政区划离线表(阿里 DataV GeoAtlas 数据,自动生成,请勿手改)
*
* 数据源:https://geo.datav.aliyun.com/areas_v3/bound/{adcode}_full.json(免密钥)
* 层级:国家民政部 adcode 体系(省级 2 位 / 地级 4 位 / 县级 6 位)
*
* COUNTY_DV 县级名 → [纬度, 经度, 所属地级名, adcode]
* PREF_DV 地级简称 → [纬度, 经度, adcode]
* COUNTY_ALIAS 县别名/简称 → 县全称
* PREF_ALIAS 地级别名/全称 → 地级简称
*
* 生成:node tools/build-admin-dv.cjs C:/tmp/datav-admin.json
*/
const COUNTY_DV = ${JSON.stringify(county)};
const PREF_DV = ${JSON.stringify(prefecture)};
const COUNTY_ALIAS = ${JSON.stringify(countyAlias)};
const PREF_ALIAS = ${JSON.stringify(prefAlias)};
module.exports = { COUNTY_DV, PREF_DV, COUNTY_ALIAS, PREF_ALIAS };
`;
fs.writeFileSync(out, js);
console.log(`县级 ${Object.keys(county).length},地级 ${Object.keys(prefecture).length},县别名 ${Object.keys(countyAlias).length},地别名 ${Object.keys(prefAlias).length}`);
console.log('写入', out, (fs.statSync(out).size / 1024).toFixed(0) + 'KB');