// 只读 CDP 采集:连接已登录 Chrome(chrome://inspect 调试模式) // 该模式只暴露浏览器级 WebSocket,不提供 /json HTTP 接口, // 因此按 chrome-devtools-mcp autoConnect 的做法读取 DevToolsActivePort。 // 用法: node cdp-probe.mjs <输出目录> import fs from 'node:fs'; import path from 'node:path'; const OUT = path.resolve(process.argv[2] || '.cdp'); fs.mkdirSync(OUT, { recursive: true }); const USER_DATA_DIR = process.env.CHROME_USER_DATA_DIR || 'C:\\Users\\Administrator\\AppData\\Local\\Google\\Chrome\\User Data'; const say = (s) => console.log(String(s)); const writeJson = (n, o) => fs.writeFileSync(path.join(OUT, n), JSON.stringify(o, null, 2), 'utf8'); const writeText = (n, s) => fs.writeFileSync(path.join(OUT, n), String(s ?? ''), 'utf8'); class Cdp { constructor(ws) { this.ws = ws; this.seq = 0; this.pending = new Map(); } static async attach(wsUrl) { const ws = new WebSocket(wsUrl); await new Promise((res, rej) => { const t = setTimeout(() => rej(new Error('ws open timeout')), 15000); ws.addEventListener('open', () => { clearTimeout(t); res(); }, { once: true }); ws.addEventListener('error', (e) => { clearTimeout(t); rej(new Error('ws error')); }, { once: true }); }); const c = new Cdp(ws); ws.addEventListener('message', (ev) => { let m; try { m = JSON.parse(ev.data); } catch { return; } if (m.id && c.pending.has(m.id)) { const p = c.pending.get(m.id); c.pending.delete(m.id); clearTimeout(p.timer); if (m.error) p.reject(new Error(m.error.message)); else p.resolve(m.result); } }); return c; } send(method, params = {}, sessionId, timeoutMs = 60000) { const id = ++this.seq; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('timeout: ' + method)); }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); const msg = { id, method, params }; if (sessionId) msg.sessionId = sessionId; this.ws.send(JSON.stringify(msg)); }); } async evalExpr(expression, sessionId) { const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, userGesture: false, }, sessionId); if (r.exceptionDetails) { const d = r.exceptionDetails; return { __error: d.text + ' :: ' + (d.exception?.description || '') }; } return r.result?.value; } close() { try { this.ws.close(); } catch {} } } // ---------------- 页面侧探针(纯只读,不点击任何按钮) ---------------- const PROBE = String.raw`(() => { const out = {}; try { const q = (s) => Array.from(document.querySelectorAll(s)); out.meta = { href: location.href, title: document.title, readyState: document.readyState }; out.iframeCount = q('iframe').length; out.antTableCount = q('.ant-table').length; out.pagination = q('.ant-pagination-item').map(e => e.innerText.trim()); out.activePage = (document.querySelector('.ant-pagination-item-active') || {}).innerText || null; out.paginationTotal = (document.querySelector('.ant-pagination-total-text') || {}).innerText || null; out.tabs = q('.ant-tabs-tab').map(e => e.innerText.trim()); out.radioGroups = q('.ant-radio-group').map(g => g.innerText.replace(/\s+/g, ' ').trim()); out.selects = q('.ant-select').map(s => s.innerText.replace(/\s+/g, ' ').trim()); out.dateInputs = q('.ant-picker input').map(i => i.value); out.buttons = q('button').map(b => b.innerText.trim()).filter(Boolean).slice(0, 50); out.headers = q('.ant-table-thead th').map(th => th.innerText.replace(/\s+/g, ' ').trim()).filter(Boolean); const rows = q('.ant-table-tbody tr'); out.renderedRowCount = rows.length; out.renderedRows = rows.map(tr => Array.from(tr.querySelectorAll('td')).map(td => (td.innerText || '').replace(/\s+/g, ' ').trim())); // 从 React fiber 取完整 dataSource(绕开虚拟滚动只渲染部分行的问题) let ds = null, dsPath = null; const anchor = rows[0] || document.querySelector('.ant-table'); const fk = anchor && Object.keys(anchor).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')); out.reactFiberFound = !!fk; if (fk) { let f = anchor[fk], d = 0; while (f && d < 80) { const p = f.memoizedProps; if (p && Array.isArray(p.dataSource) && p.dataSource.length) { ds = p.dataSource; dsPath = 'fiber depth=' + d; break; } f = f.return; d++; } } const plain = (o, depth) => { if (o === null || o === undefined) return null; const t = typeof o; if (t === 'string' || t === 'number' || t === 'boolean') return o; if (t !== 'object') return String(o); if (Array.isArray(o)) return depth > 5 ? '[array]' : o.slice(0, 300).map(x => plain(x, depth + 1)); if (o.$$typeof) return '[react-element]'; if (depth > 5) return '[object]'; const r = {}; for (const k of Object.keys(o)) { try { r[k] = plain(o[k], depth + 1); } catch { r[k] = '[err]'; } } return r; }; out.dataSource = { path: dsPath, length: ds ? ds.length : 0, keys: ds ? Object.keys(ds[0] || {}) : [] }; if (ds) out._dataSourceFull = plain(ds, 0); const main = document.querySelector('.ant-layout-content') || document.body; out._mainText = (main.innerText || '').slice(0, 30000); return out; } catch (e) { return { __error: String((e && e.stack) || e) }; } })()`; // ---------------- 主流程 ---------------- let port, wsPath; try { const raw = fs.readFileSync(path.join(USER_DATA_DIR, 'DevToolsActivePort'), 'utf8'); const lines = raw.split('\n').map(s => s.trim()).filter(Boolean); port = parseInt(lines[0], 10); wsPath = lines[1]; if (!port || !wsPath) throw new Error('bad DevToolsActivePort: ' + JSON.stringify(lines)); } catch (e) { say('[cdp] FATAL cannot read DevToolsActivePort: ' + e.message); say('[cdp] -> 请在 Chrome 打开 chrome://inspect/#remote-debugging 并启用远程调试'); process.exit(3); } const wsUrl = 'ws://127.0.0.1:' + port + wsPath; say('[cdp] endpoint = ' + wsUrl); const cdp = await Cdp.attach(wsUrl); say('[cdp] browser ws connected'); const { targetInfos } = await cdp.send('Target.getTargets'); writeJson('targets.json', targetInfos); say('[cdp] targets = ' + targetInfos.length); for (const t of targetInfos) say(' - ' + t.type + ' | ' + t.title + ' | ' + t.url); let target = targetInfos.find(t => t.type === 'page' && /seller\.goofish\.com/.test(t.url)); if (!target) target = targetInfos.find(t => t.type === 'page' && /goofish\.com/.test(t.url)); if (!target) { say('[cdp] NO goofish page target found (请确认该标签页已打开)'); cdp.close(); process.exit(2); } say('[cdp] selected = ' + target.title + ' | ' + target.url); const { sessionId } = await cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); say('[cdp] sessionId = ' + sessionId); const res = await cdp.evalExpr(PROBE, sessionId); if (!res || res.__error) { say('[cdp] PROBE ERROR: ' + (res && res.__error)); writeJson('probe-error.json', res || null); cdp.close(); process.exit(4); } const { _dataSourceFull, _mainText, ...rest } = res; writeJson('probe.json', rest); if (_dataSourceFull) writeJson('datasource.json', _dataSourceFull); if (_mainText) writeText('maintext.txt', _mainText); say('[cdp] ---------- probe summary ----------'); say(' href = ' + (rest.meta && rest.meta.href)); say(' tabs = ' + JSON.stringify(rest.tabs)); say(' dateInputs = ' + JSON.stringify(rest.dateInputs)); say(' radioGroups = ' + JSON.stringify(rest.radioGroups)); say(' selects = ' + JSON.stringify(rest.selects)); say(' pagination = ' + JSON.stringify(rest.pagination) + ' active=' + rest.activePage + ' total=' + rest.paginationTotal); say(' headers = ' + JSON.stringify(rest.headers)); say(' rows = rendered ' + rest.renderedRowCount + ' / dataSource ' + JSON.stringify(rest.dataSource)); say(' buttons = ' + JSON.stringify(rest.buttons)); say(' files = probe.json' + (_dataSourceFull ? ' + datasource.json' : '') + (_mainText ? ' + maintext.txt' : '')); const head = (rest.renderedRows || []).slice(0, 3); say(' sampleRows = ' + JSON.stringify(head)); cdp.close(); process.exit(0);