'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); });