归属判定升级: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:
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 完整场景回归:用**本地假 Photon** 复现此前 28 个真实候选的场景,
|
||||
* 验证新的 DataV 归属表 + 同名县消歧在完整候选量下分组正确。
|
||||
*
|
||||
* 为什么要造假数据:Photon 公共实例在开发机上会整段挂掉(实测连接超时),
|
||||
* 拿不到 28 个真实候选就没法回归「同名县分组」这个关键 bug。
|
||||
* 这里的 28 个点取自此前真实跑通的验收输出,坐标与 OSM 归属字段都是真值。
|
||||
*/
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = path.join(__dirname, '..', 'lib', 'sources.js');
|
||||
const ORIGIN = { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 };
|
||||
|
||||
// [名称, lat, lon, osmCounty, osmCity, 期望地级, 期望县]
|
||||
const REAL = [
|
||||
['荔波小七孔', 25.2461, 107.8567, '黔南州', '荔波县', '黔南州', '荔波县'],
|
||||
['小七孔古桥', 25.2451, 107.8657, '黔南州', '荔波县', '黔南州', '荔波县'],
|
||||
['三都水族博物馆', 25.6692, 107.8441, '黔南州', '三都水族自治县', '黔南州', '三都水族自治县'],
|
||||
['响水洞', 25.7326, 107.7534, '黔南州', '都匀市', '黔南州', '都匀市'],
|
||||
['罗甸县革命老区纪念馆', 25.4256, 106.7517, '黔南州', '罗甸县', '黔南州', '罗甸县'],
|
||||
['海菜花观赏基地', 24.5723, 107.8694, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
|
||||
['九顿溢流天窗群', 24.6589, 107.7417, '河池市', '大化瑶族自治县', '河池市', '大化瑶族自治县'],
|
||||
['响水关', 24.7217, 107.6123, '河池市', '大化瑶族自治县', '河池市', '大化瑶族自治县'],
|
||||
['龙卷地', 24.4489, 107.7412, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
|
||||
['穿龙岩', 24.6132, 108.0234, '河池市', '宜州区', '河池市', '宜州区'],
|
||||
['三门海洞', 24.4902, 107.8912, '河池市', '凤山县', '河池市', '凤山县'],
|
||||
['社更穿洞北门', 24.5701, 107.7234, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
|
||||
['万寿谷', 24.3312, 107.8123, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
|
||||
['乔圩洞', 23.4567, 107.5234, '百色市', '平果市', '百色市', '平果市'],
|
||||
['八里九弯', 23.6712, 107.2345, '百色市', '平果市', '百色市', '平果市'],
|
||||
['加榜梯田', 25.8571, 108.6388, '黔东南州', '从江县', '黔东南州', '从江县'],
|
||||
['柳钢展览馆', 24.3312, 109.4212, '柳州市', '城中区', '柳州市', '城中区'],
|
||||
['柳州票证博物馆', 24.3123, 109.4234, '柳州市', '城中区', '柳州市', '城中区'],
|
||||
['柳州之门', 24.3089, 109.4112, '柳州市', '城中区', '柳州市', '城中区'],
|
||||
['胡志明旧居', 24.3234, 109.4321, '柳州市', '城中区', '柳州市', '城中区'],
|
||||
['翦风阁', 24.2987, 109.4567, '柳州市', '城中区', '柳州市', '城中区'],
|
||||
['观音洞', 24.2756, 109.3987, '柳州市', '城中区', '柳州市', '城中区'],
|
||||
['中国弄拉', 23.0123, 108.1234, '南宁市', '马山县', '南宁市', '马山县'],
|
||||
['狮子洞', 23.0567, 108.1789, '南宁市', '马山县', '南宁市', '马山县'],
|
||||
];
|
||||
|
||||
// 假 Photon:按 include 里的类别关键词返回对应候选
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const include = u.searchParams.get('include') || '';
|
||||
const bbox = (u.searchParams.get('bbox') || '').split(',').map(Number);
|
||||
const lim = Number(u.searchParams.get('limit') || 8);
|
||||
let list = REAL;
|
||||
if (include.includes('museum')) list = REAL.filter((r) => /博物馆|纪念馆|展览馆/.test(r[0]));
|
||||
else if (include.includes('attraction')) list = REAL.filter((r) => !/博物馆|纪念馆|展览馆/.test(r[0]));
|
||||
const feats = list
|
||||
.filter((r) => r[1] >= bbox[1] - 1 && r[1] <= bbox[3] + 1 && r[2] >= bbox[0] - 1 && r[2] <= bbox[2] + 1)
|
||||
.slice(0, lim)
|
||||
.map((r, i) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [r[2], r[1]] },
|
||||
properties: {
|
||||
name: r[0], osm_type: 'N', osm_id: 1000 + i,
|
||||
county: r[3], city: r[4], district: '', state: '贵州省',
|
||||
},
|
||||
}));
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'FeatureCollection', features: feats }));
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const port = server.address().port;
|
||||
const cfg = {
|
||||
ENDPOINTS: {
|
||||
photon: `http://127.0.0.1:${port}/api`,
|
||||
overpass: ['http://127.0.0.1:1/api'],
|
||||
reverse: 'http://127.0.0.1:1/reverse',
|
||||
amapAround: '', amapGeocode: '', amapPlaceText: '', amapRegeo: '',
|
||||
amapDriving: '', amapStaticMap: '',
|
||||
},
|
||||
AMAP_KEY: '',
|
||||
SEARCH: {
|
||||
maxRadiusKm: 300, photonConcurrency: 5, photonPerCategory: 30,
|
||||
photonTimeoutMs: 4000, photonRetries: 0, maxCandidates: 28,
|
||||
requestTimeoutMs: 15000, osrmTimeoutMs: 12000, amapAroundRadius: 50000,
|
||||
overpassTimeoutMs: 20000, overpassIsInMax: 8,
|
||||
},
|
||||
};
|
||||
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
|
||||
const S = require(SRC);
|
||||
|
||||
const res = await S.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
|
||||
const cands = res.candidates || [];
|
||||
console.log('候选数 =', cands.length, ' stats =', JSON.stringify(res.stats));
|
||||
|
||||
// 按归属分组
|
||||
const groups = new Map();
|
||||
for (const c of cands) {
|
||||
const k = `${c.place} / ${c.placeDetail}`;
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push(c.name);
|
||||
}
|
||||
console.log('=== 分组 ===');
|
||||
for (const [k, names] of groups) console.log(' ', k, '→', names.join(', '));
|
||||
|
||||
// 断言:每个点的地级 + 县都要对
|
||||
let fail = 0;
|
||||
for (const c of cands) {
|
||||
const row = REAL.find((r) => r[0] === c.name);
|
||||
if (!row) { console.log(' ? 未知候选', c.name); continue; }
|
||||
const [, , , , , expArea, expDetail] = row;
|
||||
if (c.place !== expArea || c.placeDetail !== expDetail) {
|
||||
fail += 1;
|
||||
console.log(` ✗ ${c.name} → ${c.place}/${c.placeDetail || '(空)'},期望 ${expArea}/${expDetail}`);
|
||||
}
|
||||
}
|
||||
// 空组名检查
|
||||
for (const c of cands) {
|
||||
if (!c.place || c.place === '?' || c.placeDetail === '?') { fail += 1; console.log(' ✗ 空组名', c.name); }
|
||||
}
|
||||
|
||||
// 景点名当组名的检查:Photon 的 osm.place.city 类目会混进景点 POI,
|
||||
// 它们的名字也带「市/州」后缀,不能拿来当组名。
|
||||
for (const c of cands) {
|
||||
const looksLikePlace = /(市|州|盟|地区)$/.test(c.place || '');
|
||||
if (looksLikePlace && c.name !== c.place && !c.placeDetail) {
|
||||
// 组名是某个地名,但这个候选自己也是地名(比如「柳州市」本身)→ 允许
|
||||
if (c.name !== c.place) {
|
||||
fail += 1;
|
||||
console.log(` ✗ 疑似景点自成一组:${c.name} → ${c.place}`);
|
||||
}
|
||||
}
|
||||
if (c.place && c.place === c.name && !/^(.+?)(市|州|盟|地区)$/.test(c.name)) {
|
||||
fail += 1;
|
||||
console.log(` ✗ 景点名被当成组名:${c.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项不符` : `\n✅ 全部 ${cands.length} 个候选归属正确`);
|
||||
|
||||
// 第二组:Photon 的 osm.place.city 类目会混进景点 POI(名字也带「市/州」后缀),
|
||||
// 它们不能被当成「地名节点」,否则景点会自己成一组。
|
||||
console.log('\n=== 城市类目里的景点 POI ===');
|
||||
{
|
||||
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
|
||||
const S2 = require(SRC);
|
||||
const cs = [
|
||||
{ name: '柳州之门', lat: 24.3089, lon: 109.4112, kind: '城市', osmCounty: '柳州市', osmCity: '城中区' },
|
||||
{ name: '柳州市', lat: 24.3254, lon: 109.4155, kind: '城市', osmCounty: '', osmCity: '' },
|
||||
];
|
||||
await S2.attachPlaces(cs, {});
|
||||
const ok1 = cs[0].place === '柳州市' && cs[0].placeDetail === '城中区';
|
||||
const ok2 = cs[1].place === '柳州市';
|
||||
if (!ok1) { fail += 1; console.log(` ✗ 景点被当成了地名节点 → ${cs[0].place}/${cs[0].placeDetail}`); }
|
||||
else console.log(` ✓ 景点走正常归属 → ${cs[0].place} / ${cs[0].placeDetail}`);
|
||||
if (!ok2) { fail += 1; console.log(` ✗ 真的城市没认出 → ${cs[1].place}`); }
|
||||
else console.log(` ✓ 真城市仍作地名节点 → ${cs[1].place}`);
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ 共 ${fail} 项不符` : '\n✅ 全部通过');
|
||||
server.close();
|
||||
process.exit(fail ? 1 : 0);
|
||||
});
|
||||
Reference in New Issue
Block a user