归属判定升级: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
+178
View File
@@ -0,0 +1,178 @@
'use strict';
/**
* 第二步验收:分组渲染 / 折叠 / 搜索过滤 / 筛选提示条。
* 走 CDP 打开真实页面,注入一份候选数据(Photon 公共实例不稳,不依赖它),
* 然后核对 DOM 结构并截图到 preview/。
*/
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
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 = 9345;
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 function getWsUrl() {
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
return (await res.json()).webSocketDebuggerUrl;
}
(async () => {
// 启动 headless Chrome(带调试端口)
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
`--remote-debugging-port=${PORT}`,
'--user-data-dir=' + path.join(require('os').tmpdir(), 'tripverify' + Date.now()),
'about:blank',
], { stdio: 'ignore' });
let wsUrl = null;
for (let i = 0; i < 30 && !wsUrl; i += 1) {
await sleep(500);
try { wsUrl = await getWsUrl(); } 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;
};
// 注入候选数据(覆盖三个地名的多个景点),直接驱动 renderCards
await evalJs(`(() => {
const mk = (id, name, place, placeKey, placeDetail, score, km, kind) => ({
id, name, kind, place, placeKey, placeDetail, score, verdict: score >= 60 ? '推荐' : '可考虑',
distanceKm: km, bearingDeg: 45, tags: ['自然风光'], region: '',
breakdown: { weather: 20, time: 20, interest: 10 },
reasons: ['风景好'], warnings: [],
weather: { summary: { days: 3, dominant: { icon: '☀️', label: '晴' }, avgHigh: 26, avgLow: 16, maxPrecipProb: 20, totalPrecip: 0, maxWind: 8 } },
road: { durationMinutes: 120, distanceKm: km, source: 'OSRM' },
rail: { available: false },
aqi: 42, aqiInfo: { label: '优' },
});
state.analysis = {
origin: { label: '广西壮族自治区 · 河池市 · 金城江区' },
timeRange: { start: '2026-11-06', end: '2026-11-08', days: 3, weekdayHint: '周五至周日' },
constraints: { radiusKm: 150, excludeLocal: true },
summary: {}, stats: {}, dataNotes: [],
candidates: [
mk('a1', '荔波小七孔', '黔南州', '黔南', '荔波县', 78, 77, '风景区'),
mk('a2', '茂兰喀斯特森林', '黔南州', '黔南', '荔波县', 72, 80, '风景区'),
mk('a3', '邓恩铭故居', '黔南州', '黔南', '荔波县', 61, 82, '博物馆'),
mk('b1', '加榜梯田', '黔东南州', '黔东南', '从江县', 75, 165, '风景区'),
mk('b2', '西江千户苗寨', '黔东南州', '黔东南', '雷山县', 70, 199, '古村'),
mk('c1', '柳州古城', '柳州市', '柳州', '城中区', 66, 141, '古迹'),
mk('c2', '融水双龙沟', '柳州市', '柳州', '融水苗族自治县', 64, 125, '风景区'),
],
};
gotoStep(2);
renderStep2();
renderCards();
return true;
})()`);
await sleep(1200);
// 1) 分组结构
const groups = await evalJs(`(() => {
return [...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 cardCount = await evalJs(`document.querySelectorAll('#cards .dest-card').length`);
console.log('卡片数 =', cardCount, '(应为 7)');
// 2) 搜索过滤
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='溶洞'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
await sleep(500);
console.log('搜索「溶洞」后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
console.log('提示条 =', await evalJs(`document.querySelector('#filterNote')?.innerText`));
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='柳州'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
await sleep(500);
console.log('搜索「柳州」后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
console.log('分组数 =', await evalJs(`document.querySelectorAll('#cards .place-group').length`));
// 无匹配 → 空态
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='不存在的关键词xyz'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
await sleep(500);
console.log('无匹配空态 =', await evalJs(`document.querySelector('#cards .empty-mini')?.innerText`));
// 清空
await evalJs(`document.querySelector('#candSearchClear').click()`);
await sleep(500);
console.log('清空后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
// 3) 折叠
await evalJs(`document.querySelectorAll('#cards [data-place-toggle]')[0].click()`);
await sleep(400);
console.log('折叠后首组 visible =', await evalJs(`(() => { const g=document.querySelector('#cards .place-group'); return {collapsed: g.querySelector('.place-items').classList.contains('collapsed'), caret: g.querySelector('.place-caret').textContent, aria: g.querySelector('.place-head').getAttribute('aria-expanded')}; })()`));
await evalJs(`document.querySelectorAll('#cards [data-place-toggle]')[0].click()`);
await sleep(400);
console.log('展开后 collapsed =', await evalJs(`document.querySelector('#cards .place-group .place-items').classList.contains('collapsed')`));
// 4) 平铺模式
await evalJs(`(() => { const c = document.querySelector('#groupByPlace'); c.checked = false; c.dispatchEvent(new Event('change',{bubbles:true})); return true; })()`);
await sleep(500);
console.log('平铺模式:分组数 =', await evalJs(`document.querySelectorAll('#cards .place-group').length`), '卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
await evalJs(`(() => { const c = document.querySelector('#groupByPlace'); c.checked = true; c.dispatchEvent(new Event('change',{bubbles:true})); return true; })()`);
await sleep(500);
// 截图:第二步整块
const box = await evalJs(`(() => {
const el = document.querySelector('#panel2');
const r = el.getBoundingClientRect();
return { x: r.x + window.scrollX, y: r.y + window.scrollY, width: r.width, height: Math.min(r.height, 2200) };
})()`);
const shot = await S('Page.captureScreenshot', {
format: 'png', captureBeyondViewport: true,
clip: { x: box.x, y: box.y, width: box.width, height: box.height, scale: 1 },
});
if (!shot.result?.data) throw new Error('no screenshot: ' + JSON.stringify(shot).slice(0, 300));
fs.writeFileSync(path.join(OUT, 'preview-step2-groups.png'), Buffer.from(shot.result.data, 'base64'));
console.log('截图已保存 preview/preview-step2-groups.png');
browser.ws.close();
chrome.kill();
process.exit(0);
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });