@pyrokine/mcp-chrome 1.7.0 → 2.0.1
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 +34 -80
- 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 +149 -46
- package/dist/core/session.d.ts.map +1 -1
- package/dist/core/session.js +673 -181
- 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 -85
- package/dist/core/unified-session.d.ts.map +1 -1
- package/dist/core/unified-session.js +341 -650
- 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 -52
- package/dist/extension/bridge.d.ts.map +1 -1
- package/dist/extension/bridge.js +242 -111
- 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 +281 -89
- 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,29 @@ 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 (commands && commands.length > 0) {
|
|
386
|
+
delete params.text;
|
|
387
|
+
params.commands = commands;
|
|
388
|
+
}
|
|
389
|
+
if (options?.autoRepeat) {
|
|
390
|
+
params.autoRepeat = true;
|
|
391
|
+
}
|
|
392
|
+
await this.send('Input.dispatchKeyEvent', params);
|
|
441
393
|
}
|
|
442
394
|
/**
|
|
443
395
|
* 键盘释放
|
|
@@ -445,31 +397,57 @@ class SessionManager {
|
|
|
445
397
|
async keyUp(key) {
|
|
446
398
|
this.ensureSession();
|
|
447
399
|
const keyDefinition = getKeyDefinition(key);
|
|
400
|
+
const nextModifiers = MODIFIER_KEYS[key] ? this.modifiers & ~MODIFIER_KEYS[key] : this.modifiers;
|
|
448
401
|
await this.send('Input.dispatchKeyEvent', {
|
|
449
402
|
type: 'keyUp',
|
|
450
|
-
modifiers:
|
|
403
|
+
modifiers: nextModifiers,
|
|
451
404
|
...keyDefinition,
|
|
452
405
|
});
|
|
453
|
-
|
|
454
|
-
this.modifiers &= ~MODIFIER_KEYS[key];
|
|
455
|
-
}
|
|
406
|
+
this.modifiers = nextModifiers;
|
|
456
407
|
}
|
|
457
408
|
/**
|
|
458
409
|
* 输入文本
|
|
459
410
|
*/
|
|
460
411
|
async type(text, delay = 0) {
|
|
461
412
|
this.ensureSession();
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
413
|
+
// 归一化换行:\r\n 和 \r 都视作单个 \n
|
|
414
|
+
const normalized = text.replace(/\r\n?/g, '\n');
|
|
415
|
+
for (const char of normalized) {
|
|
416
|
+
if (char === '\n') {
|
|
417
|
+
// Enter 键:char 通道不接受 \n,必须分发 keyDown + char('\r') + keyUp
|
|
418
|
+
const enterParams = {
|
|
419
|
+
key: 'Enter',
|
|
420
|
+
code: 'Enter',
|
|
421
|
+
windowsVirtualKeyCode: 13,
|
|
422
|
+
nativeVirtualKeyCode: 13,
|
|
423
|
+
modifiers: this.modifiers,
|
|
424
|
+
};
|
|
425
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
426
|
+
type: 'keyDown',
|
|
427
|
+
...enterParams,
|
|
428
|
+
});
|
|
429
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
430
|
+
type: 'char',
|
|
431
|
+
text: '\r',
|
|
432
|
+
...enterParams,
|
|
433
|
+
});
|
|
434
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
435
|
+
type: 'keyUp',
|
|
436
|
+
...enterParams,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
441
|
+
type: 'keyDown',
|
|
442
|
+
modifiers: this.modifiers,
|
|
443
|
+
text: char,
|
|
444
|
+
});
|
|
445
|
+
await this.send('Input.dispatchKeyEvent', {
|
|
446
|
+
type: 'keyUp',
|
|
447
|
+
modifiers: this.modifiers,
|
|
448
|
+
text: char,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
473
451
|
if (delay > 0) {
|
|
474
452
|
await new Promise((r) => setTimeout(r, delay));
|
|
475
453
|
}
|
|
@@ -480,36 +458,28 @@ class SessionManager {
|
|
|
480
458
|
*/
|
|
481
459
|
async touchStart(x, y) {
|
|
482
460
|
this.ensureSession();
|
|
483
|
-
await this.
|
|
484
|
-
type: 'touchStart',
|
|
485
|
-
touchPoints: [{ x, y }],
|
|
486
|
-
});
|
|
461
|
+
await this.dispatchTouch({ type: 'touchStart', touchPoints: [{ x, y }] });
|
|
487
462
|
}
|
|
488
463
|
/**
|
|
489
464
|
* 触屏移动
|
|
490
465
|
*/
|
|
491
466
|
async touchMove(x, y) {
|
|
492
467
|
this.ensureSession();
|
|
493
|
-
await this.
|
|
494
|
-
type: 'touchMove',
|
|
495
|
-
touchPoints: [{ x, y }],
|
|
496
|
-
});
|
|
468
|
+
await this.dispatchTouch({ type: 'touchMove', touchPoints: [{ x, y }] });
|
|
497
469
|
}
|
|
498
470
|
/**
|
|
499
471
|
* 触屏结束
|
|
500
472
|
*/
|
|
501
473
|
async touchEnd() {
|
|
502
474
|
this.ensureSession();
|
|
503
|
-
await this.
|
|
504
|
-
type: 'touchEnd',
|
|
505
|
-
touchPoints: [],
|
|
506
|
-
});
|
|
475
|
+
await this.dispatchTouch({ type: 'touchEnd', touchPoints: [] });
|
|
507
476
|
}
|
|
508
477
|
/**
|
|
509
478
|
* 截图
|
|
510
479
|
*/
|
|
511
|
-
async screenshot(
|
|
480
|
+
async screenshot(options = {}) {
|
|
512
481
|
this.ensureSession();
|
|
482
|
+
const { fullPage = false, scale, format, quality, clip } = options;
|
|
513
483
|
const effectiveFormat = format ?? 'png';
|
|
514
484
|
const captureParams = { format: effectiveFormat };
|
|
515
485
|
if (quality !== undefined && effectiveFormat !== 'png') {
|
|
@@ -520,11 +490,20 @@ class SessionManager {
|
|
|
520
490
|
}
|
|
521
491
|
if (fullPage) {
|
|
522
492
|
// 获取页面完整高度
|
|
493
|
+
const sizeExpr = 'JSON.stringify({ width: document.documentElement.scrollWidth, ' +
|
|
494
|
+
'height: document.documentElement.scrollHeight })';
|
|
523
495
|
const { result } = (await this.send('Runtime.evaluate', {
|
|
524
|
-
expression:
|
|
496
|
+
expression: sizeExpr,
|
|
525
497
|
returnByValue: true,
|
|
526
498
|
}));
|
|
527
|
-
|
|
499
|
+
let width, height;
|
|
500
|
+
try {
|
|
501
|
+
;
|
|
502
|
+
({ width, height } = JSON.parse(result.value));
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
throw new Error(`screenshot: 无法解析页面尺寸: ${result.value}`);
|
|
506
|
+
}
|
|
528
507
|
// 设置视口
|
|
529
508
|
await this.send('Emulation.setDeviceMetricsOverride', {
|
|
530
509
|
width,
|
|
@@ -534,14 +513,19 @@ class SessionManager {
|
|
|
534
513
|
});
|
|
535
514
|
try {
|
|
536
515
|
const { data } = (await this.send('Page.captureScreenshot', captureParams));
|
|
537
|
-
return data;
|
|
516
|
+
return { data, format: effectiveFormat };
|
|
538
517
|
}
|
|
539
518
|
finally {
|
|
540
|
-
|
|
519
|
+
try {
|
|
520
|
+
await this.send('Emulation.clearDeviceMetricsOverride');
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// cleanup failure, ignore to preserve original error
|
|
524
|
+
}
|
|
541
525
|
}
|
|
542
526
|
}
|
|
543
527
|
const { data } = (await this.send('Page.captureScreenshot', captureParams));
|
|
544
|
-
return data;
|
|
528
|
+
return { data, format: effectiveFormat };
|
|
545
529
|
}
|
|
546
530
|
/**
|
|
547
531
|
* 获取页面状态
|
|
@@ -594,62 +578,459 @@ class SessionManager {
|
|
|
594
578
|
});
|
|
595
579
|
return state;
|
|
596
580
|
}
|
|
581
|
+
async getPageHtml(selector, outer = true) {
|
|
582
|
+
this.ensureSession();
|
|
583
|
+
if (selector) {
|
|
584
|
+
const prop = outer ? 'outerHTML' : 'innerHTML';
|
|
585
|
+
return this.evaluate(`((s, p) => { const el = document.querySelector(s); return el ? el[p] : '' })`, [selector, prop]);
|
|
586
|
+
}
|
|
587
|
+
return this.evaluate('document.documentElement.outerHTML');
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* 获取页面文本(IBrowserDriver 接口)
|
|
591
|
+
*/
|
|
592
|
+
async getPageText(selector) {
|
|
593
|
+
this.ensureSession();
|
|
594
|
+
if (selector) {
|
|
595
|
+
return this.evaluate(`(s => document.querySelector(s)?.textContent || '')`, [selector]);
|
|
596
|
+
}
|
|
597
|
+
return this.evaluate('document.body.innerText');
|
|
598
|
+
}
|
|
599
|
+
// ==================== IBrowserDriver: 页面读取 ====================
|
|
600
|
+
/** Extension readPage 等价物:CDP 通过 getPageState 构造 pageContent 文本 */
|
|
601
|
+
async readPage(_options) {
|
|
602
|
+
const state = await this.getPageState();
|
|
603
|
+
const elements = state.elements ?? [];
|
|
604
|
+
const lines = elements.map((e) => {
|
|
605
|
+
let line = e.role;
|
|
606
|
+
if (e.name) {
|
|
607
|
+
line += ` "${e.name}"`;
|
|
608
|
+
}
|
|
609
|
+
return line;
|
|
610
|
+
});
|
|
611
|
+
return {
|
|
612
|
+
pageContent: lines.join('\n'),
|
|
613
|
+
viewport: state.viewport,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/** CDP 通过 evaluate 注入函数枚举 IMG */
|
|
617
|
+
async getHtmlWithImages(selector, outer = true) {
|
|
618
|
+
const selectorArg = JSON.stringify(selector ?? null);
|
|
619
|
+
return this.evaluate(`(function() {
|
|
620
|
+
var root = ${selectorArg} ? document.querySelector(${selectorArg}) : document.documentElement;
|
|
621
|
+
if (!root) return {html: '', images: []};
|
|
622
|
+
var html = ${selectorArg}
|
|
623
|
+
? (${outer} ? root.outerHTML : root.innerHTML)
|
|
624
|
+
: document.documentElement.outerHTML;
|
|
625
|
+
var imgList = [];
|
|
626
|
+
if (root.tagName === 'IMG') imgList.push(root);
|
|
627
|
+
root.querySelectorAll('img').forEach(function(img) { imgList.push(img); });
|
|
628
|
+
var images = [];
|
|
629
|
+
for (var i = 0; i < imgList.length; i++) {
|
|
630
|
+
var img = imgList[i];
|
|
631
|
+
images.push({
|
|
632
|
+
index: i, src: img.src,
|
|
633
|
+
dataSrc: (function() {
|
|
634
|
+
var raw = img.dataset.src || img.dataset.lazySrc || img.dataset.original || '';
|
|
635
|
+
if (!raw) return '';
|
|
636
|
+
try { return new URL(raw, location.href).href } catch(e) { return raw }
|
|
637
|
+
})(),
|
|
638
|
+
alt: img.alt, width: img.width, height: img.height,
|
|
639
|
+
naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return {html: html, images: images};
|
|
643
|
+
})()`);
|
|
644
|
+
}
|
|
645
|
+
/** CDP 通过 evaluate 注入收集 meta 标签和 og/twitter/jsonLd */
|
|
646
|
+
async getMetadata() {
|
|
647
|
+
return this.evaluate(`(function() {
|
|
648
|
+
function meta(name) {
|
|
649
|
+
var el = document.querySelector('meta[name="'+name+'"],meta[property="'+name+'"]');
|
|
650
|
+
return el ? el.content || undefined : undefined;
|
|
651
|
+
}
|
|
652
|
+
var og = {}, tw = {};
|
|
653
|
+
document.querySelectorAll('meta[property^="og:"]').forEach(function(m) {
|
|
654
|
+
og[m.getAttribute('property')] = m.content || '';
|
|
655
|
+
});
|
|
656
|
+
document.querySelectorAll('meta[name^="twitter:"]').forEach(function(m) {
|
|
657
|
+
tw[m.getAttribute('name')] = m.content || '';
|
|
658
|
+
});
|
|
659
|
+
var jsonLd = [];
|
|
660
|
+
document.querySelectorAll('script[type="application/ld+json"]').forEach(function(s) {
|
|
661
|
+
try { jsonLd.push(JSON.parse(s.textContent || '')); } catch(e) {}
|
|
662
|
+
});
|
|
663
|
+
var alternates = [];
|
|
664
|
+
document.querySelectorAll('link[rel="alternate"]').forEach(function(l) {
|
|
665
|
+
alternates.push({
|
|
666
|
+
href: l.href,
|
|
667
|
+
type: l.getAttribute('type') || undefined,
|
|
668
|
+
hreflang: l.getAttribute('hreflang') || undefined
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
var feeds = [];
|
|
672
|
+
var feedSel = 'link[type="application/rss+xml"],link[type="application/atom+xml"]';
|
|
673
|
+
document.querySelectorAll(feedSel).forEach(function(l) {
|
|
674
|
+
feeds.push({href: l.href, type: l.getAttribute('type'), title: l.getAttribute('title') || undefined});
|
|
675
|
+
});
|
|
676
|
+
return {
|
|
677
|
+
title: document.title,
|
|
678
|
+
description: meta('description'),
|
|
679
|
+
canonical: (document.querySelector('link[rel="canonical"]') || {}).href || undefined,
|
|
680
|
+
charset: document.characterSet,
|
|
681
|
+
viewport: meta('viewport'),
|
|
682
|
+
og: og, twitter: tw, jsonLd: jsonLd, alternates: alternates, feeds: feeds
|
|
683
|
+
};
|
|
684
|
+
})()`);
|
|
685
|
+
}
|
|
686
|
+
// ==================== IBrowserDriver: 元素查找 ====================
|
|
687
|
+
/** CDP 通过 evaluate 注入 TreeWalker 查找元素 */
|
|
688
|
+
async find(selector, text, xpath, timeout) {
|
|
689
|
+
return this.evaluate(`function(selector, text, xpath) {
|
|
690
|
+
var elements;
|
|
691
|
+
if (xpath) {
|
|
692
|
+
elements = [];
|
|
693
|
+
var xr = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
694
|
+
for (var i = 0; i < xr.snapshotLength; i++) {
|
|
695
|
+
var node = xr.snapshotItem(i);
|
|
696
|
+
if (node instanceof Element) elements.push(node);
|
|
697
|
+
}
|
|
698
|
+
} else if (selector) {
|
|
699
|
+
elements = Array.from(document.querySelectorAll(selector));
|
|
700
|
+
} else if (text) {
|
|
701
|
+
// text-only:用 TreeWalker 仅遍历可能含目标文本的节点,避免 querySelectorAll('*') 的全树扫描和强制 layout
|
|
702
|
+
elements = [];
|
|
703
|
+
var lowerText = text;
|
|
704
|
+
var walker = document.createTreeWalker(
|
|
705
|
+
document.body || document.documentElement,
|
|
706
|
+
NodeFilter.SHOW_ELEMENT,
|
|
707
|
+
{
|
|
708
|
+
acceptNode: function (n) {
|
|
709
|
+
var t = n.textContent || '';
|
|
710
|
+
if (!t.includes(lowerText)) return NodeFilter.FILTER_SKIP;
|
|
711
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
while (walker.nextNode()) {
|
|
715
|
+
elements.push(walker.currentNode);
|
|
716
|
+
if (elements.length >= 200) break;
|
|
717
|
+
}
|
|
718
|
+
} else {
|
|
719
|
+
elements = Array.from(document.querySelectorAll('*'));
|
|
720
|
+
}
|
|
721
|
+
var results = [];
|
|
722
|
+
for (var j = 0; j < elements.length; j++) {
|
|
723
|
+
var el = elements[j];
|
|
724
|
+
if (text && !(el.textContent || '').includes(text)) continue;
|
|
725
|
+
var rect = el.getBoundingClientRect();
|
|
726
|
+
results.push({
|
|
727
|
+
refId: '',
|
|
728
|
+
tag: el.tagName.toLowerCase(),
|
|
729
|
+
text: (el.textContent || '').trim().substring(0, 100),
|
|
730
|
+
rect: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}
|
|
731
|
+
});
|
|
732
|
+
if (results.length >= 50) break;
|
|
733
|
+
}
|
|
734
|
+
return results;
|
|
735
|
+
}`, [selector ?? null, text ?? null, xpath ?? null], timeout);
|
|
736
|
+
}
|
|
737
|
+
// ==================== IBrowserDriver: 元素操作(refId 类,CDP 不支持) ====================
|
|
738
|
+
click(_refId) {
|
|
739
|
+
return Promise.reject(new DriverCapabilityError('CDP 模式不支持 refId 点击,请使用 input 工具的坐标点击'));
|
|
740
|
+
}
|
|
741
|
+
actionableClick(_refId, _force) {
|
|
742
|
+
return Promise.reject(new DriverCapabilityError('actionableClick 仅支持 Extension 模式'));
|
|
743
|
+
}
|
|
744
|
+
dispatchInput(_refId, _text) {
|
|
745
|
+
return Promise.reject(new DriverCapabilityError('dispatchInput 仅支持 Extension 模式'));
|
|
746
|
+
}
|
|
747
|
+
dragAndDrop(_srcRefId, _dstRefId) {
|
|
748
|
+
return Promise.reject(new DriverCapabilityError('dragAndDrop 仅支持 Extension 模式'));
|
|
749
|
+
}
|
|
750
|
+
getComputedStyle(_refId, _prop) {
|
|
751
|
+
return Promise.reject(new DriverCapabilityError('getComputedStyle 仅支持 Extension 模式'));
|
|
752
|
+
}
|
|
753
|
+
typeRef(_refId, _text, _clear) {
|
|
754
|
+
return Promise.reject(new DriverCapabilityError('CDP 模式不支持 refId 输入,请使用 input 工具'));
|
|
755
|
+
}
|
|
756
|
+
/** scrollAt:CDP 模式没有 refId 概念,无视 refId 直接 mouseWheel(保持与原 unified-session 行为一致) */
|
|
757
|
+
async scrollAt(x, y, _refId) {
|
|
758
|
+
await this.mouseWheel(x, y);
|
|
759
|
+
}
|
|
760
|
+
async getAttribute(selector, refId, attribute) {
|
|
761
|
+
if (refId !== undefined) {
|
|
762
|
+
throw new DriverCapabilityError('CDP 模式不支持 refId 查询属性,请通过 selector');
|
|
763
|
+
}
|
|
764
|
+
if (!selector) {
|
|
765
|
+
throw new DriverCapabilityError('getAttribute 需要 selector');
|
|
766
|
+
}
|
|
767
|
+
return this.evaluate(`(s, a) => { const el = document.querySelector(s); return el ? el.getAttribute(a) : null }`, [selector, attribute]);
|
|
768
|
+
}
|
|
769
|
+
// ==================== IBrowserDriver: 输入(precise) ====================
|
|
770
|
+
/** CDP 通用 inputKey 接口实现:直接转发 Input.dispatchKeyEvent,不维护 modifiers(由调用方提供) */
|
|
771
|
+
async inputKey(type, options = {}) {
|
|
772
|
+
this.ensureSession();
|
|
773
|
+
const params = { type };
|
|
774
|
+
if (options.key !== undefined) {
|
|
775
|
+
params.key = options.key;
|
|
776
|
+
}
|
|
777
|
+
if (options.code !== undefined) {
|
|
778
|
+
params.code = options.code;
|
|
779
|
+
}
|
|
780
|
+
if (options.text !== undefined) {
|
|
781
|
+
params.text = options.text;
|
|
782
|
+
}
|
|
783
|
+
if (options.unmodifiedText !== undefined) {
|
|
784
|
+
params.unmodifiedText = options.unmodifiedText;
|
|
785
|
+
}
|
|
786
|
+
if (options.location !== undefined) {
|
|
787
|
+
params.location = options.location;
|
|
788
|
+
}
|
|
789
|
+
if (options.isKeypad !== undefined) {
|
|
790
|
+
params.isKeypad = options.isKeypad;
|
|
791
|
+
}
|
|
792
|
+
if (options.autoRepeat !== undefined) {
|
|
793
|
+
params.autoRepeat = options.autoRepeat;
|
|
794
|
+
}
|
|
795
|
+
if (options.windowsVirtualKeyCode !== undefined) {
|
|
796
|
+
params.windowsVirtualKeyCode = options.windowsVirtualKeyCode;
|
|
797
|
+
}
|
|
798
|
+
if (options.modifiers !== undefined) {
|
|
799
|
+
params.modifiers = options.modifiers;
|
|
800
|
+
}
|
|
801
|
+
if (options.commands && options.commands.length > 0) {
|
|
802
|
+
params.commands = options.commands;
|
|
803
|
+
}
|
|
804
|
+
await this.send('Input.dispatchKeyEvent', params);
|
|
805
|
+
}
|
|
806
|
+
/** CDP 通用 inputMouse 接口实现:直接转发 Input.dispatchMouseEvent */
|
|
807
|
+
async inputMouse(type, x, y, options = {}) {
|
|
808
|
+
this.ensureSession();
|
|
809
|
+
const params = {
|
|
810
|
+
type,
|
|
811
|
+
x: Math.round(x),
|
|
812
|
+
y: Math.round(y),
|
|
813
|
+
};
|
|
814
|
+
if (options.button !== undefined) {
|
|
815
|
+
params.button = options.button;
|
|
816
|
+
}
|
|
817
|
+
if (options.clickCount !== undefined) {
|
|
818
|
+
params.clickCount = options.clickCount;
|
|
819
|
+
}
|
|
820
|
+
if (options.deltaX !== undefined) {
|
|
821
|
+
params.deltaX = options.deltaX;
|
|
822
|
+
}
|
|
823
|
+
if (options.deltaY !== undefined) {
|
|
824
|
+
params.deltaY = options.deltaY;
|
|
825
|
+
}
|
|
826
|
+
if (options.modifiers !== undefined) {
|
|
827
|
+
params.modifiers = options.modifiers;
|
|
828
|
+
}
|
|
829
|
+
await this.send('Input.dispatchMouseEvent', params);
|
|
830
|
+
// mouseMoved 同步坐标到 BehaviorSimulator,与 mouseMove 方法保持一致
|
|
831
|
+
if (type === 'mouseMoved') {
|
|
832
|
+
this.behaviorSimulator.setCurrentPosition({ x: Math.round(x), y: Math.round(y) });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/** CDP 通用 inputTouch 接口实现:临时启用 touch 模拟避免命令挂起 */
|
|
836
|
+
async inputTouch(type, touchPoints) {
|
|
837
|
+
this.ensureSession();
|
|
838
|
+
await this.dispatchTouch({ type, touchPoints });
|
|
839
|
+
}
|
|
840
|
+
/** CDP inputType 接口实现:直接复用现有 type 方法 */
|
|
841
|
+
inputType(text, delay = 0) {
|
|
842
|
+
return this.type(text, delay);
|
|
843
|
+
}
|
|
844
|
+
// ==================== IBrowserDriver: Stealth(CDP 不支持) ====================
|
|
845
|
+
stealthKey(_key, _type, _modifiers) {
|
|
846
|
+
return Promise.reject(new DriverCapabilityError('Stealth 模式仅在 Extension 下可用,CDP 模式请使用 inputKey'));
|
|
847
|
+
}
|
|
848
|
+
stealthClick(_x, _y, _button, _clickCount, _refId) {
|
|
849
|
+
return Promise.reject(new DriverCapabilityError('Stealth 模式仅在 Extension 下可用,CDP 模式请使用 inputMouse'));
|
|
850
|
+
}
|
|
851
|
+
stealthMouse(_type, _x, _y, _button) {
|
|
852
|
+
return Promise.reject(new DriverCapabilityError('Stealth 模式仅在 Extension 下可用,CDP 模式请使用 inputMouse'));
|
|
853
|
+
}
|
|
854
|
+
stealthType(_text, _delay) {
|
|
855
|
+
return Promise.reject(new DriverCapabilityError('Stealth 模式仅在 Extension 下可用,CDP 模式请使用 inputType'));
|
|
856
|
+
}
|
|
857
|
+
stealthInject() {
|
|
858
|
+
return Promise.reject(new DriverCapabilityError('CDP 模式 stealth 脚本在 connect/launch 时通过 stealth 参数自动注入,不支持后续手动注入'));
|
|
859
|
+
}
|
|
860
|
+
// ==================== IBrowserDriver: 日志启用 ====================
|
|
861
|
+
/** CDP 模式:Network/Runtime 域已在 attach 时启用,no-op */
|
|
862
|
+
consoleEnable() {
|
|
863
|
+
return Promise.resolve();
|
|
864
|
+
}
|
|
865
|
+
/** CDP 模式:Network 域已在 attach 时启用,no-op */
|
|
866
|
+
networkEnable() {
|
|
867
|
+
return Promise.resolve();
|
|
868
|
+
}
|
|
869
|
+
// ==================== IBrowserDriver: Tab/状态 ====================
|
|
870
|
+
/** IBrowserDriver 接口:激活页面(attach + 切到前台) */
|
|
871
|
+
async activatePage(targetId) {
|
|
872
|
+
await this.attachToTarget(targetId);
|
|
873
|
+
await this.activateTarget(targetId);
|
|
874
|
+
}
|
|
875
|
+
/** IBrowserDriver 接口:选择操作目标(attach 即可) */
|
|
876
|
+
async selectPage(targetId) {
|
|
877
|
+
await this.attachToTarget(targetId);
|
|
878
|
+
}
|
|
879
|
+
/** IBrowserDriver 接口:获取当前操作目标 ID */
|
|
880
|
+
getCurrentTargetId() {
|
|
881
|
+
return this.currentTargetId;
|
|
882
|
+
}
|
|
883
|
+
/** IBrowserDriver 接口:设置当前操作目标 ID(attach 到指定 target) */
|
|
884
|
+
setCurrentTargetId(targetId) {
|
|
885
|
+
// CDP 路径不支持同步 setCurrentTargetId(attach 是异步的);
|
|
886
|
+
// 调用方应使用 selectPage(targetId) 或 attachToTarget(targetId)
|
|
887
|
+
if (targetId === null) {
|
|
888
|
+
// 不允许同步清空 currentTargetId(会造成 sessionId 与 currentTargetId 状态漂移)
|
|
889
|
+
// 上层应通过 closePage 路径主动清理
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (targetId !== this.currentTargetId) {
|
|
893
|
+
throw new DriverCapabilityError('CDP 模式不支持同步切换 target,请用 selectPage(targetId) 或 attachToTarget(targetId)');
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// ==================== IBrowserDriver: iframe(CDP 不支持) ====================
|
|
897
|
+
resolveFrame(_frame) {
|
|
898
|
+
return Promise.reject(new DriverCapabilityError('iframe 穿透需要 Extension 模式'));
|
|
899
|
+
}
|
|
900
|
+
getCurrentFrameId() {
|
|
901
|
+
// CDP 模式没有 frameId 概念,统一返回 0(主框架)
|
|
902
|
+
return 0;
|
|
903
|
+
}
|
|
904
|
+
setCurrentFrameId(frameId) {
|
|
905
|
+
if (frameId !== 0) {
|
|
906
|
+
throw new DriverCapabilityError('iframe 穿透需要 Extension 模式');
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
evaluateInFrame(_frameId, _expression, _timeout) {
|
|
910
|
+
return Promise.reject(new DriverCapabilityError('iframe 穿透需要 Extension 模式'));
|
|
911
|
+
}
|
|
912
|
+
// ==================== IBrowserDriver: CDP 直通 ====================
|
|
913
|
+
/**
|
|
914
|
+
* 发送 CDP 命令(IBrowserDriver 接口):
|
|
915
|
+
* tabId 在 CDP 模式下忽略(CDP 单 session 概念不区分 tab);
|
|
916
|
+
* browser-level 域(Target/Browser/SystemInfo/DeviceAccess/IO)走 sendBrowserCommand
|
|
917
|
+
*/
|
|
918
|
+
debuggerSend(method, params, _tabId, timeout) {
|
|
919
|
+
const domain = method.split('.')[0];
|
|
920
|
+
const browserLevelDomains = ['Target', 'Browser', 'SystemInfo', 'DeviceAccess', 'IO'];
|
|
921
|
+
if (browserLevelDomains.includes(domain)) {
|
|
922
|
+
return this.sendBrowserCommand(method, params);
|
|
923
|
+
}
|
|
924
|
+
return this.send(method, params, timeout);
|
|
925
|
+
}
|
|
597
926
|
/**
|
|
598
927
|
* 获取 Cookies
|
|
599
|
-
* @param urls 可选,限制返回指定 URL 的 cookies
|
|
600
928
|
*/
|
|
601
|
-
async getCookies(
|
|
929
|
+
async getCookies(filter) {
|
|
602
930
|
this.ensureSession();
|
|
603
|
-
const
|
|
604
|
-
const { cookies } = (await this.send('Network.getCookies',
|
|
605
|
-
|
|
931
|
+
const urls = filter?.url ? [filter.url] : undefined;
|
|
932
|
+
const { cookies } = (await this.send('Network.getCookies', urls ? { urls } : {}));
|
|
933
|
+
if (!filter) {
|
|
934
|
+
return cookies;
|
|
935
|
+
}
|
|
936
|
+
return cookies.filter((c) => {
|
|
937
|
+
if (filter.name && c.name !== filter.name) {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
if (filter.domain) {
|
|
941
|
+
const fd = filter.domain.replace(/^\./, '');
|
|
942
|
+
const cd = (c.domain ?? '').replace(/^\./, '');
|
|
943
|
+
if (cd !== fd && !cd.endsWith('.' + fd)) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (filter.path && c.path !== filter.path) {
|
|
948
|
+
return false;
|
|
949
|
+
}
|
|
950
|
+
if (filter.secure !== undefined && c.secure !== filter.secure) {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
if (filter.session !== undefined) {
|
|
954
|
+
const isSession = (c.expires ?? -1) <= 0;
|
|
955
|
+
if (filter.session !== isSession) {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return true;
|
|
960
|
+
});
|
|
606
961
|
}
|
|
607
962
|
/**
|
|
608
963
|
* 设置 Cookie
|
|
609
964
|
*/
|
|
610
|
-
async setCookie(
|
|
965
|
+
async setCookie(params) {
|
|
611
966
|
this.ensureSession();
|
|
612
|
-
|
|
613
|
-
const
|
|
967
|
+
const url = params.url ?? this.state?.url ?? 'http://localhost';
|
|
968
|
+
const { name, value, domain, path, httpOnly, secure, sameSite, expirationDate } = params;
|
|
614
969
|
await this.send('Network.setCookie', {
|
|
615
970
|
name,
|
|
616
971
|
value,
|
|
617
972
|
url,
|
|
618
|
-
...
|
|
973
|
+
...(domain !== undefined && { domain }),
|
|
974
|
+
...(path !== undefined && { path }),
|
|
975
|
+
...(httpOnly !== undefined && { httpOnly }),
|
|
976
|
+
...(secure !== undefined && { secure }),
|
|
977
|
+
...(sameSite !== undefined && { sameSite }),
|
|
978
|
+
...(expirationDate !== undefined && { expires: expirationDate }),
|
|
619
979
|
});
|
|
620
980
|
}
|
|
621
|
-
|
|
622
|
-
* 删除 Cookie
|
|
623
|
-
*/
|
|
624
|
-
async deleteCookie(name, url) {
|
|
981
|
+
async deleteCookie(url, name) {
|
|
625
982
|
this.ensureSession();
|
|
626
|
-
const effectiveUrl = url
|
|
983
|
+
const effectiveUrl = url || this.state?.url || 'http://localhost';
|
|
627
984
|
await this.send('Network.deleteCookies', { name, url: effectiveUrl });
|
|
628
985
|
}
|
|
629
|
-
|
|
630
|
-
* 清除所有 Cookies
|
|
631
|
-
*/
|
|
632
|
-
async clearCookies() {
|
|
986
|
+
async clearCookies(filter) {
|
|
633
987
|
this.ensureSession();
|
|
634
|
-
|
|
988
|
+
// Driver 级护栏:禁止无过滤的全站清除,必须带 url/domain/name 至少一项
|
|
989
|
+
if (!filter || (!filter.url && !filter.domain && !filter.name)) {
|
|
990
|
+
throw new Error('clearCookies 必须带 url/domain/name 至少一个过滤参数(防止误清全站 cookies)');
|
|
991
|
+
}
|
|
992
|
+
const urls = filter.url ? [filter.url] : undefined;
|
|
993
|
+
const { cookies } = (await this.send('Network.getCookies', urls ? { urls } : {}));
|
|
994
|
+
let count = 0;
|
|
995
|
+
for (const cookie of cookies) {
|
|
996
|
+
if (filter.domain) {
|
|
997
|
+
const fd = filter.domain.replace(/^\./, '');
|
|
998
|
+
const cd = cookie.domain.replace(/^\./, '');
|
|
999
|
+
if (cd !== fd && !cd.endsWith('.' + fd)) {
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (filter.name && cookie.name !== filter.name) {
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
const protocol = cookie.secure ? 'https:' : 'http:';
|
|
1007
|
+
const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain;
|
|
1008
|
+
const deleteUrl = `${protocol}//${domain}${cookie.path}`;
|
|
1009
|
+
await this.send('Network.deleteCookies', { name: cookie.name, url: deleteUrl });
|
|
1010
|
+
count++;
|
|
1011
|
+
}
|
|
1012
|
+
return { count };
|
|
635
1013
|
}
|
|
636
|
-
|
|
637
|
-
* 获取控制台日志
|
|
638
|
-
*/
|
|
639
|
-
getConsoleLogs(level, limit = 100) {
|
|
1014
|
+
async getConsoleLogs(options = {}) {
|
|
640
1015
|
let logs = this.consoleLogs;
|
|
1016
|
+
const { level, pattern, clear } = options;
|
|
641
1017
|
if (level && level !== 'all') {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
1018
|
+
logs = logs.filter((l) => l.level === level ||
|
|
1019
|
+
(level === 'warning' && l.level === 'warn') ||
|
|
1020
|
+
(level === 'warn' && l.level === 'warning'));
|
|
1021
|
+
}
|
|
1022
|
+
if (pattern) {
|
|
1023
|
+
const lp = pattern.toLowerCase();
|
|
1024
|
+
logs = logs.filter((l) => l.text.toLowerCase().includes(lp));
|
|
646
1025
|
}
|
|
647
|
-
|
|
1026
|
+
const result = logs.slice(-100);
|
|
1027
|
+
if (clear) {
|
|
1028
|
+
this.consoleLogs = [];
|
|
1029
|
+
}
|
|
1030
|
+
return result;
|
|
648
1031
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
*/
|
|
652
|
-
getNetworkRequests(urlPattern, limit = 100) {
|
|
1032
|
+
async getNetworkRequests(options = {}) {
|
|
1033
|
+
const { urlPattern, clear } = options;
|
|
653
1034
|
let requests = this.networkRequests;
|
|
654
1035
|
if (urlPattern) {
|
|
655
1036
|
try {
|
|
@@ -665,7 +1046,12 @@ class SessionManager {
|
|
|
665
1046
|
requests = requests.filter((r) => r.url.toLowerCase().includes(pat));
|
|
666
1047
|
}
|
|
667
1048
|
}
|
|
668
|
-
|
|
1049
|
+
const result = requests.slice(-100);
|
|
1050
|
+
if (clear) {
|
|
1051
|
+
this.networkRequests = [];
|
|
1052
|
+
this.requestMap.clear();
|
|
1053
|
+
}
|
|
1054
|
+
return result;
|
|
669
1055
|
}
|
|
670
1056
|
/**
|
|
671
1057
|
* 清除日志
|
|
@@ -693,7 +1079,7 @@ class SessionManager {
|
|
|
693
1079
|
const callParams = {
|
|
694
1080
|
functionDeclaration: script,
|
|
695
1081
|
objectId: globalResult.objectId,
|
|
696
|
-
arguments: args.map(a => ({ value: a })),
|
|
1082
|
+
arguments: args.map((a) => ({ value: a })),
|
|
697
1083
|
returnByValue: true,
|
|
698
1084
|
awaitPromise: true,
|
|
699
1085
|
};
|
|
@@ -707,8 +1093,7 @@ class SessionManager {
|
|
|
707
1093
|
return extractCdpValue(result);
|
|
708
1094
|
}
|
|
709
1095
|
finally {
|
|
710
|
-
this.send('Runtime.releaseObject', { objectId: globalResult.objectId }).catch(() => {
|
|
711
|
-
});
|
|
1096
|
+
this.send('Runtime.releaseObject', { objectId: globalResult.objectId }).catch(() => { });
|
|
712
1097
|
}
|
|
713
1098
|
}
|
|
714
1099
|
const evalParams = {
|
|
@@ -746,12 +1131,11 @@ class SessionManager {
|
|
|
746
1131
|
}
|
|
747
1132
|
/**
|
|
748
1133
|
* 清除缓存
|
|
1134
|
+
*
|
|
1135
|
+
* 不再清 cookies:cookies 清除统一走 cookies action=clear(强制 name/domain/url 过滤)
|
|
749
1136
|
*/
|
|
750
1137
|
async clearCache(type = 'all') {
|
|
751
1138
|
this.ensureSession();
|
|
752
|
-
if (type === 'all' || type === 'cookies') {
|
|
753
|
-
await this.send('Network.clearBrowserCookies');
|
|
754
|
-
}
|
|
755
1139
|
if (type === 'all' || type === 'cache') {
|
|
756
1140
|
await this.send('Network.clearBrowserCache');
|
|
757
1141
|
}
|
|
@@ -766,9 +1150,26 @@ class SessionManager {
|
|
|
766
1150
|
}
|
|
767
1151
|
/**
|
|
768
1152
|
* 新建页面(外部入口,加锁)
|
|
1153
|
+
*
|
|
1154
|
+
* IBrowserDriver 接口:可选 url 参数,url 提供时新建后立即导航
|
|
769
1155
|
*/
|
|
770
|
-
async newPage() {
|
|
771
|
-
|
|
1156
|
+
async newPage(url, _timeout) {
|
|
1157
|
+
const target = await this.withLock(async () => this.newPageInternal());
|
|
1158
|
+
if (url) {
|
|
1159
|
+
await this.navigate(url);
|
|
1160
|
+
return {
|
|
1161
|
+
targetId: target.targetId,
|
|
1162
|
+
url: this.state?.url ?? url,
|
|
1163
|
+
title: this.state?.title ?? '',
|
|
1164
|
+
type: 'page',
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
targetId: target.targetId,
|
|
1169
|
+
url: target.url,
|
|
1170
|
+
title: target.title,
|
|
1171
|
+
type: 'page',
|
|
1172
|
+
};
|
|
772
1173
|
}
|
|
773
1174
|
/**
|
|
774
1175
|
* 激活页面(切到前台)
|
|
@@ -830,9 +1231,9 @@ class SessionManager {
|
|
|
830
1231
|
/**
|
|
831
1232
|
* 发送 CDP 命令(page-level,携带 sessionId)
|
|
832
1233
|
*
|
|
833
|
-
* 每次调用都检查连接状态,防止 close() 并发置空 this.cdp
|
|
1234
|
+
* 每次调用都检查连接状态,防止 close() 并发置空 this.cdp 后崩溃,
|
|
834
1235
|
* 多步操作(type 循环、fullPage 截图等)的 await 间隙可能被 close() 打断,
|
|
835
|
-
* ensureSession() 确保在当前 tick 内 this.cdp
|
|
1236
|
+
* ensureSession() 确保在当前 tick 内 this.cdp 非空
|
|
836
1237
|
*/
|
|
837
1238
|
send(method, params, timeout) {
|
|
838
1239
|
this.ensureSession();
|
|
@@ -846,6 +1247,95 @@ class SessionManager {
|
|
|
846
1247
|
this.ensureConnected();
|
|
847
1248
|
return this.cdp.send(method, params);
|
|
848
1249
|
}
|
|
1250
|
+
/**
|
|
1251
|
+
* 派发 CDP Input.dispatchTouchEvent,临时启用 touch 模拟避免命令挂起
|
|
1252
|
+
*/
|
|
1253
|
+
async dispatchTouch(params) {
|
|
1254
|
+
await this.send('Emulation.setTouchEmulationEnabled', { enabled: true });
|
|
1255
|
+
try {
|
|
1256
|
+
await this.send('Input.dispatchTouchEvent', params);
|
|
1257
|
+
}
|
|
1258
|
+
finally {
|
|
1259
|
+
try {
|
|
1260
|
+
await this.send('Emulation.setTouchEmulationEnabled', { enabled: false });
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
// cleanup 失败不覆盖原始错误
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* 在调用 navigate 之前注册网络空闲监听器,返回等待 idle 的 Promise
|
|
1269
|
+
* 预先注册避免 Page.navigate 返回和监听器注册之间遗漏早期请求
|
|
1270
|
+
*/
|
|
1271
|
+
startNetworkIdleWatcher(timeout, idleTime = 500) {
|
|
1272
|
+
this.ensureSession();
|
|
1273
|
+
return this.buildNetworkIdlePromise(this.cdp, timeout, idleTime);
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* 核心网络空闲等待逻辑
|
|
1277
|
+
*
|
|
1278
|
+
* 捕获 cdp 引用防止 close() 并发置 null,用局部 Set 追踪请求避免污染成员变量
|
|
1279
|
+
* close() 时通过 'disconnected' 信号立即 reject
|
|
1280
|
+
*/
|
|
1281
|
+
buildNetworkIdlePromise(cdp, timeout, idleTime) {
|
|
1282
|
+
const localPendingRequests = new Set();
|
|
1283
|
+
return new Promise((resolve, reject) => {
|
|
1284
|
+
let idleTimer = null;
|
|
1285
|
+
let timeoutTimer = null;
|
|
1286
|
+
const checkIdle = () => {
|
|
1287
|
+
if (localPendingRequests.size === 0) {
|
|
1288
|
+
if (idleTimer === null) {
|
|
1289
|
+
idleTimer = setTimeout(() => {
|
|
1290
|
+
cleanup();
|
|
1291
|
+
resolve();
|
|
1292
|
+
}, idleTime);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
else {
|
|
1296
|
+
if (idleTimer !== null) {
|
|
1297
|
+
clearTimeout(idleTimer);
|
|
1298
|
+
idleTimer = null;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
const onRequestStart = (params) => {
|
|
1303
|
+
const { requestId } = params;
|
|
1304
|
+
localPendingRequests.add(requestId);
|
|
1305
|
+
checkIdle();
|
|
1306
|
+
};
|
|
1307
|
+
const onRequestEnd = (params) => {
|
|
1308
|
+
const { requestId } = params;
|
|
1309
|
+
localPendingRequests.delete(requestId);
|
|
1310
|
+
checkIdle();
|
|
1311
|
+
};
|
|
1312
|
+
const cleanup = () => {
|
|
1313
|
+
if (idleTimer !== null) {
|
|
1314
|
+
clearTimeout(idleTimer);
|
|
1315
|
+
}
|
|
1316
|
+
if (timeoutTimer !== null) {
|
|
1317
|
+
clearTimeout(timeoutTimer);
|
|
1318
|
+
}
|
|
1319
|
+
cdp.offEvent('Network.requestWillBeSent', onRequestStart);
|
|
1320
|
+
cdp.offEvent('Network.loadingFinished', onRequestEnd);
|
|
1321
|
+
cdp.offEvent('Network.loadingFailed', onRequestEnd);
|
|
1322
|
+
cdp.removeListener('disconnected', onDisconnected);
|
|
1323
|
+
};
|
|
1324
|
+
timeoutTimer = setTimeout(() => {
|
|
1325
|
+
cleanup();
|
|
1326
|
+
reject(new NavigationTimeoutError('networkidle', timeout));
|
|
1327
|
+
}, timeout);
|
|
1328
|
+
const onDisconnected = () => {
|
|
1329
|
+
cleanup();
|
|
1330
|
+
reject(new Error('CDP 连接已关闭'));
|
|
1331
|
+
};
|
|
1332
|
+
cdp.once('disconnected', onDisconnected);
|
|
1333
|
+
cdp.onEvent('Network.requestWillBeSent', onRequestStart);
|
|
1334
|
+
cdp.onEvent('Network.loadingFinished', onRequestEnd);
|
|
1335
|
+
cdp.onEvent('Network.loadingFailed', onRequestEnd);
|
|
1336
|
+
checkIdle();
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
849
1339
|
/**
|
|
850
1340
|
* 附加到指定页面(内部版本,不加锁,供 launch/connect 等已持锁方法调用)
|
|
851
1341
|
*/
|
|
@@ -895,8 +1385,8 @@ class SessionManager {
|
|
|
895
1385
|
/**
|
|
896
1386
|
* 重置所有状态(同步,不加锁)
|
|
897
1387
|
*
|
|
898
|
-
* 供已持有 withLock 的方法调用(launch/connect),避免 close() 的 withLock
|
|
899
|
-
* 外部调用请使用 close()
|
|
1388
|
+
* 供已持有 withLock 的方法调用(launch/connect),避免 close() 的 withLock 重入死锁,
|
|
1389
|
+
* 外部调用请使用 close()
|
|
900
1390
|
*/
|
|
901
1391
|
resetState() {
|
|
902
1392
|
if (this.cdp) {
|
|
@@ -937,8 +1427,8 @@ class SessionManager {
|
|
|
937
1427
|
* 等待多个事件中的任一个触发
|
|
938
1428
|
*
|
|
939
1429
|
* 用于同时监听跨文档导航 (loadEventFired) 和同文档导航 (navigatedWithinDocument),
|
|
940
|
-
*
|
|
941
|
-
* close() 时通过 'disconnected' 信号立即 reject,不必等 timer
|
|
1430
|
+
* 任一事件触发后清理所有监听器和超时定时器
|
|
1431
|
+
* close() 时通过 'disconnected' 信号立即 reject,不必等 timer 超时
|
|
942
1432
|
*/
|
|
943
1433
|
waitForAnyEvent(events, timeout) {
|
|
944
1434
|
// 捕获当前 cdp 引用,防止 close() 并发置 null 导致回调崩溃
|
|
@@ -1020,9 +1510,9 @@ class SessionManager {
|
|
|
1020
1510
|
url: p.stackTrace?.callFrames[0]?.url,
|
|
1021
1511
|
lineNumber: p.stackTrace?.callFrames[0]?.lineNumber,
|
|
1022
1512
|
});
|
|
1023
|
-
//
|
|
1513
|
+
// 环形缓冲区:批量裁剪到 800 条,均摊 O(n) 开销
|
|
1024
1514
|
if (this.consoleLogs.length > SessionManager.MAX_LOG_ENTRIES) {
|
|
1025
|
-
this.consoleLogs.
|
|
1515
|
+
this.consoleLogs.splice(0, this.consoleLogs.length - 800);
|
|
1026
1516
|
}
|
|
1027
1517
|
});
|
|
1028
1518
|
// 网络请求
|
|
@@ -1046,9 +1536,9 @@ class SessionManager {
|
|
|
1046
1536
|
status: p.response.status,
|
|
1047
1537
|
duration: Math.round((p.timestamp - _monotonic) * 1000),
|
|
1048
1538
|
});
|
|
1049
|
-
//
|
|
1539
|
+
// 环形缓冲区:批量裁剪到 800 条,均摊 O(n) 开销
|
|
1050
1540
|
if (this.networkRequests.length > SessionManager.MAX_LOG_ENTRIES) {
|
|
1051
|
-
this.networkRequests.
|
|
1541
|
+
this.networkRequests.splice(0, this.networkRequests.length - 800);
|
|
1052
1542
|
}
|
|
1053
1543
|
this.requestMap.delete(p.requestId);
|
|
1054
1544
|
}
|
|
@@ -1095,7 +1585,7 @@ class SessionManager {
|
|
|
1095
1585
|
/**
|
|
1096
1586
|
* 获取按键定义
|
|
1097
1587
|
*/
|
|
1098
|
-
function getKeyDefinition(key) {
|
|
1588
|
+
export function getKeyDefinition(key) {
|
|
1099
1589
|
const definitions = {
|
|
1100
1590
|
// 修饰键
|
|
1101
1591
|
Control: { key: 'Control', code: 'ControlLeft', keyCode: 17 },
|
|
@@ -1126,7 +1616,9 @@ function getKeyDefinition(key) {
|
|
|
1126
1616
|
}
|
|
1127
1617
|
// 如果是单个字符,生成定义
|
|
1128
1618
|
if (key.length === 1) {
|
|
1129
|
-
|
|
1619
|
+
// Windows VK code 对字母是大写 ASCII,对数字是数字 ASCII
|
|
1620
|
+
// 用 charCodeAt 的话 'a'=97 会被识别为键码 97(不是字母 A 的 0x41=65),导致快捷键无法匹配
|
|
1621
|
+
const vkCode = key >= 'a' && key <= 'z' ? key.toUpperCase().charCodeAt(0) : key.charCodeAt(0);
|
|
1130
1622
|
const code = key >= 'a' && key <= 'z'
|
|
1131
1623
|
? `Key${key.toUpperCase()}`
|
|
1132
1624
|
: key >= 'A' && key <= 'Z'
|
|
@@ -1137,7 +1629,7 @@ function getKeyDefinition(key) {
|
|
|
1137
1629
|
return {
|
|
1138
1630
|
key,
|
|
1139
1631
|
code,
|
|
1140
|
-
keyCode:
|
|
1632
|
+
keyCode: vkCode,
|
|
1141
1633
|
text: key,
|
|
1142
1634
|
};
|
|
1143
1635
|
}
|