@pyrokine/mcp-chrome 1.0.0 → 1.3.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 (115) hide show
  1. package/README.md +236 -96
  2. package/dist/anti-detection/behavior.d.ts +0 -8
  3. package/dist/anti-detection/behavior.d.ts.map +1 -1
  4. package/dist/anti-detection/behavior.js +0 -16
  5. package/dist/anti-detection/behavior.js.map +1 -1
  6. package/dist/cdp/client.d.ts +10 -2
  7. package/dist/cdp/client.d.ts.map +1 -1
  8. package/dist/cdp/client.js +76 -61
  9. package/dist/cdp/client.js.map +1 -1
  10. package/dist/cdp/launcher.d.ts +1 -8
  11. package/dist/cdp/launcher.d.ts.map +1 -1
  12. package/dist/cdp/launcher.js +18 -24
  13. package/dist/cdp/launcher.js.map +1 -1
  14. package/dist/core/auto-wait.d.ts +8 -1
  15. package/dist/core/auto-wait.d.ts.map +1 -1
  16. package/dist/core/auto-wait.js +15 -5
  17. package/dist/core/auto-wait.js.map +1 -1
  18. package/dist/core/errors.d.ts +11 -13
  19. package/dist/core/errors.d.ts.map +1 -1
  20. package/dist/core/errors.js +50 -31
  21. package/dist/core/errors.js.map +1 -1
  22. package/dist/core/index.d.ts +1 -0
  23. package/dist/core/index.d.ts.map +1 -1
  24. package/dist/core/index.js +1 -0
  25. package/dist/core/index.js.map +1 -1
  26. package/dist/core/locator.d.ts +17 -6
  27. package/dist/core/locator.d.ts.map +1 -1
  28. package/dist/core/locator.js +137 -28
  29. package/dist/core/locator.js.map +1 -1
  30. package/dist/core/retry.d.ts.map +1 -1
  31. package/dist/core/retry.js +1 -1
  32. package/dist/core/retry.js.map +1 -1
  33. package/dist/core/session.d.ts +71 -16
  34. package/dist/core/session.d.ts.map +1 -1
  35. package/dist/core/session.js +365 -124
  36. package/dist/core/session.js.map +1 -1
  37. package/dist/core/types.d.ts +15 -0
  38. package/dist/core/types.d.ts.map +1 -1
  39. package/dist/core/types.js +11 -2
  40. package/dist/core/types.js.map +1 -1
  41. package/dist/core/unified-session.d.ts +418 -0
  42. package/dist/core/unified-session.d.ts.map +1 -0
  43. package/dist/core/unified-session.js +1044 -0
  44. package/dist/core/unified-session.js.map +1 -0
  45. package/dist/extension/bridge.d.ts +203 -0
  46. package/dist/extension/bridge.d.ts.map +1 -0
  47. package/dist/extension/bridge.js +375 -0
  48. package/dist/extension/bridge.js.map +1 -0
  49. package/dist/extension/http-server.d.ts +60 -0
  50. package/dist/extension/http-server.d.ts.map +1 -0
  51. package/dist/extension/http-server.js +319 -0
  52. package/dist/extension/http-server.js.map +1 -0
  53. package/dist/extension/index.d.ts +7 -0
  54. package/dist/extension/index.d.ts.map +1 -0
  55. package/dist/extension/index.js +6 -0
  56. package/dist/extension/index.js.map +1 -0
  57. package/dist/extension/native-host-installer.d.ts +21 -0
  58. package/dist/extension/native-host-installer.d.ts.map +1 -0
  59. package/dist/extension/native-host-installer.js +147 -0
  60. package/dist/extension/native-host-installer.js.map +1 -0
  61. package/dist/extension/socket-server.d.ts +32 -0
  62. package/dist/extension/socket-server.d.ts.map +1 -0
  63. package/dist/extension/socket-server.js +177 -0
  64. package/dist/extension/socket-server.js.map +1 -0
  65. package/dist/extension/ws-server.d.ts +40 -0
  66. package/dist/extension/ws-server.d.ts.map +1 -0
  67. package/dist/extension/ws-server.js +246 -0
  68. package/dist/extension/ws-server.js.map +1 -0
  69. package/dist/index.js +16 -67
  70. package/dist/index.js.map +1 -1
  71. package/dist/native-host/index.js +280 -0
  72. package/dist/native-host/mcp-chrome-host +2 -0
  73. package/dist/tools/browse.d.ts +3 -76
  74. package/dist/tools/browse.d.ts.map +1 -1
  75. package/dist/tools/browse.js +193 -237
  76. package/dist/tools/browse.js.map +1 -1
  77. package/dist/tools/cookies.d.ts +6 -66
  78. package/dist/tools/cookies.d.ts.map +1 -1
  79. package/dist/tools/cookies.js +129 -125
  80. package/dist/tools/cookies.js.map +1 -1
  81. package/dist/tools/evaluate.d.ts +3 -37
  82. package/dist/tools/evaluate.d.ts.map +1 -1
  83. package/dist/tools/evaluate.js +68 -55
  84. package/dist/tools/evaluate.js.map +1 -1
  85. package/dist/tools/extract.d.ts +3 -200
  86. package/dist/tools/extract.d.ts.map +1 -1
  87. package/dist/tools/extract.js +287 -238
  88. package/dist/tools/extract.js.map +1 -1
  89. package/dist/tools/index.d.ts +9 -9
  90. package/dist/tools/index.d.ts.map +1 -1
  91. package/dist/tools/index.js +9 -9
  92. package/dist/tools/index.js.map +1 -1
  93. package/dist/tools/input.d.ts +3 -228
  94. package/dist/tools/input.d.ts.map +1 -1
  95. package/dist/tools/input.js +190 -129
  96. package/dist/tools/input.js.map +1 -1
  97. package/dist/tools/logs.d.ts +3 -47
  98. package/dist/tools/logs.d.ts.map +1 -1
  99. package/dist/tools/logs.js +100 -127
  100. package/dist/tools/logs.js.map +1 -1
  101. package/dist/tools/manage.d.ts +6 -51
  102. package/dist/tools/manage.d.ts.map +1 -1
  103. package/dist/tools/manage.js +284 -209
  104. package/dist/tools/manage.js.map +1 -1
  105. package/dist/tools/schema.d.ts +75 -168
  106. package/dist/tools/schema.d.ts.map +1 -1
  107. package/dist/tools/schema.js +276 -126
  108. package/dist/tools/schema.js.map +1 -1
  109. package/dist/tools/wait.d.ts +3 -191
  110. package/dist/tools/wait.d.ts.map +1 -1
  111. package/dist/tools/wait.js +298 -155
  112. package/dist/tools/wait.js.map +1 -1
  113. package/package.json +4 -5
  114. package/scripts/start-chrome-headless.sh +0 -37
  115. package/scripts/start-chrome.sh +0 -41
@@ -12,6 +12,7 @@ import { BrowserLauncher, CDPClient, getBrowserWSEndpoint, getTargets } from '..
12
12
  import { AutoWait } from './auto-wait.js';
13
13
  import { NavigationTimeoutError, SessionNotFoundError, TargetNotFoundError } from './errors.js';
14
14
  import { Locator } from './locator.js';
15
+ import { DEFAULT_TIMEOUT, MODIFIER_KEYS, } from './types.js';
15
16
  /**
16
17
  * 会话管理器(单例)
17
18
  */
@@ -21,11 +22,14 @@ class SessionManager {
21
22
  static MAX_LOG_ENTRIES = 1000;
22
23
  launcher = null;
23
24
  cdp = null;
25
+ connectedPort = 0;
24
26
  sessionId = null;
25
27
  currentTargetId = null;
26
28
  state = null;
27
29
  behaviorSimulator = new BehaviorSimulator();
28
30
  stealthMode = 'safe';
31
+ /** 当前按下的修饰键位掩码 */
32
+ modifiers = 0;
29
33
  // 操作锁(防止并发竞态)
30
34
  operationLock = Promise.resolve();
31
35
  consoleLogs = [];
@@ -35,6 +39,12 @@ class SessionManager {
35
39
  listenersInstalled = false;
36
40
  constructor() {
37
41
  }
42
+ /**
43
+ * 获取当前调试端口
44
+ */
45
+ get port() {
46
+ return this.launcher?.port ?? (this.connectedPort || null);
47
+ }
38
48
  static getInstance() {
39
49
  if (!SessionManager.instance) {
40
50
  SessionManager.instance = new SessionManager();
@@ -43,11 +53,59 @@ class SessionManager {
43
53
  }
44
54
  /**
45
55
  * 启动浏览器
56
+ *
57
+ * 如果指定了端口,会先尝试连接该端口上已运行的浏览器。
58
+ * 只有连接失败时才启动新浏览器。
46
59
  */
47
60
  async launch(options = {}) {
48
61
  return this.withLock(async () => {
62
+ const port = options.port ?? 0;
63
+ // 如果指定了端口,先尝试连接已运行的浏览器
64
+ if (port > 0) {
65
+ try {
66
+ const endpoint = await getBrowserWSEndpoint('127.0.0.1', port);
67
+ // 连接成功,复用已有浏览器
68
+ this.resetState();
69
+ this.stealthMode = options.stealth ?? 'safe';
70
+ this.cdp = new CDPClient();
71
+ await this.cdp.connect(endpoint, options.timeout);
72
+ // 记录端口(connect 模式没有 launcher)
73
+ this.connectedPort = port;
74
+ // 获取现有页面
75
+ const targets = await getTargets('127.0.0.1', port);
76
+ let pageTarget = targets.find((t) => t.type === 'page');
77
+ // 如果没有页面或 attach 失败,创建新 tab
78
+ if (pageTarget) {
79
+ try {
80
+ await this.attachToTargetInternal(pageTarget.id);
81
+ }
82
+ catch {
83
+ // attach 失败,创建新 tab
84
+ pageTarget = undefined;
85
+ }
86
+ }
87
+ if (!pageTarget) {
88
+ // 创建新 tab(在已有窗口)
89
+ const newTarget = await this.newPageInternal();
90
+ return {
91
+ ...newTarget,
92
+ reused: true,
93
+ };
94
+ }
95
+ return {
96
+ targetId: pageTarget.id,
97
+ type: pageTarget.type,
98
+ url: pageTarget.url,
99
+ title: pageTarget.title,
100
+ reused: true,
101
+ };
102
+ }
103
+ catch {
104
+ // 连接失败,继续启动新浏览器
105
+ }
106
+ }
49
107
  // 关闭现有会话
50
- await this.close();
108
+ this.resetState();
51
109
  // 保存 stealth 模式
52
110
  this.stealthMode = options.stealth ?? 'safe';
53
111
  // 启动浏览器
@@ -63,7 +121,7 @@ class SessionManager {
63
121
  throw new Error('未找到页面');
64
122
  }
65
123
  // 附加到页面
66
- await this.attachToTarget(pageTarget.id);
124
+ await this.attachToTargetInternal(pageTarget.id);
67
125
  return {
68
126
  targetId: pageTarget.id,
69
127
  type: pageTarget.type,
@@ -77,9 +135,9 @@ class SessionManager {
77
135
  */
78
136
  async connect(options) {
79
137
  return this.withLock(async () => {
80
- const { host = '127.0.0.1', port, timeout = 30000, stealth = 'safe' } = options;
138
+ const { host = '127.0.0.1', port, timeout = DEFAULT_TIMEOUT, stealth = 'safe' } = options;
81
139
  // 关闭现有会话
82
- await this.close();
140
+ this.resetState();
83
141
  // 保存 stealth 模式
84
142
  this.stealthMode = stealth;
85
143
  // 获取 WebSocket 端点
@@ -87,6 +145,7 @@ class SessionManager {
87
145
  // 连接 CDP
88
146
  this.cdp = new CDPClient();
89
147
  await this.cdp.connect(endpoint, timeout);
148
+ this.connectedPort = port;
90
149
  // 获取第一个页面
91
150
  const targets = await getTargets(host, port);
92
151
  const pageTarget = targets.find((t) => t.type === 'page');
@@ -94,7 +153,7 @@ class SessionManager {
94
153
  throw new Error('未找到页面');
95
154
  }
96
155
  // 附加到页面
97
- await this.attachToTarget(pageTarget.id);
156
+ await this.attachToTargetInternal(pageTarget.id);
98
157
  return {
99
158
  targetId: pageTarget.id,
100
159
  type: pageTarget.type,
@@ -120,34 +179,10 @@ class SessionManager {
120
179
  }));
121
180
  }
122
181
  /**
123
- * 附加到指定页面
182
+ * 附加到指定页面(外部入口,加锁)
124
183
  */
125
184
  async attachToTarget(targetId) {
126
- this.ensureConnected();
127
- // 如果已经附加到同一个 target,跳过
128
- if (this.currentTargetId === targetId && this.sessionId) {
129
- return;
130
- }
131
- // 如果有之前的 session,先分离
132
- if (this.sessionId) {
133
- try {
134
- await this.cdp.send('Target.detachFromTarget', {
135
- sessionId: this.sessionId,
136
- });
137
- }
138
- catch {
139
- // 忽略分离错误
140
- }
141
- }
142
- // 附加到目标
143
- const { sessionId } = (await this.cdp.send('Target.attachToTarget', {
144
- targetId,
145
- flatten: true,
146
- }));
147
- this.sessionId = sessionId;
148
- this.currentTargetId = targetId;
149
- // 初始化会话
150
- await this.initSession();
185
+ return this.withLock(async () => this.attachToTargetInternal(targetId));
151
186
  }
152
187
  /**
153
188
  * 导航到 URL
@@ -155,9 +190,9 @@ class SessionManager {
155
190
  async navigate(url, options = {}) {
156
191
  return this.withLock(async () => {
157
192
  this.ensureSession();
158
- const { wait = 'load', timeout = 30000 } = options;
159
- // 导航
160
- const { errorText } = (await this.send('Page.navigate', { url }));
193
+ const { wait = 'load', timeout = DEFAULT_TIMEOUT } = options;
194
+ // 导航(传 timeout 防止 CDP 默认 30s 截断用户预算)
195
+ const { errorText } = (await this.send('Page.navigate', { url }, timeout));
161
196
  if (errorText) {
162
197
  throw new NavigationTimeoutError(url, timeout);
163
198
  }
@@ -177,9 +212,13 @@ class SessionManager {
177
212
  }
178
213
  /**
179
214
  * 等待网络空闲(无进行中的请求且持续指定时间)
215
+ *
216
+ * close() 时通过 'disconnected' 信号立即 reject,不必等 timer 超时。
180
217
  */
181
218
  async waitForNetworkIdle(timeout, idleTime = 500) {
182
219
  this.ensureSession();
220
+ // 捕获当前 cdp 引用,防止 close() 并发置 null 导致回调崩溃
221
+ const cdp = this.cdp;
183
222
  // 使用局部 Set 追踪本次等待的请求,避免污染成员变量
184
223
  const localPendingRequests = new Set();
185
224
  return new Promise((resolve, reject) => {
@@ -218,56 +257,92 @@ class SessionManager {
218
257
  if (timeoutTimer !== null) {
219
258
  clearTimeout(timeoutTimer);
220
259
  }
221
- this.cdp.offEvent('Network.requestWillBeSent', onRequestStart);
222
- this.cdp.offEvent('Network.loadingFinished', onRequestEnd);
223
- this.cdp.offEvent('Network.loadingFailed', onRequestEnd);
260
+ cdp.offEvent('Network.requestWillBeSent', onRequestStart);
261
+ cdp.offEvent('Network.loadingFinished', onRequestEnd);
262
+ cdp.offEvent('Network.loadingFailed', onRequestEnd);
263
+ cdp.removeListener('disconnected', onDisconnected);
224
264
  };
225
265
  // 超时处理
226
266
  timeoutTimer = setTimeout(() => {
227
267
  cleanup();
228
268
  reject(new NavigationTimeoutError('networkidle', timeout));
229
269
  }, timeout);
270
+ const onDisconnected = () => {
271
+ cleanup();
272
+ reject(new Error('CDP 连接已关闭'));
273
+ };
274
+ cdp.once('disconnected', onDisconnected);
230
275
  // 监听网络事件
231
- this.cdp.onEvent('Network.requestWillBeSent', onRequestStart);
232
- this.cdp.onEvent('Network.loadingFinished', onRequestEnd);
233
- this.cdp.onEvent('Network.loadingFailed', onRequestEnd);
276
+ cdp.onEvent('Network.requestWillBeSent', onRequestStart);
277
+ cdp.onEvent('Network.loadingFinished', onRequestEnd);
278
+ cdp.onEvent('Network.loadingFailed', onRequestEnd);
234
279
  // 初始检查
235
280
  checkIdle();
236
281
  });
237
282
  }
238
283
  /**
239
- * 等待导航完成(Page.loadEventFired 事件)
284
+ * 等待导航完成(跨文档导航或同文档导航)
240
285
  */
241
- async waitForNavigation(timeout = 30000) {
286
+ async waitForNavigation(timeout = DEFAULT_TIMEOUT) {
242
287
  this.ensureSession();
243
- await this.cdp.waitForEvent('Page.loadEventFired', undefined, timeout);
288
+ await this.waitForAnyEvent(['Page.loadEventFired', 'Page.navigatedWithinDocument'], timeout);
244
289
  }
245
290
  /**
246
291
  * 后退
247
292
  */
248
- async goBack() {
249
- this.ensureSession();
250
- await this.send('Page.goBack');
251
- await this.updateState();
293
+ async goBack(timeout = DEFAULT_TIMEOUT) {
294
+ return this.withLock(async () => {
295
+ this.ensureSession();
296
+ const { currentIndex, entries } = await this.send('Page.getNavigationHistory', undefined, timeout);
297
+ if (currentIndex <= 0) {
298
+ return { navigated: false };
299
+ }
300
+ // 跨文档导航触发 loadEventFired,同文档导航(hash/pushState)触发 navigatedWithinDocument
301
+ const waitPromise = this.waitForAnyEvent(['Page.loadEventFired', 'Page.navigatedWithinDocument'], timeout);
302
+ // 预注册 rejection handler:若 send() 抛错导致 waitPromise 永远不被 await,
303
+ // 其 timer reject 不会成为 unhandled rejection(Node 20 默认会退出进程)
304
+ waitPromise.catch(() => {
305
+ });
306
+ await this.send('Page.navigateToHistoryEntry', { entryId: entries[currentIndex - 1].id }, timeout);
307
+ await waitPromise;
308
+ await this.updateState();
309
+ return { navigated: true };
310
+ });
252
311
  }
253
312
  /**
254
313
  * 前进
255
314
  */
256
- async goForward() {
257
- this.ensureSession();
258
- await this.send('Page.goForward');
259
- await this.updateState();
315
+ async goForward(timeout = DEFAULT_TIMEOUT) {
316
+ return this.withLock(async () => {
317
+ this.ensureSession();
318
+ const { currentIndex, entries } = await this.send('Page.getNavigationHistory', undefined, timeout);
319
+ if (currentIndex >= entries.length - 1) {
320
+ return { navigated: false };
321
+ }
322
+ // 跨文档导航触发 loadEventFired,同文档导航(hash/pushState)触发 navigatedWithinDocument
323
+ const waitPromise = this.waitForAnyEvent(['Page.loadEventFired', 'Page.navigatedWithinDocument'], timeout);
324
+ waitPromise.catch(() => {
325
+ });
326
+ await this.send('Page.navigateToHistoryEntry', { entryId: entries[currentIndex + 1].id }, timeout);
327
+ await waitPromise;
328
+ await this.updateState();
329
+ return { navigated: true };
330
+ });
260
331
  }
261
332
  /**
262
333
  * 刷新
263
334
  */
264
335
  async reload(options = {}) {
265
- this.ensureSession();
266
- const { ignoreCache = false, timeout = 30000 } = options;
267
- const waitPromise = this.cdp.waitForEvent('Page.loadEventFired', undefined, timeout);
268
- await this.send('Page.reload', { ignoreCache });
269
- await waitPromise;
270
- await this.updateState();
336
+ return this.withLock(async () => {
337
+ this.ensureSession();
338
+ const { ignoreCache = false, timeout = DEFAULT_TIMEOUT } = options;
339
+ const waitPromise = this.cdp.waitForEvent('Page.loadEventFired', undefined, timeout);
340
+ waitPromise.catch(() => {
341
+ });
342
+ await this.send('Page.reload', { ignoreCache }, timeout);
343
+ await waitPromise;
344
+ await this.updateState();
345
+ });
271
346
  }
272
347
  /**
273
348
  * 创建定位器
@@ -276,6 +351,7 @@ class SessionManager {
276
351
  this.ensureSession();
277
352
  return new Locator(this.cdp, target, this.sessionId, {
278
353
  ...options,
354
+ nth: options?.nth ?? target.nth,
279
355
  getUrl: () => this.state?.url,
280
356
  });
281
357
  }
@@ -301,6 +377,7 @@ class SessionManager {
301
377
  type: 'mouseMoved',
302
378
  x,
303
379
  y,
380
+ modifiers: this.modifiers,
304
381
  });
305
382
  this.behaviorSimulator.setCurrentPosition({ x, y });
306
383
  }
@@ -315,6 +392,7 @@ class SessionManager {
315
392
  clickCount: 1,
316
393
  x: this.behaviorSimulator.getCurrentPosition().x,
317
394
  y: this.behaviorSimulator.getCurrentPosition().y,
395
+ modifiers: this.modifiers,
318
396
  });
319
397
  }
320
398
  /**
@@ -328,6 +406,7 @@ class SessionManager {
328
406
  clickCount: 1,
329
407
  x: this.behaviorSimulator.getCurrentPosition().x,
330
408
  y: this.behaviorSimulator.getCurrentPosition().y,
409
+ modifiers: this.modifiers,
331
410
  });
332
411
  }
333
412
  /**
@@ -342,6 +421,7 @@ class SessionManager {
342
421
  y: pos.y,
343
422
  deltaX,
344
423
  deltaY,
424
+ modifiers: this.modifiers,
345
425
  });
346
426
  }
347
427
  /**
@@ -349,9 +429,13 @@ class SessionManager {
349
429
  */
350
430
  async keyDown(key) {
351
431
  this.ensureSession();
432
+ if (MODIFIER_KEYS[key]) {
433
+ this.modifiers |= MODIFIER_KEYS[key];
434
+ }
352
435
  const keyDefinition = getKeyDefinition(key);
353
436
  await this.send('Input.dispatchKeyEvent', {
354
437
  type: 'keyDown',
438
+ modifiers: this.modifiers,
355
439
  ...keyDefinition,
356
440
  });
357
441
  }
@@ -363,8 +447,12 @@ class SessionManager {
363
447
  const keyDefinition = getKeyDefinition(key);
364
448
  await this.send('Input.dispatchKeyEvent', {
365
449
  type: 'keyUp',
450
+ modifiers: this.modifiers,
366
451
  ...keyDefinition,
367
452
  });
453
+ if (MODIFIER_KEYS[key]) {
454
+ this.modifiers &= ~MODIFIER_KEYS[key];
455
+ }
368
456
  }
369
457
  /**
370
458
  * 输入文本
@@ -374,10 +462,12 @@ class SessionManager {
374
462
  for (const char of text) {
375
463
  await this.send('Input.dispatchKeyEvent', {
376
464
  type: 'keyDown',
465
+ modifiers: this.modifiers,
377
466
  text: char,
378
467
  });
379
468
  await this.send('Input.dispatchKeyEvent', {
380
469
  type: 'keyUp',
470
+ modifiers: this.modifiers,
381
471
  text: char,
382
472
  });
383
473
  if (delay > 0) {
@@ -418,8 +508,12 @@ class SessionManager {
418
508
  /**
419
509
  * 截图
420
510
  */
421
- async screenshot(fullPage = false) {
511
+ async screenshot(fullPage = false, scale, format, quality) {
422
512
  this.ensureSession();
513
+ const captureParams = { format: format ?? 'png' };
514
+ if (quality !== undefined) {
515
+ captureParams.quality = quality;
516
+ }
423
517
  if (fullPage) {
424
518
  // 获取页面完整高度
425
519
  const { result } = (await this.send('Runtime.evaluate', {
@@ -431,17 +525,18 @@ class SessionManager {
431
525
  await this.send('Emulation.setDeviceMetricsOverride', {
432
526
  width,
433
527
  height,
434
- deviceScaleFactor: 1,
528
+ deviceScaleFactor: scale ?? 1,
435
529
  mobile: false,
436
530
  });
531
+ try {
532
+ const { data } = (await this.send('Page.captureScreenshot', captureParams));
533
+ return data;
534
+ }
535
+ finally {
536
+ await this.send('Emulation.clearDeviceMetricsOverride');
537
+ }
437
538
  }
438
- const { data } = (await this.send('Page.captureScreenshot', {
439
- format: 'png',
440
- }));
441
- if (fullPage) {
442
- // 恢复视口
443
- await this.send('Emulation.clearDeviceMetricsOverride');
444
- }
539
+ const { data } = (await this.send('Page.captureScreenshot', captureParams));
445
540
  return data;
446
541
  }
447
542
  /**
@@ -497,10 +592,12 @@ class SessionManager {
497
592
  }
498
593
  /**
499
594
  * 获取 Cookies
595
+ * @param urls 可选,限制返回指定 URL 的 cookies
500
596
  */
501
- async getCookies() {
597
+ async getCookies(urls) {
502
598
  this.ensureSession();
503
- const { cookies } = (await this.send('Network.getCookies'));
599
+ const params = urls?.length ? { urls } : {};
600
+ const { cookies } = (await this.send('Network.getCookies', params));
504
601
  return cookies;
505
602
  }
506
603
  /**
@@ -520,10 +617,10 @@ class SessionManager {
520
617
  /**
521
618
  * 删除 Cookie
522
619
  */
523
- async deleteCookie(name) {
620
+ async deleteCookie(name, url) {
524
621
  this.ensureSession();
525
- const url = this.state?.url ?? 'http://localhost';
526
- await this.send('Network.deleteCookies', { name, url });
622
+ const effectiveUrl = url ?? this.state?.url ?? 'http://localhost';
623
+ await this.send('Network.deleteCookies', { name, url: effectiveUrl });
527
624
  }
528
625
  /**
529
626
  * 清除所有 Cookies
@@ -566,17 +663,46 @@ class SessionManager {
566
663
  */
567
664
  async evaluate(script, args, timeout) {
568
665
  this.ensureSession();
569
- let expression = script;
666
+ // CDP 命令超时需大于脚本执行超时,给 WebSocket 通信留余量
667
+ const CDP_MARGIN = 5000;
668
+ const sendTimeout = timeout !== undefined ? timeout + CDP_MARGIN : undefined;
669
+ // 有参数时使用 callFunctionOn:避免大 payload 字符串拼接,参数通过协议结构化传递
570
670
  if (args && args.length > 0) {
571
- // 将参数序列化并注入
572
- const argsStr = args.map((a) => JSON.stringify(a)).join(', ');
573
- expression = `(${script})(${argsStr})`;
671
+ const { result: globalResult } = (await this.send('Runtime.evaluate', {
672
+ expression: 'globalThis',
673
+ returnByValue: false,
674
+ }));
675
+ try {
676
+ const callParams = {
677
+ functionDeclaration: script,
678
+ objectId: globalResult.objectId,
679
+ arguments: args.map(a => ({ value: a })),
680
+ returnByValue: true,
681
+ awaitPromise: true,
682
+ };
683
+ if (timeout !== undefined) {
684
+ callParams.timeout = timeout;
685
+ }
686
+ const { result, exceptionDetails } = (await this.send('Runtime.callFunctionOn', callParams, sendTimeout));
687
+ if (exceptionDetails) {
688
+ throw new Error(exceptionDetails.exception.description);
689
+ }
690
+ return result.value;
691
+ }
692
+ finally {
693
+ this.send('Runtime.releaseObject', { objectId: globalResult.objectId }).catch(() => {
694
+ });
695
+ }
574
696
  }
575
- const { result, exceptionDetails } = (await this.send('Runtime.evaluate', {
576
- expression,
697
+ const evalParams = {
698
+ expression: script,
577
699
  returnByValue: true,
578
700
  awaitPromise: true,
579
- }, timeout));
701
+ };
702
+ if (timeout !== undefined) {
703
+ evalParams.timeout = timeout;
704
+ }
705
+ const { result, exceptionDetails } = (await this.send('Runtime.evaluate', evalParams, sendTimeout));
580
706
  if (exceptionDetails) {
581
707
  throw new Error(exceptionDetails.exception.description);
582
708
  }
@@ -622,58 +748,55 @@ class SessionManager {
622
748
  }
623
749
  }
624
750
  /**
625
- * 新建页面
751
+ * 新建页面(外部入口,加锁)
626
752
  */
627
753
  async newPage() {
754
+ return this.withLock(async () => this.newPageInternal());
755
+ }
756
+ /**
757
+ * 激活页面(切到前台)
758
+ */
759
+ async activateTarget(targetId) {
628
760
  this.ensureConnected();
629
- const { targetId } = (await this.cdp.send('Target.createTarget', {
630
- url: 'about:blank',
631
- }));
632
- await this.attachToTarget(targetId);
633
- return {
634
- targetId,
635
- type: 'page',
636
- url: 'about:blank',
637
- title: '',
638
- };
761
+ // Target 域命令是 browser-level,不携带 sessionId
762
+ await this.cdp.send('Target.activateTarget', { targetId });
639
763
  }
640
764
  /**
641
765
  * 关闭页面
642
766
  */
643
767
  async closePage(targetId) {
644
- this.ensureConnected();
645
- const id = targetId ?? this.currentTargetId;
646
- if (!id) {
647
- throw new TargetNotFoundError('unknown');
648
- }
649
- await this.cdp.send('Target.closeTarget', { targetId: id });
650
- // 如果关闭的是当前页面,清除会话状态
651
- if (id === this.currentTargetId) {
652
- this.sessionId = null;
653
- this.currentTargetId = null;
654
- this.state = null;
655
- }
768
+ return this.withLock(async () => {
769
+ this.ensureConnected();
770
+ const id = targetId ?? this.currentTargetId;
771
+ if (!id) {
772
+ throw new TargetNotFoundError('unknown');
773
+ }
774
+ await this.cdp.send('Target.closeTarget', { targetId: id });
775
+ // 如果关闭的是当前页面,清除会话状态
776
+ if (id === this.currentTargetId) {
777
+ this.sessionId = null;
778
+ this.currentTargetId = null;
779
+ this.state = null;
780
+ }
781
+ });
656
782
  }
657
783
  /**
658
- * 关闭浏览器
784
+ * 关闭浏览器(外部接口)
785
+ *
786
+ * 两阶段关闭:
787
+ * 1. 立即关闭 CDP 连接:reject 所有 pending callbacks 和 waitForEvent,
788
+ * 发出 'disconnected' 信号通知 waitForAnyEvent/waitForNetworkIdle 等外部等待者
789
+ * 2. 通过 withLock 串行化状态清理:等 withLock 中的操作处理完错误后再置空引用
659
790
  */
660
791
  async close() {
661
- // 清除日志
662
- this.clearLogs();
663
- // 关闭 CDP 连接
792
+ // Phase 1: 立即关闭 CDP 连接(reject pending callbacks,清除 event listeners)
664
793
  if (this.cdp) {
665
794
  this.cdp.close();
666
- this.cdp = null;
667
- }
668
- // 关闭浏览器进程
669
- if (this.launcher) {
670
- this.launcher.close();
671
- this.launcher = null;
672
795
  }
673
- this.sessionId = null;
674
- this.currentTargetId = null;
675
- this.state = null;
676
- this.listenersInstalled = false;
796
+ // Phase 2: 串行化状态清理(等 withLock 中的操作释放后再执行)
797
+ await this.withLock(async () => {
798
+ this.resetState();
799
+ });
677
800
  }
678
801
  /**
679
802
  * 获取当前状态
@@ -688,10 +811,93 @@ class SessionManager {
688
811
  return this.cdp !== null && this.cdp.isConnected;
689
812
  }
690
813
  /**
691
- * 是否有活跃会话
814
+ * 发送 CDP 命令(page-level,携带 sessionId)
815
+ *
816
+ * 每次调用都检查连接状态,防止 close() 并发置空 this.cdp 后崩溃。
817
+ * 多步操作(type 循环、fullPage 截图等)的 await 间隙可能被 close() 打断,
818
+ * ensureSession() 确保在当前 tick 内 this.cdp 非空。
692
819
  */
693
- hasSession() {
694
- return this.sessionId !== null;
820
+ send(method, params, timeout) {
821
+ this.ensureSession();
822
+ return this.cdp.send(method, params, this.sessionId ?? undefined, timeout);
823
+ }
824
+ /**
825
+ * 发送 browser-level CDP 命令(不携带 sessionId)
826
+ * 用于 Target.*、Browser.* 等浏览器级命令
827
+ */
828
+ sendBrowserCommand(method, params) {
829
+ this.ensureConnected();
830
+ return this.cdp.send(method, params);
831
+ }
832
+ /**
833
+ * 附加到指定页面(内部版本,不加锁,供 launch/connect 等已持锁方法调用)
834
+ */
835
+ async attachToTargetInternal(targetId) {
836
+ this.ensureConnected();
837
+ // 如果已经附加到同一个 target,跳过
838
+ if (this.currentTargetId === targetId && this.sessionId) {
839
+ return;
840
+ }
841
+ // 如果有之前的 session,先分离
842
+ if (this.sessionId) {
843
+ try {
844
+ await this.cdp.send('Target.detachFromTarget', {
845
+ sessionId: this.sessionId,
846
+ });
847
+ }
848
+ catch {
849
+ // 忽略分离错误
850
+ }
851
+ }
852
+ // 附加到目标
853
+ const { sessionId } = (await this.cdp.send('Target.attachToTarget', {
854
+ targetId,
855
+ flatten: true,
856
+ }));
857
+ this.sessionId = sessionId;
858
+ this.currentTargetId = targetId;
859
+ // 初始化会话
860
+ await this.initSession();
861
+ }
862
+ /**
863
+ * 新建页面(内部版本,不加锁,供 launch 等已持锁方法调用)
864
+ */
865
+ async newPageInternal() {
866
+ this.ensureConnected();
867
+ const { targetId } = (await this.cdp.send('Target.createTarget', {
868
+ url: 'about:blank',
869
+ }));
870
+ await this.attachToTargetInternal(targetId);
871
+ return {
872
+ targetId,
873
+ type: 'page',
874
+ url: 'about:blank',
875
+ title: '',
876
+ };
877
+ }
878
+ /**
879
+ * 重置所有状态(同步,不加锁)
880
+ *
881
+ * 供已持有 withLock 的方法调用(launch/connect),避免 close() 的 withLock 重入死锁。
882
+ * 外部调用请使用 close()。
883
+ */
884
+ resetState() {
885
+ if (this.cdp) {
886
+ this.cdp.close();
887
+ this.cdp = null;
888
+ }
889
+ if (this.launcher) {
890
+ this.launcher.close();
891
+ this.launcher = null;
892
+ }
893
+ this.clearLogs();
894
+ this.modifiers = 0;
895
+ this.behaviorSimulator.setCurrentPosition({ x: 0, y: 0 });
896
+ this.sessionId = null;
897
+ this.currentTargetId = null;
898
+ this.state = null;
899
+ this.listenersInstalled = false;
900
+ this.connectedPort = 0;
695
901
  }
696
902
  /**
697
903
  * 串行执行操作(防止并发竞态)
@@ -710,6 +916,47 @@ class SessionManager {
710
916
  releaseLock();
711
917
  }
712
918
  }
919
+ /**
920
+ * 等待多个事件中的任一个触发
921
+ *
922
+ * 用于同时监听跨文档导航 (loadEventFired) 和同文档导航 (navigatedWithinDocument),
923
+ * 任一事件触发后清理所有监听器和超时定时器。
924
+ * close() 时通过 'disconnected' 信号立即 reject,不必等 timer 超时。
925
+ */
926
+ waitForAnyEvent(events, timeout) {
927
+ // 捕获当前 cdp 引用,防止 close() 并发置 null 导致回调崩溃
928
+ const cdp = this.cdp;
929
+ if (!cdp) {
930
+ return Promise.reject(new Error('CDP 连接已关闭'));
931
+ }
932
+ return new Promise((resolve, reject) => {
933
+ const listeners = [];
934
+ const cleanup = () => {
935
+ clearTimeout(timer);
936
+ for (const { event, listener } of listeners) {
937
+ cdp.offEvent(event, listener);
938
+ }
939
+ cdp.removeListener('disconnected', onDisconnected);
940
+ };
941
+ const timer = setTimeout(() => {
942
+ cleanup();
943
+ reject(new NavigationTimeoutError('navigation', timeout));
944
+ }, timeout);
945
+ const onDisconnected = () => {
946
+ cleanup();
947
+ reject(new Error('CDP 连接已关闭'));
948
+ };
949
+ cdp.once('disconnected', onDisconnected);
950
+ for (const event of events) {
951
+ const listener = () => {
952
+ cleanup();
953
+ resolve();
954
+ };
955
+ listeners.push({ event, listener });
956
+ cdp.onEvent(event, listener);
957
+ }
958
+ });
959
+ }
713
960
  /**
714
961
  * 初始化会话
715
962
  */
@@ -808,12 +1055,6 @@ class SessionManager {
808
1055
  targetId: this.currentTargetId,
809
1056
  };
810
1057
  }
811
- /**
812
- * 发送 CDP 命令
813
- */
814
- send(method, params, timeout) {
815
- return this.cdp.send(method, params, this.sessionId ?? undefined, timeout);
816
- }
817
1058
  /**
818
1059
  * 确保已连接
819
1060
  */