browser-use 0.0.1 → 0.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.
- package/LICENSE +21 -0
- package/README.md +761 -0
- package/dist/agent/cloud-events.d.ts +264 -0
- package/dist/agent/cloud-events.js +318 -0
- package/dist/agent/gif.d.ts +15 -0
- package/dist/agent/gif.js +215 -0
- package/dist/agent/index.d.ts +8 -0
- package/dist/agent/index.js +8 -0
- package/dist/agent/message-manager/service.d.ts +30 -0
- package/dist/agent/message-manager/service.js +208 -0
- package/dist/agent/message-manager/utils.d.ts +2 -0
- package/dist/agent/message-manager/utils.js +41 -0
- package/dist/agent/message-manager/views.d.ts +26 -0
- package/dist/agent/message-manager/views.js +73 -0
- package/dist/agent/prompts.d.ts +52 -0
- package/dist/agent/prompts.js +259 -0
- package/dist/agent/service.d.ts +290 -0
- package/dist/agent/service.js +2200 -0
- package/dist/agent/views.d.ts +741 -0
- package/dist/agent/views.js +537 -0
- package/dist/browser/browser.d.ts +7 -0
- package/dist/browser/browser.js +5 -0
- package/dist/browser/context.d.ts +8 -0
- package/dist/browser/context.js +4 -0
- package/dist/browser/dvd-screensaver.d.ts +101 -0
- package/dist/browser/dvd-screensaver.js +270 -0
- package/dist/browser/extensions.d.ts +63 -0
- package/dist/browser/extensions.js +359 -0
- package/dist/browser/index.d.ts +10 -0
- package/dist/browser/index.js +9 -0
- package/dist/browser/playwright-manager.d.ts +47 -0
- package/dist/browser/playwright-manager.js +146 -0
- package/dist/browser/profile.d.ts +196 -0
- package/dist/browser/profile.js +815 -0
- package/dist/browser/session.d.ts +505 -0
- package/dist/browser/session.js +3409 -0
- package/dist/browser/types.d.ts +1184 -0
- package/dist/browser/types.js +1 -0
- package/dist/browser/utils.d.ts +1 -0
- package/dist/browser/utils.js +19 -0
- package/dist/browser/views.d.ts +78 -0
- package/dist/browser/views.js +72 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +44 -0
- package/dist/config.d.ts +108 -0
- package/dist/config.js +430 -0
- package/dist/controller/index.d.ts +3 -0
- package/dist/controller/index.js +3 -0
- package/dist/controller/registry/index.d.ts +2 -0
- package/dist/controller/registry/index.js +2 -0
- package/dist/controller/registry/service.d.ts +45 -0
- package/dist/controller/registry/service.js +184 -0
- package/dist/controller/registry/views.d.ts +55 -0
- package/dist/controller/registry/views.js +174 -0
- package/dist/controller/service.d.ts +49 -0
- package/dist/controller/service.js +1176 -0
- package/dist/controller/views.d.ts +241 -0
- package/dist/controller/views.js +88 -0
- package/dist/dom/clickable-element-processor/service.d.ts +11 -0
- package/dist/dom/clickable-element-processor/service.js +60 -0
- package/dist/dom/dom_tree/index.js +1400 -0
- package/dist/dom/history-tree-processor/service.d.ts +14 -0
- package/dist/dom/history-tree-processor/service.js +75 -0
- package/dist/dom/history-tree-processor/view.d.ts +54 -0
- package/dist/dom/history-tree-processor/view.js +56 -0
- package/dist/dom/playground/extraction.d.ts +19 -0
- package/dist/dom/playground/extraction.js +187 -0
- package/dist/dom/playground/process-dom.d.ts +1 -0
- package/dist/dom/playground/process-dom.js +5 -0
- package/dist/dom/playground/test-accessibility.d.ts +44 -0
- package/dist/dom/playground/test-accessibility.js +111 -0
- package/dist/dom/service.d.ts +19 -0
- package/dist/dom/service.js +227 -0
- package/dist/dom/utils.d.ts +1 -0
- package/dist/dom/utils.js +6 -0
- package/dist/dom/views.d.ts +61 -0
- package/dist/dom/views.js +247 -0
- package/dist/event-bus.d.ts +11 -0
- package/dist/event-bus.js +19 -0
- package/dist/exceptions.d.ts +10 -0
- package/dist/exceptions.js +22 -0
- package/dist/filesystem/file-system.d.ts +68 -0
- package/dist/filesystem/file-system.js +412 -0
- package/dist/filesystem/index.d.ts +1 -0
- package/dist/filesystem/index.js +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +33 -0
- package/dist/integrations/gmail/actions.d.ts +12 -0
- package/dist/integrations/gmail/actions.js +113 -0
- package/dist/integrations/gmail/index.d.ts +2 -0
- package/dist/integrations/gmail/index.js +2 -0
- package/dist/integrations/gmail/service.d.ts +61 -0
- package/dist/integrations/gmail/service.js +260 -0
- package/dist/llm/anthropic/chat.d.ts +28 -0
- package/dist/llm/anthropic/chat.js +126 -0
- package/dist/llm/anthropic/index.d.ts +2 -0
- package/dist/llm/anthropic/index.js +2 -0
- package/dist/llm/anthropic/serializer.d.ts +68 -0
- package/dist/llm/anthropic/serializer.js +285 -0
- package/dist/llm/aws/chat-anthropic.d.ts +61 -0
- package/dist/llm/aws/chat-anthropic.js +176 -0
- package/dist/llm/aws/chat-bedrock.d.ts +15 -0
- package/dist/llm/aws/chat-bedrock.js +80 -0
- package/dist/llm/aws/index.d.ts +3 -0
- package/dist/llm/aws/index.js +3 -0
- package/dist/llm/aws/serializer.d.ts +5 -0
- package/dist/llm/aws/serializer.js +68 -0
- package/dist/llm/azure/chat.d.ts +15 -0
- package/dist/llm/azure/chat.js +83 -0
- package/dist/llm/azure/index.d.ts +1 -0
- package/dist/llm/azure/index.js +1 -0
- package/dist/llm/base.d.ts +16 -0
- package/dist/llm/base.js +1 -0
- package/dist/llm/deepseek/chat.d.ts +15 -0
- package/dist/llm/deepseek/chat.js +51 -0
- package/dist/llm/deepseek/index.d.ts +2 -0
- package/dist/llm/deepseek/index.js +2 -0
- package/dist/llm/deepseek/serializer.d.ts +6 -0
- package/dist/llm/deepseek/serializer.js +57 -0
- package/dist/llm/exceptions.d.ts +10 -0
- package/dist/llm/exceptions.js +18 -0
- package/dist/llm/google/chat.d.ts +20 -0
- package/dist/llm/google/chat.js +144 -0
- package/dist/llm/google/index.d.ts +2 -0
- package/dist/llm/google/index.js +2 -0
- package/dist/llm/google/serializer.d.ts +6 -0
- package/dist/llm/google/serializer.js +64 -0
- package/dist/llm/groq/chat.d.ts +15 -0
- package/dist/llm/groq/chat.js +52 -0
- package/dist/llm/groq/index.d.ts +3 -0
- package/dist/llm/groq/index.js +3 -0
- package/dist/llm/groq/parser.d.ts +32 -0
- package/dist/llm/groq/parser.js +189 -0
- package/dist/llm/groq/serializer.d.ts +6 -0
- package/dist/llm/groq/serializer.js +56 -0
- package/dist/llm/messages.d.ts +77 -0
- package/dist/llm/messages.js +157 -0
- package/dist/llm/ollama/chat.d.ts +15 -0
- package/dist/llm/ollama/chat.js +77 -0
- package/dist/llm/ollama/index.d.ts +2 -0
- package/dist/llm/ollama/index.js +2 -0
- package/dist/llm/ollama/serializer.d.ts +6 -0
- package/dist/llm/ollama/serializer.js +53 -0
- package/dist/llm/openai/chat.d.ts +38 -0
- package/dist/llm/openai/chat.js +174 -0
- package/dist/llm/openai/index.d.ts +3 -0
- package/dist/llm/openai/index.js +3 -0
- package/dist/llm/openai/like.d.ts +17 -0
- package/dist/llm/openai/like.js +19 -0
- package/dist/llm/openai/serializer.d.ts +6 -0
- package/dist/llm/openai/serializer.js +57 -0
- package/dist/llm/openrouter/chat.d.ts +15 -0
- package/dist/llm/openrouter/chat.js +74 -0
- package/dist/llm/openrouter/index.d.ts +2 -0
- package/dist/llm/openrouter/index.js +2 -0
- package/dist/llm/openrouter/serializer.d.ts +3 -0
- package/dist/llm/openrouter/serializer.js +3 -0
- package/dist/llm/schema.d.ts +6 -0
- package/dist/llm/schema.js +77 -0
- package/dist/llm/views.d.ts +15 -0
- package/dist/llm/views.js +12 -0
- package/dist/logging-config.d.ts +25 -0
- package/dist/logging-config.js +89 -0
- package/dist/mcp/client.d.ts +142 -0
- package/dist/mcp/client.js +638 -0
- package/dist/mcp/controller.d.ts +6 -0
- package/dist/mcp/controller.js +38 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.js +3 -0
- package/dist/mcp/server.d.ts +134 -0
- package/dist/mcp/server.js +759 -0
- package/dist/observability-decorators.d.ts +158 -0
- package/dist/observability-decorators.js +286 -0
- package/dist/observability.d.ts +23 -0
- package/dist/observability.js +58 -0
- package/dist/screenshots/index.d.ts +1 -0
- package/dist/screenshots/index.js +1 -0
- package/dist/screenshots/service.d.ts +6 -0
- package/dist/screenshots/service.js +28 -0
- package/dist/sync/auth.d.ts +27 -0
- package/dist/sync/auth.js +205 -0
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.js +2 -0
- package/dist/sync/service.d.ts +21 -0
- package/dist/sync/service.js +146 -0
- package/dist/telemetry/index.d.ts +2 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/service.d.ts +12 -0
- package/dist/telemetry/service.js +85 -0
- package/dist/telemetry/views.d.ts +112 -0
- package/dist/telemetry/views.js +112 -0
- package/dist/tokens/index.d.ts +2 -0
- package/dist/tokens/index.js +2 -0
- package/dist/tokens/service.d.ts +35 -0
- package/dist/tokens/service.js +423 -0
- package/dist/tokens/views.d.ts +58 -0
- package/dist/tokens/views.js +1 -0
- package/dist/utils.d.ts +128 -0
- package/dist/utils.js +529 -0
- package/package.json +94 -5
|
@@ -0,0 +1,3409 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { createLogger } from '../logging-config.js';
|
|
6
|
+
import { match_url_with_domain_pattern, uuid7str } from '../utils.js';
|
|
7
|
+
import { async_playwright, } from './types.js';
|
|
8
|
+
import { BrowserProfile, CHROME_DOCKER_ARGS, DEFAULT_BROWSER_PROFILE, } from './profile.js';
|
|
9
|
+
import { BrowserStateSummary, BrowserError } from './views.js';
|
|
10
|
+
import { DOMElementNode, DOMState } from '../dom/views.js';
|
|
11
|
+
import { normalize_url } from './utils.js';
|
|
12
|
+
import { DomService } from '../dom/service.js';
|
|
13
|
+
import { showDVDScreensaver, showSpinner, withDVDScreensaver, } from './dvd-screensaver.js';
|
|
14
|
+
const execAsync = promisify(exec);
|
|
15
|
+
const createEmptyDomState = () => {
|
|
16
|
+
const root = new DOMElementNode(true, null, 'html', '/html[1]', {}, []);
|
|
17
|
+
return new DOMState(root, {});
|
|
18
|
+
};
|
|
19
|
+
export class BrowserSession {
|
|
20
|
+
id;
|
|
21
|
+
browser_profile;
|
|
22
|
+
browser;
|
|
23
|
+
browser_context;
|
|
24
|
+
agent_current_page;
|
|
25
|
+
human_current_page;
|
|
26
|
+
initialized = false;
|
|
27
|
+
wss_url;
|
|
28
|
+
cdp_url;
|
|
29
|
+
browser_pid;
|
|
30
|
+
playwright;
|
|
31
|
+
cachedBrowserState = null;
|
|
32
|
+
_cachedClickableElementHashes = null;
|
|
33
|
+
currentUrl;
|
|
34
|
+
currentTitle;
|
|
35
|
+
_logger = null;
|
|
36
|
+
_tabCounter = 0;
|
|
37
|
+
_tabs = [];
|
|
38
|
+
currentTabIndex = 0;
|
|
39
|
+
historyStack = [];
|
|
40
|
+
downloaded_files = [];
|
|
41
|
+
ownsBrowserResources = true;
|
|
42
|
+
_autoDownloadPdfs = true;
|
|
43
|
+
tabPages = new Map();
|
|
44
|
+
currentPageLoadingStatus = null;
|
|
45
|
+
_subprocess = null;
|
|
46
|
+
_childProcesses = new Set();
|
|
47
|
+
attachedAgentId = null;
|
|
48
|
+
attachedSharedAgentIds = new Set();
|
|
49
|
+
_stoppingPromise = null;
|
|
50
|
+
constructor(init = {}) {
|
|
51
|
+
const sourceProfileConfig = init.browser_profile
|
|
52
|
+
? typeof structuredClone === 'function'
|
|
53
|
+
? structuredClone(init.browser_profile.config)
|
|
54
|
+
: JSON.parse(JSON.stringify(init.browser_profile.config))
|
|
55
|
+
: init.profile ?? {};
|
|
56
|
+
this.browser_profile = new BrowserProfile(sourceProfileConfig);
|
|
57
|
+
this.id = init.id ?? uuid7str();
|
|
58
|
+
this.browser = init.browser ?? null;
|
|
59
|
+
this.browser_context = init.browser_context ?? null;
|
|
60
|
+
this.agent_current_page = init.page ?? null;
|
|
61
|
+
this.human_current_page = init.page ?? null;
|
|
62
|
+
this.currentUrl = normalize_url(init.url ?? 'about:blank');
|
|
63
|
+
this.currentTitle = init.title ?? '';
|
|
64
|
+
this.wss_url = init.wss_url ?? null;
|
|
65
|
+
this.cdp_url = init.cdp_url ?? null;
|
|
66
|
+
this.browser_pid = init.browser_pid ?? null;
|
|
67
|
+
this.playwright = init.playwright ?? null;
|
|
68
|
+
this.downloaded_files = Array.isArray(init.downloaded_files)
|
|
69
|
+
? [...init.downloaded_files]
|
|
70
|
+
: [];
|
|
71
|
+
if (typeof init?.auto_download_pdfs === 'boolean') {
|
|
72
|
+
this._autoDownloadPdfs = Boolean(init.auto_download_pdfs);
|
|
73
|
+
}
|
|
74
|
+
this._tabs = [
|
|
75
|
+
{
|
|
76
|
+
page_id: this._tabCounter++,
|
|
77
|
+
url: this.currentUrl,
|
|
78
|
+
title: this.currentTitle || this.currentUrl,
|
|
79
|
+
parent_page_id: null,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
this.historyStack.push(this.currentUrl);
|
|
83
|
+
this.ownsBrowserResources = this._determineOwnership();
|
|
84
|
+
this.tabPages.set(this._tabs[0].page_id, this.agent_current_page ?? null);
|
|
85
|
+
}
|
|
86
|
+
async _waitForStableNetwork(page, signal = null) {
|
|
87
|
+
const pendingRequests = new Set();
|
|
88
|
+
let lastActivity = Date.now() / 1000;
|
|
89
|
+
// Relevant resource types that indicate page loading progress
|
|
90
|
+
const relevantResourceTypes = new Set([
|
|
91
|
+
'document',
|
|
92
|
+
'stylesheet',
|
|
93
|
+
'image',
|
|
94
|
+
'font',
|
|
95
|
+
'script',
|
|
96
|
+
'iframe',
|
|
97
|
+
]);
|
|
98
|
+
const ignoredResourceTypes = new Set([
|
|
99
|
+
'websocket',
|
|
100
|
+
'media',
|
|
101
|
+
'eventsource',
|
|
102
|
+
'manifest',
|
|
103
|
+
'other',
|
|
104
|
+
]);
|
|
105
|
+
// Expanded URL pattern filters - more comprehensive blocking
|
|
106
|
+
const ignoredUrlPatterns = [
|
|
107
|
+
'analytics',
|
|
108
|
+
'tracking',
|
|
109
|
+
'telemetry',
|
|
110
|
+
'beacon',
|
|
111
|
+
'metrics',
|
|
112
|
+
'doubleclick',
|
|
113
|
+
'adsystem',
|
|
114
|
+
'adserver',
|
|
115
|
+
'advertising',
|
|
116
|
+
'facebook.com/plugins',
|
|
117
|
+
'platform.twitter',
|
|
118
|
+
'linkedin.com/embed',
|
|
119
|
+
'livechat',
|
|
120
|
+
'zendesk',
|
|
121
|
+
'intercom',
|
|
122
|
+
'crisp.chat',
|
|
123
|
+
'hotjar',
|
|
124
|
+
'push-notifications',
|
|
125
|
+
'onesignal',
|
|
126
|
+
'pushwoosh',
|
|
127
|
+
'heartbeat',
|
|
128
|
+
'ping',
|
|
129
|
+
'alive',
|
|
130
|
+
'webrtc',
|
|
131
|
+
'rtmp://',
|
|
132
|
+
'wss://',
|
|
133
|
+
'cloudfront.net/assets',
|
|
134
|
+
'fastly.net',
|
|
135
|
+
];
|
|
136
|
+
// Content types that should be filtered
|
|
137
|
+
const relevantContentTypes = new Set([
|
|
138
|
+
'text/html',
|
|
139
|
+
'text/css',
|
|
140
|
+
'application/javascript',
|
|
141
|
+
'application/x-javascript',
|
|
142
|
+
'text/javascript',
|
|
143
|
+
'image/png',
|
|
144
|
+
'image/jpeg',
|
|
145
|
+
'image/gif',
|
|
146
|
+
'image/webp',
|
|
147
|
+
'image/svg+xml',
|
|
148
|
+
'font/woff',
|
|
149
|
+
'font/woff2',
|
|
150
|
+
'application/font-woff',
|
|
151
|
+
'application/font-woff2',
|
|
152
|
+
]);
|
|
153
|
+
// Streaming media content types to ignore
|
|
154
|
+
const streamingContentTypes = new Set([
|
|
155
|
+
'video/',
|
|
156
|
+
'audio/',
|
|
157
|
+
'application/octet-stream',
|
|
158
|
+
'application/x-mpegurl',
|
|
159
|
+
'application/vnd.apple.mpegurl',
|
|
160
|
+
]);
|
|
161
|
+
// Max response size to track (5MB)
|
|
162
|
+
const maxResponseSize = 5 * 1024 * 1024;
|
|
163
|
+
const onRequest = (request) => {
|
|
164
|
+
const resourceType = request.resourceType?.() ?? request.resourceType;
|
|
165
|
+
if (!resourceType || !relevantResourceTypes.has(resourceType)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (ignoredResourceTypes.has(resourceType)) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const url = request.url?.().toLowerCase?.() ?? request.url?.toLowerCase?.() ?? '';
|
|
172
|
+
// Filter data URLs and blob URLs
|
|
173
|
+
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Filter by URL patterns
|
|
177
|
+
if (ignoredUrlPatterns.some((pattern) => url.includes(pattern.toLowerCase()))) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Filter prefetch requests
|
|
181
|
+
const headers = request.headers?.() ?? request.headers ?? {};
|
|
182
|
+
const purpose = headers['purpose'] || headers['sec-fetch-dest'];
|
|
183
|
+
if (purpose === 'prefetch' || headers['x-moz'] === 'prefetch') {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
pendingRequests.add(request);
|
|
187
|
+
lastActivity = Date.now() / 1000;
|
|
188
|
+
};
|
|
189
|
+
const onResponse = async (response) => {
|
|
190
|
+
const request = response.request?.() ?? response.request;
|
|
191
|
+
if (!pendingRequests.has(request)) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
// Check Content-Type header
|
|
196
|
+
const headers = response.headers?.() ?? response.headers ?? {};
|
|
197
|
+
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
|
198
|
+
// Filter streaming media
|
|
199
|
+
if (streamingContentTypes.has(contentType.split(';')[0].trim())) {
|
|
200
|
+
pendingRequests.delete(request);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Check if content type is relevant
|
|
204
|
+
const baseContentType = contentType.split(';')[0].trim();
|
|
205
|
+
const isRelevant = Array.from(relevantContentTypes).some((ct) => baseContentType.startsWith(ct) || ct.startsWith(baseContentType));
|
|
206
|
+
if (contentType && !isRelevant) {
|
|
207
|
+
// Unknown content type, still track but log it
|
|
208
|
+
this.logger.debug(`Tracking unknown content type: ${baseContentType}`);
|
|
209
|
+
}
|
|
210
|
+
// Check response size (if available)
|
|
211
|
+
const contentLength = headers['content-length'] || headers['Content-Length'];
|
|
212
|
+
if (contentLength && parseInt(contentLength, 10) > maxResponseSize) {
|
|
213
|
+
this.logger.debug(`Skipping large response (${contentLength} bytes): ${request.url?.().substring?.(0, 50) ?? ''}`);
|
|
214
|
+
pendingRequests.delete(request);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
// If header inspection fails, still process the response
|
|
220
|
+
this.logger.debug(`Error inspecting response headers: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
pendingRequests.delete(request);
|
|
223
|
+
lastActivity = Date.now() / 1000;
|
|
224
|
+
};
|
|
225
|
+
const waitForIdle = async () => {
|
|
226
|
+
const startTime = Date.now() / 1000;
|
|
227
|
+
while (true) {
|
|
228
|
+
this._throwIfAborted(signal);
|
|
229
|
+
await this._waitWithAbort(100, signal);
|
|
230
|
+
this._throwIfAborted(signal);
|
|
231
|
+
const now = Date.now() / 1000;
|
|
232
|
+
if (pendingRequests.size === 0 &&
|
|
233
|
+
now - lastActivity >=
|
|
234
|
+
(this.browser_profile.wait_for_network_idle_page_load_time ?? 0.5)) {
|
|
235
|
+
this.currentPageLoadingStatus = null;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
if (now - startTime >
|
|
239
|
+
(this.browser_profile.maximum_wait_page_load_time ?? 5)) {
|
|
240
|
+
this.currentPageLoadingStatus = `Page loading was aborted after ${this.browser_profile.maximum_wait_page_load_time ?? 5}s with ${pendingRequests.size} pending network requests. You may want to use the wait action to allow more time for the page to fully load.`;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
if (typeof page?.on === 'function' && typeof page?.off === 'function') {
|
|
246
|
+
page.on('request', onRequest);
|
|
247
|
+
page.on('response', onResponse);
|
|
248
|
+
try {
|
|
249
|
+
await waitForIdle();
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
page.off('request', onRequest);
|
|
253
|
+
page.off('response', onResponse);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
this.currentPageLoadingStatus = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
_setActivePage(page) {
|
|
261
|
+
const currentTab = this._tabs[this.currentTabIndex];
|
|
262
|
+
if (currentTab) {
|
|
263
|
+
this.tabPages.set(currentTab.page_id, page ?? null);
|
|
264
|
+
}
|
|
265
|
+
this.agent_current_page = page ?? null;
|
|
266
|
+
}
|
|
267
|
+
get tabs() {
|
|
268
|
+
return this._tabs.slice();
|
|
269
|
+
}
|
|
270
|
+
get active_tab_index() {
|
|
271
|
+
return this.currentTabIndex;
|
|
272
|
+
}
|
|
273
|
+
get active_tab() {
|
|
274
|
+
return this._tabs[this.currentTabIndex] ?? null;
|
|
275
|
+
}
|
|
276
|
+
describe() {
|
|
277
|
+
return this.toString();
|
|
278
|
+
}
|
|
279
|
+
get _owns_browser_resources() {
|
|
280
|
+
return this.ownsBrowserResources;
|
|
281
|
+
}
|
|
282
|
+
claim_agent(agentId, mode = 'exclusive') {
|
|
283
|
+
if (!agentId) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
if (mode === 'shared') {
|
|
287
|
+
if (this.attachedAgentId &&
|
|
288
|
+
this.attachedAgentId !== agentId &&
|
|
289
|
+
this.attachedSharedAgentIds.size === 0) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
if (this.attachedSharedAgentIds.size === 0 && this.attachedAgentId) {
|
|
293
|
+
this.attachedSharedAgentIds.add(this.attachedAgentId);
|
|
294
|
+
}
|
|
295
|
+
this.attachedSharedAgentIds.add(agentId);
|
|
296
|
+
this.attachedAgentId = this.attachedAgentId ?? agentId;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
if (this.attachedSharedAgentIds.size > 0) {
|
|
300
|
+
if (this.attachedSharedAgentIds.size === 1 &&
|
|
301
|
+
this.attachedSharedAgentIds.has(agentId)) {
|
|
302
|
+
this.attachedSharedAgentIds.clear();
|
|
303
|
+
this.attachedAgentId = agentId;
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
if (this.attachedAgentId && this.attachedAgentId !== agentId) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
this.attachedAgentId = agentId;
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
claimAgent(agentId, mode = 'exclusive') {
|
|
315
|
+
return this.claim_agent(agentId, mode);
|
|
316
|
+
}
|
|
317
|
+
release_agent(agentId) {
|
|
318
|
+
if (this.attachedSharedAgentIds.size > 0) {
|
|
319
|
+
if (!agentId) {
|
|
320
|
+
this.attachedSharedAgentIds.clear();
|
|
321
|
+
this.attachedAgentId = null;
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (!this.attachedSharedAgentIds.has(agentId)) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
this.attachedSharedAgentIds.delete(agentId);
|
|
328
|
+
if (this.attachedSharedAgentIds.size === 0) {
|
|
329
|
+
this.attachedAgentId = null;
|
|
330
|
+
}
|
|
331
|
+
else if (this.attachedAgentId === agentId) {
|
|
332
|
+
const [nextOwner] = this.attachedSharedAgentIds;
|
|
333
|
+
this.attachedAgentId = nextOwner ?? null;
|
|
334
|
+
}
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if (!this.attachedAgentId) {
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
if (agentId && this.attachedAgentId !== agentId) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
this.attachedAgentId = null;
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
releaseAgent(agentId) {
|
|
347
|
+
return this.release_agent(agentId);
|
|
348
|
+
}
|
|
349
|
+
get_attached_agent_id() {
|
|
350
|
+
return this.attachedAgentId;
|
|
351
|
+
}
|
|
352
|
+
getAttachedAgentId() {
|
|
353
|
+
return this.get_attached_agent_id();
|
|
354
|
+
}
|
|
355
|
+
get_attached_agent_ids() {
|
|
356
|
+
if (this.attachedSharedAgentIds.size > 0) {
|
|
357
|
+
return Array.from(this.attachedSharedAgentIds);
|
|
358
|
+
}
|
|
359
|
+
return this.attachedAgentId ? [this.attachedAgentId] : [];
|
|
360
|
+
}
|
|
361
|
+
getAttachedAgentIds() {
|
|
362
|
+
return this.get_attached_agent_ids();
|
|
363
|
+
}
|
|
364
|
+
_determineOwnership() {
|
|
365
|
+
if (this.cdp_url || this.wss_url || this.browser || this.browser_context) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
_createAbortError(reason) {
|
|
371
|
+
if (reason instanceof Error) {
|
|
372
|
+
return reason;
|
|
373
|
+
}
|
|
374
|
+
const error = new Error('Operation aborted');
|
|
375
|
+
error.name = 'AbortError';
|
|
376
|
+
return error;
|
|
377
|
+
}
|
|
378
|
+
_isAbortError(error) {
|
|
379
|
+
if (!(error instanceof Error)) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
return (error.name === 'AbortError' ||
|
|
383
|
+
/abort|aborted|interrupted/i.test(error.message));
|
|
384
|
+
}
|
|
385
|
+
_throwIfAborted(signal = null) {
|
|
386
|
+
if (signal?.aborted) {
|
|
387
|
+
throw this._createAbortError(signal.reason);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async _waitWithAbort(timeoutMs, signal = null) {
|
|
391
|
+
if (timeoutMs <= 0) {
|
|
392
|
+
this._throwIfAborted(signal);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
await new Promise((resolve, reject) => {
|
|
396
|
+
const timeout = setTimeout(() => {
|
|
397
|
+
cleanup();
|
|
398
|
+
resolve();
|
|
399
|
+
}, timeoutMs);
|
|
400
|
+
const onAbort = () => {
|
|
401
|
+
clearTimeout(timeout);
|
|
402
|
+
cleanup();
|
|
403
|
+
reject(this._createAbortError(signal?.reason));
|
|
404
|
+
};
|
|
405
|
+
const cleanup = () => {
|
|
406
|
+
signal?.removeEventListener('abort', onAbort);
|
|
407
|
+
};
|
|
408
|
+
if (signal) {
|
|
409
|
+
if (signal.aborted) {
|
|
410
|
+
onAbort();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
async _withAbort(promise, signal = null) {
|
|
418
|
+
if (!signal) {
|
|
419
|
+
return promise;
|
|
420
|
+
}
|
|
421
|
+
this._throwIfAborted(signal);
|
|
422
|
+
return await new Promise((resolve, reject) => {
|
|
423
|
+
const onAbort = () => {
|
|
424
|
+
cleanup();
|
|
425
|
+
reject(this._createAbortError(signal.reason));
|
|
426
|
+
};
|
|
427
|
+
const cleanup = () => {
|
|
428
|
+
signal.removeEventListener('abort', onAbort);
|
|
429
|
+
};
|
|
430
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
431
|
+
promise
|
|
432
|
+
.then((result) => {
|
|
433
|
+
cleanup();
|
|
434
|
+
resolve(result);
|
|
435
|
+
})
|
|
436
|
+
.catch((error) => {
|
|
437
|
+
cleanup();
|
|
438
|
+
reject(error);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
_toPlaywrightOptions(value) {
|
|
443
|
+
if (value === null || value === undefined) {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
if (Array.isArray(value)) {
|
|
447
|
+
const converted = value
|
|
448
|
+
.map((item) => this._toPlaywrightOptions(item))
|
|
449
|
+
.filter((item) => item !== undefined);
|
|
450
|
+
return converted;
|
|
451
|
+
}
|
|
452
|
+
if (typeof value !== 'object' ||
|
|
453
|
+
value instanceof Date ||
|
|
454
|
+
Buffer.isBuffer(value)) {
|
|
455
|
+
return value;
|
|
456
|
+
}
|
|
457
|
+
const result = {};
|
|
458
|
+
for (const [rawKey, rawVal] of Object.entries(value)) {
|
|
459
|
+
const convertedValue = this._toPlaywrightOptions(rawVal);
|
|
460
|
+
if (convertedValue === undefined) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const normalizedKey = rawKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
464
|
+
result[normalizedKey] = convertedValue;
|
|
465
|
+
}
|
|
466
|
+
return result;
|
|
467
|
+
}
|
|
468
|
+
_isSandboxLaunchError(error) {
|
|
469
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
470
|
+
return (/no usable sandbox/i.test(message) ||
|
|
471
|
+
/chromium sandboxing failed/i.test(message) ||
|
|
472
|
+
/zygote_host_impl_linux\.cc/i.test(message));
|
|
473
|
+
}
|
|
474
|
+
_createNoSandboxLaunchOptions(launchOptions) {
|
|
475
|
+
const rawArgs = Array.isArray(launchOptions.args)
|
|
476
|
+
? launchOptions.args.filter((arg) => typeof arg === 'string')
|
|
477
|
+
: [];
|
|
478
|
+
const mergedArgs = [...rawArgs];
|
|
479
|
+
for (const arg of CHROME_DOCKER_ARGS) {
|
|
480
|
+
if (!mergedArgs.includes(arg)) {
|
|
481
|
+
mergedArgs.push(arg);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
...launchOptions,
|
|
486
|
+
chromiumSandbox: false,
|
|
487
|
+
args: mergedArgs,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
async _launchChromiumWithSandboxFallback(playwright, launchOptions) {
|
|
491
|
+
try {
|
|
492
|
+
return await playwright.chromium.launch(launchOptions);
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
const sandboxEnabled = this.browser_profile.config.chromium_sandbox;
|
|
496
|
+
if (!sandboxEnabled ||
|
|
497
|
+
!this._isSandboxLaunchError(error)) {
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
this.logger.warning('Chromium sandbox is unavailable in this environment. Retrying launch with chromium_sandbox=false (--no-sandbox).');
|
|
501
|
+
const fallbackOptions = this._createNoSandboxLaunchOptions(launchOptions);
|
|
502
|
+
return await playwright.chromium.launch(fallbackOptions);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
_connectionDescriptor() {
|
|
506
|
+
const source = this.cdp_url ||
|
|
507
|
+
this.wss_url ||
|
|
508
|
+
(this.browser_pid ? String(this.browser_pid) : 'playwright');
|
|
509
|
+
const tail = source.split('/').pop() ?? source;
|
|
510
|
+
const port = tail.includes(':') ? tail.split(':').pop() : tail;
|
|
511
|
+
return `${this.id.slice(-4)}:${port}`;
|
|
512
|
+
}
|
|
513
|
+
toString() {
|
|
514
|
+
const ownershipFlag = this.ownsBrowserResources ? '#' : '©';
|
|
515
|
+
return `BrowserSession🆂 ${this._connectionDescriptor()} ${ownershipFlag}${String(this.id).slice(-2)}`;
|
|
516
|
+
}
|
|
517
|
+
get logger() {
|
|
518
|
+
if (!this._logger) {
|
|
519
|
+
this._logger = createLogger(`browser_use.browser.session.${this.id.slice(-4)}`);
|
|
520
|
+
}
|
|
521
|
+
return this._logger;
|
|
522
|
+
}
|
|
523
|
+
async start() {
|
|
524
|
+
if (this.initialized) {
|
|
525
|
+
return this;
|
|
526
|
+
}
|
|
527
|
+
const ensurePage = async () => {
|
|
528
|
+
const current = this.agent_current_page;
|
|
529
|
+
if (current && !current.isClosed?.()) {
|
|
530
|
+
this._setActivePage(current);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const existingPages = (typeof this.browser_context?.pages === 'function'
|
|
534
|
+
? this.browser_context.pages()
|
|
535
|
+
: []) ?? [];
|
|
536
|
+
const firstOpenPage = existingPages.find((page) => !page.isClosed?.()) ?? null;
|
|
537
|
+
if (firstOpenPage) {
|
|
538
|
+
this._setActivePage(firstOpenPage);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (typeof this.browser_context?.newPage === 'function') {
|
|
542
|
+
const created = await this.browser_context.newPage();
|
|
543
|
+
this._setActivePage(created ?? null);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
this._setActivePage(null);
|
|
547
|
+
};
|
|
548
|
+
if (!this.browser_context) {
|
|
549
|
+
if (!this.browser) {
|
|
550
|
+
const playwright = this.playwright ?? (await async_playwright());
|
|
551
|
+
this.playwright = playwright;
|
|
552
|
+
if (this.cdp_url) {
|
|
553
|
+
this.browser = await playwright.chromium.connectOverCDP(this.cdp_url);
|
|
554
|
+
this.ownsBrowserResources = false;
|
|
555
|
+
}
|
|
556
|
+
else if (this.wss_url) {
|
|
557
|
+
const connectOptions = this._toPlaywrightOptions(this.browser_profile.kwargs_for_connect());
|
|
558
|
+
this.browser = await playwright.chromium.connect(this.wss_url, connectOptions ?? {});
|
|
559
|
+
this.ownsBrowserResources = false;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
const launchOptions = this._toPlaywrightOptions(await this.browser_profile.kwargs_for_launch());
|
|
563
|
+
this.browser = await this._launchChromiumWithSandboxFallback(playwright, launchOptions ?? {});
|
|
564
|
+
this.ownsBrowserResources = true;
|
|
565
|
+
const processGetter = this.browser?.process;
|
|
566
|
+
if (typeof processGetter === 'function') {
|
|
567
|
+
const processRef = processGetter.call(this.browser);
|
|
568
|
+
if (typeof processRef?.pid === 'number') {
|
|
569
|
+
this.browser_pid = processRef.pid;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const existingContexts = (typeof this.browser?.contexts === 'function'
|
|
575
|
+
? this.browser.contexts()
|
|
576
|
+
: []) ?? [];
|
|
577
|
+
if (existingContexts.length > 0) {
|
|
578
|
+
this.browser_context = existingContexts[0] ?? null;
|
|
579
|
+
}
|
|
580
|
+
else if (typeof this.browser?.newContext === 'function') {
|
|
581
|
+
const contextOptions = this._toPlaywrightOptions(this.browser_profile.kwargs_for_new_context());
|
|
582
|
+
this.browser_context = await this.browser.newContext(contextOptions ?? {});
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
this.browser_context = null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
await ensurePage();
|
|
589
|
+
if (!this.human_current_page ||
|
|
590
|
+
this.human_current_page.isClosed?.()) {
|
|
591
|
+
this.human_current_page = this.agent_current_page;
|
|
592
|
+
}
|
|
593
|
+
const activePage = await this.get_current_page();
|
|
594
|
+
if (activePage) {
|
|
595
|
+
try {
|
|
596
|
+
this.currentUrl = normalize_url(activePage.url());
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// Ignore url read errors from transient pages.
|
|
600
|
+
}
|
|
601
|
+
if (typeof activePage.title === 'function') {
|
|
602
|
+
try {
|
|
603
|
+
this.currentTitle = await activePage.title();
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// Ignore title read errors from transient pages.
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
this.initialized = true;
|
|
611
|
+
this.logger.debug(`Started ${this.describe()} with profile ${this.browser_profile.toString()}`);
|
|
612
|
+
return this;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Setup browser session by connecting to an existing browser process via PID
|
|
616
|
+
* Useful for debugging or connecting to manually launched browsers
|
|
617
|
+
* @param browserPid - Process ID of the browser to connect to
|
|
618
|
+
* @param cdpUrl - Optional CDP URL (will be discovered if not provided)
|
|
619
|
+
*/
|
|
620
|
+
async setupBrowserViaBrowserPid(browserPid, cdpUrl) {
|
|
621
|
+
this.logger.info(`Connecting to existing browser with PID ${browserPid}`);
|
|
622
|
+
this.browser_pid = browserPid;
|
|
623
|
+
// If CDP URL not provided, try to discover it
|
|
624
|
+
if (!cdpUrl) {
|
|
625
|
+
cdpUrl = (await this._discoverCdpUrl(browserPid)) ?? undefined;
|
|
626
|
+
}
|
|
627
|
+
if (!cdpUrl) {
|
|
628
|
+
throw new Error(`Could not discover CDP URL for browser PID ${browserPid}`);
|
|
629
|
+
}
|
|
630
|
+
this.cdp_url = cdpUrl;
|
|
631
|
+
this.logger.info(`Discovered CDP URL: ${cdpUrl}`);
|
|
632
|
+
// Connect to browser via CDP
|
|
633
|
+
try {
|
|
634
|
+
const playwright = await import('playwright');
|
|
635
|
+
const browser = await playwright.chromium.connectOverCDP(cdpUrl);
|
|
636
|
+
this.browser = browser;
|
|
637
|
+
this.playwright = playwright;
|
|
638
|
+
// Get or create context
|
|
639
|
+
const contexts = browser.contexts();
|
|
640
|
+
if (contexts.length > 0) {
|
|
641
|
+
this.browser_context = contexts[0];
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
this.browser_context = (await browser.newContext());
|
|
645
|
+
}
|
|
646
|
+
// Get or create page
|
|
647
|
+
if (!this.browser_context) {
|
|
648
|
+
throw new Error('Browser context not available');
|
|
649
|
+
}
|
|
650
|
+
const pages = this.browser_context.pages();
|
|
651
|
+
if (pages.length > 0) {
|
|
652
|
+
this.agent_current_page = pages[0];
|
|
653
|
+
this.human_current_page = pages[0];
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
const page = await this.browser_context.newPage();
|
|
657
|
+
this.agent_current_page = page;
|
|
658
|
+
this.human_current_page = page;
|
|
659
|
+
}
|
|
660
|
+
// We don't own this browser since we're connecting to existing one
|
|
661
|
+
this.ownsBrowserResources = false;
|
|
662
|
+
this.initialized = true;
|
|
663
|
+
this.logger.info(`Successfully connected to browser PID ${browserPid}`);
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
throw new Error(`Failed to connect to browser PID ${browserPid}: ${error.message}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Discover CDP URL from browser PID
|
|
671
|
+
* Tries common ports and checks for debugging endpoints
|
|
672
|
+
*/
|
|
673
|
+
async _discoverCdpUrl(browserPid) {
|
|
674
|
+
const commonPorts = [9222, 9223, 9224, 9225];
|
|
675
|
+
for (const port of commonPorts) {
|
|
676
|
+
try {
|
|
677
|
+
const response = await fetch(`http://localhost:${port}/json/version`);
|
|
678
|
+
if (response.ok) {
|
|
679
|
+
const data = await response.json();
|
|
680
|
+
if (data.webSocketDebuggerUrl) {
|
|
681
|
+
this.logger.debug(`Found CDP endpoint on port ${port}`);
|
|
682
|
+
return data.webSocketDebuggerUrl;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
// Port not accessible, try next
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
this.logger.warning(`Could not discover CDP URL for PID ${browserPid} on common ports`);
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
async _shutdown_browser_session() {
|
|
695
|
+
this.initialized = false;
|
|
696
|
+
this.attachedAgentId = null;
|
|
697
|
+
this.attachedSharedAgentIds.clear();
|
|
698
|
+
const closeWithTimeout = async (label, operation, timeoutMs = 3000) => {
|
|
699
|
+
let timeoutHandle = null;
|
|
700
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
701
|
+
timeoutHandle = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
702
|
+
});
|
|
703
|
+
try {
|
|
704
|
+
await Promise.race([operation, timeoutPromise]);
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
if (timeoutHandle) {
|
|
708
|
+
clearTimeout(timeoutHandle);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
if (this.ownsBrowserResources) {
|
|
713
|
+
if (typeof this.browser_context?.close === 'function') {
|
|
714
|
+
try {
|
|
715
|
+
await closeWithTimeout('Closing browser context', this.browser_context.close());
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
this.logger.debug(`Failed to close browser context: ${error.message}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (typeof this.browser?.close === 'function') {
|
|
722
|
+
try {
|
|
723
|
+
await closeWithTimeout('Closing browser instance', this.browser.close());
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
this.logger.debug(`Failed to close browser instance: ${error.message}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
// Kill child processes first
|
|
731
|
+
await this._killChildProcesses();
|
|
732
|
+
// If we own the browser resources, terminate the browser process
|
|
733
|
+
if (this.ownsBrowserResources && this.browser_pid) {
|
|
734
|
+
await this._terminateBrowserProcess();
|
|
735
|
+
}
|
|
736
|
+
this.browser = null;
|
|
737
|
+
this.browser_context = null;
|
|
738
|
+
this.agent_current_page = null;
|
|
739
|
+
this.human_current_page = null;
|
|
740
|
+
this.browser_pid = null;
|
|
741
|
+
this.cdp_url = null;
|
|
742
|
+
this.wss_url = null;
|
|
743
|
+
this.playwright = null;
|
|
744
|
+
this.cachedBrowserState = null;
|
|
745
|
+
this._tabs = [];
|
|
746
|
+
this.downloaded_files = [];
|
|
747
|
+
}
|
|
748
|
+
async close() {
|
|
749
|
+
await this.stop();
|
|
750
|
+
}
|
|
751
|
+
async get_browser_state_with_recovery(options = {}) {
|
|
752
|
+
const signal = options.signal ?? null;
|
|
753
|
+
this._throwIfAborted(signal);
|
|
754
|
+
if (!this.initialized) {
|
|
755
|
+
await this._withAbort(this.start(), signal);
|
|
756
|
+
}
|
|
757
|
+
const page = await this._withAbort(this.get_current_page(), signal);
|
|
758
|
+
this._throwIfAborted(signal);
|
|
759
|
+
this.cachedBrowserState = null;
|
|
760
|
+
let domState;
|
|
761
|
+
if (!page) {
|
|
762
|
+
domState = createEmptyDomState();
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
try {
|
|
766
|
+
const domService = new DomService(page, this.logger);
|
|
767
|
+
domState = await this._withAbort(domService.get_clickable_elements(), signal);
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
if (this._isAbortError(error)) {
|
|
771
|
+
throw error;
|
|
772
|
+
}
|
|
773
|
+
this.logger.debug(`Failed to build DOM tree: ${error.message}`);
|
|
774
|
+
domState = createEmptyDomState();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
let screenshot = null;
|
|
778
|
+
if (options.include_screenshot && page?.screenshot) {
|
|
779
|
+
try {
|
|
780
|
+
const image = await this._withAbort(page.screenshot({
|
|
781
|
+
type: 'png',
|
|
782
|
+
fullPage: true,
|
|
783
|
+
}), signal);
|
|
784
|
+
screenshot =
|
|
785
|
+
typeof image === 'string'
|
|
786
|
+
? image
|
|
787
|
+
: Buffer.from(image).toString('base64');
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
if (this._isAbortError(error)) {
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
this.logger.debug(`Failed to capture screenshot: ${error.message}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
let pageInfo = null;
|
|
797
|
+
let pixelsAbove = 0;
|
|
798
|
+
let pixelsBelow = 0;
|
|
799
|
+
let pixelsLeft = 0;
|
|
800
|
+
let pixelsRight = 0;
|
|
801
|
+
if (page) {
|
|
802
|
+
try {
|
|
803
|
+
const metrics = await this._withAbort(page.evaluate(() => {
|
|
804
|
+
const doc = document.documentElement;
|
|
805
|
+
const body = document.body;
|
|
806
|
+
const width = Math.max(doc?.scrollWidth ?? 0, body?.scrollWidth ?? 0, doc?.clientWidth ?? 0);
|
|
807
|
+
const height = Math.max(doc?.scrollHeight ?? 0, body?.scrollHeight ?? 0, doc?.clientHeight ?? 0);
|
|
808
|
+
return {
|
|
809
|
+
viewportWidth: window.innerWidth,
|
|
810
|
+
viewportHeight: window.innerHeight,
|
|
811
|
+
scrollX: window.scrollX,
|
|
812
|
+
scrollY: window.scrollY,
|
|
813
|
+
pageWidth: width,
|
|
814
|
+
pageHeight: height,
|
|
815
|
+
};
|
|
816
|
+
}), signal);
|
|
817
|
+
pixelsAbove = Math.max(metrics.scrollY ?? 0, 0);
|
|
818
|
+
const viewportHeight = metrics.viewportHeight ?? 0;
|
|
819
|
+
const viewportWidth = metrics.viewportWidth ?? 0;
|
|
820
|
+
pixelsBelow = Math.max((metrics.pageHeight ?? 0) - (metrics.scrollY + viewportHeight), 0);
|
|
821
|
+
pixelsLeft = Math.max(metrics.scrollX ?? 0, 0);
|
|
822
|
+
pixelsRight = Math.max((metrics.pageWidth ?? 0) - (metrics.scrollX + viewportWidth), 0);
|
|
823
|
+
pageInfo = {
|
|
824
|
+
viewport_width: viewportWidth,
|
|
825
|
+
viewport_height: viewportHeight,
|
|
826
|
+
page_width: metrics.pageWidth ?? viewportWidth,
|
|
827
|
+
page_height: metrics.pageHeight ?? viewportHeight,
|
|
828
|
+
scroll_x: metrics.scrollX ?? 0,
|
|
829
|
+
scroll_y: metrics.scrollY ?? 0,
|
|
830
|
+
pixels_above: pixelsAbove,
|
|
831
|
+
pixels_below: pixelsBelow,
|
|
832
|
+
pixels_left: pixelsLeft,
|
|
833
|
+
pixels_right: pixelsRight,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
if (this._isAbortError(error)) {
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
this.logger.debug(`Failed to compute page metrics: ${error.message}`);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const summary = new BrowserStateSummary(domState, {
|
|
844
|
+
url: this.currentUrl,
|
|
845
|
+
title: this.currentTitle || this.currentUrl,
|
|
846
|
+
tabs: this._buildTabs(),
|
|
847
|
+
screenshot,
|
|
848
|
+
page_info: pageInfo,
|
|
849
|
+
pixels_above: pixelsAbove,
|
|
850
|
+
pixels_below: pixelsBelow,
|
|
851
|
+
browser_errors: this.currentPageLoadingStatus
|
|
852
|
+
? [this.currentPageLoadingStatus]
|
|
853
|
+
: [],
|
|
854
|
+
is_pdf_viewer: Boolean(this.currentUrl?.toLowerCase().endsWith('.pdf')),
|
|
855
|
+
loading_status: this.currentPageLoadingStatus,
|
|
856
|
+
});
|
|
857
|
+
// Implement clickable element hash caching to detect new elements
|
|
858
|
+
if (options.cache_clickable_elements_hashes && page) {
|
|
859
|
+
const currentUrl = page.url();
|
|
860
|
+
const currentHashes = this._computeElementHashes(domState.selector_map);
|
|
861
|
+
// Mark new elements if we have cached hashes for this URL
|
|
862
|
+
if (this._cachedClickableElementHashes &&
|
|
863
|
+
this._cachedClickableElementHashes.url === currentUrl) {
|
|
864
|
+
this._markNewElements(domState.selector_map, this._cachedClickableElementHashes.hashes);
|
|
865
|
+
}
|
|
866
|
+
// Update cache with current hashes
|
|
867
|
+
this._cachedClickableElementHashes = {
|
|
868
|
+
url: currentUrl,
|
|
869
|
+
hashes: currentHashes,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
this._throwIfAborted(signal);
|
|
873
|
+
this.cachedBrowserState = summary;
|
|
874
|
+
return summary;
|
|
875
|
+
}
|
|
876
|
+
async get_current_page() {
|
|
877
|
+
if (this.agent_current_page) {
|
|
878
|
+
return this.agent_current_page;
|
|
879
|
+
}
|
|
880
|
+
const currentTab = this._tabs[this.currentTabIndex];
|
|
881
|
+
if (currentTab) {
|
|
882
|
+
const tabPage = this.tabPages.get(currentTab.page_id) ?? null;
|
|
883
|
+
if (tabPage) {
|
|
884
|
+
this._setActivePage(tabPage);
|
|
885
|
+
return tabPage;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const fallback = this.browser_context?.pages()?.[0] ?? null;
|
|
889
|
+
this._setActivePage(fallback ?? null);
|
|
890
|
+
return fallback;
|
|
891
|
+
}
|
|
892
|
+
update_current_page(page, title, url) {
|
|
893
|
+
this._setActivePage(page);
|
|
894
|
+
this.human_current_page = this.human_current_page ?? page;
|
|
895
|
+
if (url) {
|
|
896
|
+
this.currentUrl = normalize_url(url);
|
|
897
|
+
}
|
|
898
|
+
if (title) {
|
|
899
|
+
this.currentTitle = title;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
_buildTabs() {
|
|
903
|
+
if (!this._tabs.length) {
|
|
904
|
+
this._tabs.push({
|
|
905
|
+
page_id: this._tabCounter++,
|
|
906
|
+
url: this.currentUrl,
|
|
907
|
+
title: this.currentTitle || this.currentUrl,
|
|
908
|
+
parent_page_id: null,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
const tab = this._tabs[this.currentTabIndex];
|
|
913
|
+
tab.url = this.currentUrl;
|
|
914
|
+
tab.title = this.currentTitle || this.currentUrl;
|
|
915
|
+
}
|
|
916
|
+
return this._tabs.slice();
|
|
917
|
+
}
|
|
918
|
+
async navigate_to(url, options = {}) {
|
|
919
|
+
const signal = options.signal ?? null;
|
|
920
|
+
this._throwIfAborted(signal);
|
|
921
|
+
const normalized = normalize_url(url);
|
|
922
|
+
const page = await this._withAbort(this.get_current_page(), signal);
|
|
923
|
+
if (page?.goto) {
|
|
924
|
+
try {
|
|
925
|
+
this.currentPageLoadingStatus = null;
|
|
926
|
+
await this._withAbort(page.goto(normalized, { waitUntil: 'domcontentloaded' }), signal);
|
|
927
|
+
await this._waitForStableNetwork(page, signal);
|
|
928
|
+
}
|
|
929
|
+
catch (error) {
|
|
930
|
+
if (this._isAbortError(error)) {
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
const message = error.message ?? 'Navigation failed';
|
|
934
|
+
throw new BrowserError(message);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
this._throwIfAborted(signal);
|
|
938
|
+
this.currentUrl = normalized;
|
|
939
|
+
this.currentTitle = normalized;
|
|
940
|
+
this.historyStack.push(normalized);
|
|
941
|
+
if (this._tabs[this.currentTabIndex]) {
|
|
942
|
+
this._tabs[this.currentTabIndex].url = normalized;
|
|
943
|
+
this._tabs[this.currentTabIndex].title = normalized;
|
|
944
|
+
}
|
|
945
|
+
this._setActivePage(page ?? null);
|
|
946
|
+
this.cachedBrowserState = null;
|
|
947
|
+
return this.agent_current_page;
|
|
948
|
+
}
|
|
949
|
+
async create_new_tab(url, options = {}) {
|
|
950
|
+
const signal = options.signal ?? null;
|
|
951
|
+
this._throwIfAborted(signal);
|
|
952
|
+
const normalized = normalize_url(url);
|
|
953
|
+
const newTab = {
|
|
954
|
+
page_id: this._tabCounter++,
|
|
955
|
+
url: normalized,
|
|
956
|
+
title: normalized,
|
|
957
|
+
parent_page_id: null,
|
|
958
|
+
};
|
|
959
|
+
this._tabs.push(newTab);
|
|
960
|
+
this.currentTabIndex = this._tabs.length - 1;
|
|
961
|
+
this.currentUrl = normalized;
|
|
962
|
+
this.currentTitle = normalized;
|
|
963
|
+
this.historyStack.push(normalized);
|
|
964
|
+
let page = null;
|
|
965
|
+
try {
|
|
966
|
+
page =
|
|
967
|
+
(await this._withAbort(this.browser_context?.newPage?.() ?? Promise.resolve(null), signal)) ?? null;
|
|
968
|
+
if (page) {
|
|
969
|
+
this.currentPageLoadingStatus = null;
|
|
970
|
+
await this._withAbort(page.goto(normalized, { waitUntil: 'domcontentloaded' }), signal);
|
|
971
|
+
await this._waitForStableNetwork(page, signal);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
if (this._isAbortError(error)) {
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
this.logger.debug(`Failed to open new tab via Playwright: ${error.message}`);
|
|
979
|
+
}
|
|
980
|
+
this.tabPages.set(newTab.page_id, page);
|
|
981
|
+
this._setActivePage(page);
|
|
982
|
+
this.currentPageLoadingStatus = null;
|
|
983
|
+
if (!this.human_current_page) {
|
|
984
|
+
this.human_current_page = page;
|
|
985
|
+
}
|
|
986
|
+
this.cachedBrowserState = null;
|
|
987
|
+
return this.agent_current_page;
|
|
988
|
+
}
|
|
989
|
+
_resolveTabIndex(identifier) {
|
|
990
|
+
if (identifier === -1) {
|
|
991
|
+
return Math.max(0, this._tabs.length - 1);
|
|
992
|
+
}
|
|
993
|
+
const byId = this._tabs.findIndex((tab) => tab.page_id === identifier);
|
|
994
|
+
if (byId !== -1) {
|
|
995
|
+
return byId;
|
|
996
|
+
}
|
|
997
|
+
if (identifier >= 0 && identifier < this._tabs.length) {
|
|
998
|
+
return identifier;
|
|
999
|
+
}
|
|
1000
|
+
return -1;
|
|
1001
|
+
}
|
|
1002
|
+
async switch_to_tab(identifier, options = {}) {
|
|
1003
|
+
const signal = options.signal ?? null;
|
|
1004
|
+
this._throwIfAborted(signal);
|
|
1005
|
+
const index = this._resolveTabIndex(identifier);
|
|
1006
|
+
const tab = index >= 0 ? (this._tabs[index] ?? null) : null;
|
|
1007
|
+
if (!tab) {
|
|
1008
|
+
throw new Error(`Tab index ${identifier} does not exist`);
|
|
1009
|
+
}
|
|
1010
|
+
this.currentTabIndex = index;
|
|
1011
|
+
this.currentUrl = tab.url;
|
|
1012
|
+
this.currentTitle = tab.title;
|
|
1013
|
+
const page = this.tabPages.get(tab.page_id) ?? null;
|
|
1014
|
+
this._setActivePage(page);
|
|
1015
|
+
if (page?.bringToFront) {
|
|
1016
|
+
try {
|
|
1017
|
+
await this._withAbort(page.bringToFront(), signal);
|
|
1018
|
+
}
|
|
1019
|
+
catch (error) {
|
|
1020
|
+
if (this._isAbortError(error)) {
|
|
1021
|
+
throw error;
|
|
1022
|
+
}
|
|
1023
|
+
this.logger.debug(`Failed to focus tab: ${error.message}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
await this._waitForLoad(page, 5000, signal);
|
|
1027
|
+
this.cachedBrowserState = null;
|
|
1028
|
+
return page;
|
|
1029
|
+
}
|
|
1030
|
+
async close_tab(identifier) {
|
|
1031
|
+
const index = this._resolveTabIndex(identifier);
|
|
1032
|
+
if (index < 0 || index >= this._tabs.length) {
|
|
1033
|
+
throw new Error(`Tab index ${identifier} does not exist`);
|
|
1034
|
+
}
|
|
1035
|
+
const closingTab = this._tabs[index];
|
|
1036
|
+
const closingPage = this.tabPages.get(closingTab.page_id) ?? null;
|
|
1037
|
+
if (closingPage?.close) {
|
|
1038
|
+
try {
|
|
1039
|
+
await closingPage.close();
|
|
1040
|
+
}
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
this.logger.debug(`Failed to close page: ${error.message}`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
this.tabPages.delete(closingTab.page_id);
|
|
1046
|
+
this._tabs.splice(index, 1);
|
|
1047
|
+
if (this.currentTabIndex >= this._tabs.length) {
|
|
1048
|
+
this.currentTabIndex = Math.max(0, this._tabs.length - 1);
|
|
1049
|
+
}
|
|
1050
|
+
const tab = this._tabs[this.currentTabIndex] ?? null;
|
|
1051
|
+
const current = tab ? (this.tabPages.get(tab.page_id) ?? null) : null;
|
|
1052
|
+
this._setActivePage(current);
|
|
1053
|
+
this.currentPageLoadingStatus = null;
|
|
1054
|
+
this.cachedBrowserState = null;
|
|
1055
|
+
if (this._tabs.length) {
|
|
1056
|
+
const tab = this._tabs[this.currentTabIndex];
|
|
1057
|
+
this.currentUrl = tab.url;
|
|
1058
|
+
this.currentTitle = tab.title;
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
this.currentUrl = 'about:blank';
|
|
1062
|
+
this.currentTitle = 'about:blank';
|
|
1063
|
+
this._setActivePage(null);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
async go_back(options = {}) {
|
|
1067
|
+
const signal = options.signal ?? null;
|
|
1068
|
+
this._throwIfAborted(signal);
|
|
1069
|
+
if (this.historyStack.length <= 1) {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const page = await this._withAbort(this.get_current_page(), signal);
|
|
1073
|
+
if (page?.goBack) {
|
|
1074
|
+
try {
|
|
1075
|
+
await this._withAbort(page.goBack(), signal);
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
if (this._isAbortError(error)) {
|
|
1079
|
+
throw error;
|
|
1080
|
+
}
|
|
1081
|
+
this.logger.debug(`Failed to navigate back: ${error.message}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
this._throwIfAborted(signal);
|
|
1085
|
+
this.historyStack.pop();
|
|
1086
|
+
const previous = this.historyStack[this.historyStack.length - 1];
|
|
1087
|
+
this.currentUrl = previous;
|
|
1088
|
+
this.currentTitle = previous;
|
|
1089
|
+
if (this._tabs[this.currentTabIndex]) {
|
|
1090
|
+
this._tabs[this.currentTabIndex].url = previous;
|
|
1091
|
+
this._tabs[this.currentTabIndex].title = previous;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
async get_dom_element_by_index(_index, options = {}) {
|
|
1095
|
+
const selectorMap = await this.get_selector_map(options);
|
|
1096
|
+
return selectorMap?.[_index] ?? null;
|
|
1097
|
+
}
|
|
1098
|
+
set_downloaded_files(files) {
|
|
1099
|
+
if (!Array.isArray(files)) {
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
this.downloaded_files = [...files];
|
|
1103
|
+
}
|
|
1104
|
+
add_downloaded_file(filePath) {
|
|
1105
|
+
if (!filePath) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (!this.downloaded_files.includes(filePath)) {
|
|
1109
|
+
this.downloaded_files = [...this.downloaded_files, filePath];
|
|
1110
|
+
this.logger.info(`📁 Added download to session tracking (total: ${this.downloaded_files.length} files)`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
get_downloaded_files() {
|
|
1114
|
+
this.logger.debug(`📁 Retrieved ${this.downloaded_files.length} downloaded files from session tracking`);
|
|
1115
|
+
return [...this.downloaded_files];
|
|
1116
|
+
}
|
|
1117
|
+
set_auto_download_pdfs(enabled) {
|
|
1118
|
+
this._autoDownloadPdfs = Boolean(enabled);
|
|
1119
|
+
this.logger.info(`📄 PDF auto-download ${this._autoDownloadPdfs ? 'enabled' : 'disabled'}`);
|
|
1120
|
+
}
|
|
1121
|
+
auto_download_pdfs() {
|
|
1122
|
+
return this._autoDownloadPdfs;
|
|
1123
|
+
}
|
|
1124
|
+
static async get_unique_filename(directory, filename) {
|
|
1125
|
+
const resolvedDir = path.resolve(directory);
|
|
1126
|
+
const parsed = path.parse(filename);
|
|
1127
|
+
let candidate = filename;
|
|
1128
|
+
let counter = 1;
|
|
1129
|
+
while (fs.existsSync(path.join(resolvedDir, candidate))) {
|
|
1130
|
+
candidate = `${parsed.name} (${counter})${parsed.ext}`;
|
|
1131
|
+
counter += 1;
|
|
1132
|
+
}
|
|
1133
|
+
return candidate;
|
|
1134
|
+
}
|
|
1135
|
+
async get_selector_map(options = {}) {
|
|
1136
|
+
if (!this.cachedBrowserState) {
|
|
1137
|
+
await this.get_browser_state_with_recovery({
|
|
1138
|
+
cache_clickable_elements_hashes: true,
|
|
1139
|
+
include_screenshot: false,
|
|
1140
|
+
signal: options.signal ?? null,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
return this.cachedBrowserState?.selector_map ?? {};
|
|
1144
|
+
}
|
|
1145
|
+
static is_file_input(node) {
|
|
1146
|
+
if (!node) {
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
return (node.tag_name?.toLowerCase() === 'input' &&
|
|
1150
|
+
(node.attributes?.type ?? '').toLowerCase() === 'file');
|
|
1151
|
+
}
|
|
1152
|
+
is_file_input(node) {
|
|
1153
|
+
return BrowserSession.is_file_input(node);
|
|
1154
|
+
}
|
|
1155
|
+
async find_file_upload_element_by_index(index, maxHeight = 3, maxDescendantDepth = 3, options = {}) {
|
|
1156
|
+
const selectorMap = await this.get_selector_map(options);
|
|
1157
|
+
const root = selectorMap[index];
|
|
1158
|
+
if (!root) {
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
const findInDescendants = (node, depth) => {
|
|
1162
|
+
if (depth < 0) {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
if (BrowserSession.is_file_input(node)) {
|
|
1166
|
+
return node;
|
|
1167
|
+
}
|
|
1168
|
+
for (const child of node.children) {
|
|
1169
|
+
if (child instanceof DOMElementNode) {
|
|
1170
|
+
const found = findInDescendants(child, depth - 1);
|
|
1171
|
+
if (found) {
|
|
1172
|
+
return found;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return null;
|
|
1177
|
+
};
|
|
1178
|
+
let current = root;
|
|
1179
|
+
let remainingHeight = maxHeight;
|
|
1180
|
+
while (current && remainingHeight >= 0) {
|
|
1181
|
+
const direct = findInDescendants(current, maxDescendantDepth);
|
|
1182
|
+
if (direct) {
|
|
1183
|
+
return direct;
|
|
1184
|
+
}
|
|
1185
|
+
if (current.parent) {
|
|
1186
|
+
for (const sibling of current.parent.children) {
|
|
1187
|
+
if (sibling instanceof DOMElementNode && sibling !== current) {
|
|
1188
|
+
const fromSibling = findInDescendants(sibling, maxDescendantDepth);
|
|
1189
|
+
if (fromSibling) {
|
|
1190
|
+
return fromSibling;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
current = current.parent;
|
|
1196
|
+
remainingHeight -= 1;
|
|
1197
|
+
}
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
async get_locate_element(node) {
|
|
1201
|
+
const page = await this.get_current_page();
|
|
1202
|
+
if (!page || !node?.xpath) {
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
const locator = page.locator(`xpath=${node.xpath}`);
|
|
1207
|
+
const count = await locator.count();
|
|
1208
|
+
if (count === 0) {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
return locator;
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
this.logger.debug(`Failed to locate element via xpath ${node.xpath}: ${error.message}`);
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
async _input_text_element_node(node, text, options = {}) {
|
|
1219
|
+
const signal = options.signal ?? null;
|
|
1220
|
+
this._throwIfAborted(signal);
|
|
1221
|
+
const locator = await this.get_locate_element(node);
|
|
1222
|
+
if (!locator) {
|
|
1223
|
+
throw new Error('Element not found');
|
|
1224
|
+
}
|
|
1225
|
+
await this._withAbort(locator.click({ timeout: 5000 }), signal);
|
|
1226
|
+
await this._withAbort(locator.fill(text, { timeout: 5000 }), signal);
|
|
1227
|
+
}
|
|
1228
|
+
async _click_element_node(node, options = {}) {
|
|
1229
|
+
const signal = options.signal ?? null;
|
|
1230
|
+
this._throwIfAborted(signal);
|
|
1231
|
+
const locator = await this.get_locate_element(node);
|
|
1232
|
+
if (!locator) {
|
|
1233
|
+
throw new Error('Element not found');
|
|
1234
|
+
}
|
|
1235
|
+
const page = await this._withAbort(this.get_current_page(), signal);
|
|
1236
|
+
const performClick = async () => {
|
|
1237
|
+
await this._withAbort(locator.click({ timeout: 5000 }), signal);
|
|
1238
|
+
};
|
|
1239
|
+
const downloadsDir = this.browser_profile.downloads_path;
|
|
1240
|
+
if (downloadsDir && page?.waitForEvent) {
|
|
1241
|
+
const downloadPromise = page.waitForEvent('download', { timeout: 5000 });
|
|
1242
|
+
await performClick();
|
|
1243
|
+
try {
|
|
1244
|
+
const download = await this._withAbort(downloadPromise, signal);
|
|
1245
|
+
const suggested = typeof download.suggestedFilename === 'function'
|
|
1246
|
+
? download.suggestedFilename()
|
|
1247
|
+
: 'download';
|
|
1248
|
+
const uniqueFilename = await BrowserSession.get_unique_filename(downloadsDir, suggested);
|
|
1249
|
+
const downloadPath = path.join(downloadsDir, uniqueFilename);
|
|
1250
|
+
if (typeof download.saveAs === 'function') {
|
|
1251
|
+
await download.saveAs(downloadPath);
|
|
1252
|
+
}
|
|
1253
|
+
this.add_downloaded_file(downloadPath);
|
|
1254
|
+
return downloadPath;
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
if (this._isAbortError(error)) {
|
|
1258
|
+
throw error;
|
|
1259
|
+
}
|
|
1260
|
+
this.logger.debug(`No download triggered within timeout: ${error.message}`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
await performClick();
|
|
1265
|
+
}
|
|
1266
|
+
await this._waitForLoad(page, 5000, signal);
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
async _waitForLoad(page, timeout = 5000, signal = null) {
|
|
1270
|
+
if (!page || typeof page.waitForLoadState !== 'function') {
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
await this._withAbort(page.waitForLoadState('domcontentloaded', { timeout }), signal);
|
|
1275
|
+
}
|
|
1276
|
+
catch (error) {
|
|
1277
|
+
if (this._isAbortError(error)) {
|
|
1278
|
+
throw error;
|
|
1279
|
+
}
|
|
1280
|
+
this.logger.debug(`waitForLoadState failed: ${error.message}`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// ==================== Cookie Management ====================
|
|
1284
|
+
/**
|
|
1285
|
+
* Get all cookies from the current browser context
|
|
1286
|
+
*/
|
|
1287
|
+
async get_cookies() {
|
|
1288
|
+
if (this.browser_context?.cookies) {
|
|
1289
|
+
return await this.browser_context.cookies();
|
|
1290
|
+
}
|
|
1291
|
+
return [];
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Save cookies to a file (deprecated, use save_storage_state instead)
|
|
1295
|
+
* @deprecated Use save_storage_state() instead
|
|
1296
|
+
*/
|
|
1297
|
+
async save_cookies(...args) {
|
|
1298
|
+
return this.save_storage_state(...args);
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Load cookies from a file (deprecated, use load_storage_state instead)
|
|
1302
|
+
* @deprecated Use load_storage_state() instead
|
|
1303
|
+
*/
|
|
1304
|
+
async load_cookies_from_file(...args) {
|
|
1305
|
+
return this.load_storage_state(...args);
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Save the current storage state (cookies, localStorage, sessionStorage) to a file
|
|
1309
|
+
*/
|
|
1310
|
+
async save_storage_state(filePath) {
|
|
1311
|
+
if (!this.browser_context) {
|
|
1312
|
+
this.logger.warning('Cannot save storage state: browser context not initialized');
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const targetPath = filePath || this.browser_profile.cookies_file;
|
|
1316
|
+
if (!targetPath) {
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
try {
|
|
1320
|
+
const resolvedPath = path.resolve(targetPath);
|
|
1321
|
+
const dirPath = path.dirname(resolvedPath);
|
|
1322
|
+
// Create directory if it doesn't exist
|
|
1323
|
+
if (!fs.existsSync(dirPath)) {
|
|
1324
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
1325
|
+
}
|
|
1326
|
+
// Get storage state from browser context
|
|
1327
|
+
const storageState = await this.browser_context.storageState();
|
|
1328
|
+
// Write to temporary file first
|
|
1329
|
+
const tempPath = `${resolvedPath}.tmp`;
|
|
1330
|
+
fs.writeFileSync(tempPath, JSON.stringify(storageState, null, 2));
|
|
1331
|
+
// Backup existing file if present
|
|
1332
|
+
if (fs.existsSync(resolvedPath)) {
|
|
1333
|
+
const backupPath = `${resolvedPath}.bak`;
|
|
1334
|
+
try {
|
|
1335
|
+
fs.renameSync(resolvedPath, backupPath);
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
// Ignore backup errors
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// Move temp file to target
|
|
1342
|
+
fs.renameSync(tempPath, resolvedPath);
|
|
1343
|
+
const cookieCount = storageState.cookies?.length || 0;
|
|
1344
|
+
this.logger.info(`🍪 Saved ${cookieCount} cookies to ${path.basename(resolvedPath)}`);
|
|
1345
|
+
}
|
|
1346
|
+
catch (error) {
|
|
1347
|
+
this.logger.warning(`❌ Failed to save storage state: ${error.message}`);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Load storage state (cookies, localStorage, sessionStorage) from a file
|
|
1352
|
+
*/
|
|
1353
|
+
async load_storage_state(filePath) {
|
|
1354
|
+
const targetPath = filePath || this.browser_profile.cookies_file;
|
|
1355
|
+
if (!targetPath) {
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
try {
|
|
1359
|
+
const resolvedPath = path.resolve(targetPath);
|
|
1360
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
1361
|
+
this.logger.warning(`Storage state file not found: ${resolvedPath}`);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const storageStateContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
1365
|
+
const storageState = JSON.parse(storageStateContent);
|
|
1366
|
+
if (this.browser_context?.addCookies) {
|
|
1367
|
+
// Add cookies to context
|
|
1368
|
+
if (storageState.cookies && Array.isArray(storageState.cookies)) {
|
|
1369
|
+
await this.browser_context.addCookies(storageState.cookies);
|
|
1370
|
+
this.logger.info(`🍪 Loaded ${storageState.cookies.length} cookies from ${path.basename(resolvedPath)}`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
catch (error) {
|
|
1375
|
+
this.logger.warning(`❌ Failed to load storage state: ${error.message}`);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
// ==================== JavaScript Execution ====================
|
|
1379
|
+
/**
|
|
1380
|
+
* Execute JavaScript in the current page context
|
|
1381
|
+
*/
|
|
1382
|
+
async execute_javascript(script) {
|
|
1383
|
+
const page = await this.get_current_page();
|
|
1384
|
+
if (!page) {
|
|
1385
|
+
throw new Error('No page available to execute JavaScript');
|
|
1386
|
+
}
|
|
1387
|
+
return await page.evaluate(script);
|
|
1388
|
+
}
|
|
1389
|
+
// ==================== Page Information ====================
|
|
1390
|
+
/**
|
|
1391
|
+
* Get comprehensive page information (size, scroll position, etc.)
|
|
1392
|
+
*/
|
|
1393
|
+
async get_page_info(page) {
|
|
1394
|
+
const targetPage = page || (await this.get_current_page());
|
|
1395
|
+
if (!targetPage) {
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
const pageData = await targetPage.evaluate(() => {
|
|
1399
|
+
return {
|
|
1400
|
+
// Current viewport dimensions
|
|
1401
|
+
viewport_width: window.innerWidth,
|
|
1402
|
+
viewport_height: window.innerHeight,
|
|
1403
|
+
// Total page dimensions
|
|
1404
|
+
page_width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0),
|
|
1405
|
+
page_height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight || 0),
|
|
1406
|
+
// Current scroll position
|
|
1407
|
+
scroll_x: window.scrollX ||
|
|
1408
|
+
window.pageXOffset ||
|
|
1409
|
+
document.documentElement.scrollLeft ||
|
|
1410
|
+
0,
|
|
1411
|
+
scroll_y: window.scrollY ||
|
|
1412
|
+
window.pageYOffset ||
|
|
1413
|
+
document.documentElement.scrollTop ||
|
|
1414
|
+
0,
|
|
1415
|
+
};
|
|
1416
|
+
});
|
|
1417
|
+
// Calculate derived values
|
|
1418
|
+
const viewport_width = Math.floor(pageData.viewport_width);
|
|
1419
|
+
const viewport_height = Math.floor(pageData.viewport_height);
|
|
1420
|
+
const page_width = Math.floor(pageData.page_width);
|
|
1421
|
+
const page_height = Math.floor(pageData.page_height);
|
|
1422
|
+
const scroll_x = Math.floor(pageData.scroll_x);
|
|
1423
|
+
const scroll_y = Math.floor(pageData.scroll_y);
|
|
1424
|
+
// Calculate scroll information
|
|
1425
|
+
const pixels_above = scroll_y;
|
|
1426
|
+
const pixels_below = Math.max(0, page_height - (scroll_y + viewport_height));
|
|
1427
|
+
const pixels_left = scroll_x;
|
|
1428
|
+
const pixels_right = Math.max(0, page_width - (scroll_x + viewport_width));
|
|
1429
|
+
return {
|
|
1430
|
+
viewport_width,
|
|
1431
|
+
viewport_height,
|
|
1432
|
+
page_width,
|
|
1433
|
+
page_height,
|
|
1434
|
+
scroll_x,
|
|
1435
|
+
scroll_y,
|
|
1436
|
+
pixels_above,
|
|
1437
|
+
pixels_below,
|
|
1438
|
+
pixels_left,
|
|
1439
|
+
pixels_right,
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Get the HTML content of the current page
|
|
1444
|
+
*/
|
|
1445
|
+
async get_page_html() {
|
|
1446
|
+
const page = await this.get_current_page();
|
|
1447
|
+
if (!page) {
|
|
1448
|
+
return '';
|
|
1449
|
+
}
|
|
1450
|
+
return await page.content();
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Get a debug view of the page structure including iframes
|
|
1454
|
+
*/
|
|
1455
|
+
async get_page_structure() {
|
|
1456
|
+
const page = await this.get_current_page();
|
|
1457
|
+
if (!page) {
|
|
1458
|
+
return '';
|
|
1459
|
+
}
|
|
1460
|
+
const debug_script = `(() => {
|
|
1461
|
+
function getPageStructure(element = document, depth = 0, maxDepth = 10) {
|
|
1462
|
+
if (depth >= maxDepth) return '';
|
|
1463
|
+
|
|
1464
|
+
const indent = ' '.repeat(depth);
|
|
1465
|
+
let structure = '';
|
|
1466
|
+
|
|
1467
|
+
// Skip certain elements that clutter the output
|
|
1468
|
+
const skipTags = new Set(['script', 'style', 'link', 'meta', 'noscript']);
|
|
1469
|
+
|
|
1470
|
+
// Add current element info if it's not the document
|
|
1471
|
+
if (element !== document) {
|
|
1472
|
+
const tagName = element.tagName.toLowerCase();
|
|
1473
|
+
|
|
1474
|
+
// Skip uninteresting elements
|
|
1475
|
+
if (skipTags.has(tagName)) return '';
|
|
1476
|
+
|
|
1477
|
+
const id = element.id ? \`#\${element.id}\` : '';
|
|
1478
|
+
const classes = element.className && typeof element.className === 'string' ?
|
|
1479
|
+
\`.\${element.className.split(' ').filter(c => c).join('.')}\` : '';
|
|
1480
|
+
|
|
1481
|
+
// Get additional useful attributes
|
|
1482
|
+
const attrs = [];
|
|
1483
|
+
if (element.getAttribute('role')) attrs.push(\`role="\${element.getAttribute('role')}"\`);
|
|
1484
|
+
if (element.getAttribute('aria-label')) attrs.push(\`aria-label="\${element.getAttribute('aria-label')}"\`);
|
|
1485
|
+
if (element.getAttribute('type')) attrs.push(\`type="\${element.getAttribute('type')}"\`);
|
|
1486
|
+
if (element.getAttribute('name')) attrs.push(\`name="\${element.getAttribute('name')}"\`);
|
|
1487
|
+
if (element.getAttribute('src')) {
|
|
1488
|
+
const src = element.getAttribute('src');
|
|
1489
|
+
attrs.push(\`src="\${src.substring(0, 50)}\${src.length > 50 ? '...' : ''}"\`);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Add element info
|
|
1493
|
+
structure += \`\${indent}\${tagName}\${id}\${classes}\${attrs.length ? ' [' + attrs.join(', ') + ']' : ''}\\n\`;
|
|
1494
|
+
|
|
1495
|
+
// Handle iframes specially
|
|
1496
|
+
if (tagName === 'iframe') {
|
|
1497
|
+
try {
|
|
1498
|
+
const iframeDoc = element.contentDocument || element.contentWindow?.document;
|
|
1499
|
+
if (iframeDoc) {
|
|
1500
|
+
structure += \`\${indent} [IFRAME CONTENT]:\\n\`;
|
|
1501
|
+
structure += getPageStructure(iframeDoc, depth + 2, maxDepth);
|
|
1502
|
+
} else {
|
|
1503
|
+
structure += \`\${indent} [CROSS-ORIGIN IFRAME - Cannot access]\\n\`;
|
|
1504
|
+
}
|
|
1505
|
+
} catch (e) {
|
|
1506
|
+
structure += \`\${indent} [IFRAME - Access denied]\\n\`;
|
|
1507
|
+
}
|
|
1508
|
+
return structure;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Process children
|
|
1513
|
+
const children = element.children || element.documentElement?.children || [];
|
|
1514
|
+
for (let i = 0; i < children.length; i++) {
|
|
1515
|
+
structure += getPageStructure(children[i], depth + 1, maxDepth);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
return structure;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
return getPageStructure();
|
|
1522
|
+
})()`;
|
|
1523
|
+
return await page.evaluate(debug_script);
|
|
1524
|
+
}
|
|
1525
|
+
// ==================== Navigation & History ====================
|
|
1526
|
+
/**
|
|
1527
|
+
* Navigate forward in browser history
|
|
1528
|
+
*/
|
|
1529
|
+
async go_forward() {
|
|
1530
|
+
try {
|
|
1531
|
+
const page = await this.get_current_page();
|
|
1532
|
+
if (page?.goForward) {
|
|
1533
|
+
await page.goForward({ timeout: 10000, waitUntil: 'load' });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
catch (error) {
|
|
1537
|
+
this.logger.debug(`⏭️ Error during go_forward: ${error.message}`);
|
|
1538
|
+
// Verify page is still usable after navigation error
|
|
1539
|
+
if (error.message.toLowerCase().includes('timeout')) {
|
|
1540
|
+
const page = await this.get_current_page();
|
|
1541
|
+
try {
|
|
1542
|
+
await page?.evaluate('1');
|
|
1543
|
+
}
|
|
1544
|
+
catch (evalError) {
|
|
1545
|
+
this.logger.error(`❌ Page crashed after go_forward timeout: ${evalError.message}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Refresh the current page
|
|
1552
|
+
*/
|
|
1553
|
+
async refresh() {
|
|
1554
|
+
try {
|
|
1555
|
+
const page = await this.get_current_page();
|
|
1556
|
+
if (page?.reload) {
|
|
1557
|
+
this.currentPageLoadingStatus = null;
|
|
1558
|
+
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
1559
|
+
await this._waitForStableNetwork(page);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
catch (error) {
|
|
1563
|
+
this.logger.debug(`🔄 Error during refresh: ${error.message}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
// ==================== Element Waiting ====================
|
|
1567
|
+
/**
|
|
1568
|
+
* Wait for an element to appear on the page
|
|
1569
|
+
*/
|
|
1570
|
+
async wait_for_element(selector, timeout = 10000) {
|
|
1571
|
+
const page = await this.get_current_page();
|
|
1572
|
+
if (!page) {
|
|
1573
|
+
throw new Error('No page available');
|
|
1574
|
+
}
|
|
1575
|
+
await page.waitForSelector(selector, { state: 'visible', timeout });
|
|
1576
|
+
}
|
|
1577
|
+
// ==================== Screenshots ====================
|
|
1578
|
+
/**
|
|
1579
|
+
* Take a screenshot of the current page
|
|
1580
|
+
* @param full_page Whether to capture the full scrollable page
|
|
1581
|
+
* @returns Base64 encoded PNG screenshot
|
|
1582
|
+
*/
|
|
1583
|
+
async take_screenshot(full_page = false) {
|
|
1584
|
+
const page = await this.get_current_page();
|
|
1585
|
+
if (!page) {
|
|
1586
|
+
throw new Error('No page available for screenshot');
|
|
1587
|
+
}
|
|
1588
|
+
if (!this.browser_context) {
|
|
1589
|
+
throw new Error('Browser context is not set');
|
|
1590
|
+
}
|
|
1591
|
+
// Check if it's a new tab page
|
|
1592
|
+
const url = page.url();
|
|
1593
|
+
if (url === 'about:blank' ||
|
|
1594
|
+
url === 'chrome://newtab/' ||
|
|
1595
|
+
url === 'edge://newtab/') {
|
|
1596
|
+
this.logger.warning(`▫️ Skipping screenshot of empty page: ${url}`);
|
|
1597
|
+
// Return a 4px placeholder
|
|
1598
|
+
return 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAD0lEQVQIHWP8//8/AxYMACgtBP9g8jqYAAAAAElFTkSuQmCC';
|
|
1599
|
+
}
|
|
1600
|
+
// Bring page to front before rendering
|
|
1601
|
+
try {
|
|
1602
|
+
await page.bringToFront();
|
|
1603
|
+
}
|
|
1604
|
+
catch (error) {
|
|
1605
|
+
// Ignore errors
|
|
1606
|
+
}
|
|
1607
|
+
// Take screenshot using CDP for better performance
|
|
1608
|
+
let cdp_session = null;
|
|
1609
|
+
try {
|
|
1610
|
+
this.logger.debug(`📸 Taking ${full_page ? 'full-page' : 'viewport'} PNG screenshot via CDP: ${url}`);
|
|
1611
|
+
// Create CDP session for the screenshot
|
|
1612
|
+
cdp_session = await this.browser_context.newCDPSession(page);
|
|
1613
|
+
// Capture screenshot via CDP
|
|
1614
|
+
const screenshot_response = await cdp_session.send('Page.captureScreenshot', {
|
|
1615
|
+
captureBeyondViewport: false,
|
|
1616
|
+
fromSurface: true,
|
|
1617
|
+
format: 'png',
|
|
1618
|
+
});
|
|
1619
|
+
const screenshot_b64 = screenshot_response.data;
|
|
1620
|
+
if (!screenshot_b64) {
|
|
1621
|
+
throw new Error(`CDP returned empty screenshot data for page ${url}`);
|
|
1622
|
+
}
|
|
1623
|
+
return screenshot_b64;
|
|
1624
|
+
}
|
|
1625
|
+
catch (error) {
|
|
1626
|
+
const error_str = error.message || String(error);
|
|
1627
|
+
if (error_str.toLowerCase().includes('timeout')) {
|
|
1628
|
+
this.logger.warning(`⏱️ Screenshot timed out on page ${url}: ${error_str}`);
|
|
1629
|
+
}
|
|
1630
|
+
else {
|
|
1631
|
+
this.logger.error(`❌ Screenshot failed on page ${url}: ${error_str}`);
|
|
1632
|
+
}
|
|
1633
|
+
throw error;
|
|
1634
|
+
}
|
|
1635
|
+
finally {
|
|
1636
|
+
if (cdp_session) {
|
|
1637
|
+
try {
|
|
1638
|
+
await cdp_session.detach();
|
|
1639
|
+
}
|
|
1640
|
+
catch (error) {
|
|
1641
|
+
// Ignore detach errors
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
// ==================== Event Listeners ====================
|
|
1647
|
+
/**
|
|
1648
|
+
* Add a request event listener to the current page
|
|
1649
|
+
*/
|
|
1650
|
+
async on_request(callback) {
|
|
1651
|
+
const page = await this.get_current_page();
|
|
1652
|
+
if (page && typeof page.on === 'function') {
|
|
1653
|
+
page.on('request', callback);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Add a response event listener to the current page
|
|
1658
|
+
*/
|
|
1659
|
+
async on_response(callback) {
|
|
1660
|
+
const page = await this.get_current_page();
|
|
1661
|
+
if (page && typeof page.on === 'function') {
|
|
1662
|
+
page.on('response', callback);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Remove a request event listener from the current page
|
|
1667
|
+
*/
|
|
1668
|
+
async off_request(callback) {
|
|
1669
|
+
const page = await this.get_current_page();
|
|
1670
|
+
if (page && typeof page.off === 'function') {
|
|
1671
|
+
page.off('request', callback);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Remove a response event listener from the current page
|
|
1676
|
+
*/
|
|
1677
|
+
async off_response(callback) {
|
|
1678
|
+
const page = await this.get_current_page();
|
|
1679
|
+
if (page && typeof page.off === 'function') {
|
|
1680
|
+
page.off('response', callback);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
// ==================== P2 Additional Functions ====================
|
|
1684
|
+
/**
|
|
1685
|
+
* Get information about all open tabs
|
|
1686
|
+
* @returns Array of tab information including page_id, url, and title
|
|
1687
|
+
*/
|
|
1688
|
+
async get_tabs_info() {
|
|
1689
|
+
if (!this.browser_context) {
|
|
1690
|
+
return [];
|
|
1691
|
+
}
|
|
1692
|
+
const tabs_info = [];
|
|
1693
|
+
const pages = this.browser_context.pages();
|
|
1694
|
+
for (let page_id = 0; page_id < pages.length; page_id++) {
|
|
1695
|
+
const page = pages[page_id];
|
|
1696
|
+
// Skip chrome:// pages and new tab pages
|
|
1697
|
+
const isNewTab = page.url() === 'about:blank' ||
|
|
1698
|
+
page.url().startsWith('chrome://newtab');
|
|
1699
|
+
if (isNewTab || page.url().startsWith('chrome://')) {
|
|
1700
|
+
if (isNewTab) {
|
|
1701
|
+
tabs_info.push({
|
|
1702
|
+
page_id,
|
|
1703
|
+
url: page.url(),
|
|
1704
|
+
title: 'ignore this tab and do not use it',
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
else {
|
|
1708
|
+
tabs_info.push({
|
|
1709
|
+
page_id,
|
|
1710
|
+
url: page.url(),
|
|
1711
|
+
title: page.url(),
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
// Normal pages - try to get title with timeout
|
|
1717
|
+
try {
|
|
1718
|
+
const titlePromise = page.title();
|
|
1719
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1720
|
+
setTimeout(() => reject(new Error('timeout')), 2000);
|
|
1721
|
+
});
|
|
1722
|
+
const title = await Promise.race([titlePromise, timeoutPromise]);
|
|
1723
|
+
tabs_info.push({ page_id, url: page.url(), title });
|
|
1724
|
+
}
|
|
1725
|
+
catch (error) {
|
|
1726
|
+
this.logger.debug(`⚠️ Failed to get tab info for tab #${page_id}: ${page.url()} (using fallback title)`);
|
|
1727
|
+
if (isNewTab) {
|
|
1728
|
+
tabs_info.push({
|
|
1729
|
+
page_id,
|
|
1730
|
+
url: page.url(),
|
|
1731
|
+
title: 'ignore this tab and do not use it',
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
else {
|
|
1735
|
+
tabs_info.push({
|
|
1736
|
+
page_id,
|
|
1737
|
+
url: page.url(),
|
|
1738
|
+
title: page.url(), // Use URL as fallback title
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
return tabs_info;
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Check if a page is responsive by trying to evaluate simple JavaScript
|
|
1747
|
+
* @param page - The page to check
|
|
1748
|
+
* @param timeout - Timeout in seconds (default: 5)
|
|
1749
|
+
* @returns True if page is responsive, false otherwise
|
|
1750
|
+
*/
|
|
1751
|
+
async _is_page_responsive(page, timeout = 5.0) {
|
|
1752
|
+
try {
|
|
1753
|
+
const evalPromise = page.evaluate('1');
|
|
1754
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1755
|
+
setTimeout(() => reject(new Error('timeout')), timeout * 1000);
|
|
1756
|
+
});
|
|
1757
|
+
await Promise.race([evalPromise, timeoutPromise]);
|
|
1758
|
+
return true;
|
|
1759
|
+
}
|
|
1760
|
+
catch (error) {
|
|
1761
|
+
return false;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Get scroll information for the current page
|
|
1766
|
+
* @returns Object with scroll position and page dimensions
|
|
1767
|
+
*/
|
|
1768
|
+
async get_scroll_info() {
|
|
1769
|
+
const page = await this.get_current_page();
|
|
1770
|
+
if (!page) {
|
|
1771
|
+
return {
|
|
1772
|
+
scroll_x: 0,
|
|
1773
|
+
scroll_y: 0,
|
|
1774
|
+
page_width: 0,
|
|
1775
|
+
page_height: 0,
|
|
1776
|
+
viewport_width: 0,
|
|
1777
|
+
viewport_height: 0,
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
return await page.evaluate(() => {
|
|
1781
|
+
return {
|
|
1782
|
+
scroll_x: window.scrollX ||
|
|
1783
|
+
window.pageXOffset ||
|
|
1784
|
+
document.documentElement.scrollLeft ||
|
|
1785
|
+
0,
|
|
1786
|
+
scroll_y: window.scrollY ||
|
|
1787
|
+
window.pageYOffset ||
|
|
1788
|
+
document.documentElement.scrollTop ||
|
|
1789
|
+
0,
|
|
1790
|
+
page_width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0),
|
|
1791
|
+
page_height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight || 0),
|
|
1792
|
+
viewport_width: window.innerWidth,
|
|
1793
|
+
viewport_height: window.innerHeight,
|
|
1794
|
+
};
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Get a summary of the current browser state
|
|
1799
|
+
* @param cache_clickable_elements_hashes - Cache clickable element hashes to detect new elements
|
|
1800
|
+
* @param include_screenshot - Include screenshot in state summary
|
|
1801
|
+
* @returns BrowserStateSummary with current page state
|
|
1802
|
+
*/
|
|
1803
|
+
async get_state_summary(cache_clickable_elements_hashes = true, include_screenshot = true) {
|
|
1804
|
+
this.logger.debug('🔄 Starting get_state_summary...');
|
|
1805
|
+
const updated_state = await this._get_updated_state(-1, include_screenshot);
|
|
1806
|
+
// Implement clickable element hash caching to detect new elements
|
|
1807
|
+
if (cache_clickable_elements_hashes) {
|
|
1808
|
+
const page = await this.get_current_page();
|
|
1809
|
+
if (page) {
|
|
1810
|
+
const currentUrl = page.url();
|
|
1811
|
+
const currentHashes = this._computeElementHashes(updated_state.selector_map);
|
|
1812
|
+
// Mark new elements if we have cached hashes for this URL
|
|
1813
|
+
if (this._cachedClickableElementHashes &&
|
|
1814
|
+
this._cachedClickableElementHashes.url === currentUrl) {
|
|
1815
|
+
this._markNewElements(updated_state.selector_map, this._cachedClickableElementHashes.hashes);
|
|
1816
|
+
}
|
|
1817
|
+
// Update cache with current hashes
|
|
1818
|
+
this._cachedClickableElementHashes = {
|
|
1819
|
+
url: currentUrl,
|
|
1820
|
+
hashes: currentHashes,
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
this.cachedBrowserState = updated_state;
|
|
1825
|
+
return this.cachedBrowserState;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Get minimal state summary without DOM processing, but with screenshot
|
|
1829
|
+
* Used when page is in error state or unresponsive
|
|
1830
|
+
*/
|
|
1831
|
+
async get_minimal_state_summary() {
|
|
1832
|
+
try {
|
|
1833
|
+
const page = await this.get_current_page();
|
|
1834
|
+
const url = page ? page.url() : 'unknown';
|
|
1835
|
+
// Try to get title safely
|
|
1836
|
+
let title = 'Page Load Error';
|
|
1837
|
+
try {
|
|
1838
|
+
if (page) {
|
|
1839
|
+
const titlePromise = page.title();
|
|
1840
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000));
|
|
1841
|
+
title = await Promise.race([titlePromise, timeoutPromise]);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
catch (error) {
|
|
1845
|
+
// Keep default title
|
|
1846
|
+
}
|
|
1847
|
+
// Try to get tabs info safely
|
|
1848
|
+
let tabs_info = [];
|
|
1849
|
+
try {
|
|
1850
|
+
const tabsPromise = this.get_tabs_info();
|
|
1851
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000));
|
|
1852
|
+
tabs_info = await Promise.race([tabsPromise, timeoutPromise]);
|
|
1853
|
+
}
|
|
1854
|
+
catch (error) {
|
|
1855
|
+
// Keep empty tabs
|
|
1856
|
+
}
|
|
1857
|
+
// Create minimal DOM element for error state
|
|
1858
|
+
const minimal_element_tree = new DOMElementNode(true, null, 'body', '/body', {}, []);
|
|
1859
|
+
// Try to get screenshot
|
|
1860
|
+
let screenshot_b64 = null;
|
|
1861
|
+
try {
|
|
1862
|
+
screenshot_b64 = await this.take_screenshot();
|
|
1863
|
+
}
|
|
1864
|
+
catch (error) {
|
|
1865
|
+
this.logger.debug(`Screenshot failed in minimal state: ${error.message}`);
|
|
1866
|
+
}
|
|
1867
|
+
// Use default viewport dimensions
|
|
1868
|
+
const viewport = this.browser_profile.viewport || {
|
|
1869
|
+
width: 1280,
|
|
1870
|
+
height: 720,
|
|
1871
|
+
};
|
|
1872
|
+
const dom_state = new DOMState(minimal_element_tree, {});
|
|
1873
|
+
return new BrowserStateSummary(dom_state, {
|
|
1874
|
+
url,
|
|
1875
|
+
title,
|
|
1876
|
+
tabs: tabs_info,
|
|
1877
|
+
screenshot: screenshot_b64,
|
|
1878
|
+
page_info: {
|
|
1879
|
+
viewport_width: viewport.width,
|
|
1880
|
+
viewport_height: viewport.height,
|
|
1881
|
+
page_width: viewport.width,
|
|
1882
|
+
page_height: viewport.height,
|
|
1883
|
+
scroll_x: 0,
|
|
1884
|
+
scroll_y: 0,
|
|
1885
|
+
pixels_above: 0,
|
|
1886
|
+
pixels_below: 0,
|
|
1887
|
+
pixels_left: 0,
|
|
1888
|
+
pixels_right: 0,
|
|
1889
|
+
},
|
|
1890
|
+
pixels_above: 0,
|
|
1891
|
+
pixels_below: 0,
|
|
1892
|
+
browser_errors: ['Page in error state - minimal navigation available'],
|
|
1893
|
+
is_pdf_viewer: false,
|
|
1894
|
+
loading_status: this.currentPageLoadingStatus,
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
catch (error) {
|
|
1898
|
+
this.logger.error(`Failed to get minimal state summary: ${error.message}`);
|
|
1899
|
+
throw error;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Internal method to get updated browser state with DOM processing
|
|
1904
|
+
* @param focus_element - Element index to focus on (default: -1)
|
|
1905
|
+
* @param include_screenshot - Whether to include screenshot
|
|
1906
|
+
*/
|
|
1907
|
+
async _get_updated_state(focus_element = -1, include_screenshot = true) {
|
|
1908
|
+
const page = await this.get_current_page();
|
|
1909
|
+
if (!page) {
|
|
1910
|
+
throw new Error('No current page available');
|
|
1911
|
+
}
|
|
1912
|
+
const page_url = page.url();
|
|
1913
|
+
// Check for new tab or chrome:// pages - fast path
|
|
1914
|
+
const is_empty_page = this._is_new_tab_page(page_url) || page_url.startsWith('chrome://');
|
|
1915
|
+
if (is_empty_page) {
|
|
1916
|
+
this.logger.debug(`⚡ Fast path for empty page: ${page_url}`);
|
|
1917
|
+
// Create minimal DOM state
|
|
1918
|
+
const minimal_element_tree = new DOMElementNode(false, null, 'body', '', {}, []);
|
|
1919
|
+
const tabs_info = await this.get_tabs_info();
|
|
1920
|
+
const viewport = this.browser_profile.viewport || {
|
|
1921
|
+
width: 1280,
|
|
1922
|
+
height: 720,
|
|
1923
|
+
};
|
|
1924
|
+
const dom_state = new DOMState(minimal_element_tree, {});
|
|
1925
|
+
return new BrowserStateSummary(dom_state, {
|
|
1926
|
+
url: page_url,
|
|
1927
|
+
title: this._is_new_tab_page(page_url) ? 'New Tab' : 'Chrome Page',
|
|
1928
|
+
tabs: tabs_info,
|
|
1929
|
+
screenshot: null,
|
|
1930
|
+
page_info: {
|
|
1931
|
+
viewport_width: viewport.width,
|
|
1932
|
+
viewport_height: viewport.height,
|
|
1933
|
+
page_width: viewport.width,
|
|
1934
|
+
page_height: viewport.height,
|
|
1935
|
+
scroll_x: 0,
|
|
1936
|
+
scroll_y: 0,
|
|
1937
|
+
pixels_above: 0,
|
|
1938
|
+
pixels_below: 0,
|
|
1939
|
+
pixels_left: 0,
|
|
1940
|
+
pixels_right: 0,
|
|
1941
|
+
},
|
|
1942
|
+
pixels_above: 0,
|
|
1943
|
+
pixels_below: 0,
|
|
1944
|
+
browser_errors: [],
|
|
1945
|
+
is_pdf_viewer: false,
|
|
1946
|
+
loading_status: this.currentPageLoadingStatus,
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
// Normal path for regular pages
|
|
1950
|
+
this.logger.debug('🧹 Removing highlights...');
|
|
1951
|
+
try {
|
|
1952
|
+
await this.remove_highlights();
|
|
1953
|
+
}
|
|
1954
|
+
catch (error) {
|
|
1955
|
+
this.logger.debug('Timeout removing highlights');
|
|
1956
|
+
}
|
|
1957
|
+
// Check for PDF and auto-download if needed
|
|
1958
|
+
try {
|
|
1959
|
+
const pdf_path = await this._auto_download_pdf_if_needed(page);
|
|
1960
|
+
if (pdf_path) {
|
|
1961
|
+
this.logger.info(`📄 PDF auto-downloaded: ${pdf_path}`);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
catch (error) {
|
|
1965
|
+
this.logger.debug(`PDF auto-download check failed: ${error.message}`);
|
|
1966
|
+
}
|
|
1967
|
+
// DOM processing
|
|
1968
|
+
this.logger.debug('🌳 Starting DOM processing...');
|
|
1969
|
+
const dom_service = new DomService(page, this.logger);
|
|
1970
|
+
let content;
|
|
1971
|
+
try {
|
|
1972
|
+
const domPromise = dom_service.get_clickable_elements(this.browser_profile.highlight_elements, focus_element, this.browser_profile.viewport_expansion);
|
|
1973
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('DOM processing timeout')), 45000));
|
|
1974
|
+
content = await Promise.race([domPromise, timeoutPromise]);
|
|
1975
|
+
this.logger.debug('✅ DOM processing completed');
|
|
1976
|
+
}
|
|
1977
|
+
catch (error) {
|
|
1978
|
+
this.logger.warning(`DOM processing timed out for ${page_url}`);
|
|
1979
|
+
this.logger.warning('🔄 Falling back to minimal DOM state...');
|
|
1980
|
+
// Create minimal DOM state for fallback
|
|
1981
|
+
const minimal_element_tree = new DOMElementNode(true, null, 'body', '/body', {}, []);
|
|
1982
|
+
content = new DOMState(minimal_element_tree, {});
|
|
1983
|
+
}
|
|
1984
|
+
// Get tabs info
|
|
1985
|
+
this.logger.debug('📋 Getting tabs info...');
|
|
1986
|
+
const tabs_info = await this.get_tabs_info();
|
|
1987
|
+
this.logger.debug('✅ Tabs info completed');
|
|
1988
|
+
// Screenshot
|
|
1989
|
+
let screenshot_b64 = null;
|
|
1990
|
+
if (include_screenshot) {
|
|
1991
|
+
try {
|
|
1992
|
+
this.logger.debug('📸 Capturing screenshot...');
|
|
1993
|
+
screenshot_b64 = await this.take_screenshot();
|
|
1994
|
+
}
|
|
1995
|
+
catch (error) {
|
|
1996
|
+
this.logger.warning(`❌ Screenshot failed for ${page_url}: ${error.message}`);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
// Get page info and scroll info
|
|
2000
|
+
const page_info = await this.get_page_info(page);
|
|
2001
|
+
let pixels_above = 0;
|
|
2002
|
+
let pixels_below = 0;
|
|
2003
|
+
try {
|
|
2004
|
+
this.logger.debug('📏 Getting scroll info...');
|
|
2005
|
+
const scroll_info = await Promise.race([
|
|
2006
|
+
this.get_scroll_info(),
|
|
2007
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)),
|
|
2008
|
+
]);
|
|
2009
|
+
// Calculate pixels above/below viewport
|
|
2010
|
+
pixels_above = Math.max(0, scroll_info.scroll_y);
|
|
2011
|
+
const viewport_bottom = scroll_info.scroll_y + scroll_info.viewport_height;
|
|
2012
|
+
pixels_below = Math.max(0, scroll_info.page_height - viewport_bottom);
|
|
2013
|
+
this.logger.debug('✅ Scroll info completed');
|
|
2014
|
+
}
|
|
2015
|
+
catch (error) {
|
|
2016
|
+
this.logger.warning(`Failed to get scroll info: ${error.message}`);
|
|
2017
|
+
}
|
|
2018
|
+
// Get title
|
|
2019
|
+
let title = 'Title unavailable';
|
|
2020
|
+
try {
|
|
2021
|
+
const titlePromise = page.title();
|
|
2022
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000));
|
|
2023
|
+
title = await Promise.race([titlePromise, timeoutPromise]);
|
|
2024
|
+
}
|
|
2025
|
+
catch (error) {
|
|
2026
|
+
// Keep default title
|
|
2027
|
+
}
|
|
2028
|
+
// Check for errors
|
|
2029
|
+
const browser_errors = [];
|
|
2030
|
+
if (Object.keys(content.selector_map).length === 0) {
|
|
2031
|
+
browser_errors.push(`DOM processing timed out for ${page_url} - using minimal state. Basic navigation still available.`);
|
|
2032
|
+
}
|
|
2033
|
+
// Check if PDF viewer
|
|
2034
|
+
const is_pdf_viewer = await this._is_pdf_viewer(page);
|
|
2035
|
+
const browser_state = new BrowserStateSummary(content, {
|
|
2036
|
+
url: page_url,
|
|
2037
|
+
title,
|
|
2038
|
+
tabs: tabs_info,
|
|
2039
|
+
screenshot: screenshot_b64,
|
|
2040
|
+
page_info,
|
|
2041
|
+
pixels_above,
|
|
2042
|
+
pixels_below,
|
|
2043
|
+
browser_errors,
|
|
2044
|
+
is_pdf_viewer,
|
|
2045
|
+
loading_status: this.currentPageLoadingStatus,
|
|
2046
|
+
});
|
|
2047
|
+
this.logger.debug('✅ get_state_summary completed successfully');
|
|
2048
|
+
return browser_state;
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Check if a URL is a new tab page
|
|
2052
|
+
*/
|
|
2053
|
+
_is_new_tab_page(url) {
|
|
2054
|
+
return (url === 'about:blank' ||
|
|
2055
|
+
url === 'about:newtab' ||
|
|
2056
|
+
url === 'chrome://newtab/');
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Check if page is displaying a PDF
|
|
2060
|
+
*/
|
|
2061
|
+
async _is_pdf_viewer(page) {
|
|
2062
|
+
try {
|
|
2063
|
+
const url = page.url();
|
|
2064
|
+
if (url.endsWith('.pdf') || url.includes('.pdf?')) {
|
|
2065
|
+
return true;
|
|
2066
|
+
}
|
|
2067
|
+
// Check for PDF viewer in page content
|
|
2068
|
+
const is_pdf = await page.evaluate(() => {
|
|
2069
|
+
return (document.querySelector('embed[type="application/pdf"]') !== null ||
|
|
2070
|
+
document.querySelector('object[type="application/pdf"]') !== null);
|
|
2071
|
+
});
|
|
2072
|
+
return is_pdf;
|
|
2073
|
+
}
|
|
2074
|
+
catch (error) {
|
|
2075
|
+
return false;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Auto-download PDF if detected and auto-download is enabled
|
|
2080
|
+
*/
|
|
2081
|
+
async _auto_download_pdf_if_needed(page) {
|
|
2082
|
+
const downloadsPath = this.browser_profile.downloads_path;
|
|
2083
|
+
if (!downloadsPath || !this._autoDownloadPdfs) {
|
|
2084
|
+
return null;
|
|
2085
|
+
}
|
|
2086
|
+
try {
|
|
2087
|
+
const is_pdf = await this._is_pdf_viewer(page);
|
|
2088
|
+
if (!is_pdf) {
|
|
2089
|
+
return null;
|
|
2090
|
+
}
|
|
2091
|
+
const url = page.url();
|
|
2092
|
+
this.logger.info(`📄 PDF detected: ${url}`);
|
|
2093
|
+
let pdfFilename = path.basename(url.split('?')[0]);
|
|
2094
|
+
if (!pdfFilename || !pdfFilename.toLowerCase().endsWith('.pdf')) {
|
|
2095
|
+
const parsed = new URL(url);
|
|
2096
|
+
pdfFilename = path.basename(parsed.pathname) || 'document.pdf';
|
|
2097
|
+
if (!pdfFilename.toLowerCase().endsWith('.pdf')) {
|
|
2098
|
+
pdfFilename += '.pdf';
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
if (this.downloaded_files.some((downloaded) => path.basename(downloaded) === pdfFilename)) {
|
|
2102
|
+
this.logger.debug(`📄 PDF already downloaded: ${pdfFilename}`);
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
this.logger.info(`📄 Auto-downloading PDF from: ${url}`);
|
|
2106
|
+
const downloadResult = await page.evaluate(async (pdfUrl) => {
|
|
2107
|
+
try {
|
|
2108
|
+
const response = await fetch(pdfUrl, {
|
|
2109
|
+
cache: 'force-cache',
|
|
2110
|
+
});
|
|
2111
|
+
if (!response.ok) {
|
|
2112
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
2113
|
+
}
|
|
2114
|
+
const blob = await response.blob();
|
|
2115
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
2116
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
2117
|
+
const cacheHeader = response.headers.get('x-cache') || '';
|
|
2118
|
+
const fromCache = response.headers.has('age') ||
|
|
2119
|
+
cacheHeader.toLowerCase().includes('hit');
|
|
2120
|
+
return {
|
|
2121
|
+
data: Array.from(uint8Array),
|
|
2122
|
+
fromCache,
|
|
2123
|
+
responseSize: uint8Array.length,
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
catch (error) {
|
|
2127
|
+
return {
|
|
2128
|
+
data: [],
|
|
2129
|
+
fromCache: false,
|
|
2130
|
+
responseSize: 0,
|
|
2131
|
+
error: error instanceof Error ? error.message : 'Unknown fetch error',
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
}, url);
|
|
2135
|
+
if (downloadResult?.error) {
|
|
2136
|
+
this.logger.warning(`⚠️ Failed to auto-download PDF from ${url}: ${downloadResult.error}`);
|
|
2137
|
+
return null;
|
|
2138
|
+
}
|
|
2139
|
+
if (!downloadResult ||
|
|
2140
|
+
!Array.isArray(downloadResult.data) ||
|
|
2141
|
+
downloadResult.data.length === 0) {
|
|
2142
|
+
this.logger.warning(`⚠️ No data received when downloading PDF from ${url}`);
|
|
2143
|
+
return null;
|
|
2144
|
+
}
|
|
2145
|
+
await fs.promises.mkdir(downloadsPath, { recursive: true });
|
|
2146
|
+
const uniqueFilename = await BrowserSession.get_unique_filename(downloadsPath, pdfFilename);
|
|
2147
|
+
const downloadPath = path.join(downloadsPath, uniqueFilename);
|
|
2148
|
+
await fs.promises.writeFile(downloadPath, Buffer.from(downloadResult.data));
|
|
2149
|
+
this.add_downloaded_file(downloadPath);
|
|
2150
|
+
const cacheStatus = downloadResult.fromCache
|
|
2151
|
+
? 'from cache'
|
|
2152
|
+
: 'from network';
|
|
2153
|
+
const responseSize = Number(downloadResult.responseSize || 0);
|
|
2154
|
+
this.logger.info(`📄 Auto-downloaded PDF (${cacheStatus}, ${responseSize.toLocaleString()} bytes): ${downloadPath}`);
|
|
2155
|
+
return downloadPath;
|
|
2156
|
+
}
|
|
2157
|
+
catch (error) {
|
|
2158
|
+
this.logger.debug(`PDF detection failed: ${error.message}`);
|
|
2159
|
+
return null;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Check if an element is visible on the page
|
|
2164
|
+
*/
|
|
2165
|
+
async _is_visible(element) {
|
|
2166
|
+
try {
|
|
2167
|
+
const is_hidden = await element.isHidden();
|
|
2168
|
+
const bbox = await element.boundingBox();
|
|
2169
|
+
return !is_hidden && bbox !== null && bbox.width > 0 && bbox.height > 0;
|
|
2170
|
+
}
|
|
2171
|
+
catch (error) {
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Locate an element by XPath
|
|
2177
|
+
*/
|
|
2178
|
+
async get_locate_element_by_xpath(xpath) {
|
|
2179
|
+
const page = await this.get_current_page();
|
|
2180
|
+
if (!page) {
|
|
2181
|
+
return null;
|
|
2182
|
+
}
|
|
2183
|
+
try {
|
|
2184
|
+
// Use XPath to locate the element
|
|
2185
|
+
const element_handle = await page
|
|
2186
|
+
.locator(`xpath=${xpath}`)
|
|
2187
|
+
.elementHandle();
|
|
2188
|
+
if (element_handle) {
|
|
2189
|
+
const is_visible = await this._is_visible(element_handle);
|
|
2190
|
+
if (is_visible) {
|
|
2191
|
+
await element_handle.scrollIntoViewIfNeeded({ timeout: 1000 });
|
|
2192
|
+
}
|
|
2193
|
+
return element_handle;
|
|
2194
|
+
}
|
|
2195
|
+
return null;
|
|
2196
|
+
}
|
|
2197
|
+
catch (error) {
|
|
2198
|
+
this.logger.error(`❌ Failed to locate xpath ${xpath}: ${error.message}`);
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Locate an element by CSS selector
|
|
2204
|
+
*/
|
|
2205
|
+
async get_locate_element_by_css_selector(css_selector) {
|
|
2206
|
+
const page = await this.get_current_page();
|
|
2207
|
+
if (!page) {
|
|
2208
|
+
return null;
|
|
2209
|
+
}
|
|
2210
|
+
try {
|
|
2211
|
+
// Use CSS selector to locate the element
|
|
2212
|
+
const element_handle = await page.locator(css_selector).elementHandle();
|
|
2213
|
+
if (element_handle) {
|
|
2214
|
+
const is_visible = await this._is_visible(element_handle);
|
|
2215
|
+
if (is_visible) {
|
|
2216
|
+
await element_handle.scrollIntoViewIfNeeded({ timeout: 1000 });
|
|
2217
|
+
}
|
|
2218
|
+
return element_handle;
|
|
2219
|
+
}
|
|
2220
|
+
return null;
|
|
2221
|
+
}
|
|
2222
|
+
catch (error) {
|
|
2223
|
+
this.logger.error(`❌ Failed to locate element ${css_selector}: ${error.message}`);
|
|
2224
|
+
return null;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Locate an element by text content
|
|
2229
|
+
* @param text - Text to search for
|
|
2230
|
+
* @param nth - Which matching element to return (0-based index)
|
|
2231
|
+
* @param element_type - Optional tag name to filter by (e.g., 'button', 'span')
|
|
2232
|
+
*/
|
|
2233
|
+
async get_locate_element_by_text(text, nth = 0, element_type = null) {
|
|
2234
|
+
const page = await this.get_current_page();
|
|
2235
|
+
if (!page) {
|
|
2236
|
+
return null;
|
|
2237
|
+
}
|
|
2238
|
+
try {
|
|
2239
|
+
// Build selector: filter by element type and text
|
|
2240
|
+
const selector = element_type
|
|
2241
|
+
? `${element_type}:text("${text}")`
|
|
2242
|
+
: `:text("${text}")`;
|
|
2243
|
+
// Get all matching elements
|
|
2244
|
+
const locator = page.locator(selector);
|
|
2245
|
+
const count = await locator.count();
|
|
2246
|
+
if (count === 0) {
|
|
2247
|
+
this.logger.error(`❌ No element with text '${text}' found`);
|
|
2248
|
+
return null;
|
|
2249
|
+
}
|
|
2250
|
+
// Filter visible elements
|
|
2251
|
+
const visible_elements = [];
|
|
2252
|
+
for (let i = 0; i < count; i++) {
|
|
2253
|
+
const element_handle = await locator.nth(i).elementHandle();
|
|
2254
|
+
if (element_handle && (await this._is_visible(element_handle))) {
|
|
2255
|
+
visible_elements.push(element_handle);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
if (visible_elements.length === 0) {
|
|
2259
|
+
this.logger.error(`❌ No visible element with text '${text}' found`);
|
|
2260
|
+
return null;
|
|
2261
|
+
}
|
|
2262
|
+
if (nth >= visible_elements.length) {
|
|
2263
|
+
this.logger.error(`❌ Element with text '${text}' not found at index #${nth}`);
|
|
2264
|
+
return null;
|
|
2265
|
+
}
|
|
2266
|
+
const element_handle = visible_elements[nth];
|
|
2267
|
+
const is_visible = await this._is_visible(element_handle);
|
|
2268
|
+
if (is_visible) {
|
|
2269
|
+
await element_handle.scrollIntoViewIfNeeded({ timeout: 1000 });
|
|
2270
|
+
}
|
|
2271
|
+
return element_handle;
|
|
2272
|
+
}
|
|
2273
|
+
catch (error) {
|
|
2274
|
+
this.logger.error(`❌ Failed to locate element by text '${text}': ${error.message}`);
|
|
2275
|
+
return null;
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Check if browser session is connected and has valid browser/context objects
|
|
2280
|
+
* @param restart - If true, attempt to create a new tab if no pages exist
|
|
2281
|
+
*/
|
|
2282
|
+
async is_connected(restart = true) {
|
|
2283
|
+
if (!this.browser_context) {
|
|
2284
|
+
return false;
|
|
2285
|
+
}
|
|
2286
|
+
try {
|
|
2287
|
+
// Check if browser is connected
|
|
2288
|
+
if (this.browser && !this.browser.isConnected()) {
|
|
2289
|
+
return false;
|
|
2290
|
+
}
|
|
2291
|
+
// Check if browser context's browser is connected (context may reference a different browser object)
|
|
2292
|
+
const context_browser = this.browser_context.browser?.();
|
|
2293
|
+
if (context_browser && !context_browser.isConnected()) {
|
|
2294
|
+
return false;
|
|
2295
|
+
}
|
|
2296
|
+
// Check if context has at least one page
|
|
2297
|
+
const pages = this.browser_context.pages();
|
|
2298
|
+
if (pages.length === 0) {
|
|
2299
|
+
if (restart) {
|
|
2300
|
+
// Try to create a new page to keep context alive
|
|
2301
|
+
try {
|
|
2302
|
+
await this.browser_context.newPage();
|
|
2303
|
+
}
|
|
2304
|
+
catch (error) {
|
|
2305
|
+
return false;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
else {
|
|
2309
|
+
return false;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
return true;
|
|
2313
|
+
}
|
|
2314
|
+
catch (error) {
|
|
2315
|
+
return false;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* Check if a URL is allowed based on allowed_domains configuration
|
|
2320
|
+
* @param url - URL to check
|
|
2321
|
+
*/
|
|
2322
|
+
_is_url_allowed(url) {
|
|
2323
|
+
if (!this.browser_profile.allowed_domains ||
|
|
2324
|
+
this.browser_profile.allowed_domains.length === 0) {
|
|
2325
|
+
return true; // No restrictions if allowed_domains not configured
|
|
2326
|
+
}
|
|
2327
|
+
// Always allow new tab pages
|
|
2328
|
+
if (this._is_new_tab_page(url)) {
|
|
2329
|
+
return true;
|
|
2330
|
+
}
|
|
2331
|
+
// Check against allowed domains
|
|
2332
|
+
for (const allowed_domain of this.browser_profile.allowed_domains) {
|
|
2333
|
+
try {
|
|
2334
|
+
if (match_url_with_domain_pattern(url, allowed_domain, true)) {
|
|
2335
|
+
return true;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
catch (error) {
|
|
2339
|
+
this.logger.warning(`Invalid domain pattern: ${allowed_domain}`);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
return false;
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Navigate helper with URL validation
|
|
2346
|
+
*/
|
|
2347
|
+
async navigate(url) {
|
|
2348
|
+
// Validate URL is allowed
|
|
2349
|
+
if (!this._is_url_allowed(url)) {
|
|
2350
|
+
throw new Error(`URL ${url} is not in allowed_domains. ` +
|
|
2351
|
+
`Current allowed_domains: ${JSON.stringify(this.browser_profile.allowed_domains)}`);
|
|
2352
|
+
}
|
|
2353
|
+
await this.navigate_to(url);
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Kill the browser session (force close even if keep_alive=true)
|
|
2357
|
+
*/
|
|
2358
|
+
async kill() {
|
|
2359
|
+
this.logger.info('💀 Force killing browser session...');
|
|
2360
|
+
// Temporarily disable keep_alive to ensure browser closes
|
|
2361
|
+
const original_keep_alive = this.browser_profile.keep_alive;
|
|
2362
|
+
this.browser_profile.keep_alive = false;
|
|
2363
|
+
try {
|
|
2364
|
+
await this.close();
|
|
2365
|
+
}
|
|
2366
|
+
finally {
|
|
2367
|
+
// Restore original keep_alive setting
|
|
2368
|
+
this.browser_profile.keep_alive = original_keep_alive;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Alias for close() to match Python API
|
|
2373
|
+
*/
|
|
2374
|
+
async stop() {
|
|
2375
|
+
if (this.browser_profile.keep_alive) {
|
|
2376
|
+
this.logger.info('🕊️ BrowserSession.stop() called but keep_alive=true, leaving browser running. Use .kill() to force close.');
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
if (this._stoppingPromise) {
|
|
2380
|
+
await this._stoppingPromise;
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
const hasActiveResources = this.initialized ||
|
|
2384
|
+
Boolean(this.browser ||
|
|
2385
|
+
this.browser_context ||
|
|
2386
|
+
this.browser_pid ||
|
|
2387
|
+
this._subprocess ||
|
|
2388
|
+
this._childProcesses.size > 0);
|
|
2389
|
+
if (!hasActiveResources) {
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
this._stoppingPromise = this._shutdown_browser_session();
|
|
2393
|
+
try {
|
|
2394
|
+
await this._stoppingPromise;
|
|
2395
|
+
}
|
|
2396
|
+
finally {
|
|
2397
|
+
this._stoppingPromise = null;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Perform a click action with download and navigation handling
|
|
2402
|
+
* @param element_node - DOM element to click
|
|
2403
|
+
*/
|
|
2404
|
+
async perform_click(element_node) {
|
|
2405
|
+
const page = await this.get_current_page();
|
|
2406
|
+
if (!page) {
|
|
2407
|
+
throw new Error('No current page available');
|
|
2408
|
+
}
|
|
2409
|
+
const element_handle = await this.get_locate_element(element_node);
|
|
2410
|
+
if (!element_handle) {
|
|
2411
|
+
throw new Error(`Element not found: ${JSON.stringify(element_node)}`);
|
|
2412
|
+
}
|
|
2413
|
+
// Check if downloads are enabled
|
|
2414
|
+
const downloads_path = this.browser_profile.downloads_path;
|
|
2415
|
+
if (downloads_path) {
|
|
2416
|
+
try {
|
|
2417
|
+
// Try to detect file download
|
|
2418
|
+
const download_promise = page.waitForEvent('download', {
|
|
2419
|
+
timeout: 5000,
|
|
2420
|
+
});
|
|
2421
|
+
// Perform the click
|
|
2422
|
+
await element_handle.click();
|
|
2423
|
+
// Wait for download or timeout
|
|
2424
|
+
const download = await download_promise;
|
|
2425
|
+
// Save the downloaded file
|
|
2426
|
+
const suggested_filename = download.suggestedFilename();
|
|
2427
|
+
const unique_filename = await BrowserSession.get_unique_filename(downloads_path, suggested_filename);
|
|
2428
|
+
const download_path = path.join(downloads_path, unique_filename);
|
|
2429
|
+
await download.saveAs(download_path);
|
|
2430
|
+
this.logger.info(`⬇️ Downloaded file to: ${download_path}`);
|
|
2431
|
+
// Track the downloaded file
|
|
2432
|
+
this.add_downloaded_file(download_path);
|
|
2433
|
+
return download_path;
|
|
2434
|
+
}
|
|
2435
|
+
catch (error) {
|
|
2436
|
+
// No download triggered, treat as normal click
|
|
2437
|
+
this.logger.debug('No download triggered within timeout. Checking navigation...');
|
|
2438
|
+
try {
|
|
2439
|
+
await page.waitForLoadState();
|
|
2440
|
+
}
|
|
2441
|
+
catch (e) {
|
|
2442
|
+
this.logger.warning(`Navigation check failed: ${e.message}`);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
else {
|
|
2447
|
+
// No downloads path configured, just click
|
|
2448
|
+
await element_handle.click();
|
|
2449
|
+
}
|
|
2450
|
+
return null;
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Remove all highlights from the current page
|
|
2454
|
+
*/
|
|
2455
|
+
async remove_highlights() {
|
|
2456
|
+
const page = await this.get_current_page();
|
|
2457
|
+
if (!page) {
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
try {
|
|
2461
|
+
await page.evaluate(() => {
|
|
2462
|
+
// Remove all elements with browser-use highlight class
|
|
2463
|
+
const highlights = document.querySelectorAll('.browser-use-highlight');
|
|
2464
|
+
highlights.forEach((el) => el.remove());
|
|
2465
|
+
// Remove inline highlight styles
|
|
2466
|
+
const styled = document.querySelectorAll('[style*="browser-use"]');
|
|
2467
|
+
styled.forEach((el) => {
|
|
2468
|
+
if (el.style) {
|
|
2469
|
+
el.style.outline = '';
|
|
2470
|
+
el.style.border = '';
|
|
2471
|
+
}
|
|
2472
|
+
});
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
catch (error) {
|
|
2476
|
+
this.logger.debug(`Failed to remove highlights: ${error.message}`);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
// region - Trace Recording
|
|
2480
|
+
/**
|
|
2481
|
+
* Start tracing on browser context if traces_dir is configured
|
|
2482
|
+
* Note: Currently optional as it may cause performance issues in some cases
|
|
2483
|
+
*/
|
|
2484
|
+
async _startContextTracing() {
|
|
2485
|
+
if (this.browser_profile.traces_dir && this.browser_context) {
|
|
2486
|
+
try {
|
|
2487
|
+
this.logger.debug(`📽️ Starting tracing (will save to: ${this.browser_profile.traces_dir})`);
|
|
2488
|
+
await this.browser_context.tracing.start({
|
|
2489
|
+
screenshots: true,
|
|
2490
|
+
snapshots: true,
|
|
2491
|
+
sources: false, // Reduce trace size
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
catch (error) {
|
|
2495
|
+
this.logger.warning(`Failed to start tracing: ${error.message}`);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Save browser trace recording
|
|
2501
|
+
*/
|
|
2502
|
+
async _saveTraceRecording() {
|
|
2503
|
+
if (this.browser_profile.traces_dir && this.browser_context) {
|
|
2504
|
+
try {
|
|
2505
|
+
const tracesPath = this.browser_profile.traces_dir;
|
|
2506
|
+
let finalTracePath;
|
|
2507
|
+
// Check if path has extension
|
|
2508
|
+
if (path.extname(tracesPath)) {
|
|
2509
|
+
// Path has extension, use as-is (user specified exact file path)
|
|
2510
|
+
finalTracePath = tracesPath;
|
|
2511
|
+
}
|
|
2512
|
+
else {
|
|
2513
|
+
// Path has no extension, treat as directory and create filename
|
|
2514
|
+
const traceFilename = `BrowserSession_${this.id}.zip`;
|
|
2515
|
+
finalTracePath = path.join(tracesPath, traceFilename);
|
|
2516
|
+
}
|
|
2517
|
+
this.logger.info(`🎥 Saving browser_context trace to ${finalTracePath}...`);
|
|
2518
|
+
await this.browser_context.tracing.stop({ path: finalTracePath });
|
|
2519
|
+
}
|
|
2520
|
+
catch (error) {
|
|
2521
|
+
this.logger.warning(`Failed to save trace recording: ${error.message}`);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
// endregion
|
|
2526
|
+
// region - CDP Advanced Integration
|
|
2527
|
+
/**
|
|
2528
|
+
* Scroll using CDP Input.synthesizeScrollGesture for universal compatibility
|
|
2529
|
+
* @param page - The page to scroll
|
|
2530
|
+
* @param pixels - Number of pixels to scroll (positive = up, negative = down)
|
|
2531
|
+
* @returns true if successful, false if failed
|
|
2532
|
+
*/
|
|
2533
|
+
async _scrollWithCdpGesture(page, pixels) {
|
|
2534
|
+
try {
|
|
2535
|
+
// Use CDP to synthesize scroll gesture - works in all contexts including PDFs
|
|
2536
|
+
const cdpSession = await this.browser_context.newCDPSession(page);
|
|
2537
|
+
// Get viewport center for scroll origin
|
|
2538
|
+
const viewport = await page.evaluate(() => ({
|
|
2539
|
+
width: window.innerWidth,
|
|
2540
|
+
height: window.innerHeight,
|
|
2541
|
+
}));
|
|
2542
|
+
const centerX = Math.floor(viewport.width / 2);
|
|
2543
|
+
const centerY = Math.floor(viewport.height / 2);
|
|
2544
|
+
await cdpSession.send('Input.synthesizeScrollGesture', {
|
|
2545
|
+
x: centerX,
|
|
2546
|
+
y: centerY,
|
|
2547
|
+
xDistance: 0,
|
|
2548
|
+
yDistance: -pixels, // Negative = scroll down, Positive = scroll up
|
|
2549
|
+
gestureSourceType: 'mouse', // Use mouse gestures for better compatibility
|
|
2550
|
+
speed: 3000, // Pixels per second
|
|
2551
|
+
});
|
|
2552
|
+
try {
|
|
2553
|
+
await Promise.race([
|
|
2554
|
+
cdpSession.detach(),
|
|
2555
|
+
new Promise((resolve) => setTimeout(resolve, 1000)),
|
|
2556
|
+
]);
|
|
2557
|
+
}
|
|
2558
|
+
catch {
|
|
2559
|
+
// Ignore detach errors
|
|
2560
|
+
}
|
|
2561
|
+
this.logger.debug(`📄 Scrolled via CDP Input.synthesizeScrollGesture: ${pixels}px`);
|
|
2562
|
+
return true;
|
|
2563
|
+
}
|
|
2564
|
+
catch (error) {
|
|
2565
|
+
this.logger.warning(`❌ Scrolling via CDP Input.synthesizeScrollGesture failed: ${error.message}`);
|
|
2566
|
+
return false;
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Scroll the current page container
|
|
2571
|
+
* @param pixels - Number of pixels to scroll (positive = up, negative = down)
|
|
2572
|
+
*/
|
|
2573
|
+
async _scrollContainer(pixels) {
|
|
2574
|
+
const page = await this.getCurrentPage();
|
|
2575
|
+
if (!page) {
|
|
2576
|
+
throw new Error('No active page available for scrolling');
|
|
2577
|
+
}
|
|
2578
|
+
// Try CDP scroll gesture first (works universally including PDFs)
|
|
2579
|
+
if (await this._scrollWithCdpGesture(page, pixels)) {
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
// Fallback to JavaScript for older browsers or when CDP fails
|
|
2583
|
+
this.logger.debug('Falling back to JavaScript scrolling');
|
|
2584
|
+
const SMART_SCROLL_JS = `(dy) => {
|
|
2585
|
+
const bigEnough = el => el.clientHeight >= window.innerHeight * 0.5;
|
|
2586
|
+
const canScroll = el =>
|
|
2587
|
+
el &&
|
|
2588
|
+
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
|
2589
|
+
el.scrollHeight > el.clientHeight &&
|
|
2590
|
+
bigEnough(el);
|
|
2591
|
+
|
|
2592
|
+
let el = document.activeElement;
|
|
2593
|
+
while (el && !canScroll(el) && el !== document.body) el = el.parentElement;
|
|
2594
|
+
|
|
2595
|
+
el = canScroll(el)
|
|
2596
|
+
? el
|
|
2597
|
+
: [...document.querySelectorAll('*')].find(canScroll)
|
|
2598
|
+
|| document.scrollingElement
|
|
2599
|
+
|| document.documentElement;
|
|
2600
|
+
|
|
2601
|
+
if (el === document.scrollingElement ||
|
|
2602
|
+
el === document.documentElement ||
|
|
2603
|
+
el === document.body) {
|
|
2604
|
+
window.scrollBy(0, dy);
|
|
2605
|
+
} else {
|
|
2606
|
+
el.scrollBy(0, dy);
|
|
2607
|
+
}
|
|
2608
|
+
}`;
|
|
2609
|
+
await page.evaluate(SMART_SCROLL_JS, pixels);
|
|
2610
|
+
}
|
|
2611
|
+
/**
|
|
2612
|
+
* Compute hashes for all clickable elements in the selector map
|
|
2613
|
+
* @param selectorMap - Selector map from DOM state
|
|
2614
|
+
* @returns Set of element hashes
|
|
2615
|
+
*/
|
|
2616
|
+
_computeElementHashes(selectorMap) {
|
|
2617
|
+
const hashes = new Set();
|
|
2618
|
+
for (const [index, element] of Object.entries(selectorMap)) {
|
|
2619
|
+
if (element instanceof DOMElementNode) {
|
|
2620
|
+
// Create hash from element's xpath and key attributes
|
|
2621
|
+
const hashParts = [
|
|
2622
|
+
element.xpath || '',
|
|
2623
|
+
element.tag_name || '',
|
|
2624
|
+
JSON.stringify(element.attributes || {}),
|
|
2625
|
+
];
|
|
2626
|
+
const hash = hashParts.join('|');
|
|
2627
|
+
hashes.add(hash);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
return hashes;
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Mark elements in the selector map as new if they weren't in the cached hashes
|
|
2634
|
+
* @param selectorMap - Selector map to update
|
|
2635
|
+
* @param cachedHashes - Previously cached element hashes
|
|
2636
|
+
*/
|
|
2637
|
+
_markNewElements(selectorMap, cachedHashes) {
|
|
2638
|
+
for (const [index, element] of Object.entries(selectorMap)) {
|
|
2639
|
+
if (element instanceof DOMElementNode) {
|
|
2640
|
+
// Create hash for current element
|
|
2641
|
+
const hashParts = [
|
|
2642
|
+
element.xpath || '',
|
|
2643
|
+
element.tag_name || '',
|
|
2644
|
+
JSON.stringify(element.attributes || {}),
|
|
2645
|
+
];
|
|
2646
|
+
const hash = hashParts.join('|');
|
|
2647
|
+
// Mark as new if not in cached hashes
|
|
2648
|
+
if (!cachedHashes.has(hash)) {
|
|
2649
|
+
// Add a marker to the element's attributes to indicate it's new
|
|
2650
|
+
element.attributes = element.attributes || {};
|
|
2651
|
+
element.attributes['__browser_use_new_element'] = true;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* Helper to get a safe method name from the calling context
|
|
2658
|
+
* Used for recovery error messages
|
|
2659
|
+
*/
|
|
2660
|
+
_getCurrentMethodName() {
|
|
2661
|
+
try {
|
|
2662
|
+
const stack = new Error().stack;
|
|
2663
|
+
if (!stack)
|
|
2664
|
+
return 'unknown';
|
|
2665
|
+
const lines = stack.split('\n');
|
|
2666
|
+
// Skip first 3 lines: Error, this method, and the caller
|
|
2667
|
+
const callerLine = lines[3] || '';
|
|
2668
|
+
const match = callerLine.match(/at (?:BrowserSession\.)?(\w+)/);
|
|
2669
|
+
return match ? match[1] : 'unknown';
|
|
2670
|
+
}
|
|
2671
|
+
catch {
|
|
2672
|
+
return 'unknown';
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* Get current page with fallback logic
|
|
2677
|
+
* Alias for compatibility with Python API
|
|
2678
|
+
*/
|
|
2679
|
+
async getCurrentPage() {
|
|
2680
|
+
return await this.get_current_page();
|
|
2681
|
+
}
|
|
2682
|
+
/**
|
|
2683
|
+
* Log warning about unsafe glob patterns
|
|
2684
|
+
* @param pattern - The glob pattern being used
|
|
2685
|
+
*/
|
|
2686
|
+
_logGlobWarning(pattern) {
|
|
2687
|
+
const unsafePatterns = [
|
|
2688
|
+
'**/*',
|
|
2689
|
+
'**/.*',
|
|
2690
|
+
'~/*',
|
|
2691
|
+
'/etc/*',
|
|
2692
|
+
'/sys/*',
|
|
2693
|
+
'/proc/*',
|
|
2694
|
+
];
|
|
2695
|
+
const isUnsafe = unsafePatterns.some((unsafe) => pattern.includes(unsafe) ||
|
|
2696
|
+
pattern.startsWith(unsafe.replace('**/', '')));
|
|
2697
|
+
if (isUnsafe) {
|
|
2698
|
+
this.logger.warning(`⚠️ Potentially unsafe glob pattern detected: "${pattern}". ` +
|
|
2699
|
+
`This could access system files or expose sensitive data.`);
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* Create a shallow copy of the browser session
|
|
2704
|
+
* Note: This doesn't copy the actual browser instance, just the session metadata
|
|
2705
|
+
* @returns A new BrowserSession instance with copied state
|
|
2706
|
+
*/
|
|
2707
|
+
modelCopy() {
|
|
2708
|
+
return new BrowserSession({
|
|
2709
|
+
id: this.id,
|
|
2710
|
+
browser_profile: this.browser_profile,
|
|
2711
|
+
browser: this.browser,
|
|
2712
|
+
browser_context: this.browser_context,
|
|
2713
|
+
page: this.agent_current_page,
|
|
2714
|
+
title: this.currentTitle,
|
|
2715
|
+
url: this.currentUrl,
|
|
2716
|
+
wss_url: this.wss_url,
|
|
2717
|
+
cdp_url: this.cdp_url,
|
|
2718
|
+
browser_pid: this.browser_pid,
|
|
2719
|
+
playwright: this.playwright,
|
|
2720
|
+
downloaded_files: [...this.downloaded_files],
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
model_copy() {
|
|
2724
|
+
return this.modelCopy();
|
|
2725
|
+
}
|
|
2726
|
+
// endregion
|
|
2727
|
+
// region - Page Health Check and Recovery
|
|
2728
|
+
_inRecovery = false;
|
|
2729
|
+
/**
|
|
2730
|
+
* Check if a page is responsive by trying to evaluate simple JavaScript
|
|
2731
|
+
* @param page - The page to check
|
|
2732
|
+
* @param timeout - Timeout in seconds (default: 5.0)
|
|
2733
|
+
* @returns true if page is responsive, false otherwise
|
|
2734
|
+
*/
|
|
2735
|
+
async _isPageResponsive(page, timeout = 5.0) {
|
|
2736
|
+
try {
|
|
2737
|
+
const timeoutMs = timeout * 1000;
|
|
2738
|
+
await Promise.race([
|
|
2739
|
+
page.evaluate('1'),
|
|
2740
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs)),
|
|
2741
|
+
]);
|
|
2742
|
+
return true;
|
|
2743
|
+
}
|
|
2744
|
+
catch (error) {
|
|
2745
|
+
return false;
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* Force close a crashed page using CDP from a clean temporary page
|
|
2750
|
+
* @param pageUrl - The URL of the page to force close
|
|
2751
|
+
* @returns true if successful, false otherwise
|
|
2752
|
+
*/
|
|
2753
|
+
async _forceClosePageViaCdp(pageUrl) {
|
|
2754
|
+
try {
|
|
2755
|
+
if (!this.browser_context) {
|
|
2756
|
+
throw new Error('Browser context is not set up yet');
|
|
2757
|
+
}
|
|
2758
|
+
// Create a clean page for CDP operations
|
|
2759
|
+
const tempPage = await Promise.race([
|
|
2760
|
+
this.browser_context.newPage(),
|
|
2761
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout creating temp page')), 5000)),
|
|
2762
|
+
]);
|
|
2763
|
+
await Promise.race([
|
|
2764
|
+
tempPage.goto('about:blank'),
|
|
2765
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout navigating to blank')), 2000)),
|
|
2766
|
+
]);
|
|
2767
|
+
try {
|
|
2768
|
+
// Create CDP session from the clean page
|
|
2769
|
+
const cdpSession = await Promise.race([
|
|
2770
|
+
this.browser_context.newCDPSession(tempPage),
|
|
2771
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout creating CDP session')), 5000)),
|
|
2772
|
+
]);
|
|
2773
|
+
try {
|
|
2774
|
+
// Get all browser targets
|
|
2775
|
+
const targets = (await Promise.race([
|
|
2776
|
+
cdpSession.send('Target.getTargets'),
|
|
2777
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout getting targets')), 2000)),
|
|
2778
|
+
]));
|
|
2779
|
+
// Find the crashed page target
|
|
2780
|
+
let blockedTargetId = null;
|
|
2781
|
+
const targetInfos = targets.targetInfos || [];
|
|
2782
|
+
for (const target of targetInfos) {
|
|
2783
|
+
if (target.type === 'page' && target.url === pageUrl) {
|
|
2784
|
+
blockedTargetId = target.targetId;
|
|
2785
|
+
break;
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
if (blockedTargetId) {
|
|
2789
|
+
// Force close the target
|
|
2790
|
+
this.logger.warning(`🪓 Force-closing crashed page target_id=${blockedTargetId} via CDP: ${pageUrl.substring(0, 50)}...`);
|
|
2791
|
+
await Promise.race([
|
|
2792
|
+
cdpSession.send('Target.closeTarget', {
|
|
2793
|
+
targetId: blockedTargetId,
|
|
2794
|
+
}),
|
|
2795
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout closing target')), 2000)),
|
|
2796
|
+
]);
|
|
2797
|
+
return true;
|
|
2798
|
+
}
|
|
2799
|
+
else {
|
|
2800
|
+
this.logger.debug(`❌ Could not find CDP page target_id to force-close: ${pageUrl.substring(0, 50)} (concurrency issues?)`);
|
|
2801
|
+
return false;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
finally {
|
|
2805
|
+
try {
|
|
2806
|
+
await Promise.race([
|
|
2807
|
+
cdpSession.detach(),
|
|
2808
|
+
new Promise((resolve) => setTimeout(resolve, 1000)),
|
|
2809
|
+
]);
|
|
2810
|
+
}
|
|
2811
|
+
catch {
|
|
2812
|
+
// Ignore detach errors
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
finally {
|
|
2817
|
+
await tempPage.close();
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
catch (error) {
|
|
2821
|
+
this.logger.error(`❌ Using raw CDP to force-close crashed page failed: ${error.message}`);
|
|
2822
|
+
return false;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
/**
|
|
2826
|
+
* Try to reopen a URL in a new page and check if it's responsive
|
|
2827
|
+
* @param url - The URL to reopen
|
|
2828
|
+
* @param timeoutMs - Navigation timeout in milliseconds
|
|
2829
|
+
* @returns true if successful and responsive, false otherwise
|
|
2830
|
+
*/
|
|
2831
|
+
async _tryReopenUrl(url, timeoutMs) {
|
|
2832
|
+
if (!url ||
|
|
2833
|
+
url.startsWith('about:') ||
|
|
2834
|
+
url.startsWith('chrome://') ||
|
|
2835
|
+
url.startsWith('edge://')) {
|
|
2836
|
+
return false;
|
|
2837
|
+
}
|
|
2838
|
+
const timeout = timeoutMs || this.browser_profile.default_navigation_timeout || 6000;
|
|
2839
|
+
try {
|
|
2840
|
+
this.logger.debug(`🔄 Attempting to reload URL that crashed: ${url.substring(0, 50)}`);
|
|
2841
|
+
if (!this.browser_context) {
|
|
2842
|
+
throw new Error('Browser context is not set');
|
|
2843
|
+
}
|
|
2844
|
+
// Create new page directly to avoid circular dependency
|
|
2845
|
+
const newPage = await this.browser_context.newPage();
|
|
2846
|
+
this.agent_current_page = newPage;
|
|
2847
|
+
// Update human tab reference if there is no human tab yet
|
|
2848
|
+
if (!this.human_current_page || this.human_current_page.isClosed()) {
|
|
2849
|
+
this.human_current_page = newPage;
|
|
2850
|
+
}
|
|
2851
|
+
// Set viewport for new tab
|
|
2852
|
+
if (this.browser_profile.window_size) {
|
|
2853
|
+
await newPage.setViewportSize(this.browser_profile.window_size);
|
|
2854
|
+
}
|
|
2855
|
+
// Navigate with timeout
|
|
2856
|
+
try {
|
|
2857
|
+
await Promise.race([
|
|
2858
|
+
newPage.goto(url, { waitUntil: 'load', timeout }),
|
|
2859
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Navigation timeout')), timeout + 500)),
|
|
2860
|
+
]);
|
|
2861
|
+
}
|
|
2862
|
+
catch (error) {
|
|
2863
|
+
this.logger.debug(`⚠️ Attempting to reload previously crashed URL ${url.substring(0, 50)} failed again: ${error.name}`);
|
|
2864
|
+
}
|
|
2865
|
+
// Wait a bit for any transient blocking to resolve
|
|
2866
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
2867
|
+
// Check if the reopened page is responsive
|
|
2868
|
+
const isResponsive = await this._isPageResponsive(newPage, 2.0);
|
|
2869
|
+
if (isResponsive) {
|
|
2870
|
+
this.logger.info(`✅ Page recovered and is now responsive after reopening on: ${url.substring(0, 50)}`);
|
|
2871
|
+
return true;
|
|
2872
|
+
}
|
|
2873
|
+
else {
|
|
2874
|
+
this.logger.warning(`⚠️ Reopened page ${url.substring(0, 50)} is still unresponsive`);
|
|
2875
|
+
// Close the unresponsive page before returning
|
|
2876
|
+
try {
|
|
2877
|
+
await this._forceClosePageViaCdp(newPage.url());
|
|
2878
|
+
}
|
|
2879
|
+
catch (error) {
|
|
2880
|
+
this.logger.error(`❌ Failed to close crashed page ${url.substring(0, 50)} via CDP: ${error.message} (something is very wrong or system is extremely overloaded)`);
|
|
2881
|
+
}
|
|
2882
|
+
this.agent_current_page = null; // Clear reference to closed page
|
|
2883
|
+
return false;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
catch (error) {
|
|
2887
|
+
this.logger.error(`❌ Retrying crashed page ${url.substring(0, 50)} failed: ${error.message}`);
|
|
2888
|
+
return false;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
/**
|
|
2892
|
+
* Create a new blank page as a fallback when recovery fails
|
|
2893
|
+
* @param url - The original URL that failed
|
|
2894
|
+
*/
|
|
2895
|
+
async _createBlankFallbackPage(url) {
|
|
2896
|
+
this.logger.warning(`⚠️ Resetting to about:blank as fallback because browser is unable to load the original URL without crashing: ${url.substring(0, 50)}`);
|
|
2897
|
+
// Close any existing broken page
|
|
2898
|
+
if (this.agent_current_page && !this.agent_current_page.isClosed()) {
|
|
2899
|
+
try {
|
|
2900
|
+
await this.agent_current_page.close();
|
|
2901
|
+
}
|
|
2902
|
+
catch {
|
|
2903
|
+
// Ignore close errors
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
if (!this.browser_context) {
|
|
2907
|
+
throw new Error('Browser context is not set');
|
|
2908
|
+
}
|
|
2909
|
+
// Create fresh page directly (avoid decorated methods to prevent circular dependency)
|
|
2910
|
+
const newPage = await this.browser_context.newPage();
|
|
2911
|
+
this.agent_current_page = newPage;
|
|
2912
|
+
// Update human tab reference if there is no human tab yet
|
|
2913
|
+
if (!this.human_current_page || this.human_current_page.isClosed()) {
|
|
2914
|
+
this.human_current_page = newPage;
|
|
2915
|
+
}
|
|
2916
|
+
// Set viewport for new tab
|
|
2917
|
+
if (this.browser_profile.window_size) {
|
|
2918
|
+
await newPage.setViewportSize(this.browser_profile.window_size);
|
|
2919
|
+
}
|
|
2920
|
+
// Navigate to blank
|
|
2921
|
+
try {
|
|
2922
|
+
await newPage.goto('about:blank', { waitUntil: 'load', timeout: 5000 });
|
|
2923
|
+
}
|
|
2924
|
+
catch (error) {
|
|
2925
|
+
this.logger.error(`❌ Failed to navigate to about:blank: ${error.message} (something is very wrong or system is extremely overloaded)`);
|
|
2926
|
+
throw error;
|
|
2927
|
+
}
|
|
2928
|
+
// Verify it's responsive
|
|
2929
|
+
if (!(await this._isPageResponsive(newPage, 1.0))) {
|
|
2930
|
+
throw new BrowserError('Browser is unable to load any new about:blank pages (something is very wrong or browser is extremely overloaded)');
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
/**
|
|
2934
|
+
* Recover from an unresponsive page by closing and reopening it
|
|
2935
|
+
* @param callingMethod - The name of the method that detected the unresponsive page
|
|
2936
|
+
* @param timeoutMs - Navigation timeout in milliseconds
|
|
2937
|
+
*/
|
|
2938
|
+
async _recoverUnresponsivePage(callingMethod, timeoutMs) {
|
|
2939
|
+
this.logger.warning(`⚠️ Page JS engine became unresponsive in ${callingMethod}(), attempting recovery...`);
|
|
2940
|
+
const timeout = Math.min(3000, timeoutMs || this.browser_profile.default_navigation_timeout || 5000);
|
|
2941
|
+
// Check if browser connection is still alive
|
|
2942
|
+
if (this.browser && !this.browser.isConnected()) {
|
|
2943
|
+
this.logger.error('❌ Browser connection lost - browser process may have crashed');
|
|
2944
|
+
throw new Error('Browser connection lost - cannot recover unresponsive page');
|
|
2945
|
+
}
|
|
2946
|
+
// Prevent re-entrance
|
|
2947
|
+
if (this._inRecovery) {
|
|
2948
|
+
this.logger.debug('Already in recovery, skipping nested recovery attempt');
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
this._inRecovery = true;
|
|
2952
|
+
try {
|
|
2953
|
+
// Get current URL before recovery
|
|
2954
|
+
if (!this.agent_current_page) {
|
|
2955
|
+
throw new Error('Agent current page is not set');
|
|
2956
|
+
}
|
|
2957
|
+
const currentUrl = this.agent_current_page.url();
|
|
2958
|
+
// Clear page references
|
|
2959
|
+
const blockedPage = this.agent_current_page;
|
|
2960
|
+
this.agent_current_page = null;
|
|
2961
|
+
if (blockedPage === this.human_current_page) {
|
|
2962
|
+
this.human_current_page = null;
|
|
2963
|
+
}
|
|
2964
|
+
// Force-close the crashed page via CDP
|
|
2965
|
+
this.logger.debug('🪓 Page Recovery Step 1/3: Force-closing crashed page via CDP...');
|
|
2966
|
+
await this._forceClosePageViaCdp(currentUrl);
|
|
2967
|
+
// Remove the closed page from browser_context.pages by forcing a refresh
|
|
2968
|
+
if (this.browser_context && this.browser_context.pages()) {
|
|
2969
|
+
for (const page of this.browser_context.pages().slice()) {
|
|
2970
|
+
const pageUrl = page.url();
|
|
2971
|
+
if (pageUrl === currentUrl &&
|
|
2972
|
+
!page.isClosed() &&
|
|
2973
|
+
!pageUrl.startsWith('about:') &&
|
|
2974
|
+
!pageUrl.startsWith('chrome://') &&
|
|
2975
|
+
!pageUrl.startsWith('edge://')) {
|
|
2976
|
+
try {
|
|
2977
|
+
await page.close();
|
|
2978
|
+
this.logger.debug(`🪓 Closed page because it has a known crash-causing URL: ${pageUrl.substring(0, 50)}`);
|
|
2979
|
+
}
|
|
2980
|
+
catch {
|
|
2981
|
+
// Page might already be closed via CDP
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
// Try to reopen the URL (in case blocking was transient)
|
|
2987
|
+
this.logger.debug('🍼 Page Recovery Step 2/3: Trying to reopen the URL again...');
|
|
2988
|
+
if (await this._tryReopenUrl(currentUrl, timeout)) {
|
|
2989
|
+
this.logger.debug('✅ Page Recovery Step 3/3: Page loading succeeded after 2nd attempt!');
|
|
2990
|
+
return; // Success!
|
|
2991
|
+
}
|
|
2992
|
+
// If that failed, fall back to blank page
|
|
2993
|
+
this.logger.debug('❌ Page Recovery Step 3/3: Loading the page a 2nd time failed as well, browser seems unable to load this URL without getting stuck, retreating to a safe page...');
|
|
2994
|
+
await this._createBlankFallbackPage(currentUrl);
|
|
2995
|
+
}
|
|
2996
|
+
finally {
|
|
2997
|
+
// Always clear recovery flag
|
|
2998
|
+
this._inRecovery = false;
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
// endregion
|
|
3002
|
+
// region - Enhanced CSS Selector Generation
|
|
3003
|
+
/**
|
|
3004
|
+
* Generate enhanced CSS selector for an element
|
|
3005
|
+
* Handles special characters and provides fallback strategies
|
|
3006
|
+
* @param xpath - XPath of the element
|
|
3007
|
+
* @param element - Optional element node for additional context
|
|
3008
|
+
* @returns Enhanced CSS selector string
|
|
3009
|
+
*/
|
|
3010
|
+
_enhancedCssSelectorForElement(xpath, element) {
|
|
3011
|
+
// Try to convert XPath to CSS selector
|
|
3012
|
+
const cssSelector = this._xpathToCss(xpath);
|
|
3013
|
+
if (cssSelector) {
|
|
3014
|
+
return cssSelector;
|
|
3015
|
+
}
|
|
3016
|
+
// Fallback: use element attributes if available
|
|
3017
|
+
if (element) {
|
|
3018
|
+
const selectors = [];
|
|
3019
|
+
// Try ID first (most specific)
|
|
3020
|
+
if (element.attributes?.id) {
|
|
3021
|
+
const id = this._escapeSelector(element.attributes.id);
|
|
3022
|
+
selectors.push(`#${id}`);
|
|
3023
|
+
}
|
|
3024
|
+
// Try class names
|
|
3025
|
+
if (element.attributes?.class) {
|
|
3026
|
+
const classes = element.attributes.class
|
|
3027
|
+
.split(/\s+/)
|
|
3028
|
+
.filter((c) => c.length > 0)
|
|
3029
|
+
.map((c) => `.${this._escapeSelector(c)}`)
|
|
3030
|
+
.join('');
|
|
3031
|
+
if (classes) {
|
|
3032
|
+
selectors.push(`${element.tag_name}${classes}`);
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
// Try name attribute
|
|
3036
|
+
if (element.attributes?.name) {
|
|
3037
|
+
const name = this._escapeSelector(element.attributes.name);
|
|
3038
|
+
selectors.push(`${element.tag_name}[name="${name}"]`);
|
|
3039
|
+
}
|
|
3040
|
+
// Try data attributes
|
|
3041
|
+
for (const [key, value] of Object.entries(element.attributes || {})) {
|
|
3042
|
+
if (key.startsWith('data-')) {
|
|
3043
|
+
const escaped = this._escapeSelector(String(value));
|
|
3044
|
+
selectors.push(`${element.tag_name}[${key}="${escaped}"]`);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
if (selectors.length > 0) {
|
|
3048
|
+
return selectors[0];
|
|
3049
|
+
}
|
|
3050
|
+
// Last resort: just the tag name
|
|
3051
|
+
return element.tag_name || 'div';
|
|
3052
|
+
}
|
|
3053
|
+
// Ultimate fallback
|
|
3054
|
+
return 'body';
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* Convert XPath to CSS selector
|
|
3058
|
+
* Handles simple XPath expressions
|
|
3059
|
+
*/
|
|
3060
|
+
_xpathToCss(xpath) {
|
|
3061
|
+
try {
|
|
3062
|
+
// Remove leading slashes
|
|
3063
|
+
let path = xpath.replace(/^\/+/, '');
|
|
3064
|
+
// Handle simple cases like /html/body/div[1]/span[2]
|
|
3065
|
+
const parts = path.split('/');
|
|
3066
|
+
const cssparts = [];
|
|
3067
|
+
for (const part of parts) {
|
|
3068
|
+
// Extract tag and index: div[1] -> {tag: 'div', index: 1}
|
|
3069
|
+
const match = part.match(/^([a-zA-Z0-9_-]+)(?:\[(\d+)\])?$/);
|
|
3070
|
+
if (match) {
|
|
3071
|
+
const [, tag, index] = match;
|
|
3072
|
+
if (index) {
|
|
3073
|
+
// CSS uses nth-of-type (1-indexed like XPath)
|
|
3074
|
+
cssparts.push(`${tag}:nth-of-type(${index})`);
|
|
3075
|
+
}
|
|
3076
|
+
else {
|
|
3077
|
+
cssparts.push(tag);
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
else {
|
|
3081
|
+
// Complex XPath, can't convert
|
|
3082
|
+
return null;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
return cssparts.join(' > ');
|
|
3086
|
+
}
|
|
3087
|
+
catch {
|
|
3088
|
+
return null;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
/**
|
|
3092
|
+
* Escape special characters in CSS selectors
|
|
3093
|
+
* Handles characters that need escaping in CSS
|
|
3094
|
+
*/
|
|
3095
|
+
_escapeSelector(selector) {
|
|
3096
|
+
// Escape special CSS characters
|
|
3097
|
+
return selector.replace(/[!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~]/g, '\\$&');
|
|
3098
|
+
}
|
|
3099
|
+
// endregion
|
|
3100
|
+
// region - User Data Directory Management
|
|
3101
|
+
/**
|
|
3102
|
+
* Prepare user data directory for browser profile
|
|
3103
|
+
* Handles singleton lock conflicts and creates temp profiles if needed
|
|
3104
|
+
*/
|
|
3105
|
+
async prepareUserDataDir(userDataDir) {
|
|
3106
|
+
if (!userDataDir) {
|
|
3107
|
+
// Use profile's user data dir or create temp one
|
|
3108
|
+
userDataDir =
|
|
3109
|
+
this.browser_profile.user_data_dir ||
|
|
3110
|
+
(await this._createTempUserDataDir());
|
|
3111
|
+
}
|
|
3112
|
+
// Check for singleton lock conflicts
|
|
3113
|
+
const hasConflict = await this._checkForSingletonLockConflict(userDataDir);
|
|
3114
|
+
if (hasConflict) {
|
|
3115
|
+
this.logger.warning(`Singleton lock detected in ${userDataDir}, falling back to temp profile`);
|
|
3116
|
+
userDataDir = await this._fallbackToTempProfile();
|
|
3117
|
+
}
|
|
3118
|
+
// Ensure directory exists
|
|
3119
|
+
if (!fs.existsSync(userDataDir)) {
|
|
3120
|
+
fs.mkdirSync(userDataDir, { recursive: true });
|
|
3121
|
+
this.logger.debug(`Created user data directory: ${userDataDir}`);
|
|
3122
|
+
}
|
|
3123
|
+
return userDataDir;
|
|
3124
|
+
}
|
|
3125
|
+
/**
|
|
3126
|
+
* Check if user data directory has a singleton lock
|
|
3127
|
+
* This happens when another Chrome instance is using the profile
|
|
3128
|
+
*/
|
|
3129
|
+
async _checkForSingletonLockConflict(userDataDir) {
|
|
3130
|
+
try {
|
|
3131
|
+
const singletonLockFile = path.join(userDataDir, 'SingletonLock');
|
|
3132
|
+
const singletonSocketFile = path.join(userDataDir, 'SingletonSocket');
|
|
3133
|
+
const singletonCookieFile = path.join(userDataDir, 'SingletonCookie');
|
|
3134
|
+
// Check if any singleton lock files exist
|
|
3135
|
+
if (fs.existsSync(singletonLockFile) ||
|
|
3136
|
+
fs.existsSync(singletonSocketFile) ||
|
|
3137
|
+
fs.existsSync(singletonCookieFile)) {
|
|
3138
|
+
// Try to detect if process is still alive (Unix-like systems)
|
|
3139
|
+
if (process.platform !== 'win32' && fs.existsSync(singletonLockFile)) {
|
|
3140
|
+
try {
|
|
3141
|
+
// Try to read the lock file to get PID
|
|
3142
|
+
const lockContent = fs.readFileSync(singletonLockFile, 'utf-8');
|
|
3143
|
+
const pidMatch = lockContent.match(/(\d+)/);
|
|
3144
|
+
if (pidMatch) {
|
|
3145
|
+
const pid = parseInt(pidMatch[1], 10);
|
|
3146
|
+
try {
|
|
3147
|
+
// Check if process exists (signal 0 doesn't kill, just checks)
|
|
3148
|
+
process.kill(pid, 0);
|
|
3149
|
+
return true; // Process exists, lock is valid
|
|
3150
|
+
}
|
|
3151
|
+
catch {
|
|
3152
|
+
// Process doesn't exist, stale lock
|
|
3153
|
+
this.logger.debug(`Stale singleton lock detected, removing`);
|
|
3154
|
+
fs.unlinkSync(singletonLockFile);
|
|
3155
|
+
return false;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
catch {
|
|
3160
|
+
// Couldn't read lock file
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
return true;
|
|
3164
|
+
}
|
|
3165
|
+
return false;
|
|
3166
|
+
}
|
|
3167
|
+
catch (error) {
|
|
3168
|
+
this.logger.debug(`Error checking singleton lock: ${error.message}`);
|
|
3169
|
+
return false;
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
/**
|
|
3173
|
+
* Fallback to a temporary profile when the primary one is locked
|
|
3174
|
+
*/
|
|
3175
|
+
async _fallbackToTempProfile() {
|
|
3176
|
+
const tempDir = await this._createTempUserDataDir();
|
|
3177
|
+
this.logger.info(`Using temporary profile: ${tempDir}`);
|
|
3178
|
+
return tempDir;
|
|
3179
|
+
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Create a temporary user data directory
|
|
3182
|
+
*/
|
|
3183
|
+
async _createTempUserDataDir() {
|
|
3184
|
+
const osTempDir = require('os').tmpdir();
|
|
3185
|
+
const tempDir = path.join(osTempDir, `browser-use-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
3186
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
3187
|
+
return tempDir;
|
|
3188
|
+
}
|
|
3189
|
+
// endregion
|
|
3190
|
+
// region - Page Visibility Listeners
|
|
3191
|
+
/**
|
|
3192
|
+
* Setup listeners for page visibility changes
|
|
3193
|
+
* Tracks when user switches tabs to update human_current_page
|
|
3194
|
+
*/
|
|
3195
|
+
async _setupCurrentPageChangeListeners() {
|
|
3196
|
+
if (!this.browser_context) {
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
// Listen for page events to track which page the user is viewing
|
|
3200
|
+
this.browser_context.on?.('page', (page) => {
|
|
3201
|
+
this.logger.debug(`New page created: ${page.url?.() || 'about:blank'}`);
|
|
3202
|
+
// Note: 'visibilitychange' is not a standard Playwright page event
|
|
3203
|
+
// Visibility tracking would need to be implemented differently
|
|
3204
|
+
// (e.g., through page.evaluate polling or browser context events)
|
|
3205
|
+
// Track new page
|
|
3206
|
+
if (page.url && !page.url().startsWith('about:')) {
|
|
3207
|
+
this.human_current_page = page;
|
|
3208
|
+
}
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Callback when tab visibility changes
|
|
3213
|
+
* Updates human_current_page to reflect which tab the user is viewing
|
|
3214
|
+
*/
|
|
3215
|
+
_onTabVisibilityChange(page) {
|
|
3216
|
+
try {
|
|
3217
|
+
// Check if page is visible
|
|
3218
|
+
page
|
|
3219
|
+
.evaluate?.(() => document.visibilityState === 'visible')
|
|
3220
|
+
.then((isVisible) => {
|
|
3221
|
+
if (isVisible) {
|
|
3222
|
+
this.logger.debug(`Tab became visible: ${page.url?.() || 'unknown'}`);
|
|
3223
|
+
this.human_current_page = page;
|
|
3224
|
+
}
|
|
3225
|
+
})
|
|
3226
|
+
.catch(() => {
|
|
3227
|
+
// Ignore errors from closed pages
|
|
3228
|
+
});
|
|
3229
|
+
}
|
|
3230
|
+
catch {
|
|
3231
|
+
// Ignore errors
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
// endregion
|
|
3235
|
+
// region - Process Management
|
|
3236
|
+
/**
|
|
3237
|
+
* Kill all child processes spawned by this browser session
|
|
3238
|
+
*/
|
|
3239
|
+
async _killChildProcesses() {
|
|
3240
|
+
if (this._childProcesses.size === 0) {
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
this.logger.debug(`Killing ${this._childProcesses.size} child processes`);
|
|
3244
|
+
for (const pid of this._childProcesses) {
|
|
3245
|
+
try {
|
|
3246
|
+
// Try to kill the process
|
|
3247
|
+
process.kill(pid, 'SIGTERM');
|
|
3248
|
+
this.logger.debug(`Sent SIGTERM to process ${pid}`);
|
|
3249
|
+
// Wait briefly and check if still alive
|
|
3250
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
3251
|
+
try {
|
|
3252
|
+
// Check if process still exists
|
|
3253
|
+
process.kill(pid, 0);
|
|
3254
|
+
// If we get here, process is still alive, force kill
|
|
3255
|
+
process.kill(pid, 'SIGKILL');
|
|
3256
|
+
this.logger.debug(`Sent SIGKILL to process ${pid}`);
|
|
3257
|
+
}
|
|
3258
|
+
catch {
|
|
3259
|
+
// Process is dead, ignore
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
catch (error) {
|
|
3263
|
+
// Process doesn't exist or we don't have permission
|
|
3264
|
+
this.logger.debug(`Could not kill process ${pid}: ${error.message}`);
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
this._childProcesses.clear();
|
|
3268
|
+
}
|
|
3269
|
+
/**
|
|
3270
|
+
* Terminate the browser process and all its children
|
|
3271
|
+
*/
|
|
3272
|
+
async _terminateBrowserProcess() {
|
|
3273
|
+
if (!this.browser_pid) {
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
try {
|
|
3277
|
+
this.logger.debug(`Terminating browser process ${this.browser_pid}`);
|
|
3278
|
+
// Platform-specific process tree termination
|
|
3279
|
+
if (process.platform === 'win32') {
|
|
3280
|
+
// Windows: use taskkill to kill process tree
|
|
3281
|
+
await execAsync(`taskkill /PID ${this.browser_pid} /T /F`).catch(() => {
|
|
3282
|
+
// Ignore errors if process already dead
|
|
3283
|
+
});
|
|
3284
|
+
}
|
|
3285
|
+
else {
|
|
3286
|
+
// Unix-like: kill process group
|
|
3287
|
+
try {
|
|
3288
|
+
// Try to kill the process group
|
|
3289
|
+
process.kill(-this.browser_pid, 'SIGTERM');
|
|
3290
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
3291
|
+
// Check if still alive and force kill if needed
|
|
3292
|
+
try {
|
|
3293
|
+
process.kill(-this.browser_pid, 0);
|
|
3294
|
+
process.kill(-this.browser_pid, 'SIGKILL');
|
|
3295
|
+
}
|
|
3296
|
+
catch {
|
|
3297
|
+
// Process is dead
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
catch {
|
|
3301
|
+
// Fallback to killing just the process
|
|
3302
|
+
try {
|
|
3303
|
+
process.kill(this.browser_pid, 'SIGTERM');
|
|
3304
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
3305
|
+
process.kill(this.browser_pid, 'SIGKILL');
|
|
3306
|
+
}
|
|
3307
|
+
catch {
|
|
3308
|
+
// Process doesn't exist
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
catch (error) {
|
|
3314
|
+
this.logger.debug(`Error terminating browser process: ${error.message}`);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Get child processes of a given PID
|
|
3319
|
+
* Cross-platform implementation using ps on Unix-like systems and WMIC on Windows
|
|
3320
|
+
*/
|
|
3321
|
+
async _getChildProcesses(pid) {
|
|
3322
|
+
try {
|
|
3323
|
+
if (process.platform === 'win32') {
|
|
3324
|
+
// Windows: use WMIC
|
|
3325
|
+
const { stdout } = await execAsync(`wmic process where (ParentProcessId=${pid}) get ProcessId`);
|
|
3326
|
+
const pids = stdout
|
|
3327
|
+
.split('\n')
|
|
3328
|
+
.slice(1) // Skip header
|
|
3329
|
+
.map((line) => parseInt(line.trim(), 10))
|
|
3330
|
+
.filter((p) => !isNaN(p));
|
|
3331
|
+
return pids;
|
|
3332
|
+
}
|
|
3333
|
+
else {
|
|
3334
|
+
// Unix-like: use ps
|
|
3335
|
+
const { stdout } = await execAsync(`ps -o pid= --ppid ${pid}`);
|
|
3336
|
+
const pids = stdout
|
|
3337
|
+
.split('\n')
|
|
3338
|
+
.map((line) => parseInt(line.trim(), 10))
|
|
3339
|
+
.filter((p) => !isNaN(p));
|
|
3340
|
+
return pids;
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
catch {
|
|
3344
|
+
return [];
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Track a child process
|
|
3349
|
+
*/
|
|
3350
|
+
_trackChildProcess(pid) {
|
|
3351
|
+
this._childProcesses.add(pid);
|
|
3352
|
+
}
|
|
3353
|
+
/**
|
|
3354
|
+
* Untrack a child process
|
|
3355
|
+
*/
|
|
3356
|
+
_untrackChildProcess(pid) {
|
|
3357
|
+
this._childProcesses.delete(pid);
|
|
3358
|
+
}
|
|
3359
|
+
// region: Loading Animations
|
|
3360
|
+
/**
|
|
3361
|
+
* Show DVD screensaver loading animation
|
|
3362
|
+
* Returns a function to stop the animation
|
|
3363
|
+
*
|
|
3364
|
+
* @param message - Message to display (default: 'Loading...')
|
|
3365
|
+
* @param fps - Frames per second (default: 10)
|
|
3366
|
+
* @returns Function to stop the animation
|
|
3367
|
+
*
|
|
3368
|
+
* @example
|
|
3369
|
+
* const stopAnimation = this._showDvdScreensaverLoadingAnimation('Loading page...');
|
|
3370
|
+
* await someLongOperation();
|
|
3371
|
+
* stopAnimation();
|
|
3372
|
+
*/
|
|
3373
|
+
_showDvdScreensaverLoadingAnimation(message = 'Loading...', fps = 10) {
|
|
3374
|
+
return showDVDScreensaver(message, fps);
|
|
3375
|
+
}
|
|
3376
|
+
/**
|
|
3377
|
+
* Show simple spinner loading animation
|
|
3378
|
+
* Returns a function to stop the animation
|
|
3379
|
+
*
|
|
3380
|
+
* @param message - Message to display (default: 'Loading...')
|
|
3381
|
+
* @param fps - Frames per second (default: 10)
|
|
3382
|
+
* @returns Function to stop the animation
|
|
3383
|
+
*
|
|
3384
|
+
* @example
|
|
3385
|
+
* const stopSpinner = this._showSpinnerLoadingAnimation('Processing...');
|
|
3386
|
+
* await someLongOperation();
|
|
3387
|
+
* stopSpinner();
|
|
3388
|
+
*/
|
|
3389
|
+
_showSpinnerLoadingAnimation(message = 'Loading...', fps = 10) {
|
|
3390
|
+
return showSpinner(message, fps);
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* Execute an async operation with DVD screensaver animation
|
|
3394
|
+
*
|
|
3395
|
+
* @param operation - Async operation to execute
|
|
3396
|
+
* @param message - Message to display during operation
|
|
3397
|
+
* @returns Result of the operation
|
|
3398
|
+
*
|
|
3399
|
+
* @example
|
|
3400
|
+
* const page = await this._withDvdScreensaver(
|
|
3401
|
+
* async () => await this.browser_context!.newPage(),
|
|
3402
|
+
* 'Opening new page...'
|
|
3403
|
+
* );
|
|
3404
|
+
*/
|
|
3405
|
+
async _withDvdScreensaver(operation, message = 'Loading...') {
|
|
3406
|
+
return withDVDScreensaver(operation, message);
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
export { DEFAULT_BROWSER_PROFILE };
|