归属判定升级: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
+137
View File
@@ -0,0 +1,137 @@
'use strict';
/**
* 补齐归属表里缺失的条目(第二轮)。
*
* 第一轮对「荔波小七孔」这类名字搜不到——Photon 里收录的是「小七孔」这个景点,
* 全名反而搜不到。所以这里做两件事:
* 1. 去掉「风景区/景区/公园/自然保护区/度假区」等后缀再搜一次;
* 2. 搜不到就用「名字里剥掉修饰词的核心词」再搜。
* 命中后仍然用**坐标距离**在候选里挑最近的,避免同名异地。
*
* 用法:node tools/build-admin-fix.cjs
*/
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)));
}
function pickArea(props) {
const clean = (s) => String(s || '').trim();
const state = clean(props.state);
const county = clean(props.county);
const city = clean(props.city);
// 优先 county(Photon 里是地级市/州),其次 city(县/区),最后 state
const area = county || city || state;
const detail = county && city && city !== county ? city : '';
return { area, detail, state, level: county ? 'prefecture' : (city ? 'county' : 'province') };
}
/** 名字变体:原名、去后缀、剥掉「XX」前缀的核心词 */
function variants(name) {
const out = new Set([name]);
const noSuffix = name.replace(/(风景区|景区|旅游区|度假区|自然保护区|森林公园|地质公园|湿地公园|国家公园|风景名胜区|风景区|风景区|景区|景点|公园|博物馆|纪念馆|故居|遗址|石林|峡谷|瀑布|温泉|梯田|草原|湖泊|湿地|海滩|岛|山|湖|园)$/g, '').trim();
if (noSuffix && noSuffix.length >= 2) out.add(noSuffix);
// 「A小七孔」→「小七孔」这类:去掉末尾的行政区前缀
const core = noSuffix.replace(/^(.{2,4}?)(?=[\u4e00-\u9fa5]{2,4}$)/, '');
if (core && core.length >= 2) out.add(core);
return [...out];
}
async function query(name) {
for (let i = 0; i < 3; i += 1) {
try {
const u = new URL(PHOTON);
u.searchParams.set('q', name);
u.searchParams.set('limit', '6');
u.searchParams.set('lang', 'default');
const res = await fetch(u.toString());
const text = await res.text();
if (!text.startsWith('{')) throw new Error('non-json');
return JSON.parse(text).features || [];
} catch (err) {
if (i === 2) return null;
await new Promise((r) => setTimeout(r, 1200 * (i + 1)));
}
}
return null;
}
async function lookup(name, lat, lon) {
for (const v of variants(name)) {
const feats = await query(v);
if (!feats || !feats.length) continue;
let best = null;
for (const f of feats) {
const co = f.geometry?.coordinates;
if (!co) continue;
const d = haversine(lat, lon, Number(co[1]), Number(co[0]));
if (!best || d < best.d) best = { d, props: f.properties || {} };
}
if (!best || best.d > 40) continue;
const area = pickArea(best.props);
if (area.area) return area;
await new Promise((r) => setTimeout(r, 500));
}
return null;
}
async function main() {
const file = path.join(__dirname, '..', 'lib', 'places-admin.js');
const src = fs.readFileSync(file, 'utf8');
const SPOT_AREA = JSON.parse(src.match(/const SPOT_AREA = (\{.*?\});/s)[1]);
const AREA_COORD = JSON.parse(src.match(/const AREA_COORD = (\{.*?\});/s)[1]);
const items = [];
for (const s of cn.EXTRA_SCENIC) items.push({ group: 'scenic', name: s[0], lat: s[1], lon: s[2] });
for (const s of niche) items.push({ group: 'niche', name: s[0], lat: s[1], lon: s[2] });
const missing = items.filter((it) => !SPOT_AREA[`${it.group}:${it.name}`]);
console.log('missing', missing.length, '/', items.length);
let fixed = 0;
for (let i = 0; i < missing.length; i += 1) {
const it = missing[i];
const area = await lookup(it.name, it.lat, it.lon);
if (area) {
SPOT_AREA[`${it.group}:${it.name}`] = area;
if (!AREA_COORD[area.area]) AREA_COORD[area.area] = [it.lat, it.lon];
fixed += 1;
}
if ((i + 1) % 10 === 0) console.log(` ${i + 1}/${missing.length} fixed=${fixed}`);
await new Promise((r) => setTimeout(r, 600));
}
const body = `'use strict';
/**
* 内置地点库的归属地名(自动生成,勿手改)。
*
* 生成方式:
* tools/build-admin.cjs —— 首轮,Photon 按名检索 + 坐标距离消歧
* tools/build-admin-fix.cjs —— 二轮,去后缀/核心词重试,补首轮搜不到的
*
* 字段:
* SPOT_AREA { 'scenic:荔波小七孔': { area, detail, state, level } }
* AREA_COORD { '荔波县': [lat, lon] } 各地级/县级名的代表坐标,运行时做最近市兜底
*/
const SPOT_AREA = ${JSON.stringify(SPOT_AREA)};
const AREA_COORD = ${JSON.stringify(AREA_COORD)};
module.exports = { SPOT_AREA, AREA_COORD };
`;
fs.writeFileSync(file, body, 'utf8');
console.log('written', file, 'entries', Object.keys(SPOT_AREA).length, 'fixed', fixed);
}
main();