@pyrokine/mcp-chrome 1.0.0 → 1.1.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 (103) hide show
  1. package/README.md +176 -86
  2. package/dist/cdp/client.d.ts +10 -0
  3. package/dist/cdp/client.d.ts.map +1 -1
  4. package/dist/cdp/client.js +48 -18
  5. package/dist/cdp/client.js.map +1 -1
  6. package/dist/cdp/launcher.d.ts.map +1 -1
  7. package/dist/cdp/launcher.js +14 -4
  8. package/dist/cdp/launcher.js.map +1 -1
  9. package/dist/core/auto-wait.d.ts +7 -0
  10. package/dist/core/auto-wait.d.ts.map +1 -1
  11. package/dist/core/auto-wait.js +14 -4
  12. package/dist/core/auto-wait.js.map +1 -1
  13. package/dist/core/errors.d.ts +1 -0
  14. package/dist/core/errors.d.ts.map +1 -1
  15. package/dist/core/errors.js +32 -7
  16. package/dist/core/errors.js.map +1 -1
  17. package/dist/core/index.d.ts +1 -0
  18. package/dist/core/index.d.ts.map +1 -1
  19. package/dist/core/index.js +1 -0
  20. package/dist/core/index.js.map +1 -1
  21. package/dist/core/locator.d.ts +12 -0
  22. package/dist/core/locator.d.ts.map +1 -1
  23. package/dist/core/locator.js +73 -10
  24. package/dist/core/locator.js.map +1 -1
  25. package/dist/core/session.d.ts +66 -10
  26. package/dist/core/session.d.ts.map +1 -1
  27. package/dist/core/session.js +261 -60
  28. package/dist/core/session.js.map +1 -1
  29. package/dist/core/types.d.ts +11 -0
  30. package/dist/core/types.d.ts.map +1 -1
  31. package/dist/core/types.js +5 -2
  32. package/dist/core/types.js.map +1 -1
  33. package/dist/core/unified-session.d.ts +431 -0
  34. package/dist/core/unified-session.d.ts.map +1 -0
  35. package/dist/core/unified-session.js +1010 -0
  36. package/dist/core/unified-session.js.map +1 -0
  37. package/dist/extension/bridge.d.ts +222 -0
  38. package/dist/extension/bridge.d.ts.map +1 -0
  39. package/dist/extension/bridge.js +421 -0
  40. package/dist/extension/bridge.js.map +1 -0
  41. package/dist/extension/http-server.d.ts +58 -0
  42. package/dist/extension/http-server.d.ts.map +1 -0
  43. package/dist/extension/http-server.js +313 -0
  44. package/dist/extension/http-server.js.map +1 -0
  45. package/dist/extension/index.d.ts +7 -0
  46. package/dist/extension/index.d.ts.map +1 -0
  47. package/dist/extension/index.js +6 -0
  48. package/dist/extension/index.js.map +1 -0
  49. package/dist/extension/native-host-installer.d.ts +21 -0
  50. package/dist/extension/native-host-installer.d.ts.map +1 -0
  51. package/dist/extension/native-host-installer.js +147 -0
  52. package/dist/extension/native-host-installer.js.map +1 -0
  53. package/dist/extension/socket-server.d.ts +32 -0
  54. package/dist/extension/socket-server.d.ts.map +1 -0
  55. package/dist/extension/socket-server.js +177 -0
  56. package/dist/extension/socket-server.js.map +1 -0
  57. package/dist/extension/ws-server.d.ts +40 -0
  58. package/dist/extension/ws-server.d.ts.map +1 -0
  59. package/dist/extension/ws-server.js +246 -0
  60. package/dist/extension/ws-server.js.map +1 -0
  61. package/dist/index.js +6 -4
  62. package/dist/index.js.map +1 -1
  63. package/dist/native-host/index.js +280 -0
  64. package/dist/native-host/mcp-chrome-host +2 -0
  65. package/dist/tools/browse.d.ts +4 -0
  66. package/dist/tools/browse.d.ts.map +1 -1
  67. package/dist/tools/browse.js +137 -25
  68. package/dist/tools/browse.js.map +1 -1
  69. package/dist/tools/cookies.d.ts +33 -25
  70. package/dist/tools/cookies.d.ts.map +1 -1
  71. package/dist/tools/cookies.js +124 -38
  72. package/dist/tools/cookies.js.map +1 -1
  73. package/dist/tools/evaluate.d.ts +15 -0
  74. package/dist/tools/evaluate.d.ts.map +1 -1
  75. package/dist/tools/evaluate.js +87 -23
  76. package/dist/tools/evaluate.js.map +1 -1
  77. package/dist/tools/extract.d.ts +28 -2
  78. package/dist/tools/extract.d.ts.map +1 -1
  79. package/dist/tools/extract.js +319 -198
  80. package/dist/tools/extract.js.map +1 -1
  81. package/dist/tools/input.d.ts +30 -0
  82. package/dist/tools/input.d.ts.map +1 -1
  83. package/dist/tools/input.js +167 -19
  84. package/dist/tools/input.js.map +1 -1
  85. package/dist/tools/logs.d.ts +4 -0
  86. package/dist/tools/logs.d.ts.map +1 -1
  87. package/dist/tools/logs.js +102 -68
  88. package/dist/tools/logs.js.map +1 -1
  89. package/dist/tools/manage.d.ts +16 -0
  90. package/dist/tools/manage.d.ts.map +1 -1
  91. package/dist/tools/manage.js +215 -10
  92. package/dist/tools/manage.js.map +1 -1
  93. package/dist/tools/schema.d.ts +86 -13
  94. package/dist/tools/schema.d.ts.map +1 -1
  95. package/dist/tools/schema.js +240 -1
  96. package/dist/tools/schema.js.map +1 -1
  97. package/dist/tools/wait.d.ts +30 -0
  98. package/dist/tools/wait.d.ts.map +1 -1
  99. package/dist/tools/wait.js +329 -115
  100. package/dist/tools/wait.js.map +1 -1
  101. package/package.json +4 -5
  102. package/scripts/start-chrome-headless.sh +0 -37
  103. package/scripts/start-chrome.sh +0 -41
@@ -0,0 +1,1010 @@
1
+ /**
2
+ * 统一会话管理器
3
+ *
4
+ * 支持两种模式:
5
+ * 1. Extension 模式:通过 Chrome Extension 操作用户浏览器(推荐)
6
+ * 2. CDP 模式:通过 Chrome DevTools Protocol 操作(Fallback)
7
+ */
8
+ import { ExtensionBridge } from '../extension/index.js';
9
+ import { getSession as getCdpSession } from './session.js';
10
+ class UnifiedSessionManager {
11
+ static instance;
12
+ static CONNECTION_COOLDOWN = 30000; // 连接失败后 30 秒内不重试
13
+ extensionBridge = null;
14
+ inputMode = 'precise'; // 默认使用 precise 模式,可绕过 CSP 限制
15
+ currentMousePosition = { x: 0, y: 0 }; // 跟踪鼠标位置
16
+ lastConnectionFailure = 0;
17
+ tabSwitchLock = Promise.resolve(); // 串行化 tab 切换,防止并发竞态
18
+ requireExtension = false; // 指定 tabId 或 frame 时为 true,禁止 CDP 回退
19
+ constructor() { }
20
+ static getInstance() {
21
+ if (!UnifiedSessionManager.instance) {
22
+ UnifiedSessionManager.instance = new UnifiedSessionManager();
23
+ }
24
+ return UnifiedSessionManager.instance;
25
+ }
26
+ /**
27
+ * 启动 Unix Socket 服务器,等待 Native Host 连接
28
+ */
29
+ async startExtensionServer() {
30
+ if (this.extensionBridge) {
31
+ return; // 已经启动
32
+ }
33
+ this.extensionBridge = new ExtensionBridge();
34
+ try {
35
+ await this.extensionBridge.start();
36
+ console.error(`[MCP] Extension HTTP server listening on port ${this.extensionBridge.getPort()}`);
37
+ }
38
+ catch (error) {
39
+ console.error('[MCP] Failed to start Extension server:', error);
40
+ this.extensionBridge = null;
41
+ }
42
+ }
43
+ /**
44
+ * 获取当前连接模式
45
+ */
46
+ getMode() {
47
+ if (this.extensionBridge?.isConnected()) {
48
+ return 'extension';
49
+ }
50
+ if (getCdpSession().isConnected()) {
51
+ return 'cdp';
52
+ }
53
+ return 'none';
54
+ }
55
+ /**
56
+ * 获取当前输入模式
57
+ */
58
+ getInputMode() {
59
+ return this.inputMode;
60
+ }
61
+ /**
62
+ * 获取当前鼠标位置
63
+ */
64
+ getMousePosition() {
65
+ return { ...this.currentMousePosition };
66
+ }
67
+ /**
68
+ * 设置输入模式
69
+ * @param mode 'stealth' - JS 事件模拟(推荐,不触发调试提示)
70
+ * 'precise' - debugger API(精确但会有调试提示)
71
+ */
72
+ setInputMode(mode) {
73
+ this.inputMode = mode;
74
+ console.error(`[MCP] Input mode set to: ${mode}`);
75
+ }
76
+ /**
77
+ * 是否已连接(任一模式)
78
+ */
79
+ isConnected() {
80
+ return this.getMode() !== 'none';
81
+ }
82
+ /**
83
+ * 是否 Extension 已连接
84
+ */
85
+ isExtensionConnected() {
86
+ return this.extensionBridge?.isConnected() ?? false;
87
+ }
88
+ /**
89
+ * 检查 CDP 回退是否允许
90
+ *
91
+ * 当 requireExtension 为 true(tabId 或 frame 已指定)时,CDP 回退会操作错误目标,必须阻止。
92
+ * 允许时返回 false(供 ensureExtensionConnected 直接返回),不允许时抛出。
93
+ */
94
+ assertCdpFallbackAllowed() {
95
+ if (this.requireExtension) {
96
+ throw new Error('Extension 已断开,当前操作需要 Extension(指定 tabId 或 frame)时不可回退 CDP(操作目标不一致)');
97
+ }
98
+ return false;
99
+ }
100
+ /**
101
+ * 是否启用了 Extension 模式(不管当前是否连接)
102
+ * 用于判断应该使用哪种模式
103
+ */
104
+ isExtensionModeEnabled() {
105
+ return this.extensionBridge !== null;
106
+ }
107
+ /**
108
+ * 等待 Extension 连接
109
+ * @param timeout 超时时间,0 表示无限等待
110
+ */
111
+ async waitForExtensionConnection(timeout = 0) {
112
+ if (!this.extensionBridge) {
113
+ return false;
114
+ }
115
+ return this.extensionBridge.waitForConnection(timeout);
116
+ }
117
+ /**
118
+ * 确保 Extension 已连接,如果断开则等待重连
119
+ * 返回 true 表示 Extension 可用,false 表示应 fallback 到 CDP
120
+ *
121
+ * 设计理念:Server 和 Extension 的启动时机完全独立,无任何要求。
122
+ * - 先装 Extension,一个月/一年后启动 Server → 能连上
123
+ * - 先启动 Server,再打开 Chrome → 能连上
124
+ * - 关闭再打开任何一方 → 能自动重连
125
+ *
126
+ * 超时设为 30 秒:足够等待 Extension 启动,但不会永远卡住。
127
+ *
128
+ * @param maxWait 调用方的端到端预算(毫秒)。传入时取 min(maxWait, 30000) 作为连接等待上限,
129
+ * 避免工具 timeout 被连接等待吞掉。不传则使用默认 30s。
130
+ */
131
+ async ensureExtensionConnected(maxWait) {
132
+ if (!this.extensionBridge) {
133
+ return this.assertCdpFallbackAllowed();
134
+ }
135
+ if (this.extensionBridge.isConnected()) {
136
+ return true;
137
+ }
138
+ // CDP 已连接时跳过 Extension 等待,直接使用 CDP 回退
139
+ if (getCdpSession().isConnected()) {
140
+ return this.assertCdpFallbackAllowed();
141
+ }
142
+ // 冷却期内不重复等待,避免每次操作都阻塞 30 秒
143
+ if (Date.now() - this.lastConnectionFailure < UnifiedSessionManager.CONNECTION_COOLDOWN) {
144
+ return this.assertCdpFallbackAllowed();
145
+ }
146
+ // Extension 服务器已启动但断开连接,等待重连
147
+ const waitTimeout = maxWait !== undefined ? Math.min(maxWait, 30000) : 30000;
148
+ if (waitTimeout <= 0) {
149
+ return this.assertCdpFallbackAllowed();
150
+ }
151
+ console.error(`[MCP] Waiting for Chrome Extension connection (${waitTimeout}ms timeout)...`);
152
+ console.error('[MCP] Please ensure Chrome is running with MCP Chrome extension installed.');
153
+ const connected = await this.extensionBridge.waitForConnection(waitTimeout);
154
+ if (connected) {
155
+ console.error('[MCP] Chrome Extension connected successfully');
156
+ this.lastConnectionFailure = 0;
157
+ return true;
158
+ }
159
+ console.error('[MCP] Chrome Extension connection timeout');
160
+ this.lastConnectionFailure = Date.now();
161
+ return this.assertCdpFallbackAllowed();
162
+ }
163
+ /**
164
+ * 启动浏览器(CDP 模式)或等待 Extension 连接
165
+ */
166
+ async launch(options = {}) {
167
+ // 优先检查 Extension 是否已连接,如果断开则等待重连(受 timeout 约束)
168
+ if (await this.ensureExtensionConnected(options.timeout)) {
169
+ // createTab 会设置 currentTabId,需要加锁
170
+ const tab = await this.withTabLock(async () => {
171
+ return this.extensionBridge.createTab(undefined, options.timeout);
172
+ });
173
+ return {
174
+ targetId: String(tab.id),
175
+ type: 'page',
176
+ url: tab.url,
177
+ title: tab.title,
178
+ mode: 'extension',
179
+ };
180
+ }
181
+ // Fallback 到 CDP 模式
182
+ const target = await getCdpSession().launch(options);
183
+ return {
184
+ ...target,
185
+ mode: 'cdp',
186
+ };
187
+ }
188
+ /**
189
+ * 连接到已运行的浏览器(CDP 模式)
190
+ */
191
+ async connect(options) {
192
+ const target = await getCdpSession().connect(options);
193
+ return {
194
+ ...target,
195
+ mode: 'cdp',
196
+ };
197
+ }
198
+ /**
199
+ * 列出所有页面
200
+ */
201
+ async listTargets() {
202
+ // 优先使用 Extension,如果断开则等待重连
203
+ if (await this.ensureExtensionConnected()) {
204
+ const tabs = await this.extensionBridge.listTabs();
205
+ const currentTabId = this.extensionBridge.getCurrentTabId();
206
+ return tabs.map(tab => ({
207
+ targetId: String(tab.id),
208
+ type: 'page',
209
+ url: tab.url,
210
+ title: tab.title,
211
+ mode: 'extension',
212
+ managed: tab.managed,
213
+ isActive: tab.id === currentTabId,
214
+ }));
215
+ }
216
+ if (getCdpSession().isConnected()) {
217
+ const targets = await getCdpSession().listTargets();
218
+ const currentTargetId = getCdpSession().getState()?.targetId;
219
+ return targets.map(t => ({
220
+ ...t,
221
+ mode: 'cdp',
222
+ isActive: t.targetId === currentTargetId,
223
+ }));
224
+ }
225
+ return [];
226
+ }
227
+ /**
228
+ * 导航到 URL
229
+ */
230
+ async navigate(url, options = {}) {
231
+ if (await this.ensureExtensionConnected(options.timeout)) {
232
+ // 加锁保护 currentTabId,防止并发 withTabId 将命令路由到临时切换的 tab
233
+ await this.withTabLock(async () => {
234
+ await this.extensionBridge.navigate(url, {
235
+ waitUntil: options.wait,
236
+ timeout: options.timeout,
237
+ });
238
+ });
239
+ return;
240
+ }
241
+ await getCdpSession().navigate(url, options);
242
+ }
243
+ /**
244
+ * 后退
245
+ */
246
+ async goBack(timeout) {
247
+ if (await this.ensureExtensionConnected(timeout)) {
248
+ return this.withTabLock(async () => {
249
+ const result = await this.extensionBridge.goBack(timeout);
250
+ return { navigated: result.navigated };
251
+ });
252
+ }
253
+ return getCdpSession().goBack(timeout);
254
+ }
255
+ /**
256
+ * 前进
257
+ */
258
+ async goForward(timeout) {
259
+ if (await this.ensureExtensionConnected(timeout)) {
260
+ return this.withTabLock(async () => {
261
+ const result = await this.extensionBridge.goForward(timeout);
262
+ return { navigated: result.navigated };
263
+ });
264
+ }
265
+ return getCdpSession().goForward(timeout);
266
+ }
267
+ /**
268
+ * 刷新
269
+ */
270
+ async reload(options = {}) {
271
+ if (await this.ensureExtensionConnected(options.timeout)) {
272
+ await this.withTabLock(async () => {
273
+ await this.extensionBridge.reload(options.ignoreCache, options.waitUntil, options.timeout);
274
+ });
275
+ return;
276
+ }
277
+ await getCdpSession().reload(options);
278
+ }
279
+ /**
280
+ * 读取页面(Accessibility Tree)
281
+ */
282
+ async readPage(options) {
283
+ if (await this.ensureExtensionConnected()) {
284
+ return this.extensionBridge.readPage(options);
285
+ }
286
+ // CDP 模式使用 getPageState
287
+ const state = await getCdpSession().getPageState();
288
+ const elements = state.elements || [];
289
+ // 构建简单的文本表示
290
+ const lines = elements.map(e => {
291
+ let line = e.role;
292
+ if (e.name)
293
+ line += ` "${e.name}"`;
294
+ return line;
295
+ });
296
+ return {
297
+ pageContent: lines.join('\n'),
298
+ viewport: state.viewport,
299
+ };
300
+ }
301
+ /**
302
+ * 截图
303
+ */
304
+ async screenshot(options) {
305
+ if (await this.ensureExtensionConnected()) {
306
+ const result = await this.extensionBridge.screenshot(options);
307
+ return result.data;
308
+ }
309
+ return getCdpSession().screenshot(options?.fullPage);
310
+ }
311
+ /**
312
+ * 点击元素
313
+ */
314
+ async click(refId) {
315
+ if (await this.ensureExtensionConnected()) {
316
+ await this.extensionBridge.click(refId);
317
+ return;
318
+ }
319
+ // CDP 模式需要通过 Locator
320
+ throw new Error('CDP 模式下请使用 input 工具的 click action');
321
+ }
322
+ /**
323
+ * 输入文本
324
+ */
325
+ async type(refId, text, clear = false) {
326
+ if (await this.ensureExtensionConnected()) {
327
+ await this.extensionBridge.type(refId, text, clear);
328
+ return;
329
+ }
330
+ throw new Error('CDP 模式下请使用 input 工具');
331
+ }
332
+ /**
333
+ * 滚动
334
+ */
335
+ async scroll(x, y, refId) {
336
+ if (await this.ensureExtensionConnected()) {
337
+ await this.extensionBridge.scroll(x, y, refId);
338
+ return;
339
+ }
340
+ await getCdpSession().mouseWheel(x, y);
341
+ }
342
+ /**
343
+ * 执行 JavaScript
344
+ *
345
+ * 双路径策略(同 find()):
346
+ * - 传入 timeout(轮询上下文):isExtensionConnected() 快速失败,端到端预算受控
347
+ * - 不传 timeout(一次性调用):ensureExtensionConnected() 允许等待重连(最多 30s)
348
+ *
349
+ * stealth 模式:使用 chrome.scripting.executeScript(受 CSP 限制)
350
+ * precise 模式:使用 debugger API Runtime.evaluate(可绕过 CSP)
351
+ *
352
+ * 使用 args 时 script 必须是函数表达式,会被包装为 IIFE:(script)(arg1, arg2, ...)
353
+ * @param timeout 端到端预算(毫秒),同时作为脚本执行超时和 sendCommand 的端到端预算
354
+ */
355
+ async evaluate(code, mode, timeout, args) {
356
+ const effectiveMode = mode ?? this.inputMode;
357
+ // 检测裸 return 语句,自动包裹 IIFE
358
+ // 排除已经是函数表达式或 IIFE 的情况
359
+ let expression = code;
360
+ if (/\breturn\b/.test(code) && !/^\s*([(\[]|function\b|async\b|class\b)/.test(code)) {
361
+ expression = `(() => { ${code} })()`;
362
+ }
363
+ if (args && args.length > 0) {
364
+ const argsStr = args.map(a => JSON.stringify(a)).join(', ');
365
+ expression = `(${code})(${argsStr})`;
366
+ }
367
+ if (timeout !== undefined) {
368
+ // 轮询上下文:快速失败,端到端预算受控
369
+ if (!this.isExtensionConnected()) {
370
+ this.assertCdpFallbackAllowed();
371
+ return getCdpSession().evaluate(code, args, timeout);
372
+ }
373
+ }
374
+ else {
375
+ // 非轮询上下文:允许等待重连;连接失败时回退 CDP
376
+ if (!(await this.ensureExtensionConnected())) {
377
+ return getCdpSession().evaluate(code, args, timeout);
378
+ }
379
+ }
380
+ // Extension 路径
381
+ const currentFrameId = this.extensionBridge.getCurrentFrameId();
382
+ if (effectiveMode === 'precise') {
383
+ if (currentFrameId !== 0) {
384
+ // iframe 上下文:通过 evaluateInFrame 使用 contextId 精确定位
385
+ const result = await this.extensionBridge.evaluateInFrame(currentFrameId, expression, timeout);
386
+ if (result.exceptionDetails) {
387
+ throw new Error(result.exceptionDetails.text);
388
+ }
389
+ return result.result?.value;
390
+ }
391
+ // 主 frame:直接 Runtime.evaluate
392
+ const params = {
393
+ expression,
394
+ returnByValue: true,
395
+ awaitPromise: true,
396
+ };
397
+ if (timeout !== undefined) {
398
+ params.timeout = timeout;
399
+ }
400
+ // timeout 即端到端预算,直接作为 RPC 超时(不额外加 margin)
401
+ const result = await this.extensionBridge.debuggerSend('Runtime.evaluate', params, undefined, timeout);
402
+ if (result.exceptionDetails) {
403
+ throw new Error(result.exceptionDetails.text);
404
+ }
405
+ return result.result?.value;
406
+ }
407
+ return await this.extensionBridge.evaluate(expression, timeout, timeout);
408
+ }
409
+ /**
410
+ * 获取页面文本
411
+ */
412
+ async getText(selector) {
413
+ if (await this.ensureExtensionConnected()) {
414
+ return this.extensionBridge.getText(selector);
415
+ }
416
+ if (selector) {
417
+ return getCdpSession().evaluate(`(s => document.querySelector(s)?.textContent || '')`, [selector]);
418
+ }
419
+ return getCdpSession().evaluate('document.body.innerText');
420
+ }
421
+ /**
422
+ * 获取页面 HTML
423
+ */
424
+ async getHtml(selector, outer = true) {
425
+ if (await this.ensureExtensionConnected()) {
426
+ return this.extensionBridge.getHtml(selector, outer);
427
+ }
428
+ if (selector) {
429
+ const prop = outer ? 'outerHTML' : 'innerHTML';
430
+ return getCdpSession().evaluate(`((s, p) => { const el = document.querySelector(s); return el ? el[p] : ''; })`, [selector, prop]);
431
+ }
432
+ return getCdpSession().evaluate('document.documentElement.outerHTML');
433
+ }
434
+ /**
435
+ * 查找元素
436
+ *
437
+ * 双路径策略(约束:轮询/预算敏感调用必须传 timeout;一次性调用不传 timeout):
438
+ * - 传入 timeout(轮询上下文):isExtensionConnected() 快速失败,不会主动等待重连;
439
+ * 仅在"预检通过但竞态断连落入 sendCommand"时才发生预算内的连接等待
440
+ * - 不传 timeout(一次性调用):ensureExtensionConnected() 允许等待重连(最多 30s)
441
+ * @param timeout 端到端预算(毫秒),包含连接等待和请求超时,传给 bridge.find → sendCommand
442
+ */
443
+ async find(selector, text, xpath, timeout) {
444
+ if (timeout !== undefined) {
445
+ // 轮询上下文:快速失败,端到端预算受控
446
+ if (this.isExtensionConnected()) {
447
+ return this.extensionBridge.find(selector, text, xpath, timeout);
448
+ }
449
+ throw new Error('Extension 未连接');
450
+ }
451
+ // 非轮询上下文:允许等待重连
452
+ if (await this.ensureExtensionConnected()) {
453
+ return this.extensionBridge.find(selector, text, xpath);
454
+ }
455
+ throw new Error('Extension 未连接');
456
+ }
457
+ /**
458
+ * 获取元素属性
459
+ */
460
+ async getAttribute(selector, refId, attribute) {
461
+ if (await this.ensureExtensionConnected()) {
462
+ return this.extensionBridge.getAttribute(selector, refId, attribute);
463
+ }
464
+ throw new Error('CDP 模式下请使用 extract 工具');
465
+ }
466
+ /**
467
+ * 获取 Cookies
468
+ */
469
+ async getCookies(filter) {
470
+ if (await this.ensureExtensionConnected()) {
471
+ return this.extensionBridge.getCookies(filter);
472
+ }
473
+ // CDP 模式:支持按字段过滤
474
+ const urls = filter?.url ? [filter.url] : undefined;
475
+ const cookies = await getCdpSession().getCookies(urls);
476
+ if (!filter)
477
+ return cookies;
478
+ return cookies.filter((c) => {
479
+ if (filter.name && c.name !== filter.name)
480
+ return false;
481
+ if (filter.domain) {
482
+ // 域名匹配:精确匹配或子域匹配(.example.com 匹配 sub.example.com)
483
+ const filterDomain = filter.domain.replace(/^\./, '');
484
+ const cookieDomain = (c.domain ?? '').replace(/^\./, '');
485
+ if (cookieDomain !== filterDomain && !cookieDomain.endsWith('.' + filterDomain)) {
486
+ return false;
487
+ }
488
+ }
489
+ if (filter.path && c.path !== filter.path)
490
+ return false;
491
+ if (filter.secure !== undefined && c.secure !== filter.secure)
492
+ return false;
493
+ if (filter.session !== undefined) {
494
+ // session cookie: expires 为 -1 或 0(CDP 返回 session cookie 的 expires 为 -1)
495
+ const isSession = (c.expires ?? -1) <= 0;
496
+ if (filter.session !== isSession)
497
+ return false;
498
+ }
499
+ return true;
500
+ });
501
+ }
502
+ /**
503
+ * 设置 Cookie
504
+ */
505
+ async setCookie(name, value, options = {}) {
506
+ if (await this.ensureExtensionConnected()) {
507
+ const state = this.extensionBridge.getState();
508
+ const url = options.url || state?.url || 'http://localhost';
509
+ // 转换 sameSite 值到 Chrome cookies API 格式
510
+ let chromeSameSite;
511
+ if (options.sameSite) {
512
+ const sameSiteMap = {
513
+ None: 'no_restriction',
514
+ Lax: 'lax',
515
+ Strict: 'strict',
516
+ };
517
+ chromeSameSite = sameSiteMap[options.sameSite];
518
+ }
519
+ await this.extensionBridge.setCookie({
520
+ url,
521
+ name,
522
+ value,
523
+ domain: options.domain,
524
+ path: options.path,
525
+ secure: options.secure,
526
+ httpOnly: options.httpOnly,
527
+ sameSite: chromeSameSite,
528
+ expirationDate: options.expirationDate,
529
+ });
530
+ return;
531
+ }
532
+ await getCdpSession().setCookie(name, value, options);
533
+ }
534
+ /**
535
+ * 删除 Cookie
536
+ */
537
+ async deleteCookie(url, name) {
538
+ if (await this.ensureExtensionConnected()) {
539
+ await this.extensionBridge.deleteCookie(url, name);
540
+ return;
541
+ }
542
+ await getCdpSession().deleteCookie(name, url);
543
+ }
544
+ /**
545
+ * 清空 Cookies
546
+ */
547
+ async clearCookies(filter) {
548
+ if (await this.ensureExtensionConnected()) {
549
+ return await this.extensionBridge.clearCookies(filter);
550
+ }
551
+ // CDP 模式:有 filter 时先获取匹配的 cookies 再逐条删除,无 filter 时清除全部
552
+ if (filter && (filter.url || filter.domain)) {
553
+ // 优先使用 url 过滤缩小范围,减少不必要的遍历
554
+ const urls = filter.url ? [filter.url] : undefined;
555
+ const cookies = await getCdpSession().getCookies(urls);
556
+ let count = 0;
557
+ for (const cookie of cookies) {
558
+ // domain 进一步过滤(url 过滤后可能仍包含不匹配 domain 的 cookie)
559
+ if (filter.domain) {
560
+ const filterDomain = filter.domain.replace(/^\./, '');
561
+ const cookieDomain = cookie.domain.replace(/^\./, '');
562
+ if (cookieDomain !== filterDomain && !cookieDomain.endsWith('.' + filterDomain)) {
563
+ continue;
564
+ }
565
+ }
566
+ // 构造删除 URL:必须匹配 cookie 自身的 domain/path/secure
567
+ const protocol = cookie.secure ? 'https:' : 'http:';
568
+ const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain;
569
+ const deleteUrl = `${protocol}//${domain}${cookie.path}`;
570
+ await getCdpSession().deleteCookie(cookie.name, deleteUrl);
571
+ count++;
572
+ }
573
+ return { count };
574
+ }
575
+ await getCdpSession().clearCookies();
576
+ return { count: -1 };
577
+ }
578
+ /**
579
+ * 创建新页面
580
+ */
581
+ async newPage(url) {
582
+ if (await this.ensureExtensionConnected()) {
583
+ // createTab 会设置 currentTabId,需要加锁
584
+ const tab = await this.withTabLock(async () => {
585
+ return this.extensionBridge.createTab(url);
586
+ });
587
+ return {
588
+ targetId: String(tab.id),
589
+ type: 'page',
590
+ url: tab.url,
591
+ title: tab.title,
592
+ };
593
+ }
594
+ const target = await getCdpSession().newPage();
595
+ if (url) {
596
+ await getCdpSession().navigate(url);
597
+ }
598
+ return target;
599
+ }
600
+ /**
601
+ * 关闭页面
602
+ */
603
+ async closePage(targetId) {
604
+ if (await this.ensureExtensionConnected()) {
605
+ // closeTab 可能触发 currentTabId 重置,需要加锁
606
+ await this.withTabLock(async () => {
607
+ const tabId = targetId
608
+ ? this.parseTabId(targetId)
609
+ : this.extensionBridge.getCurrentTabId();
610
+ if (tabId === null) {
611
+ throw new Error('没有可关闭的页面,请指定 targetId');
612
+ }
613
+ await this.extensionBridge.closeTab(tabId);
614
+ });
615
+ return;
616
+ }
617
+ await getCdpSession().closePage(targetId);
618
+ }
619
+ /**
620
+ * 激活页面(切到前台)
621
+ */
622
+ async activatePage(targetId) {
623
+ if (await this.ensureExtensionConnected()) {
624
+ const tabId = this.parseTabId(targetId);
625
+ await this.withTabLock(async () => {
626
+ await this.extensionBridge.activateTab(tabId);
627
+ });
628
+ return;
629
+ }
630
+ // CDP 模式:attach 到目标并切到前台
631
+ await getCdpSession().attachToTarget(targetId);
632
+ await getCdpSession().activateTarget(targetId);
633
+ }
634
+ /**
635
+ * 选择要操作的页面(不切到前台,只设置当前操作目标)
636
+ */
637
+ async selectPage(targetId) {
638
+ if (this.isExtensionConnected()) {
639
+ const tabId = this.parseTabId(targetId);
640
+ await this.withTabLock(async () => {
641
+ this.extensionBridge.setCurrentTabId(tabId);
642
+ });
643
+ return;
644
+ }
645
+ // CDP 模式下需要 attach 到目标 target
646
+ await getCdpSession().attachToTarget(targetId);
647
+ }
648
+ /**
649
+ * 串行化所有 tab 切换操作,防止并发请求互相覆盖 currentTabId。
650
+ * 调用者:selectPage/activatePage/newPage/closePage/navigate/reload/launch/withTabId。
651
+ *
652
+ * 注意:此锁不可重入。fn() 内禁止调用任何使用 withTabLock 的方法,否则会死锁。
653
+ * 当前所有 fn() 只调用 bridge 的原子操作(createTab/navigate/evaluate 等),不存在此问题。
654
+ */
655
+ async withTabLock(fn) {
656
+ const previousLock = this.tabSwitchLock;
657
+ let releaseLock;
658
+ this.tabSwitchLock = new Promise(resolve => { releaseLock = resolve; });
659
+ try {
660
+ await previousLock;
661
+ return await fn();
662
+ }
663
+ finally {
664
+ releaseLock();
665
+ }
666
+ }
667
+ /**
668
+ * 临时切换操作目标 tab,执行完后恢复
669
+ *
670
+ * 用于多 tab 并行场景:指定 tabId 时临时切换到该 tab 执行操作,
671
+ * 不影响 browse attach 设置的默认 tab。
672
+ *
673
+ * 即使不传 tabId,也需要加锁:fn() 内调用 bridge 方法会读取 currentTabId,
674
+ * 不加锁则并发的 withTabId(someId, ...) 可能在 fn() 执行中途修改 currentTabId。
675
+ */
676
+ async withTabId(tabId, fn) {
677
+ // Extension 未连接时不需要锁和 tab 切换(CDP 模式无 currentTabId 竞态)
678
+ if (!this.extensionBridge?.isConnected())
679
+ return fn();
680
+ if (!tabId) {
681
+ // 不切换 tab,但需要加锁保护 currentTabId 不被并发修改
682
+ return this.withTabLock(fn);
683
+ }
684
+ const numericTabId = this.parseTabId(tabId);
685
+ return this.withTabLock(async () => {
686
+ const previousTabId = this.extensionBridge.getCurrentTabId();
687
+ this.extensionBridge.setCurrentTabId(numericTabId);
688
+ // tabId 明确指定时,禁止 CDP 回退(CDP 不感知 Extension tab)
689
+ const previousRequireExtension = this.requireExtension;
690
+ this.requireExtension = true;
691
+ try {
692
+ return await fn();
693
+ }
694
+ finally {
695
+ this.requireExtension = previousRequireExtension;
696
+ this.extensionBridge.setCurrentTabId(previousTabId);
697
+ }
698
+ });
699
+ }
700
+ /**
701
+ * 临时切换操作目标 iframe,执行完后恢复
702
+ *
703
+ * frame 支持 CSS 选择器(如 "iframe#main")或索引(如 0)。
704
+ * 内部通过 Extension 的 resolveFrame 将选择器解析为 Chrome frameId。
705
+ *
706
+ * 与 withTabId 配合使用时,应嵌套在 withTabId 内部:
707
+ * withTabId(tabId, () => withFrame(frame, () => { ... }))
708
+ */
709
+ async withFrame(frame, fn) {
710
+ if (frame === undefined)
711
+ return fn();
712
+ if (!this.extensionBridge?.isConnected()) {
713
+ throw new Error('iframe 穿透需要 Extension 模式');
714
+ }
715
+ const { frameId } = await this.extensionBridge.resolveFrame(frame);
716
+ const previousFrameId = this.extensionBridge.getCurrentFrameId();
717
+ const previousRequireExtension = this.requireExtension;
718
+ this.extensionBridge.setCurrentFrameId(frameId);
719
+ this.requireExtension = true;
720
+ try {
721
+ return await fn();
722
+ }
723
+ finally {
724
+ this.requireExtension = previousRequireExtension;
725
+ this.extensionBridge.setCurrentFrameId(previousFrameId);
726
+ }
727
+ }
728
+ /**
729
+ * 获取当前状态
730
+ */
731
+ getState() {
732
+ if (this.extensionBridge?.isConnected()) {
733
+ return this.extensionBridge.getState();
734
+ }
735
+ const cdpState = getCdpSession().getState();
736
+ return cdpState ? { url: cdpState.url, title: cdpState.title } : null;
737
+ }
738
+ /**
739
+ * 关闭所有连接
740
+ */
741
+ async close() {
742
+ if (this.extensionBridge) {
743
+ await this.extensionBridge.stop();
744
+ this.extensionBridge = null;
745
+ }
746
+ await getCdpSession().close();
747
+ }
748
+ /**
749
+ * 解析 tab ID 字符串为数字,校验 NaN
750
+ */
751
+ parseTabId(id) {
752
+ const tabId = parseInt(id, 10);
753
+ if (isNaN(tabId)) {
754
+ throw new Error(`无效的 Tab ID: ${id}`);
755
+ }
756
+ return tabId;
757
+ }
758
+ // ==================== 键鼠输入 ====================
759
+ // stealth 模式:使用 JS 事件模拟,不触发调试提示,推荐用于反检测场景
760
+ // precise 模式:使用 debugger API,精确但会显示"扩展程序正在调试此浏览器"
761
+ /**
762
+ * 按下键盘按键
763
+ */
764
+ async keyDown(key) {
765
+ if (await this.ensureExtensionConnected()) {
766
+ if (this.inputMode === 'stealth') {
767
+ await this.extensionBridge.stealthKey(key, 'down');
768
+ }
769
+ else {
770
+ await this.extensionBridge.inputKey('keyDown', { key, code: key });
771
+ }
772
+ return;
773
+ }
774
+ await getCdpSession().keyDown(key);
775
+ }
776
+ /**
777
+ * 释放键盘按键
778
+ */
779
+ async keyUp(key) {
780
+ if (await this.ensureExtensionConnected()) {
781
+ if (this.inputMode === 'stealth') {
782
+ await this.extensionBridge.stealthKey(key, 'up');
783
+ }
784
+ else {
785
+ await this.extensionBridge.inputKey('keyUp', { key, code: key });
786
+ }
787
+ return;
788
+ }
789
+ await getCdpSession().keyUp(key);
790
+ }
791
+ /**
792
+ * 输入文本
793
+ */
794
+ async typeText(text, delay = 0) {
795
+ if (await this.ensureExtensionConnected()) {
796
+ if (this.inputMode === 'stealth') {
797
+ await this.extensionBridge.stealthType(text, delay);
798
+ }
799
+ else {
800
+ await this.extensionBridge.inputType(text, delay);
801
+ }
802
+ return;
803
+ }
804
+ await getCdpSession().type(text, delay);
805
+ }
806
+ /**
807
+ * 鼠标移动
808
+ */
809
+ async mouseMove(x, y) {
810
+ this.currentMousePosition = { x, y }; // 更新位置
811
+ if (await this.ensureExtensionConnected()) {
812
+ if (this.inputMode === 'stealth') {
813
+ await this.extensionBridge.stealthMouse('mousemove', x, y);
814
+ }
815
+ else {
816
+ await this.extensionBridge.inputMouse('mouseMoved', x, y);
817
+ }
818
+ return;
819
+ }
820
+ await getCdpSession().mouseMove(x, y);
821
+ }
822
+ /**
823
+ * 鼠标按下
824
+ */
825
+ async mouseDown(button = 'left') {
826
+ const effectiveButton = (button === 'back' || button === 'forward') ? 'left' : button;
827
+ const { x, y } = this.currentMousePosition; // 使用当前位置
828
+ if (await this.ensureExtensionConnected()) {
829
+ if (this.inputMode === 'stealth') {
830
+ await this.extensionBridge.stealthMouse('mousedown', x, y, effectiveButton);
831
+ }
832
+ else {
833
+ await this.extensionBridge.inputMouse('mousePressed', x, y, { button: effectiveButton, clickCount: 1 });
834
+ }
835
+ return;
836
+ }
837
+ await getCdpSession().mouseDown(effectiveButton);
838
+ }
839
+ /**
840
+ * 鼠标释放
841
+ */
842
+ async mouseUp(button = 'left') {
843
+ const effectiveButton = (button === 'back' || button === 'forward') ? 'left' : button;
844
+ const { x, y } = this.currentMousePosition; // 使用当前位置
845
+ if (await this.ensureExtensionConnected()) {
846
+ if (this.inputMode === 'stealth') {
847
+ await this.extensionBridge.stealthMouse('mouseup', x, y, effectiveButton);
848
+ }
849
+ else {
850
+ await this.extensionBridge.inputMouse('mouseReleased', x, y, { button: effectiveButton });
851
+ }
852
+ return;
853
+ }
854
+ await getCdpSession().mouseUp(effectiveButton);
855
+ }
856
+ /**
857
+ * 鼠标滚轮
858
+ */
859
+ async mouseWheel(deltaX, deltaY) {
860
+ if (await this.ensureExtensionConnected()) {
861
+ const { x, y } = this.currentMousePosition;
862
+ await this.extensionBridge.inputMouse('mouseWheel', x, y, { deltaX, deltaY });
863
+ return;
864
+ }
865
+ await getCdpSession().mouseWheel(deltaX, deltaY);
866
+ }
867
+ /**
868
+ * 点击(stealth 模式专用)
869
+ */
870
+ async clickAt(x, y, button = 'left') {
871
+ if (await this.ensureExtensionConnected()) {
872
+ if (this.inputMode === 'stealth') {
873
+ await this.extensionBridge.stealthClick(x, y, button);
874
+ }
875
+ else {
876
+ await this.extensionBridge.inputMouse('mousePressed', x, y, { button: button, clickCount: 1 });
877
+ await this.extensionBridge.inputMouse('mouseReleased', x, y, { button: button });
878
+ }
879
+ return;
880
+ }
881
+ await getCdpSession().mouseMove(x, y);
882
+ await getCdpSession().mouseDown(button);
883
+ await getCdpSession().mouseUp(button);
884
+ }
885
+ /**
886
+ * 注入反检测脚本
887
+ */
888
+ async injectStealth() {
889
+ if (await this.ensureExtensionConnected()) {
890
+ await this.extensionBridge.stealthInject();
891
+ return;
892
+ }
893
+ throw new Error('CDP 模式下 stealth 脚本在 connect/launch 时通过 stealth 参数自动注入,不支持后续手动注入');
894
+ }
895
+ /**
896
+ * 触摸开始
897
+ */
898
+ async touchStart(x, y) {
899
+ if (await this.ensureExtensionConnected()) {
900
+ await this.extensionBridge.inputTouch('touchStart', [{ x, y, id: 0 }]);
901
+ return;
902
+ }
903
+ await getCdpSession().touchStart(x, y);
904
+ }
905
+ /**
906
+ * 触摸移动
907
+ */
908
+ async touchMove(x, y) {
909
+ if (await this.ensureExtensionConnected()) {
910
+ await this.extensionBridge.inputTouch('touchMove', [{ x, y, id: 0 }]);
911
+ return;
912
+ }
913
+ await getCdpSession().touchMove(x, y);
914
+ }
915
+ /**
916
+ * 触摸结束
917
+ */
918
+ async touchEnd() {
919
+ if (await this.ensureExtensionConnected()) {
920
+ await this.extensionBridge.inputTouch('touchEnd', []);
921
+ return;
922
+ }
923
+ await getCdpSession().touchEnd();
924
+ }
925
+ // ==================== 控制台日志 ====================
926
+ /**
927
+ * 启用控制台日志捕获
928
+ */
929
+ async enableConsole() {
930
+ if (await this.ensureExtensionConnected()) {
931
+ await this.extensionBridge.consoleEnable();
932
+ return;
933
+ }
934
+ // CDP 模式已经在 logs 工具中实现
935
+ }
936
+ /**
937
+ * 获取控制台日志
938
+ */
939
+ async getConsoleLogs(options = {}) {
940
+ if (await this.ensureExtensionConnected()) {
941
+ return this.extensionBridge.consoleGet(options);
942
+ }
943
+ // CDP 模式需要单独实现
944
+ return [];
945
+ }
946
+ /**
947
+ * 清除控制台日志
948
+ */
949
+ async clearConsoleLogs() {
950
+ if (await this.ensureExtensionConnected()) {
951
+ await this.extensionBridge.consoleClear();
952
+ return;
953
+ }
954
+ }
955
+ // ==================== 网络日志 ====================
956
+ /**
957
+ * 启用网络日志捕获
958
+ */
959
+ async enableNetwork() {
960
+ if (await this.ensureExtensionConnected()) {
961
+ await this.extensionBridge.networkEnable();
962
+ return;
963
+ }
964
+ // CDP 模式已在 session 中启用 Network.enable
965
+ }
966
+ /**
967
+ * 获取网络请求日志
968
+ */
969
+ async getNetworkRequests(options = {}) {
970
+ if (await this.ensureExtensionConnected()) {
971
+ return this.extensionBridge.networkGet(options);
972
+ }
973
+ return [];
974
+ }
975
+ /**
976
+ * 清除网络日志
977
+ */
978
+ async clearNetworkLogs() {
979
+ if (await this.ensureExtensionConnected()) {
980
+ await this.extensionBridge.networkClear();
981
+ return;
982
+ }
983
+ }
984
+ // ==================== Debugger 直接访问 ====================
985
+ /**
986
+ * 发送 CDP 命令(高级用法)
987
+ *
988
+ * 自动识别 browser-level 域(Target、Browser、SystemInfo、DeviceAccess、IO)不携带 sessionId,
989
+ * 其他域默认携带 sessionId(page-level 命令)。
990
+ */
991
+ async sendCdpCommand(method, params) {
992
+ if (await this.ensureExtensionConnected()) {
993
+ return this.extensionBridge.debuggerSend(method, params);
994
+ }
995
+ // CDP 模式:browser-level 域不携带 sessionId
996
+ const domain = method.split('.')[0];
997
+ const browserLevelDomains = ['Target', 'Browser', 'SystemInfo', 'DeviceAccess', 'IO'];
998
+ if (browserLevelDomains.includes(domain)) {
999
+ return getCdpSession().sendBrowserCommand(method, params);
1000
+ }
1001
+ return getCdpSession().send(method, params);
1002
+ }
1003
+ }
1004
+ /**
1005
+ * 获取统一会话管理器实例
1006
+ */
1007
+ export function getUnifiedSession() {
1008
+ return UnifiedSessionManager.getInstance();
1009
+ }
1010
+ //# sourceMappingURL=unified-session.js.map