@pyrokine/mcp-chrome 1.7.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -31
- package/dist/anti-detection/behavior.d.ts.map +1 -1
- package/dist/anti-detection/behavior.js.map +1 -1
- package/dist/anti-detection/index.d.ts +1 -1
- package/dist/anti-detection/index.d.ts.map +1 -1
- package/dist/anti-detection/index.js +1 -1
- package/dist/anti-detection/index.js.map +1 -1
- package/dist/anti-detection/injection.d.ts +6 -2
- package/dist/anti-detection/injection.d.ts.map +1 -1
- package/dist/anti-detection/injection.js +32 -79
- package/dist/anti-detection/injection.js.map +1 -1
- package/dist/cdp/client.d.ts +2 -2
- package/dist/cdp/client.d.ts.map +1 -1
- package/dist/cdp/client.js +8 -10
- package/dist/cdp/client.js.map +1 -1
- package/dist/cdp/index.d.ts.map +1 -1
- package/dist/cdp/index.js.map +1 -1
- package/dist/cdp/launcher.d.ts.map +1 -1
- package/dist/cdp/launcher.js +40 -13
- package/dist/cdp/launcher.js.map +1 -1
- package/dist/core/auto-wait.d.ts +2 -2
- package/dist/core/auto-wait.d.ts.map +1 -1
- package/dist/core/auto-wait.js +2 -2
- package/dist/core/auto-wait.js.map +1 -1
- package/dist/core/browser-driver.d.ts +307 -0
- package/dist/core/browser-driver.d.ts.map +1 -0
- package/dist/core/browser-driver.js +21 -0
- package/dist/core/browser-driver.js.map +1 -0
- package/dist/core/error-sanitizer.d.ts +25 -0
- package/dist/core/error-sanitizer.d.ts.map +1 -0
- package/dist/core/error-sanitizer.js +66 -0
- package/dist/core/error-sanitizer.js.map +1 -0
- package/dist/core/errors.d.ts +10 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +17 -4
- package/dist/core/errors.js.map +1 -1
- package/dist/core/extension-errors.d.ts +20 -0
- package/dist/core/extension-errors.d.ts.map +1 -0
- package/dist/core/extension-errors.js +40 -0
- package/dist/core/extension-errors.js.map +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/locator.d.ts +2 -2
- package/dist/core/locator.d.ts.map +1 -1
- package/dist/core/locator.js +25 -65
- package/dist/core/locator.js.map +1 -1
- package/dist/core/retry.d.ts +2 -2
- package/dist/core/retry.d.ts.map +1 -1
- package/dist/core/retry.js +2 -2
- package/dist/core/retry.js.map +1 -1
- package/dist/core/session.d.ts +153 -46
- package/dist/core/session.d.ts.map +1 -1
- package/dist/core/session.js +672 -177
- package/dist/core/session.js.map +1 -1
- package/dist/core/types.d.ts +9 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +13 -6
- package/dist/core/types.js.map +1 -1
- package/dist/core/unified-session.d.ts +46 -81
- package/dist/core/unified-session.d.ts.map +1 -1
- package/dist/core/unified-session.js +338 -635
- package/dist/core/unified-session.js.map +1 -1
- package/dist/core/utils.d.ts +7 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +33 -0
- package/dist/core/utils.js.map +1 -0
- package/dist/extension/bridge.d.ts +69 -50
- package/dist/extension/bridge.d.ts.map +1 -1
- package/dist/extension/bridge.js +176 -77
- package/dist/extension/bridge.js.map +1 -1
- package/dist/extension/http-server.d.ts +6 -4
- package/dist/extension/http-server.d.ts.map +1 -1
- package/dist/extension/http-server.js +45 -31
- package/dist/extension/http-server.js.map +1 -1
- package/dist/extension/index.d.ts.map +1 -1
- package/dist/extension/index.js.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/browse.d.ts.map +1 -1
- package/dist/tools/browse.js +32 -34
- package/dist/tools/browse.js.map +1 -1
- package/dist/tools/cookies.d.ts.map +1 -1
- package/dist/tools/cookies.js +38 -16
- package/dist/tools/cookies.js.map +1 -1
- package/dist/tools/evaluate.d.ts.map +1 -1
- package/dist/tools/evaluate.js +54 -23
- package/dist/tools/evaluate.js.map +1 -1
- package/dist/tools/extract.d.ts.map +1 -1
- package/dist/tools/extract.js +221 -153
- package/dist/tools/extract.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +271 -90
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/logs.d.ts.map +1 -1
- package/dist/tools/logs.js +31 -17
- package/dist/tools/logs.js.map +1 -1
- package/dist/tools/manage.d.ts.map +1 -1
- package/dist/tools/manage.js +25 -28
- package/dist/tools/manage.js.map +1 -1
- package/dist/tools/schema.d.ts +1 -1
- package/dist/tools/schema.d.ts.map +1 -1
- package/dist/tools/schema.js +31 -55
- package/dist/tools/schema.js.map +1 -1
- package/dist/tools/wait.d.ts.map +1 -1
- package/dist/tools/wait.js +19 -16
- package/dist/tools/wait.js.map +1 -1
- package/package.json +48 -40
package/dist/core/session.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { BehaviorSimulator, getAntiDetectionScript } from '../anti-detection/index.js';
|
|
11
11
|
import { BrowserLauncher, CDPClient, getBrowserWSEndpoint, getTargets } from '../cdp/index.js';
|
|
12
12
|
import { AutoWait } from './auto-wait.js';
|
|
13
|
-
import {
|
|
13
|
+
import { DriverCapabilityError, } from './browser-driver.js';
|
|
14
|
+
import { NavigationError, NavigationTimeoutError, SessionNotFoundError, TargetNotFoundError } from './errors.js';
|
|
14
15
|
import { Locator } from './locator.js';
|
|
15
16
|
import { DEFAULT_TIMEOUT, extractCdpValue, formatCdpException, MODIFIER_KEYS, } from './types.js';
|
|
16
17
|
/**
|
|
@@ -37,8 +38,7 @@ class SessionManager {
|
|
|
37
38
|
requestMap = new Map();
|
|
38
39
|
// 监听器安装标志(防止重复安装)
|
|
39
40
|
listenersInstalled = false;
|
|
40
|
-
constructor() {
|
|
41
|
-
}
|
|
41
|
+
constructor() { }
|
|
42
42
|
/**
|
|
43
43
|
* 获取当前调试端口
|
|
44
44
|
*/
|
|
@@ -54,8 +54,8 @@ class SessionManager {
|
|
|
54
54
|
/**
|
|
55
55
|
* 启动浏览器
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* 如果指定了端口,会先尝试连接该端口上已运行的浏览器,
|
|
58
|
+
* 只有连接失败时才启动新浏览器
|
|
59
59
|
*/
|
|
60
60
|
async launch(options = {}) {
|
|
61
61
|
return this.withLock(async () => {
|
|
@@ -172,10 +172,12 @@ class SessionManager {
|
|
|
172
172
|
return targetInfos
|
|
173
173
|
.filter((t) => t.type === 'page')
|
|
174
174
|
.map((t) => ({
|
|
175
|
+
id: t.targetId,
|
|
175
176
|
targetId: t.targetId,
|
|
176
|
-
type: t.type,
|
|
177
177
|
url: t.url,
|
|
178
178
|
title: t.title,
|
|
179
|
+
type: t.type,
|
|
180
|
+
active: t.targetId === this.currentTargetId,
|
|
179
181
|
}));
|
|
180
182
|
}
|
|
181
183
|
/**
|
|
@@ -191,19 +193,20 @@ class SessionManager {
|
|
|
191
193
|
return this.withLock(async () => {
|
|
192
194
|
this.ensureSession();
|
|
193
195
|
const { wait = 'load', timeout = DEFAULT_TIMEOUT } = options;
|
|
196
|
+
// networkidle:先注册监听器再 navigate,避免 navigate 返回和监听器注册之间的竞态窗口
|
|
197
|
+
// noinspection ES6MissingAwait — 故意不 await,Promise 在 navigate 完成后才消费
|
|
198
|
+
const networkIdlePromise = wait === 'networkidle' ? this.startNetworkIdleWatcher(timeout) : null;
|
|
194
199
|
// 导航(传 timeout 防止 CDP 默认 30s 截断用户预算)
|
|
195
200
|
const { errorText } = (await this.send('Page.navigate', { url }, timeout));
|
|
196
201
|
if (errorText) {
|
|
197
|
-
throw new
|
|
202
|
+
throw new NavigationError(url, errorText);
|
|
198
203
|
}
|
|
199
204
|
// 根据 wait 类型等待
|
|
200
|
-
if (
|
|
201
|
-
await
|
|
205
|
+
if (networkIdlePromise) {
|
|
206
|
+
await networkIdlePromise;
|
|
202
207
|
}
|
|
203
208
|
else {
|
|
204
|
-
const eventName = wait === 'domcontentloaded'
|
|
205
|
-
? 'Page.domContentEventFired'
|
|
206
|
-
: 'Page.loadEventFired';
|
|
209
|
+
const eventName = wait === 'domcontentloaded' ? 'Page.domContentEventFired' : 'Page.loadEventFired';
|
|
207
210
|
await this.cdp.waitForEvent(eventName, undefined, timeout);
|
|
208
211
|
}
|
|
209
212
|
// 更新状态
|
|
@@ -213,72 +216,11 @@ class SessionManager {
|
|
|
213
216
|
/**
|
|
214
217
|
* 等待网络空闲(无进行中的请求且持续指定时间)
|
|
215
218
|
*
|
|
216
|
-
* close() 时通过 'disconnected' 信号立即 reject,不必等 timer
|
|
219
|
+
* close() 时通过 'disconnected' 信号立即 reject,不必等 timer 超时
|
|
217
220
|
*/
|
|
218
221
|
async waitForNetworkIdle(timeout, idleTime = 500) {
|
|
219
222
|
this.ensureSession();
|
|
220
|
-
|
|
221
|
-
const cdp = this.cdp;
|
|
222
|
-
// 使用局部 Set 追踪本次等待的请求,避免污染成员变量
|
|
223
|
-
const localPendingRequests = new Set();
|
|
224
|
-
return new Promise((resolve, reject) => {
|
|
225
|
-
let idleTimer = null;
|
|
226
|
-
let timeoutTimer = null;
|
|
227
|
-
const checkIdle = () => {
|
|
228
|
-
if (localPendingRequests.size === 0) {
|
|
229
|
-
if (idleTimer === null) {
|
|
230
|
-
idleTimer = setTimeout(() => {
|
|
231
|
-
cleanup();
|
|
232
|
-
resolve();
|
|
233
|
-
}, idleTime);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
if (idleTimer !== null) {
|
|
238
|
-
clearTimeout(idleTimer);
|
|
239
|
-
idleTimer = null;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
const onRequestStart = (params) => {
|
|
244
|
-
const { requestId } = params;
|
|
245
|
-
localPendingRequests.add(requestId);
|
|
246
|
-
checkIdle();
|
|
247
|
-
};
|
|
248
|
-
const onRequestEnd = (params) => {
|
|
249
|
-
const { requestId } = params;
|
|
250
|
-
localPendingRequests.delete(requestId);
|
|
251
|
-
checkIdle();
|
|
252
|
-
};
|
|
253
|
-
const cleanup = () => {
|
|
254
|
-
if (idleTimer !== null) {
|
|
255
|
-
clearTimeout(idleTimer);
|
|
256
|
-
}
|
|
257
|
-
if (timeoutTimer !== null) {
|
|
258
|
-
clearTimeout(timeoutTimer);
|
|
259
|
-
}
|
|
260
|
-
cdp.offEvent('Network.requestWillBeSent', onRequestStart);
|
|
261
|
-
cdp.offEvent('Network.loadingFinished', onRequestEnd);
|
|
262
|
-
cdp.offEvent('Network.loadingFailed', onRequestEnd);
|
|
263
|
-
cdp.removeListener('disconnected', onDisconnected);
|
|
264
|
-
};
|
|
265
|
-
// 超时处理
|
|
266
|
-
timeoutTimer = setTimeout(() => {
|
|
267
|
-
cleanup();
|
|
268
|
-
reject(new NavigationTimeoutError('networkidle', timeout));
|
|
269
|
-
}, timeout);
|
|
270
|
-
const onDisconnected = () => {
|
|
271
|
-
cleanup();
|
|
272
|
-
reject(new Error('CDP 连接已关闭'));
|
|
273
|
-
};
|
|
274
|
-
cdp.once('disconnected', onDisconnected);
|
|
275
|
-
// 监听网络事件
|
|
276
|
-
cdp.onEvent('Network.requestWillBeSent', onRequestStart);
|
|
277
|
-
cdp.onEvent('Network.loadingFinished', onRequestEnd);
|
|
278
|
-
cdp.onEvent('Network.loadingFailed', onRequestEnd);
|
|
279
|
-
// 初始检查
|
|
280
|
-
checkIdle();
|
|
281
|
-
});
|
|
223
|
+
return this.buildNetworkIdlePromise(this.cdp, timeout, idleTime);
|
|
282
224
|
}
|
|
283
225
|
/**
|
|
284
226
|
* 等待导航完成(跨文档导航或同文档导航)
|
|
@@ -301,8 +243,7 @@ class SessionManager {
|
|
|
301
243
|
const waitPromise = this.waitForAnyEvent(['Page.loadEventFired', 'Page.navigatedWithinDocument'], timeout);
|
|
302
244
|
// 预注册 rejection handler:若 send() 抛错导致 waitPromise 永远不被 await,
|
|
303
245
|
// 其 timer reject 不会成为 unhandled rejection(Node 20 默认会退出进程)
|
|
304
|
-
waitPromise.catch(() => {
|
|
305
|
-
});
|
|
246
|
+
waitPromise.catch(() => { });
|
|
306
247
|
await this.send('Page.navigateToHistoryEntry', { entryId: entries[currentIndex - 1].id }, timeout);
|
|
307
248
|
await waitPromise;
|
|
308
249
|
await this.updateState();
|
|
@@ -321,8 +262,7 @@ class SessionManager {
|
|
|
321
262
|
}
|
|
322
263
|
// 跨文档导航触发 loadEventFired,同文档导航(hash/pushState)触发 navigatedWithinDocument
|
|
323
264
|
const waitPromise = this.waitForAnyEvent(['Page.loadEventFired', 'Page.navigatedWithinDocument'], timeout);
|
|
324
|
-
waitPromise.catch(() => {
|
|
325
|
-
});
|
|
265
|
+
waitPromise.catch(() => { });
|
|
326
266
|
await this.send('Page.navigateToHistoryEntry', { entryId: entries[currentIndex + 1].id }, timeout);
|
|
327
267
|
await waitPromise;
|
|
328
268
|
await this.updateState();
|
|
@@ -332,13 +272,11 @@ class SessionManager {
|
|
|
332
272
|
/**
|
|
333
273
|
* 刷新
|
|
334
274
|
*/
|
|
335
|
-
async reload(
|
|
275
|
+
async reload(ignoreCache = false, _waitUntil, timeout = DEFAULT_TIMEOUT) {
|
|
336
276
|
return this.withLock(async () => {
|
|
337
277
|
this.ensureSession();
|
|
338
|
-
const { ignoreCache = false, timeout = DEFAULT_TIMEOUT } = options;
|
|
339
278
|
const waitPromise = this.cdp.waitForEvent('Page.loadEventFired', undefined, timeout);
|
|
340
|
-
waitPromise.catch(() => {
|
|
341
|
-
});
|
|
279
|
+
waitPromise.catch(() => { });
|
|
342
280
|
await this.send('Page.reload', { ignoreCache }, timeout);
|
|
343
281
|
await waitPromise;
|
|
344
282
|
await this.updateState();
|
|
@@ -373,23 +311,26 @@ class SessionManager {
|
|
|
373
311
|
*/
|
|
374
312
|
async mouseMove(x, y) {
|
|
375
313
|
this.ensureSession();
|
|
314
|
+
// 整数点位:避免 sub-pixel 渲染时坐标命中元素边界外的情况(与 Playwright 一致)
|
|
315
|
+
const ix = Math.round(x);
|
|
316
|
+
const iy = Math.round(y);
|
|
376
317
|
await this.send('Input.dispatchMouseEvent', {
|
|
377
318
|
type: 'mouseMoved',
|
|
378
|
-
x,
|
|
379
|
-
y,
|
|
319
|
+
x: ix,
|
|
320
|
+
y: iy,
|
|
380
321
|
modifiers: this.modifiers,
|
|
381
322
|
});
|
|
382
|
-
this.behaviorSimulator.setCurrentPosition({ x, y });
|
|
323
|
+
this.behaviorSimulator.setCurrentPosition({ x: ix, y: iy });
|
|
383
324
|
}
|
|
384
325
|
/**
|
|
385
326
|
* 鼠标按下
|
|
386
327
|
*/
|
|
387
|
-
async mouseDown(button = 'left') {
|
|
328
|
+
async mouseDown(button = 'left', clickCount = 1) {
|
|
388
329
|
this.ensureSession();
|
|
389
330
|
await this.send('Input.dispatchMouseEvent', {
|
|
390
331
|
type: 'mousePressed',
|
|
391
332
|
button,
|
|
392
|
-
clickCount
|
|
333
|
+
clickCount,
|
|
393
334
|
x: this.behaviorSimulator.getCurrentPosition().x,
|
|
394
335
|
y: this.behaviorSimulator.getCurrentPosition().y,
|
|
395
336
|
modifiers: this.modifiers,
|
|
@@ -398,12 +339,12 @@ class SessionManager {
|
|
|
398
339
|
/**
|
|
399
340
|
* 鼠标释放
|
|
400
341
|
*/
|
|
401
|
-
async mouseUp(button = 'left') {
|
|
342
|
+
async mouseUp(button = 'left', clickCount = 1) {
|
|
402
343
|
this.ensureSession();
|
|
403
344
|
await this.send('Input.dispatchMouseEvent', {
|
|
404
345
|
type: 'mouseReleased',
|
|
405
346
|
button,
|
|
406
|
-
clickCount
|
|
347
|
+
clickCount,
|
|
407
348
|
x: this.behaviorSimulator.getCurrentPosition().x,
|
|
408
349
|
y: this.behaviorSimulator.getCurrentPosition().y,
|
|
409
350
|
modifiers: this.modifiers,
|
|
@@ -426,18 +367,28 @@ class SessionManager {
|
|
|
426
367
|
}
|
|
427
368
|
/**
|
|
428
369
|
* 键盘按下
|
|
370
|
+
*
|
|
371
|
+
* options.rawKeyDown=true 配合 options.autoRepeat=true 表示长按重复(与 Puppeteer 一致),
|
|
372
|
+
* 上层 unified-session 维护 pressedKeys Set 决定是否启用
|
|
429
373
|
*/
|
|
430
|
-
async keyDown(key) {
|
|
374
|
+
async keyDown(key, commands, options) {
|
|
431
375
|
this.ensureSession();
|
|
432
376
|
if (MODIFIER_KEYS[key]) {
|
|
433
377
|
this.modifiers |= MODIFIER_KEYS[key];
|
|
434
378
|
}
|
|
435
379
|
const keyDefinition = getKeyDefinition(key);
|
|
436
|
-
|
|
437
|
-
type: 'keyDown',
|
|
380
|
+
const params = {
|
|
381
|
+
type: options?.rawKeyDown ? 'rawKeyDown' : 'keyDown',
|
|
438
382
|
modifiers: this.modifiers,
|
|
439
383
|
...keyDefinition,
|
|
440
|
-
}
|
|
384
|
+
};
|
|
385
|
+
if (options?.autoRepeat) {
|
|
386
|
+
params.autoRepeat = true;
|
|
387
|
+
}
|
|
388
|
+
if (commands && commands.length > 0) {
|
|
389
|
+
params.commands = commands;
|
|
390
|
+
}
|
|
391
|
+
await this.send('Input.dispatchKeyEvent', params);
|
|
441
392
|
}
|
|
442
393
|
/**
|
|
443
394
|
* 键盘释放
|
|
@@ -454,22 +405,55 @@ class SessionManager {
|
|
|
454
405
|
this.modifiers &= ~MODIFIER_KEYS[key];
|
|
455
406
|
}
|
|
456
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* 同步外部修饰键状态(供 UnifiedSessionManager 在 CDP 回退路径使用,确保两路 modifiers 一致)
|
|
410
|
+
*/
|
|
411
|
+
setModifiers(value) {
|
|
412
|
+
this.modifiers = value;
|
|
413
|
+
}
|
|
457
414
|
/**
|
|
458
415
|
* 输入文本
|
|
459
416
|
*/
|
|
460
417
|
async type(text, delay = 0) {
|
|
461
418
|
this.ensureSession();
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
419
|
+
// 归一化换行:\r\n 和 \r 都视作单个 \n
|
|
420
|
+
const normalized = text.replace(/\r\n?/g, '\n');
|
|
421
|
+
for (const char of normalized) {
|
|
422
|
+
if (char === '\n') {
|
|
423
|
+
// Enter 键:char 通道不接受 \n,必须分发 keyDown + char('\r') + keyUp
|
|
424
|
+
const enterParams = {
|
|
425
|
+
key: 'Enter',
|
|
426
|
+
code: 'Enter',
|
|
427
|
+
windowsVirtualKeyCode: 13,
|
|
428
|
+
nativeVirtualKeyCode: 13,
|
|
429
|
+
modifiers: this.modifiers,
|
|
430
|
+
};
|
|
431
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
432
|
+
type: 'keyDown',
|
|
433
|
+
...enterParams,
|
|
434
|
+
});
|
|
435
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
436
|
+
type: 'char',
|
|
437
|
+
text: '\r',
|
|
438
|
+
...enterParams,
|
|
439
|
+
});
|
|
440
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
441
|
+
type: 'keyUp',
|
|
442
|
+
...enterParams,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
447
|
+
type: 'keyDown',
|
|
448
|
+
modifiers: this.modifiers,
|
|
449
|
+
text: char,
|
|
450
|
+
});
|
|
451
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
452
|
+
type: 'keyUp',
|
|
453
|
+
modifiers: this.modifiers,
|
|
454
|
+
text: char,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
473
457
|
if (delay > 0) {
|
|
474
458
|
await new Promise((r) => setTimeout(r, delay));
|
|
475
459
|
}
|
|
@@ -480,36 +464,28 @@ class SessionManager {
|
|
|
480
464
|
*/
|
|
481
465
|
async touchStart(x, y) {
|
|
482
466
|
this.ensureSession();
|
|
483
|
-
await this.
|
|
484
|
-
type: 'touchStart',
|
|
485
|
-
touchPoints: [{ x, y }],
|
|
486
|
-
});
|
|
467
|
+
await this.dispatchTouch({ type: 'touchStart', touchPoints: [{ x, y }] });
|
|
487
468
|
}
|
|
488
469
|
/**
|
|
489
470
|
* 触屏移动
|
|
490
471
|
*/
|
|
491
472
|
async touchMove(x, y) {
|
|
492
473
|
this.ensureSession();
|
|
493
|
-
await this.
|
|
494
|
-
type: 'touchMove',
|
|
495
|
-
touchPoints: [{ x, y }],
|
|
496
|
-
});
|
|
474
|
+
await this.dispatchTouch({ type: 'touchMove', touchPoints: [{ x, y }] });
|
|
497
475
|
}
|
|
498
476
|
/**
|
|
499
477
|
* 触屏结束
|
|
500
478
|
*/
|
|
501
479
|
async touchEnd() {
|
|
502
480
|
this.ensureSession();
|
|
503
|
-
await this.
|
|
504
|
-
type: 'touchEnd',
|
|
505
|
-
touchPoints: [],
|
|
506
|
-
});
|
|
481
|
+
await this.dispatchTouch({ type: 'touchEnd', touchPoints: [] });
|
|
507
482
|
}
|
|
508
483
|
/**
|
|
509
484
|
* 截图
|
|
510
485
|
*/
|
|
511
|
-
async screenshot(
|
|
486
|
+
async screenshot(options = {}) {
|
|
512
487
|
this.ensureSession();
|
|
488
|
+
const { fullPage = false, scale, format, quality, clip } = options;
|
|
513
489
|
const effectiveFormat = format ?? 'png';
|
|
514
490
|
const captureParams = { format: effectiveFormat };
|
|
515
491
|
if (quality !== undefined && effectiveFormat !== 'png') {
|
|
@@ -520,11 +496,20 @@ class SessionManager {
|
|
|
520
496
|
}
|
|
521
497
|
if (fullPage) {
|
|
522
498
|
// 获取页面完整高度
|
|
499
|
+
const sizeExpr = 'JSON.stringify({ width: document.documentElement.scrollWidth, ' +
|
|
500
|
+
'height: document.documentElement.scrollHeight })';
|
|
523
501
|
const { result } = (await this.send('Runtime.evaluate', {
|
|
524
|
-
expression:
|
|
502
|
+
expression: sizeExpr,
|
|
525
503
|
returnByValue: true,
|
|
526
504
|
}));
|
|
527
|
-
|
|
505
|
+
let width, height;
|
|
506
|
+
try {
|
|
507
|
+
;
|
|
508
|
+
({ width, height } = JSON.parse(result.value));
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
throw new Error(`screenshot: 无法解析页面尺寸: ${result.value}`);
|
|
512
|
+
}
|
|
528
513
|
// 设置视口
|
|
529
514
|
await this.send('Emulation.setDeviceMetricsOverride', {
|
|
530
515
|
width,
|
|
@@ -534,14 +519,19 @@ class SessionManager {
|
|
|
534
519
|
});
|
|
535
520
|
try {
|
|
536
521
|
const { data } = (await this.send('Page.captureScreenshot', captureParams));
|
|
537
|
-
return data;
|
|
522
|
+
return { data, format: effectiveFormat };
|
|
538
523
|
}
|
|
539
524
|
finally {
|
|
540
|
-
|
|
525
|
+
try {
|
|
526
|
+
await this.send('Emulation.clearDeviceMetricsOverride');
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// cleanup failure, ignore to preserve original error
|
|
530
|
+
}
|
|
541
531
|
}
|
|
542
532
|
}
|
|
543
533
|
const { data } = (await this.send('Page.captureScreenshot', captureParams));
|
|
544
|
-
return data;
|
|
534
|
+
return { data, format: effectiveFormat };
|
|
545
535
|
}
|
|
546
536
|
/**
|
|
547
537
|
* 获取页面状态
|
|
@@ -594,62 +584,456 @@ class SessionManager {
|
|
|
594
584
|
});
|
|
595
585
|
return state;
|
|
596
586
|
}
|
|
587
|
+
async getPageHtml(selector, outer = true) {
|
|
588
|
+
this.ensureSession();
|
|
589
|
+
if (selector) {
|
|
590
|
+
const prop = outer ? 'outerHTML' : 'innerHTML';
|
|
591
|
+
return this.evaluate(`((s, p) => { const el = document.querySelector(s); return el ? el[p] : '' })`, [selector, prop]);
|
|
592
|
+
}
|
|
593
|
+
return this.evaluate('document.documentElement.outerHTML');
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* 获取页面文本(IBrowserDriver 接口)
|
|
597
|
+
*/
|
|
598
|
+
async getPageText(selector) {
|
|
599
|
+
this.ensureSession();
|
|
600
|
+
if (selector) {
|
|
601
|
+
return this.evaluate(`(s => document.querySelector(s)?.textContent || '')`, [selector]);
|
|
602
|
+
}
|
|
603
|
+
return this.evaluate('document.body.innerText');
|
|
604
|
+
}
|
|
605
|
+
// ==================== IBrowserDriver: 页面读取 ====================
|
|
606
|
+
/** Extension readPage 等价物:CDP 通过 getPageState 构造 pageContent 文本 */
|
|
607
|
+
async readPage(_options) {
|
|
608
|
+
const state = await this.getPageState();
|
|
609
|
+
const elements = state.elements ?? [];
|
|
610
|
+
const lines = elements.map((e) => {
|
|
611
|
+
let line = e.role;
|
|
612
|
+
if (e.name) {
|
|
613
|
+
line += ` "${e.name}"`;
|
|
614
|
+
}
|
|
615
|
+
return line;
|
|
616
|
+
});
|
|
617
|
+
return {
|
|
618
|
+
pageContent: lines.join('\n'),
|
|
619
|
+
viewport: state.viewport,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
/** CDP 通过 evaluate 注入函数枚举 IMG */
|
|
623
|
+
async getHtmlWithImages(selector, outer = true) {
|
|
624
|
+
const selectorArg = JSON.stringify(selector ?? null);
|
|
625
|
+
return this.evaluate(`(function() {
|
|
626
|
+
var root = ${selectorArg} ? document.querySelector(${selectorArg}) : document.documentElement;
|
|
627
|
+
if (!root) return {html: '', images: []};
|
|
628
|
+
var html = ${selectorArg}
|
|
629
|
+
? (${outer} ? root.outerHTML : root.innerHTML)
|
|
630
|
+
: document.documentElement.outerHTML;
|
|
631
|
+
var imgList = [];
|
|
632
|
+
if (root.tagName === 'IMG') imgList.push(root);
|
|
633
|
+
root.querySelectorAll('img').forEach(function(img) { imgList.push(img); });
|
|
634
|
+
var images = [];
|
|
635
|
+
for (var i = 0; i < imgList.length; i++) {
|
|
636
|
+
var img = imgList[i];
|
|
637
|
+
images.push({
|
|
638
|
+
index: i, src: img.src,
|
|
639
|
+
dataSrc: (function() {
|
|
640
|
+
var raw = img.dataset.src || img.dataset.lazySrc || img.dataset.original || '';
|
|
641
|
+
if (!raw) return '';
|
|
642
|
+
try { return new URL(raw, location.href).href } catch(e) { return raw }
|
|
643
|
+
})(),
|
|
644
|
+
alt: img.alt, width: img.width, height: img.height,
|
|
645
|
+
naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return {html: html, images: images};
|
|
649
|
+
})()`);
|
|
650
|
+
}
|
|
651
|
+
/** CDP 通过 evaluate 注入收集 meta 标签和 og/twitter/jsonLd */
|
|
652
|
+
async getMetadata() {
|
|
653
|
+
return this.evaluate(`(function() {
|
|
654
|
+
function meta(name) {
|
|
655
|
+
var el = document.querySelector('meta[name="'+name+'"],meta[property="'+name+'"]');
|
|
656
|
+
return el ? el.content || undefined : undefined;
|
|
657
|
+
}
|
|
658
|
+
var og = {}, tw = {};
|
|
659
|
+
document.querySelectorAll('meta[property^="og:"]').forEach(function(m) {
|
|
660
|
+
og[m.getAttribute('property')] = m.content || '';
|
|
661
|
+
});
|
|
662
|
+
document.querySelectorAll('meta[name^="twitter:"]').forEach(function(m) {
|
|
663
|
+
tw[m.getAttribute('name')] = m.content || '';
|
|
664
|
+
});
|
|
665
|
+
var jsonLd = [];
|
|
666
|
+
document.querySelectorAll('script[type="application/ld+json"]').forEach(function(s) {
|
|
667
|
+
try { jsonLd.push(JSON.parse(s.textContent || '')); } catch(e) {}
|
|
668
|
+
});
|
|
669
|
+
var alternates = [];
|
|
670
|
+
document.querySelectorAll('link[rel="alternate"]').forEach(function(l) {
|
|
671
|
+
alternates.push({
|
|
672
|
+
href: l.href,
|
|
673
|
+
type: l.getAttribute('type') || undefined,
|
|
674
|
+
hreflang: l.getAttribute('hreflang') || undefined
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
var feeds = [];
|
|
678
|
+
var feedSel = 'link[type="application/rss+xml"],link[type="application/atom+xml"]';
|
|
679
|
+
document.querySelectorAll(feedSel).forEach(function(l) {
|
|
680
|
+
feeds.push({href: l.href, type: l.getAttribute('type'), title: l.getAttribute('title') || undefined});
|
|
681
|
+
});
|
|
682
|
+
return {
|
|
683
|
+
title: document.title,
|
|
684
|
+
description: meta('description'),
|
|
685
|
+
canonical: (document.querySelector('link[rel="canonical"]') || {}).href || undefined,
|
|
686
|
+
charset: document.characterSet,
|
|
687
|
+
viewport: meta('viewport'),
|
|
688
|
+
og: og, twitter: tw, jsonLd: jsonLd, alternates: alternates, feeds: feeds
|
|
689
|
+
};
|
|
690
|
+
})()`);
|
|
691
|
+
}
|
|
692
|
+
// ==================== IBrowserDriver: 元素查找 ====================
|
|
693
|
+
/** CDP 通过 evaluate 注入 TreeWalker 查找元素 */
|
|
694
|
+
async find(selector, text, xpath, timeout) {
|
|
695
|
+
return this.evaluate(`function(selector, text, xpath) {
|
|
696
|
+
var elements;
|
|
697
|
+
if (xpath) {
|
|
698
|
+
elements = [];
|
|
699
|
+
var xr = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
700
|
+
for (var i = 0; i < xr.snapshotLength; i++) {
|
|
701
|
+
var node = xr.snapshotItem(i);
|
|
702
|
+
if (node instanceof Element) elements.push(node);
|
|
703
|
+
}
|
|
704
|
+
} else if (selector) {
|
|
705
|
+
elements = Array.from(document.querySelectorAll(selector));
|
|
706
|
+
} else if (text) {
|
|
707
|
+
// text-only:用 TreeWalker 仅遍历可能含目标文本的节点,避免 querySelectorAll('*') 的全树扫描和强制 layout
|
|
708
|
+
elements = [];
|
|
709
|
+
var lowerText = text;
|
|
710
|
+
var walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_ELEMENT, {
|
|
711
|
+
acceptNode: function (n) {
|
|
712
|
+
var t = n.textContent || '';
|
|
713
|
+
if (!t.includes(lowerText)) return NodeFilter.FILTER_SKIP;
|
|
714
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
while (walker.nextNode()) {
|
|
718
|
+
elements.push(walker.currentNode);
|
|
719
|
+
if (elements.length >= 200) break;
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
elements = Array.from(document.querySelectorAll('*'));
|
|
723
|
+
}
|
|
724
|
+
var results = [];
|
|
725
|
+
for (var j = 0; j < elements.length; j++) {
|
|
726
|
+
var el = elements[j];
|
|
727
|
+
if (text && !(el.textContent || '').includes(text)) continue;
|
|
728
|
+
var rect = el.getBoundingClientRect();
|
|
729
|
+
results.push({
|
|
730
|
+
refId: '',
|
|
731
|
+
tag: el.tagName.toLowerCase(),
|
|
732
|
+
text: (el.textContent || '').trim().substring(0, 100),
|
|
733
|
+
rect: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}
|
|
734
|
+
});
|
|
735
|
+
if (results.length >= 50) break;
|
|
736
|
+
}
|
|
737
|
+
return results;
|
|
738
|
+
}`, [selector ?? null, text ?? null, xpath ?? null], timeout);
|
|
739
|
+
}
|
|
740
|
+
// ==================== IBrowserDriver: 元素操作(refId 类,CDP 不支持) ====================
|
|
741
|
+
click(_refId) {
|
|
742
|
+
return Promise.reject(new DriverCapabilityError('CDP 模式不支持 refId 点击,请使用 input 工具的坐标点击'));
|
|
743
|
+
}
|
|
744
|
+
actionableClick(_refId, _force) {
|
|
745
|
+
return Promise.reject(new DriverCapabilityError('actionableClick 仅支持 Extension 模式'));
|
|
746
|
+
}
|
|
747
|
+
dispatchInput(_refId, _text) {
|
|
748
|
+
return Promise.reject(new DriverCapabilityError('dispatchInput 仅支持 Extension 模式'));
|
|
749
|
+
}
|
|
750
|
+
dragAndDrop(_srcRefId, _dstRefId) {
|
|
751
|
+
return Promise.reject(new DriverCapabilityError('dragAndDrop 仅支持 Extension 模式'));
|
|
752
|
+
}
|
|
753
|
+
getComputedStyle(_refId, _prop) {
|
|
754
|
+
return Promise.reject(new DriverCapabilityError('getComputedStyle 仅支持 Extension 模式'));
|
|
755
|
+
}
|
|
756
|
+
typeRef(_refId, _text, _clear) {
|
|
757
|
+
return Promise.reject(new DriverCapabilityError('CDP 模式不支持 refId 输入,请使用 input 工具'));
|
|
758
|
+
}
|
|
759
|
+
/** scrollAt:CDP 模式没有 refId 概念,无视 refId 直接 mouseWheel(保持与原 unified-session 行为一致) */
|
|
760
|
+
async scrollAt(x, y, _refId) {
|
|
761
|
+
await this.mouseWheel(x, y);
|
|
762
|
+
}
|
|
763
|
+
async getAttribute(selector, refId, attribute) {
|
|
764
|
+
if (refId !== undefined) {
|
|
765
|
+
throw new DriverCapabilityError('CDP 模式不支持 refId 查询属性,请通过 selector');
|
|
766
|
+
}
|
|
767
|
+
if (!selector) {
|
|
768
|
+
throw new DriverCapabilityError('getAttribute 需要 selector');
|
|
769
|
+
}
|
|
770
|
+
return this.evaluate(`(s, a) => { const el = document.querySelector(s); return el ? el.getAttribute(a) : null }`, [selector, attribute]);
|
|
771
|
+
}
|
|
772
|
+
// ==================== IBrowserDriver: 输入(precise) ====================
|
|
773
|
+
/** CDP 通用 inputKey 接口实现:直接转发 Input.dispatchKeyEvent,不维护 modifiers(由调用方提供) */
|
|
774
|
+
async inputKey(type, options = {}) {
|
|
775
|
+
this.ensureSession();
|
|
776
|
+
const params = { type };
|
|
777
|
+
if (options.key !== undefined) {
|
|
778
|
+
params.key = options.key;
|
|
779
|
+
}
|
|
780
|
+
if (options.code !== undefined) {
|
|
781
|
+
params.code = options.code;
|
|
782
|
+
}
|
|
783
|
+
if (options.text !== undefined) {
|
|
784
|
+
params.text = options.text;
|
|
785
|
+
}
|
|
786
|
+
if (options.unmodifiedText !== undefined) {
|
|
787
|
+
params.unmodifiedText = options.unmodifiedText;
|
|
788
|
+
}
|
|
789
|
+
if (options.location !== undefined) {
|
|
790
|
+
params.location = options.location;
|
|
791
|
+
}
|
|
792
|
+
if (options.isKeypad !== undefined) {
|
|
793
|
+
params.isKeypad = options.isKeypad;
|
|
794
|
+
}
|
|
795
|
+
if (options.autoRepeat !== undefined) {
|
|
796
|
+
params.autoRepeat = options.autoRepeat;
|
|
797
|
+
}
|
|
798
|
+
if (options.windowsVirtualKeyCode !== undefined) {
|
|
799
|
+
params.windowsVirtualKeyCode = options.windowsVirtualKeyCode;
|
|
800
|
+
}
|
|
801
|
+
if (options.modifiers !== undefined) {
|
|
802
|
+
params.modifiers = options.modifiers;
|
|
803
|
+
}
|
|
804
|
+
if (options.commands && options.commands.length > 0) {
|
|
805
|
+
params.commands = options.commands;
|
|
806
|
+
}
|
|
807
|
+
await this.send('Input.dispatchKeyEvent', params);
|
|
808
|
+
}
|
|
809
|
+
/** CDP 通用 inputMouse 接口实现:直接转发 Input.dispatchMouseEvent */
|
|
810
|
+
async inputMouse(type, x, y, options = {}) {
|
|
811
|
+
this.ensureSession();
|
|
812
|
+
const params = {
|
|
813
|
+
type,
|
|
814
|
+
x: Math.round(x),
|
|
815
|
+
y: Math.round(y),
|
|
816
|
+
};
|
|
817
|
+
if (options.button !== undefined) {
|
|
818
|
+
params.button = options.button;
|
|
819
|
+
}
|
|
820
|
+
if (options.clickCount !== undefined) {
|
|
821
|
+
params.clickCount = options.clickCount;
|
|
822
|
+
}
|
|
823
|
+
if (options.deltaX !== undefined) {
|
|
824
|
+
params.deltaX = options.deltaX;
|
|
825
|
+
}
|
|
826
|
+
if (options.deltaY !== undefined) {
|
|
827
|
+
params.deltaY = options.deltaY;
|
|
828
|
+
}
|
|
829
|
+
if (options.modifiers !== undefined) {
|
|
830
|
+
params.modifiers = options.modifiers;
|
|
831
|
+
}
|
|
832
|
+
await this.send('Input.dispatchMouseEvent', params);
|
|
833
|
+
// mouseMoved 同步坐标到 BehaviorSimulator,与 mouseMove 方法保持一致
|
|
834
|
+
if (type === 'mouseMoved') {
|
|
835
|
+
this.behaviorSimulator.setCurrentPosition({ x: Math.round(x), y: Math.round(y) });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/** CDP 通用 inputTouch 接口实现:临时启用 touch 模拟避免命令挂起 */
|
|
839
|
+
async inputTouch(type, touchPoints) {
|
|
840
|
+
this.ensureSession();
|
|
841
|
+
await this.dispatchTouch({ type, touchPoints });
|
|
842
|
+
}
|
|
843
|
+
/** CDP inputType 接口实现:直接复用现有 type 方法 */
|
|
844
|
+
inputType(text, delay = 0) {
|
|
845
|
+
return this.type(text, delay);
|
|
846
|
+
}
|
|
847
|
+
// ==================== IBrowserDriver: Stealth(CDP 不支持) ====================
|
|
848
|
+
stealthKey(_key, _type, _modifiers) {
|
|
849
|
+
return Promise.reject(new DriverCapabilityError('Stealth 模式仅在 Extension 下可用,CDP 模式请使用 inputKey'));
|
|
850
|
+
}
|
|
851
|
+
stealthClick(_x, _y, _button, _clickCount, _refId) {
|
|
852
|
+
return Promise.reject(new DriverCapabilityError('Stealth 模式仅在 Extension 下可用,CDP 模式请使用 inputMouse'));
|
|
853
|
+
}
|
|
854
|
+
stealthMouse(_type, _x, _y, _button) {
|
|
855
|
+
return Promise.reject(new DriverCapabilityError('Stealth 模式仅在 Extension 下可用,CDP 模式请使用 inputMouse'));
|
|
856
|
+
}
|
|
857
|
+
stealthType(_text, _delay) {
|
|
858
|
+
return Promise.reject(new DriverCapabilityError('Stealth 模式仅在 Extension 下可用,CDP 模式请使用 inputType'));
|
|
859
|
+
}
|
|
860
|
+
stealthInject() {
|
|
861
|
+
return Promise.reject(new DriverCapabilityError('CDP 模式 stealth 脚本在 connect/launch 时通过 stealth 参数自动注入,不支持后续手动注入'));
|
|
862
|
+
}
|
|
863
|
+
// ==================== IBrowserDriver: 日志启用 ====================
|
|
864
|
+
/** CDP 模式:Network/Runtime 域已在 attach 时启用,no-op */
|
|
865
|
+
consoleEnable() {
|
|
866
|
+
return Promise.resolve();
|
|
867
|
+
}
|
|
868
|
+
/** CDP 模式:Network 域已在 attach 时启用,no-op */
|
|
869
|
+
networkEnable() {
|
|
870
|
+
return Promise.resolve();
|
|
871
|
+
}
|
|
872
|
+
// ==================== IBrowserDriver: Tab/状态 ====================
|
|
873
|
+
/** IBrowserDriver 接口:激活页面(attach + 切到前台) */
|
|
874
|
+
async activatePage(targetId) {
|
|
875
|
+
await this.attachToTarget(targetId);
|
|
876
|
+
await this.activateTarget(targetId);
|
|
877
|
+
}
|
|
878
|
+
/** IBrowserDriver 接口:选择操作目标(attach 即可) */
|
|
879
|
+
async selectPage(targetId) {
|
|
880
|
+
await this.attachToTarget(targetId);
|
|
881
|
+
}
|
|
882
|
+
/** IBrowserDriver 接口:获取当前操作目标 ID */
|
|
883
|
+
getCurrentTargetId() {
|
|
884
|
+
return this.currentTargetId;
|
|
885
|
+
}
|
|
886
|
+
/** IBrowserDriver 接口:设置当前操作目标 ID(attach 到指定 target) */
|
|
887
|
+
setCurrentTargetId(targetId) {
|
|
888
|
+
// CDP 路径不支持同步 setCurrentTargetId(attach 是异步的);
|
|
889
|
+
// 调用方应使用 selectPage(targetId) 或 attachToTarget(targetId)
|
|
890
|
+
if (targetId === null) {
|
|
891
|
+
// 不允许同步清空 currentTargetId(会造成 sessionId 与 currentTargetId 状态漂移)
|
|
892
|
+
// 上层应通过 closePage 路径主动清理
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (targetId !== this.currentTargetId) {
|
|
896
|
+
throw new DriverCapabilityError('CDP 模式不支持同步切换 target,请用 selectPage(targetId) 或 attachToTarget(targetId)');
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// ==================== IBrowserDriver: iframe(CDP 不支持) ====================
|
|
900
|
+
resolveFrame(_frame) {
|
|
901
|
+
return Promise.reject(new DriverCapabilityError('iframe 穿透需要 Extension 模式'));
|
|
902
|
+
}
|
|
903
|
+
getCurrentFrameId() {
|
|
904
|
+
// CDP 模式没有 frameId 概念,统一返回 0(主框架)
|
|
905
|
+
return 0;
|
|
906
|
+
}
|
|
907
|
+
setCurrentFrameId(frameId) {
|
|
908
|
+
if (frameId !== 0) {
|
|
909
|
+
throw new DriverCapabilityError('iframe 穿透需要 Extension 模式');
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
evaluateInFrame(_frameId, _expression, _timeout) {
|
|
913
|
+
return Promise.reject(new DriverCapabilityError('iframe 穿透需要 Extension 模式'));
|
|
914
|
+
}
|
|
915
|
+
// ==================== IBrowserDriver: CDP 直通 ====================
|
|
916
|
+
/**
|
|
917
|
+
* 发送 CDP 命令(IBrowserDriver 接口):
|
|
918
|
+
* tabId 在 CDP 模式下忽略(CDP 单 session 概念不区分 tab);
|
|
919
|
+
* browser-level 域(Target/Browser/SystemInfo/DeviceAccess/IO)走 sendBrowserCommand
|
|
920
|
+
*/
|
|
921
|
+
debuggerSend(method, params, _tabId, timeout) {
|
|
922
|
+
const domain = method.split('.')[0];
|
|
923
|
+
const browserLevelDomains = ['Target', 'Browser', 'SystemInfo', 'DeviceAccess', 'IO'];
|
|
924
|
+
if (browserLevelDomains.includes(domain)) {
|
|
925
|
+
return this.sendBrowserCommand(method, params);
|
|
926
|
+
}
|
|
927
|
+
return this.send(method, params, timeout);
|
|
928
|
+
}
|
|
597
929
|
/**
|
|
598
930
|
* 获取 Cookies
|
|
599
|
-
* @param urls 可选,限制返回指定 URL 的 cookies
|
|
600
931
|
*/
|
|
601
|
-
async getCookies(
|
|
932
|
+
async getCookies(filter) {
|
|
602
933
|
this.ensureSession();
|
|
603
|
-
const
|
|
604
|
-
const { cookies } = (await this.send('Network.getCookies',
|
|
605
|
-
|
|
934
|
+
const urls = filter?.url ? [filter.url] : undefined;
|
|
935
|
+
const { cookies } = (await this.send('Network.getCookies', urls ? { urls } : {}));
|
|
936
|
+
if (!filter) {
|
|
937
|
+
return cookies;
|
|
938
|
+
}
|
|
939
|
+
return cookies.filter((c) => {
|
|
940
|
+
if (filter.name && c.name !== filter.name) {
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
if (filter.domain) {
|
|
944
|
+
const fd = filter.domain.replace(/^\./, '');
|
|
945
|
+
const cd = (c.domain ?? '').replace(/^\./, '');
|
|
946
|
+
if (cd !== fd && !cd.endsWith('.' + fd)) {
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (filter.path && c.path !== filter.path) {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
if (filter.secure !== undefined && c.secure !== filter.secure) {
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
if (filter.session !== undefined) {
|
|
957
|
+
const isSession = (c.expires ?? -1) <= 0;
|
|
958
|
+
if (filter.session !== isSession) {
|
|
959
|
+
return false;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return true;
|
|
963
|
+
});
|
|
606
964
|
}
|
|
607
965
|
/**
|
|
608
966
|
* 设置 Cookie
|
|
609
967
|
*/
|
|
610
|
-
async setCookie(
|
|
968
|
+
async setCookie(params) {
|
|
611
969
|
this.ensureSession();
|
|
612
|
-
|
|
613
|
-
const
|
|
970
|
+
const url = params.url ?? this.state?.url ?? 'http://localhost';
|
|
971
|
+
const { name, value, domain, path, httpOnly, secure, sameSite, expirationDate } = params;
|
|
614
972
|
await this.send('Network.setCookie', {
|
|
615
973
|
name,
|
|
616
974
|
value,
|
|
617
975
|
url,
|
|
618
|
-
...
|
|
976
|
+
...(domain !== undefined && { domain }),
|
|
977
|
+
...(path !== undefined && { path }),
|
|
978
|
+
...(httpOnly !== undefined && { httpOnly }),
|
|
979
|
+
...(secure !== undefined && { secure }),
|
|
980
|
+
...(sameSite !== undefined && { sameSite }),
|
|
981
|
+
...(expirationDate !== undefined && { expires: expirationDate }),
|
|
619
982
|
});
|
|
620
983
|
}
|
|
621
|
-
|
|
622
|
-
* 删除 Cookie
|
|
623
|
-
*/
|
|
624
|
-
async deleteCookie(name, url) {
|
|
984
|
+
async deleteCookie(url, name) {
|
|
625
985
|
this.ensureSession();
|
|
626
|
-
const effectiveUrl = url
|
|
986
|
+
const effectiveUrl = url || this.state?.url || 'http://localhost';
|
|
627
987
|
await this.send('Network.deleteCookies', { name, url: effectiveUrl });
|
|
628
988
|
}
|
|
629
|
-
|
|
630
|
-
* 清除所有 Cookies
|
|
631
|
-
*/
|
|
632
|
-
async clearCookies() {
|
|
989
|
+
async clearCookies(filter) {
|
|
633
990
|
this.ensureSession();
|
|
634
|
-
|
|
991
|
+
// Driver 级护栏:禁止无过滤的全站清除,必须带 url/domain/name 至少一项
|
|
992
|
+
if (!filter || (!filter.url && !filter.domain && !filter.name)) {
|
|
993
|
+
throw new Error('clearCookies 必须带 url/domain/name 至少一个过滤参数(防止误清全站 cookies)');
|
|
994
|
+
}
|
|
995
|
+
const urls = filter.url ? [filter.url] : undefined;
|
|
996
|
+
const { cookies } = (await this.send('Network.getCookies', urls ? { urls } : {}));
|
|
997
|
+
let count = 0;
|
|
998
|
+
for (const cookie of cookies) {
|
|
999
|
+
if (filter.domain) {
|
|
1000
|
+
const fd = filter.domain.replace(/^\./, '');
|
|
1001
|
+
const cd = cookie.domain.replace(/^\./, '');
|
|
1002
|
+
if (cd !== fd && !cd.endsWith('.' + fd)) {
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (filter.name && cookie.name !== filter.name) {
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
const protocol = cookie.secure ? 'https:' : 'http:';
|
|
1010
|
+
const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain;
|
|
1011
|
+
const deleteUrl = `${protocol}//${domain}${cookie.path}`;
|
|
1012
|
+
await this.send('Network.deleteCookies', { name: cookie.name, url: deleteUrl });
|
|
1013
|
+
count++;
|
|
1014
|
+
}
|
|
1015
|
+
return { count };
|
|
635
1016
|
}
|
|
636
|
-
|
|
637
|
-
* 获取控制台日志
|
|
638
|
-
*/
|
|
639
|
-
getConsoleLogs(level, limit = 100) {
|
|
1017
|
+
async getConsoleLogs(options = {}) {
|
|
640
1018
|
let logs = this.consoleLogs;
|
|
1019
|
+
const { level, pattern, clear } = options;
|
|
641
1020
|
if (level && level !== 'all') {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
1021
|
+
logs = logs.filter((l) => l.level === level ||
|
|
1022
|
+
(level === 'warning' && l.level === 'warn') ||
|
|
1023
|
+
(level === 'warn' && l.level === 'warning'));
|
|
1024
|
+
}
|
|
1025
|
+
if (pattern) {
|
|
1026
|
+
const lp = pattern.toLowerCase();
|
|
1027
|
+
logs = logs.filter((l) => l.text.toLowerCase().includes(lp));
|
|
646
1028
|
}
|
|
647
|
-
|
|
1029
|
+
const result = logs.slice(-100);
|
|
1030
|
+
if (clear) {
|
|
1031
|
+
this.consoleLogs = [];
|
|
1032
|
+
}
|
|
1033
|
+
return result;
|
|
648
1034
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
*/
|
|
652
|
-
getNetworkRequests(urlPattern, limit = 100) {
|
|
1035
|
+
async getNetworkRequests(options = {}) {
|
|
1036
|
+
const { urlPattern, clear } = options;
|
|
653
1037
|
let requests = this.networkRequests;
|
|
654
1038
|
if (urlPattern) {
|
|
655
1039
|
try {
|
|
@@ -665,7 +1049,12 @@ class SessionManager {
|
|
|
665
1049
|
requests = requests.filter((r) => r.url.toLowerCase().includes(pat));
|
|
666
1050
|
}
|
|
667
1051
|
}
|
|
668
|
-
|
|
1052
|
+
const result = requests.slice(-100);
|
|
1053
|
+
if (clear) {
|
|
1054
|
+
this.networkRequests = [];
|
|
1055
|
+
this.requestMap.clear();
|
|
1056
|
+
}
|
|
1057
|
+
return result;
|
|
669
1058
|
}
|
|
670
1059
|
/**
|
|
671
1060
|
* 清除日志
|
|
@@ -693,7 +1082,7 @@ class SessionManager {
|
|
|
693
1082
|
const callParams = {
|
|
694
1083
|
functionDeclaration: script,
|
|
695
1084
|
objectId: globalResult.objectId,
|
|
696
|
-
arguments: args.map(a => ({ value: a })),
|
|
1085
|
+
arguments: args.map((a) => ({ value: a })),
|
|
697
1086
|
returnByValue: true,
|
|
698
1087
|
awaitPromise: true,
|
|
699
1088
|
};
|
|
@@ -707,8 +1096,7 @@ class SessionManager {
|
|
|
707
1096
|
return extractCdpValue(result);
|
|
708
1097
|
}
|
|
709
1098
|
finally {
|
|
710
|
-
this.send('Runtime.releaseObject', { objectId: globalResult.objectId }).catch(() => {
|
|
711
|
-
});
|
|
1099
|
+
this.send('Runtime.releaseObject', { objectId: globalResult.objectId }).catch(() => { });
|
|
712
1100
|
}
|
|
713
1101
|
}
|
|
714
1102
|
const evalParams = {
|
|
@@ -746,12 +1134,11 @@ class SessionManager {
|
|
|
746
1134
|
}
|
|
747
1135
|
/**
|
|
748
1136
|
* 清除缓存
|
|
1137
|
+
*
|
|
1138
|
+
* 不再清 cookies:cookies 清除统一走 cookies action=clear(强制 name/domain/url 过滤)
|
|
749
1139
|
*/
|
|
750
1140
|
async clearCache(type = 'all') {
|
|
751
1141
|
this.ensureSession();
|
|
752
|
-
if (type === 'all' || type === 'cookies') {
|
|
753
|
-
await this.send('Network.clearBrowserCookies');
|
|
754
|
-
}
|
|
755
1142
|
if (type === 'all' || type === 'cache') {
|
|
756
1143
|
await this.send('Network.clearBrowserCache');
|
|
757
1144
|
}
|
|
@@ -766,9 +1153,26 @@ class SessionManager {
|
|
|
766
1153
|
}
|
|
767
1154
|
/**
|
|
768
1155
|
* 新建页面(外部入口,加锁)
|
|
1156
|
+
*
|
|
1157
|
+
* IBrowserDriver 接口:可选 url 参数,url 提供时新建后立即导航
|
|
769
1158
|
*/
|
|
770
|
-
async newPage() {
|
|
771
|
-
|
|
1159
|
+
async newPage(url, _timeout) {
|
|
1160
|
+
const target = await this.withLock(async () => this.newPageInternal());
|
|
1161
|
+
if (url) {
|
|
1162
|
+
await this.navigate(url);
|
|
1163
|
+
return {
|
|
1164
|
+
targetId: target.targetId,
|
|
1165
|
+
url: this.state?.url ?? url,
|
|
1166
|
+
title: this.state?.title ?? '',
|
|
1167
|
+
type: 'page',
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
return {
|
|
1171
|
+
targetId: target.targetId,
|
|
1172
|
+
url: target.url,
|
|
1173
|
+
title: target.title,
|
|
1174
|
+
type: 'page',
|
|
1175
|
+
};
|
|
772
1176
|
}
|
|
773
1177
|
/**
|
|
774
1178
|
* 激活页面(切到前台)
|
|
@@ -830,9 +1234,9 @@ class SessionManager {
|
|
|
830
1234
|
/**
|
|
831
1235
|
* 发送 CDP 命令(page-level,携带 sessionId)
|
|
832
1236
|
*
|
|
833
|
-
* 每次调用都检查连接状态,防止 close() 并发置空 this.cdp
|
|
1237
|
+
* 每次调用都检查连接状态,防止 close() 并发置空 this.cdp 后崩溃,
|
|
834
1238
|
* 多步操作(type 循环、fullPage 截图等)的 await 间隙可能被 close() 打断,
|
|
835
|
-
* ensureSession() 确保在当前 tick 内 this.cdp
|
|
1239
|
+
* ensureSession() 确保在当前 tick 内 this.cdp 非空
|
|
836
1240
|
*/
|
|
837
1241
|
send(method, params, timeout) {
|
|
838
1242
|
this.ensureSession();
|
|
@@ -846,6 +1250,95 @@ class SessionManager {
|
|
|
846
1250
|
this.ensureConnected();
|
|
847
1251
|
return this.cdp.send(method, params);
|
|
848
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* 派发 CDP Input.dispatchTouchEvent,临时启用 touch 模拟避免命令挂起
|
|
1255
|
+
*/
|
|
1256
|
+
async dispatchTouch(params) {
|
|
1257
|
+
await this.send('Emulation.setTouchEmulationEnabled', { enabled: true });
|
|
1258
|
+
try {
|
|
1259
|
+
await this.send('Input.dispatchTouchEvent', params);
|
|
1260
|
+
}
|
|
1261
|
+
finally {
|
|
1262
|
+
try {
|
|
1263
|
+
await this.send('Emulation.setTouchEmulationEnabled', { enabled: false });
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
// cleanup 失败不覆盖原始错误
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* 在调用 navigate 之前注册网络空闲监听器,返回等待 idle 的 Promise
|
|
1272
|
+
* 预先注册避免 Page.navigate 返回和监听器注册之间遗漏早期请求
|
|
1273
|
+
*/
|
|
1274
|
+
startNetworkIdleWatcher(timeout, idleTime = 500) {
|
|
1275
|
+
this.ensureSession();
|
|
1276
|
+
return this.buildNetworkIdlePromise(this.cdp, timeout, idleTime);
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* 核心网络空闲等待逻辑
|
|
1280
|
+
*
|
|
1281
|
+
* 捕获 cdp 引用防止 close() 并发置 null,用局部 Set 追踪请求避免污染成员变量
|
|
1282
|
+
* close() 时通过 'disconnected' 信号立即 reject
|
|
1283
|
+
*/
|
|
1284
|
+
buildNetworkIdlePromise(cdp, timeout, idleTime) {
|
|
1285
|
+
const localPendingRequests = new Set();
|
|
1286
|
+
return new Promise((resolve, reject) => {
|
|
1287
|
+
let idleTimer = null;
|
|
1288
|
+
let timeoutTimer = null;
|
|
1289
|
+
const checkIdle = () => {
|
|
1290
|
+
if (localPendingRequests.size === 0) {
|
|
1291
|
+
if (idleTimer === null) {
|
|
1292
|
+
idleTimer = setTimeout(() => {
|
|
1293
|
+
cleanup();
|
|
1294
|
+
resolve();
|
|
1295
|
+
}, idleTime);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
else {
|
|
1299
|
+
if (idleTimer !== null) {
|
|
1300
|
+
clearTimeout(idleTimer);
|
|
1301
|
+
idleTimer = null;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
const onRequestStart = (params) => {
|
|
1306
|
+
const { requestId } = params;
|
|
1307
|
+
localPendingRequests.add(requestId);
|
|
1308
|
+
checkIdle();
|
|
1309
|
+
};
|
|
1310
|
+
const onRequestEnd = (params) => {
|
|
1311
|
+
const { requestId } = params;
|
|
1312
|
+
localPendingRequests.delete(requestId);
|
|
1313
|
+
checkIdle();
|
|
1314
|
+
};
|
|
1315
|
+
const cleanup = () => {
|
|
1316
|
+
if (idleTimer !== null) {
|
|
1317
|
+
clearTimeout(idleTimer);
|
|
1318
|
+
}
|
|
1319
|
+
if (timeoutTimer !== null) {
|
|
1320
|
+
clearTimeout(timeoutTimer);
|
|
1321
|
+
}
|
|
1322
|
+
cdp.offEvent('Network.requestWillBeSent', onRequestStart);
|
|
1323
|
+
cdp.offEvent('Network.loadingFinished', onRequestEnd);
|
|
1324
|
+
cdp.offEvent('Network.loadingFailed', onRequestEnd);
|
|
1325
|
+
cdp.removeListener('disconnected', onDisconnected);
|
|
1326
|
+
};
|
|
1327
|
+
timeoutTimer = setTimeout(() => {
|
|
1328
|
+
cleanup();
|
|
1329
|
+
reject(new NavigationTimeoutError('networkidle', timeout));
|
|
1330
|
+
}, timeout);
|
|
1331
|
+
const onDisconnected = () => {
|
|
1332
|
+
cleanup();
|
|
1333
|
+
reject(new Error('CDP 连接已关闭'));
|
|
1334
|
+
};
|
|
1335
|
+
cdp.once('disconnected', onDisconnected);
|
|
1336
|
+
cdp.onEvent('Network.requestWillBeSent', onRequestStart);
|
|
1337
|
+
cdp.onEvent('Network.loadingFinished', onRequestEnd);
|
|
1338
|
+
cdp.onEvent('Network.loadingFailed', onRequestEnd);
|
|
1339
|
+
checkIdle();
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
849
1342
|
/**
|
|
850
1343
|
* 附加到指定页面(内部版本,不加锁,供 launch/connect 等已持锁方法调用)
|
|
851
1344
|
*/
|
|
@@ -895,8 +1388,8 @@ class SessionManager {
|
|
|
895
1388
|
/**
|
|
896
1389
|
* 重置所有状态(同步,不加锁)
|
|
897
1390
|
*
|
|
898
|
-
* 供已持有 withLock 的方法调用(launch/connect),避免 close() 的 withLock
|
|
899
|
-
* 外部调用请使用 close()
|
|
1391
|
+
* 供已持有 withLock 的方法调用(launch/connect),避免 close() 的 withLock 重入死锁,
|
|
1392
|
+
* 外部调用请使用 close()
|
|
900
1393
|
*/
|
|
901
1394
|
resetState() {
|
|
902
1395
|
if (this.cdp) {
|
|
@@ -937,8 +1430,8 @@ class SessionManager {
|
|
|
937
1430
|
* 等待多个事件中的任一个触发
|
|
938
1431
|
*
|
|
939
1432
|
* 用于同时监听跨文档导航 (loadEventFired) 和同文档导航 (navigatedWithinDocument),
|
|
940
|
-
*
|
|
941
|
-
* close() 时通过 'disconnected' 信号立即 reject,不必等 timer
|
|
1433
|
+
* 任一事件触发后清理所有监听器和超时定时器
|
|
1434
|
+
* close() 时通过 'disconnected' 信号立即 reject,不必等 timer 超时
|
|
942
1435
|
*/
|
|
943
1436
|
waitForAnyEvent(events, timeout) {
|
|
944
1437
|
// 捕获当前 cdp 引用,防止 close() 并发置 null 导致回调崩溃
|
|
@@ -1020,9 +1513,9 @@ class SessionManager {
|
|
|
1020
1513
|
url: p.stackTrace?.callFrames[0]?.url,
|
|
1021
1514
|
lineNumber: p.stackTrace?.callFrames[0]?.lineNumber,
|
|
1022
1515
|
});
|
|
1023
|
-
//
|
|
1516
|
+
// 环形缓冲区:批量裁剪到 800 条,均摊 O(n) 开销
|
|
1024
1517
|
if (this.consoleLogs.length > SessionManager.MAX_LOG_ENTRIES) {
|
|
1025
|
-
this.consoleLogs.
|
|
1518
|
+
this.consoleLogs.splice(0, this.consoleLogs.length - 800);
|
|
1026
1519
|
}
|
|
1027
1520
|
});
|
|
1028
1521
|
// 网络请求
|
|
@@ -1046,9 +1539,9 @@ class SessionManager {
|
|
|
1046
1539
|
status: p.response.status,
|
|
1047
1540
|
duration: Math.round((p.timestamp - _monotonic) * 1000),
|
|
1048
1541
|
});
|
|
1049
|
-
//
|
|
1542
|
+
// 环形缓冲区:批量裁剪到 800 条,均摊 O(n) 开销
|
|
1050
1543
|
if (this.networkRequests.length > SessionManager.MAX_LOG_ENTRIES) {
|
|
1051
|
-
this.networkRequests.
|
|
1544
|
+
this.networkRequests.splice(0, this.networkRequests.length - 800);
|
|
1052
1545
|
}
|
|
1053
1546
|
this.requestMap.delete(p.requestId);
|
|
1054
1547
|
}
|
|
@@ -1095,7 +1588,7 @@ class SessionManager {
|
|
|
1095
1588
|
/**
|
|
1096
1589
|
* 获取按键定义
|
|
1097
1590
|
*/
|
|
1098
|
-
function getKeyDefinition(key) {
|
|
1591
|
+
export function getKeyDefinition(key) {
|
|
1099
1592
|
const definitions = {
|
|
1100
1593
|
// 修饰键
|
|
1101
1594
|
Control: { key: 'Control', code: 'ControlLeft', keyCode: 17 },
|
|
@@ -1126,7 +1619,9 @@ function getKeyDefinition(key) {
|
|
|
1126
1619
|
}
|
|
1127
1620
|
// 如果是单个字符,生成定义
|
|
1128
1621
|
if (key.length === 1) {
|
|
1129
|
-
|
|
1622
|
+
// Windows VK code 对字母是大写 ASCII,对数字是数字 ASCII
|
|
1623
|
+
// 用 charCodeAt 的话 'a'=97 会被识别为键码 97(不是字母 A 的 0x41=65),导致快捷键无法匹配
|
|
1624
|
+
const vkCode = key >= 'a' && key <= 'z' ? key.toUpperCase().charCodeAt(0) : key.charCodeAt(0);
|
|
1130
1625
|
const code = key >= 'a' && key <= 'z'
|
|
1131
1626
|
? `Key${key.toUpperCase()}`
|
|
1132
1627
|
: key >= 'A' && key <= 'Z'
|
|
@@ -1137,7 +1632,7 @@ function getKeyDefinition(key) {
|
|
|
1137
1632
|
return {
|
|
1138
1633
|
key,
|
|
1139
1634
|
code,
|
|
1140
|
-
keyCode:
|
|
1635
|
+
keyCode: vkCode,
|
|
1141
1636
|
text: key,
|
|
1142
1637
|
};
|
|
1143
1638
|
}
|