用阿里 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)
179 lines
8.7 KiB
JavaScript
179 lines
8.7 KiB
JavaScript
'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); });
|