187 lines
8.3 KiB
JavaScript
187 lines
8.3 KiB
JavaScript
// 只读 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);
|