@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.
- package/README.md +236 -96
- package/dist/anti-detection/behavior.d.ts +0 -8
- package/dist/anti-detection/behavior.d.ts.map +1 -1
- package/dist/anti-detection/behavior.js +0 -16
- package/dist/anti-detection/behavior.js.map +1 -1
- package/dist/cdp/client.d.ts +10 -2
- package/dist/cdp/client.d.ts.map +1 -1
- package/dist/cdp/client.js +76 -61
- package/dist/cdp/client.js.map +1 -1
- package/dist/cdp/launcher.d.ts +1 -8
- package/dist/cdp/launcher.d.ts.map +1 -1
- package/dist/cdp/launcher.js +18 -24
- package/dist/cdp/launcher.js.map +1 -1
- package/dist/core/auto-wait.d.ts +8 -1
- package/dist/core/auto-wait.d.ts.map +1 -1
- package/dist/core/auto-wait.js +15 -5
- package/dist/core/auto-wait.js.map +1 -1
- package/dist/core/errors.d.ts +11 -13
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +50 -31
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/locator.d.ts +17 -6
- package/dist/core/locator.d.ts.map +1 -1
- package/dist/core/locator.js +137 -28
- package/dist/core/locator.js.map +1 -1
- package/dist/core/retry.d.ts.map +1 -1
- package/dist/core/retry.js +1 -1
- package/dist/core/retry.js.map +1 -1
- package/dist/core/session.d.ts +71 -16
- package/dist/core/session.d.ts.map +1 -1
- package/dist/core/session.js +365 -124
- package/dist/core/session.js.map +1 -1
- package/dist/core/types.d.ts +15 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +11 -2
- package/dist/core/types.js.map +1 -1
- package/dist/core/unified-session.d.ts +418 -0
- package/dist/core/unified-session.d.ts.map +1 -0
- package/dist/core/unified-session.js +1044 -0
- package/dist/core/unified-session.js.map +1 -0
- package/dist/extension/bridge.d.ts +203 -0
- package/dist/extension/bridge.d.ts.map +1 -0
- package/dist/extension/bridge.js +375 -0
- package/dist/extension/bridge.js.map +1 -0
- package/dist/extension/http-server.d.ts +60 -0
- package/dist/extension/http-server.d.ts.map +1 -0
- package/dist/extension/http-server.js +319 -0
- package/dist/extension/http-server.js.map +1 -0
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.d.ts.map +1 -0
- package/dist/extension/index.js +6 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/native-host-installer.d.ts +21 -0
- package/dist/extension/native-host-installer.d.ts.map +1 -0
- package/dist/extension/native-host-installer.js +147 -0
- package/dist/extension/native-host-installer.js.map +1 -0
- package/dist/extension/socket-server.d.ts +32 -0
- package/dist/extension/socket-server.d.ts.map +1 -0
- package/dist/extension/socket-server.js +177 -0
- package/dist/extension/socket-server.js.map +1 -0
- package/dist/extension/ws-server.d.ts +40 -0
- package/dist/extension/ws-server.d.ts.map +1 -0
- package/dist/extension/ws-server.js +246 -0
- package/dist/extension/ws-server.js.map +1 -0
- package/dist/index.js +16 -67
- package/dist/index.js.map +1 -1
- package/dist/native-host/index.js +280 -0
- package/dist/native-host/mcp-chrome-host +2 -0
- package/dist/tools/browse.d.ts +3 -76
- package/dist/tools/browse.d.ts.map +1 -1
- package/dist/tools/browse.js +193 -237
- package/dist/tools/browse.js.map +1 -1
- package/dist/tools/cookies.d.ts +6 -66
- package/dist/tools/cookies.d.ts.map +1 -1
- package/dist/tools/cookies.js +129 -125
- package/dist/tools/cookies.js.map +1 -1
- package/dist/tools/evaluate.d.ts +3 -37
- package/dist/tools/evaluate.d.ts.map +1 -1
- package/dist/tools/evaluate.js +68 -55
- package/dist/tools/evaluate.js.map +1 -1
- package/dist/tools/extract.d.ts +3 -200
- package/dist/tools/extract.d.ts.map +1 -1
- package/dist/tools/extract.js +287 -238
- package/dist/tools/extract.js.map +1 -1
- package/dist/tools/index.d.ts +9 -9
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +9 -9
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/input.d.ts +3 -228
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +190 -129
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/logs.d.ts +3 -47
- package/dist/tools/logs.d.ts.map +1 -1
- package/dist/tools/logs.js +100 -127
- package/dist/tools/logs.js.map +1 -1
- package/dist/tools/manage.d.ts +6 -51
- package/dist/tools/manage.d.ts.map +1 -1
- package/dist/tools/manage.js +284 -209
- package/dist/tools/manage.js.map +1 -1
- package/dist/tools/schema.d.ts +75 -168
- package/dist/tools/schema.d.ts.map +1 -1
- package/dist/tools/schema.js +276 -126
- package/dist/tools/schema.js.map +1 -1
- package/dist/tools/wait.d.ts +3 -191
- package/dist/tools/wait.d.ts.map +1 -1
- package/dist/tools/wait.js +298 -155
- package/dist/tools/wait.js.map +1 -1
- package/package.json +4 -5
- package/scripts/start-chrome-headless.sh +0 -37
- package/scripts/start-chrome.sh +0 -41
package/dist/core/session.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 =
|
|
138
|
+
const { host = '127.0.0.1', port, timeout = DEFAULT_TIMEOUT, stealth = 'safe' } = options;
|
|
81
139
|
// 关闭现有会话
|
|
82
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
*
|
|
284
|
+
* 等待导航完成(跨文档导航或同文档导航)
|
|
240
285
|
*/
|
|
241
|
-
async waitForNavigation(timeout =
|
|
286
|
+
async waitForNavigation(timeout = DEFAULT_TIMEOUT) {
|
|
242
287
|
this.ensureSession();
|
|
243
|
-
await this.
|
|
288
|
+
await this.waitForAnyEvent(['Page.loadEventFired', 'Page.navigatedWithinDocument'], timeout);
|
|
244
289
|
}
|
|
245
290
|
/**
|
|
246
291
|
* 后退
|
|
247
292
|
*/
|
|
248
|
-
async goBack() {
|
|
249
|
-
this.
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
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
|
|
576
|
-
expression,
|
|
697
|
+
const evalParams = {
|
|
698
|
+
expression: script,
|
|
577
699
|
returnByValue: true,
|
|
578
700
|
awaitPromise: true,
|
|
579
|
-
}
|
|
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
|
-
|
|
630
|
-
|
|
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.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
this.
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
674
|
-
this.
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
*/
|