@pyrokine/mcp-chrome 1.7.0 → 2.0.0

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