归属判定升级: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
+138
View File
@@ -0,0 +1,138 @@
'use strict';
/**
* 第二步真实数据验收:走完整分析流程(用高德 Key 覆盖),
* 检查真实候选的分组、搜索、折叠与卡片渲染是否有 undefined。
*/
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const OUT = path.join(__dirname, '..', 'preview');
const BASE = process.env.BASE || 'http://127.0.0.1:8787';
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const PORT = 9346;
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
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 () => {
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
`--remote-debugging-port=${PORT}`,
'--user-data-dir=' + path.join(os.tmpdir(), 'tripverify2' + 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-06', endDate: '2026-11-08',
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(1500);
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);
// 检查 undefined
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 kw = groups[0]?.items[0]?.slice(0, 2) || '柳州';
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='${kw}'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
await sleep(500);
console.log(`搜索「${kw}」→ 卡片`, await evalJs(`document.querySelectorAll('#cards .dest-card').length`), '/ 提示条:', await evalJs(`document.querySelector('#filterNote')?.innerText`));
await evalJs(`document.querySelector('#candSearchClear').click()`);
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-real.png'), Buffer.from(shot.result.data, 'base64'));
console.log('截图已保存 preview/preview-step2-real.png');
browser.ws.close();
chrome.kill();
process.exit(0);
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });