用阿里 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)
226 lines
10 KiB
JavaScript
226 lines
10 KiB
JavaScript
'use strict';
|
|
/**
|
|
* 端到端 UI 验收(完整候选量)。
|
|
*
|
|
* 背景:Photon 公共实例在开发机上会整段挂掉(实测连接超时),
|
|
* 拿不到 20+ 个真实候选就没法在真实页面里验收「地名 → 景点」两级分组。
|
|
* 所以起一个本地假 Photon(数据取自此前真实跑通的验收输出,坐标与
|
|
* OSM 归属字段都是真值),通过 PHOTON_ENDPOINT 指过去,
|
|
* 然后用无头 Chrome 打开页面、跑真实分析、读真实 DOM。
|
|
*
|
|
* 用法:node tools/verify-ui-full.cjs
|
|
*/
|
|
const { spawn } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const http = require('http');
|
|
|
|
const OUT = path.join(__dirname, '..', 'preview');
|
|
const PORT = 9351;
|
|
const PHOTON_PORT = 9352;
|
|
const BASE = process.env.BASE || 'http://127.0.0.1:8788';
|
|
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
|
|
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
|
|
// [名称, 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, '南宁市', '马山县'],
|
|
];
|
|
|
|
class CDP {
|
|
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); }
|
|
static async connect(url) {
|
|
const ws = new WebSocket(url);
|
|
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
|
const c = new CDP(ws);
|
|
ws.onmessage = (e) => {
|
|
const m = JSON.parse(e.data);
|
|
if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); }
|
|
};
|
|
return c;
|
|
}
|
|
send(method, params = {}, sessionId) {
|
|
const id = ++this.id;
|
|
return new Promise((res) => {
|
|
this.waits.set(id, res);
|
|
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
});
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
// ---- 假 Photon
|
|
const photon = http.createServer((req, res) => {
|
|
const u = new URL(req.url, 'http://x');
|
|
const include = u.searchParams.get('include') || '';
|
|
const bbox = (u.searchParams.get('bbox') || '0,0,0,0').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 }));
|
|
});
|
|
await new Promise((r) => photon.listen(PHOTON_PORT, '127.0.0.1', r));
|
|
|
|
// ---- 带 PHOTON_ENDPOINT 启动服务端
|
|
const server = spawn('node', ['server.js'], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env: { ...process.env, PHOTON_ENDPOINT: `http://127.0.0.1:${PHOTON_PORT}/api` },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let banner = '';
|
|
server.stdout.on('data', (d) => { banner += d.toString(); });
|
|
server.stderr.on('data', (d) => { banner += d.toString(); });
|
|
await sleep(3000);
|
|
const m = banner.match(/http:\/\/127\.0\.0\.1:(\d+)/);
|
|
const base = m ? `http://127.0.0.1:${m[1]}` : BASE;
|
|
console.log('服务端地址 =', base);
|
|
|
|
// ---- 无头 Chrome
|
|
const chrome = spawn(CHROME, [
|
|
'--headless=new', '--disable-gpu', '--hide-scrollbars',
|
|
`--remote-debugging-port=${PORT}`,
|
|
'--user-data-dir=' + path.join(os.tmpdir(), 'tripverifyfull' + Date.now()),
|
|
'about:blank',
|
|
], { stdio: 'ignore' });
|
|
|
|
let wsUrl = null;
|
|
for (let i = 0; i < 30 && !wsUrl; i += 1) {
|
|
await sleep(500);
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
|
wsUrl = (await res.json()).webSocketDebuggerUrl;
|
|
} catch (e) { /* 还没起来 */ }
|
|
}
|
|
if (!wsUrl) { console.error('chrome 未启动'); process.exit(1); }
|
|
|
|
const browser = await CDP.connect(wsUrl);
|
|
const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' });
|
|
const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
|
|
const S = (m, p) => browser.send(m, p, sessionId);
|
|
await S('Page.enable');
|
|
await S('Runtime.enable');
|
|
await S('Emulation.setDeviceMetricsOverride', { width: 1280, height: 1400, deviceScaleFactor: 1, mobile: false });
|
|
await S('Page.navigate', { url: base });
|
|
await sleep(2500);
|
|
|
|
const evalJs = async (expr) => {
|
|
const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
|
|
if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400));
|
|
return r.result?.result?.value;
|
|
};
|
|
|
|
const real = await evalJs(`(async () => {
|
|
const res = await fetch('/api/analyze', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
origin: { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 },
|
|
startDate: '2026-11-20', endDate: '2026-11-22',
|
|
radiusKm: 150, maxOneWayHours: 2.5, excludeLocal: true,
|
|
transportPrefs: ['不限'], interests: [],
|
|
}),
|
|
});
|
|
const txt = await res.text();
|
|
for (const line of txt.split('\\n')) {
|
|
if (!line.startsWith('data:')) continue;
|
|
const j = JSON.parse(line.slice(5).trim());
|
|
if (j.type === 'result') return j.data;
|
|
if (j.type === 'error') return { error: j.message };
|
|
}
|
|
return { error: 'no result' };
|
|
})()`);
|
|
if (real.error) { console.error('分析失败:', real.error); process.exit(1); }
|
|
console.log('真实候选数 =', real.candidates.length);
|
|
|
|
await evalJs(`(() => {
|
|
state.analysis = ${JSON.stringify(real)};
|
|
gotoStep(2); renderStep2(); renderCards();
|
|
return true;
|
|
})()`);
|
|
await sleep(1800);
|
|
|
|
const groups = await evalJs(`(() => [...document.querySelectorAll('#cards .place-group')].map(g => ({
|
|
key: g.dataset.place,
|
|
head: g.querySelector('.place-head').innerText.replace(/\\n/g, ' | '),
|
|
items: [...g.querySelectorAll('.dest-card .card-title h4')].map(x => x.textContent),
|
|
})))()`);
|
|
console.log('=== 页面真实分组 ===');
|
|
for (const g of groups) console.log(' ', g.key, '→', g.items.join(', '), '||', g.head);
|
|
|
|
const undef = await evalJs(`(() => {
|
|
const t = document.querySelector('#cards').innerText;
|
|
const hits = (t.match(/undefined/g) || []).length;
|
|
return { hits, sample: hits ? t.split('\\n').filter(x => x.includes('undefined')).slice(0, 5) : [] };
|
|
})()`);
|
|
console.log('卡片内 undefined 出现次数 =', undef.hits, undef.sample);
|
|
|
|
// 空组名检查
|
|
const emptyGroup = await evalJs(`(() => [...document.querySelectorAll('#cards .place-group')]
|
|
.filter(g => !g.dataset.place || g.dataset.place === '?' || /\\|\\s*\\?/.test(g.innerText))
|
|
.map(g => g.dataset.place))()`);
|
|
console.log('空组名分组 =', emptyGroup.length, emptyGroup);
|
|
|
|
// 搜索
|
|
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='荔波'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
|
|
await sleep(600);
|
|
console.log('搜索「荔波」→ 卡片', await evalJs(`document.querySelectorAll('#cards .dest-card').length`),
|
|
'/ 提示条:', await evalJs(`document.querySelector('#filterNote')?.innerText`));
|
|
await evalJs(`document.querySelector('#candSearchClear').click()`);
|
|
await sleep(500);
|
|
|
|
// 折叠
|
|
const beforeH = await evalJs(`document.querySelector('#cards .place-group').getBoundingClientRect().height`);
|
|
await evalJs(`(() => { const h=document.querySelector('#cards .place-head'); if(h) h.click(); return true; })()`);
|
|
await sleep(600);
|
|
const afterH = await evalJs(`document.querySelector('#cards .place-group').getBoundingClientRect().height`);
|
|
console.log('折叠前后高度 =', Math.round(beforeH), '→', Math.round(afterH), afterH < beforeH ? '(已折叠)' : '(未折叠)');
|
|
await evalJs(`(() => { const h=document.querySelector('#cards .place-head'); if(h) h.click(); return true; })()`);
|
|
await sleep(500);
|
|
|
|
const box = await evalJs(`(() => { const r = document.querySelector('#panel2').getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: Math.min(r.height, 2400) }; })()`);
|
|
const shot = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { ...box, scale: 1 } });
|
|
fs.writeFileSync(path.join(OUT, 'preview-step2-full.png'), Buffer.from(shot.result.data, 'base64'));
|
|
console.log('截图已保存 preview/preview-step2-full.png');
|
|
|
|
browser.ws.close();
|
|
chrome.kill();
|
|
server.kill();
|
|
photon.close();
|
|
process.exit(undef.hits || emptyGroup.length ? 1 : 0);
|
|
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });
|