用阿里 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)
140 lines
6.3 KiB
JavaScript
140 lines
6.3 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Overpass 兜底路径验证(Photon 全挂场景)。
|
|
*
|
|
* 起一个**本地假 Overpass**(识别 query 里的特征串返回不同数据),
|
|
* 把 config.ENDPOINTS.overpass 指过去,验证:
|
|
* 1. Photon 全挂时确实走 Overpass 兜底
|
|
* 2. Overpass 候选的归属正确(环江喀斯特 → 河池市/环江县,不跑到荔波县)
|
|
* 3. is_in 反查结果经过统一查表归一(OSM 简称 → 标准县全称)
|
|
* 4. Overpass 返回 200 但内容是 HTML 错误页时,要被识别为失败而不是当数据解析
|
|
*/
|
|
const http = require('http');
|
|
const path = require('path');
|
|
|
|
const SRC = path.join(__dirname, '..', 'lib', 'sources.js');
|
|
const ORIGIN = { lat: 24.69562, lon: 108.08091 };
|
|
|
|
// 假 Overpass:按 query 特征分发
|
|
const ADMIN_ISIN = JSON.stringify({
|
|
version: 0.6,
|
|
elements: [
|
|
{ type: 'relation', id: 1, tags: { admin_level: '4', name: '广西壮族自治区' } },
|
|
{ type: 'relation', id: 2, tags: { admin_level: '5', name: '河池市' } },
|
|
{ type: 'relation', id: 3, tags: { admin_level: '6', name: '环江毛南族自治县' } },
|
|
],
|
|
});
|
|
// 行政区别名:故意用**简称**「环江」,验证会过查表归一成全称
|
|
const ADMIN_ISIN_SHORT = JSON.stringify({
|
|
version: 0.6,
|
|
elements: [
|
|
{ type: 'relation', id: 2, tags: { admin_level: '5', name: '河池市' } },
|
|
{ type: 'relation', id: 3, tags: { admin_level: '6', name: '环江' } },
|
|
],
|
|
});
|
|
// POI:环江喀斯特(25.1, 108.28,环江县境内)
|
|
const POIS = JSON.stringify({
|
|
version: 0.6,
|
|
elements: [
|
|
{ type: 'node', id: 11, lat: 25.10, lon: 108.28, tags: { name: '环江喀斯特世界自然遗产-核心功能区', tourism: 'attraction' } },
|
|
{ type: 'node', id: 12, lat: 25.24, lon: 107.90, tags: { name: '小七孔古镇', tourism: 'attraction' } },
|
|
],
|
|
});
|
|
const HTML_ERROR = '<!DOCTYPE html><html><body>Error : runtime error: duplicate_query</body></html>';
|
|
|
|
const server = http.createServer((req, res) => {
|
|
let body = '';
|
|
req.on('data', (c) => { body += c; });
|
|
req.on('end', () => {
|
|
const q = decodeURIComponent(body.replace(/^data=/, ''));
|
|
const send = (txt, code = 200) => {
|
|
res.writeHead(code, { 'content-type': 'application/json' });
|
|
res.end(txt);
|
|
};
|
|
if (q.includes('is_in')) {
|
|
// 25.10,108.28 用全称;25.24,107.90 用简称(测试归一)
|
|
if (q.includes('25.10000,108.28000')) return send(ADMIN_ISIN);
|
|
if (q.includes('25.24000,107.90000')) return send(ADMIN_ISIN_SHORT);
|
|
return send(JSON.stringify({ version: 0.6, elements: [] }));
|
|
}
|
|
if (q.includes('"tourism"')) {
|
|
// 第二次尝试时返回 HTML 错误页(测「200 但不是 JSON」)
|
|
if (process.env.HTML_MODE === '1') return send(HTML_ERROR);
|
|
return send(POIS);
|
|
}
|
|
if (q.includes('admin_level')) {
|
|
return send(JSON.stringify({ version: 0.6, elements: [] }));
|
|
}
|
|
send(JSON.stringify({ version: 0.6, elements: [] }));
|
|
});
|
|
});
|
|
|
|
server.listen(0, '127.0.0.1', async () => {
|
|
const port = server.address().port;
|
|
const cfg = {
|
|
ENDPOINTS: {
|
|
overpass: [`http://127.0.0.1:${port}/api/interpreter`],
|
|
photon: '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: 200, maxCandidates: 20 },
|
|
PHOTON: { categories: [], concurrency: 1, perCategory: 2, timeoutMs: 500, retries: 0 },
|
|
};
|
|
|
|
// 强制清掉模块缓存,让新的 cfg 生效
|
|
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
|
|
const S = require(SRC);
|
|
|
|
console.log('=== Overpass 兜底路径验证(Photon 全挂)===');
|
|
const res = await S.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
|
|
const cands = res.candidates || [];
|
|
console.log('候选数 =', cands.length);
|
|
console.log('stats =', JSON.stringify(res.stats));
|
|
for (const c of cands) {
|
|
console.log(` ${c.name} → ${c.place || '(空)'} / ${c.placeDetail || '(空)'} [${c.source}]`);
|
|
}
|
|
|
|
// 断言
|
|
const fail = [];
|
|
const find = (kw) => cands.find((c) => c.name.includes(kw));
|
|
const huanjiang = find('环江喀斯特');
|
|
if (!huanjiang) fail.push('环江喀斯特没进候选');
|
|
else {
|
|
if (huanjiang.place !== '河池市') fail.push(`环江喀斯特地级应为 河池市,实际 ${huanjiang.place}`);
|
|
if (huanjiang.placeDetail !== '环江毛南族自治县') fail.push(`环江喀斯特县应为 环江毛南族自治县,实际 ${huanjiang.placeDetail}`);
|
|
}
|
|
const xiaoqikong = find('小七孔古镇');
|
|
if (!xiaoqikong) fail.push('小七孔古镇没进候选');
|
|
else if (xiaoqikong.placeDetail !== '环江毛南族自治县') {
|
|
// mock 的 is_in 只给了 OSM 简称「环江」,应被查表归一成标准全称
|
|
fail.push(`小七孔的县名没归一,实际 ${xiaoqikong.placeDetail}`);
|
|
}
|
|
for (const c of cands) {
|
|
if (!c.place || c.place === '?') fail.push(`${c.name} 分组名为空`);
|
|
if (c.placeDetail === '?') fail.push(`${c.name} 下层地名为空`);
|
|
}
|
|
|
|
console.log(fail.length ? '\n❌ 失败:\n ' + fail.join('\n ') : '\n✅ 全部通过');
|
|
|
|
// 第二组:Overpass 返回 200 但内容是 HTML 错误页(duplicate_query 之类)。
|
|
// 这种必须被识别为失败,不能当数据解析出一个空数组还「看起来成功」。
|
|
console.log('\n=== Overpass 返回 200 + HTML 错误页 ===');
|
|
process.env.HTML_MODE = '1';
|
|
// 第二轮必须绕过缓存,否则拿到的是第一轮的结果
|
|
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 res2 = await S2.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
|
|
const cands2 = res2.candidates || [];
|
|
console.log('stats =', JSON.stringify(res2.stats));
|
|
const overpassPois = cands2.filter((c) => c.source === 'OpenStreetMap / Overpass');
|
|
if (overpassPois.length) fail.push(`HTML 错误页被当成了数据,解析出 ${overpassPois.length} 个假 POI`);
|
|
else console.log('✅ HTML 错误页被正确识别为失败,未产生假候选');
|
|
delete process.env.HTML_MODE;
|
|
|
|
server.close();
|
|
process.exit(fail.length ? 1 : 0);
|
|
});
|