@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.
Files changed (109) hide show
  1. package/README.md +71 -31
  2. package/dist/anti-detection/behavior.d.ts.map +1 -1
  3. package/dist/anti-detection/behavior.js.map +1 -1
  4. package/dist/anti-detection/index.d.ts +1 -1
  5. package/dist/anti-detection/index.d.ts.map +1 -1
  6. package/dist/anti-detection/index.js +1 -1
  7. package/dist/anti-detection/index.js.map +1 -1
  8. package/dist/anti-detection/injection.d.ts +6 -2
  9. package/dist/anti-detection/injection.d.ts.map +1 -1
  10. package/dist/anti-detection/injection.js +34 -80
  11. package/dist/anti-detection/injection.js.map +1 -1
  12. package/dist/cdp/client.d.ts +2 -2
  13. package/dist/cdp/client.d.ts.map +1 -1
  14. package/dist/cdp/client.js +8 -10
  15. package/dist/cdp/client.js.map +1 -1
  16. package/dist/cdp/index.d.ts.map +1 -1
  17. package/dist/cdp/index.js.map +1 -1
  18. package/dist/cdp/launcher.d.ts.map +1 -1
  19. package/dist/cdp/launcher.js +40 -13
  20. package/dist/cdp/launcher.js.map +1 -1
  21. package/dist/core/auto-wait.d.ts +2 -2
  22. package/dist/core/auto-wait.d.ts.map +1 -1
  23. package/dist/core/auto-wait.js +2 -2
  24. package/dist/core/auto-wait.js.map +1 -1
  25. package/dist/core/browser-driver.d.ts +307 -0
  26. package/dist/core/browser-driver.d.ts.map +1 -0
  27. package/dist/core/browser-driver.js +21 -0
  28. package/dist/core/browser-driver.js.map +1 -0
  29. package/dist/core/error-sanitizer.d.ts +25 -0
  30. package/dist/core/error-sanitizer.d.ts.map +1 -0
  31. package/dist/core/error-sanitizer.js +66 -0
  32. package/dist/core/error-sanitizer.js.map +1 -0
  33. package/dist/core/errors.d.ts +10 -1
  34. package/dist/core/errors.d.ts.map +1 -1
  35. package/dist/core/errors.js +17 -4
  36. package/dist/core/errors.js.map +1 -1
  37. package/dist/core/extension-errors.d.ts +20 -0
  38. package/dist/core/extension-errors.d.ts.map +1 -0
  39. package/dist/core/extension-errors.js +40 -0
  40. package/dist/core/extension-errors.js.map +1 -0
  41. package/dist/core/index.d.ts.map +1 -1
  42. package/dist/core/index.js.map +1 -1
  43. package/dist/core/locator.d.ts +2 -2
  44. package/dist/core/locator.d.ts.map +1 -1
  45. package/dist/core/locator.js +25 -65
  46. package/dist/core/locator.js.map +1 -1
  47. package/dist/core/retry.d.ts +2 -2
  48. package/dist/core/retry.d.ts.map +1 -1
  49. package/dist/core/retry.js +2 -2
  50. package/dist/core/retry.js.map +1 -1
  51. package/dist/core/session.d.ts +149 -46
  52. package/dist/core/session.d.ts.map +1 -1
  53. package/dist/core/session.js +673 -181
  54. package/dist/core/session.js.map +1 -1
  55. package/dist/core/types.d.ts +9 -3
  56. package/dist/core/types.d.ts.map +1 -1
  57. package/dist/core/types.js +13 -6
  58. package/dist/core/types.js.map +1 -1
  59. package/dist/core/unified-session.d.ts +46 -85
  60. package/dist/core/unified-session.d.ts.map +1 -1
  61. package/dist/core/unified-session.js +341 -650
  62. package/dist/core/unified-session.js.map +1 -1
  63. package/dist/core/utils.d.ts +7 -0
  64. package/dist/core/utils.d.ts.map +1 -0
  65. package/dist/core/utils.js +33 -0
  66. package/dist/core/utils.js.map +1 -0
  67. package/dist/extension/bridge.d.ts +69 -52
  68. package/dist/extension/bridge.d.ts.map +1 -1
  69. package/dist/extension/bridge.js +242 -111
  70. package/dist/extension/bridge.js.map +1 -1
  71. package/dist/extension/http-server.d.ts +6 -4
  72. package/dist/extension/http-server.d.ts.map +1 -1
  73. package/dist/extension/http-server.js +45 -31
  74. package/dist/extension/http-server.js.map +1 -1
  75. package/dist/extension/index.d.ts.map +1 -1
  76. package/dist/extension/index.js.map +1 -1
  77. package/dist/index.js +3 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/tools/browse.d.ts.map +1 -1
  80. package/dist/tools/browse.js +32 -34
  81. package/dist/tools/browse.js.map +1 -1
  82. package/dist/tools/cookies.d.ts.map +1 -1
  83. package/dist/tools/cookies.js +38 -16
  84. package/dist/tools/cookies.js.map +1 -1
  85. package/dist/tools/evaluate.d.ts.map +1 -1
  86. package/dist/tools/evaluate.js +54 -23
  87. package/dist/tools/evaluate.js.map +1 -1
  88. package/dist/tools/extract.d.ts.map +1 -1
  89. package/dist/tools/extract.js +221 -153
  90. package/dist/tools/extract.js.map +1 -1
  91. package/dist/tools/index.d.ts.map +1 -1
  92. package/dist/tools/index.js.map +1 -1
  93. package/dist/tools/input.d.ts.map +1 -1
  94. package/dist/tools/input.js +281 -89
  95. package/dist/tools/input.js.map +1 -1
  96. package/dist/tools/logs.d.ts.map +1 -1
  97. package/dist/tools/logs.js +31 -17
  98. package/dist/tools/logs.js.map +1 -1
  99. package/dist/tools/manage.d.ts.map +1 -1
  100. package/dist/tools/manage.js +25 -28
  101. package/dist/tools/manage.js.map +1 -1
  102. package/dist/tools/schema.d.ts +1 -1
  103. package/dist/tools/schema.d.ts.map +1 -1
  104. package/dist/tools/schema.js +31 -55
  105. package/dist/tools/schema.js.map +1 -1
  106. package/dist/tools/wait.d.ts.map +1 -1
  107. package/dist/tools/wait.js +19 -16
  108. package/dist/tools/wait.js.map +1 -1
  109. package/package.json +48 -40
@@ -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 { NavigationTimeoutError, SessionNotFoundError, TargetNotFoundError } from './errors.js';
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 NavigationTimeoutError(url, timeout);
202
+ throw new NavigationError(url, errorText);
198
203
  }
199
204
  // 根据 wait 类型等待
200
- if (wait === 'networkidle') {
201
- await this.waitForNetworkIdle(timeout);
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
- // 捕获当前 cdp 引用,防止 close() 并发置 null 导致回调崩溃
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(options = {}) {
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: 1,
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: 1,
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
- await this.send('Input.dispatchKeyEvent', {
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: this.modifiers,
403
+ modifiers: nextModifiers,
451
404
  ...keyDefinition,
452
405
  });
453
- if (MODIFIER_KEYS[key]) {
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
- for (const char of text) {
463
- await this.send('Input.dispatchKeyEvent', {
464
- type: 'keyDown',
465
- modifiers: this.modifiers,
466
- text: char,
467
- });
468
- await this.send('Input.dispatchKeyEvent', {
469
- type: 'keyUp',
470
- modifiers: this.modifiers,
471
- text: char,
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.send('Input.dispatchTouchEvent', {
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.send('Input.dispatchTouchEvent', {
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.send('Input.dispatchTouchEvent', {
504
- type: 'touchEnd',
505
- touchPoints: [],
506
- });
475
+ await this.dispatchTouch({ type: 'touchEnd', touchPoints: [] });
507
476
  }
508
477
  /**
509
478
  * 截图
510
479
  */
511
- async screenshot(fullPage = false, scale, format, quality, clip) {
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: 'JSON.stringify({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })',
496
+ expression: sizeExpr,
525
497
  returnByValue: true,
526
498
  }));
527
- const { width, height } = JSON.parse(result.value);
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
- await this.send('Emulation.clearDeviceMetricsOverride');
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(urls) {
929
+ async getCookies(filter) {
602
930
  this.ensureSession();
603
- const params = urls?.length ? { urls } : {};
604
- const { cookies } = (await this.send('Network.getCookies', params));
605
- return cookies;
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(name, value, options = {}) {
965
+ async setCookie(params) {
611
966
  this.ensureSession();
612
- // 获取当前 URL
613
- const url = this.state?.url ?? 'http://localhost';
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
- ...options,
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 ?? this.state?.url ?? 'http://localhost';
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
- await this.send('Network.clearBrowserCookies');
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
- // warning/warn 统一匹配(CDP warning,其他源可能用 warn)
643
- logs = logs.filter((l) => l.level === level
644
- || (level === 'warning' && l.level === 'warn')
645
- || (level === 'warn' && l.level === 'warning'));
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
- return logs.slice(-limit);
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
- return requests.slice(-limit);
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
- return this.withLock(async () => this.newPageInternal());
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.shift();
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.shift();
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
- const charCode = key.charCodeAt(0);
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: charCode,
1632
+ keyCode: vkCode,
1141
1633
  text: key,
1142
1634
  };
1143
1635
  }