@olib-ai/owl-browser-sdk 2.0.5 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -0
- package/dist/extraction/content-cleaner.d.ts +40 -0
- package/dist/extraction/content-cleaner.d.ts.map +1 -0
- package/dist/extraction/content-cleaner.js +393 -0
- package/dist/extraction/content-cleaner.js.map +1 -0
- package/dist/extraction/extractor.d.ts +139 -0
- package/dist/extraction/extractor.d.ts.map +1 -0
- package/dist/extraction/extractor.js +212 -0
- package/dist/extraction/extractor.js.map +1 -0
- package/dist/extraction/html-processor.d.ts +75 -0
- package/dist/extraction/html-processor.d.ts.map +1 -0
- package/dist/extraction/html-processor.js +192 -0
- package/dist/extraction/html-processor.js.map +1 -0
- package/dist/extraction/index.d.ts +14 -0
- package/dist/extraction/index.d.ts.map +1 -0
- package/dist/extraction/index.js +19 -0
- package/dist/extraction/index.js.map +1 -0
- package/dist/extraction/list-extractor.d.ts +24 -0
- package/dist/extraction/list-extractor.d.ts.map +1 -0
- package/dist/extraction/list-extractor.js +303 -0
- package/dist/extraction/list-extractor.js.map +1 -0
- package/dist/extraction/meta-extractor.d.ts +40 -0
- package/dist/extraction/meta-extractor.d.ts.map +1 -0
- package/dist/extraction/meta-extractor.js +216 -0
- package/dist/extraction/meta-extractor.js.map +1 -0
- package/dist/extraction/pagination.d.ts +29 -0
- package/dist/extraction/pagination.d.ts.map +1 -0
- package/dist/extraction/pagination.js +323 -0
- package/dist/extraction/pagination.js.map +1 -0
- package/dist/extraction/pattern-detector.d.ts +16 -0
- package/dist/extraction/pattern-detector.d.ts.map +1 -0
- package/dist/extraction/pattern-detector.js +390 -0
- package/dist/extraction/pattern-detector.js.map +1 -0
- package/dist/extraction/scrape-session.d.ts +23 -0
- package/dist/extraction/scrape-session.d.ts.map +1 -0
- package/dist/extraction/scrape-session.js +192 -0
- package/dist/extraction/scrape-session.js.map +1 -0
- package/dist/extraction/selector-engine.d.ts +23 -0
- package/dist/extraction/selector-engine.d.ts.map +1 -0
- package/dist/extraction/selector-engine.js +127 -0
- package/dist/extraction/selector-engine.js.map +1 -0
- package/dist/extraction/table-extractor.d.ts +29 -0
- package/dist/extraction/table-extractor.d.ts.map +1 -0
- package/dist/extraction/table-extractor.js +282 -0
- package/dist/extraction/table-extractor.js.map +1 -0
- package/dist/extraction/transforms.d.ts +47 -0
- package/dist/extraction/transforms.d.ts.map +1 -0
- package/dist/extraction/transforms.js +277 -0
- package/dist/extraction/transforms.js.map +1 -0
- package/dist/extraction/types.d.ts +199 -0
- package/dist/extraction/types.d.ts.map +1 -0
- package/dist/extraction/types.js +5 -0
- package/dist/extraction/types.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/playwright/browser-type.d.ts +101 -0
- package/dist/playwright/browser-type.d.ts.map +1 -0
- package/dist/playwright/browser-type.js +134 -0
- package/dist/playwright/browser-type.js.map +1 -0
- package/dist/playwright/browser.d.ts +98 -0
- package/dist/playwright/browser.d.ts.map +1 -0
- package/dist/playwright/browser.js +229 -0
- package/dist/playwright/browser.js.map +1 -0
- package/dist/playwright/context.d.ts +217 -0
- package/dist/playwright/context.d.ts.map +1 -0
- package/dist/playwright/context.js +518 -0
- package/dist/playwright/context.js.map +1 -0
- package/dist/playwright/extractor.d.ts +108 -0
- package/dist/playwright/extractor.d.ts.map +1 -0
- package/dist/playwright/extractor.js +404 -0
- package/dist/playwright/extractor.js.map +1 -0
- package/dist/playwright/frame.d.ts +147 -0
- package/dist/playwright/frame.d.ts.map +1 -0
- package/dist/playwright/frame.js +492 -0
- package/dist/playwright/frame.js.map +1 -0
- package/dist/playwright/index.d.ts +163 -0
- package/dist/playwright/index.d.ts.map +1 -0
- package/dist/playwright/index.js +313 -0
- package/dist/playwright/index.js.map +1 -0
- package/dist/playwright/keyboard.d.ts +74 -0
- package/dist/playwright/keyboard.d.ts.map +1 -0
- package/dist/playwright/keyboard.js +187 -0
- package/dist/playwright/keyboard.js.map +1 -0
- package/dist/playwright/locator.d.ts +237 -0
- package/dist/playwright/locator.d.ts.map +1 -0
- package/dist/playwright/locator.js +667 -0
- package/dist/playwright/locator.js.map +1 -0
- package/dist/playwright/mouse.d.ts +82 -0
- package/dist/playwright/mouse.d.ts.map +1 -0
- package/dist/playwright/mouse.js +137 -0
- package/dist/playwright/mouse.js.map +1 -0
- package/dist/playwright/page-helpers.d.ts +267 -0
- package/dist/playwright/page-helpers.d.ts.map +1 -0
- package/dist/playwright/page-helpers.js +449 -0
- package/dist/playwright/page-helpers.js.map +1 -0
- package/dist/playwright/page.d.ts +605 -0
- package/dist/playwright/page.d.ts.map +1 -0
- package/dist/playwright/page.js +1698 -0
- package/dist/playwright/page.js.map +1 -0
- package/dist/playwright/response.d.ts +100 -0
- package/dist/playwright/response.d.ts.map +1 -0
- package/dist/playwright/response.js +194 -0
- package/dist/playwright/response.js.map +1 -0
- package/dist/playwright/types.d.ts +354 -0
- package/dist/playwright/types.d.ts.map +1 -0
- package/dist/playwright/types.js +8 -0
- package/dist/playwright/types.js.map +1 -0
- package/openapi.json +327 -35
- package/package.json +10 -1
|
@@ -0,0 +1,1698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright-compatible Page class for Owl Browser.
|
|
3
|
+
*
|
|
4
|
+
* This is the primary interface for browser interaction. Each Page instance
|
|
5
|
+
* maps to a single browser context + tab in Owl Browser, translating
|
|
6
|
+
* Playwright API calls into the corresponding tool executions.
|
|
7
|
+
*
|
|
8
|
+
* Supports: navigation, interaction, content extraction, screenshots,
|
|
9
|
+
* evaluation, waiting, network interception (route/unroute), dialog
|
|
10
|
+
* handling, console monitoring, downloads, file upload, clipboard,
|
|
11
|
+
* scrolling, zoom, viewport, frames, tabs, video recording, and emulation.
|
|
12
|
+
*/
|
|
13
|
+
import { writeFile } from 'node:fs/promises';
|
|
14
|
+
import { queryAll as extractQueryAll, queryFirst as extractQueryFirst, extractTable as extractTableFn, extractMeta as extractMetaFn, extractStructuredData as extractStructuredDataFn, countElements as countElementsFn, } from './extractor.js';
|
|
15
|
+
import { Buffer } from 'node:buffer';
|
|
16
|
+
import { createResponse } from './response.js';
|
|
17
|
+
import { Keyboard } from './keyboard.js';
|
|
18
|
+
import { Mouse } from './mouse.js';
|
|
19
|
+
import { Frame, FrameLocator } from './frame.js';
|
|
20
|
+
import { Locator, NthLocator } from './locator.js';
|
|
21
|
+
import { Route, Dialog, ConsoleMessage, Download, Video, } from './page-helpers.js';
|
|
22
|
+
/**
|
|
23
|
+
* Page provides the main API for interacting with a single browser tab.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors the Playwright Page API surface. All async methods translate
|
|
26
|
+
* to Owl Browser tool executions via the underlying OwlBrowser client.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const page = await context.newPage();
|
|
31
|
+
* await page.goto('https://example.com');
|
|
32
|
+
* await page.click('#submit');
|
|
33
|
+
* const title = await page.title();
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export class Page {
|
|
37
|
+
_client;
|
|
38
|
+
_contextId;
|
|
39
|
+
_keyboard;
|
|
40
|
+
_mouse;
|
|
41
|
+
_mainFrame;
|
|
42
|
+
_url = 'about:blank';
|
|
43
|
+
_viewport = null;
|
|
44
|
+
_closed = false;
|
|
45
|
+
_tabId;
|
|
46
|
+
_routes = [];
|
|
47
|
+
_eventHandlers = new Map();
|
|
48
|
+
_video = null;
|
|
49
|
+
_dialogPollTimer = null;
|
|
50
|
+
_consolePollTimer = null;
|
|
51
|
+
_consoleLogOffset = 0;
|
|
52
|
+
_dialogSetupReady = null;
|
|
53
|
+
constructor(client, contextId, tabId) {
|
|
54
|
+
this._client = client;
|
|
55
|
+
this._contextId = contextId;
|
|
56
|
+
this._tabId = tabId ?? null;
|
|
57
|
+
this._keyboard = new Keyboard(client, contextId);
|
|
58
|
+
this._mouse = new Mouse(client, contextId);
|
|
59
|
+
this._mainFrame = new Frame(client, contextId, null, 'main');
|
|
60
|
+
}
|
|
61
|
+
// ==================== Properties ====================
|
|
62
|
+
/** The Keyboard instance for this page. */
|
|
63
|
+
get keyboard() {
|
|
64
|
+
return this._keyboard;
|
|
65
|
+
}
|
|
66
|
+
/** The Mouse instance for this page. */
|
|
67
|
+
get mouse() {
|
|
68
|
+
return this._mouse;
|
|
69
|
+
}
|
|
70
|
+
/** The main Frame of this page. */
|
|
71
|
+
mainFrame() {
|
|
72
|
+
return this._mainFrame;
|
|
73
|
+
}
|
|
74
|
+
/** The current page URL (synchronous getter). */
|
|
75
|
+
get url() {
|
|
76
|
+
return this._url;
|
|
77
|
+
}
|
|
78
|
+
/** Whether the page has been closed. */
|
|
79
|
+
isClosed() {
|
|
80
|
+
return this._closed;
|
|
81
|
+
}
|
|
82
|
+
/** The current viewport size, or null if not set. */
|
|
83
|
+
viewportSize() {
|
|
84
|
+
return this._viewport;
|
|
85
|
+
}
|
|
86
|
+
/** The underlying context ID. */
|
|
87
|
+
get contextId() {
|
|
88
|
+
return this._contextId;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Execute a browser tool with auto-injected context_id.
|
|
92
|
+
*
|
|
93
|
+
* @internal Used by Locator and other classes to execute tools.
|
|
94
|
+
* @param toolName - Browser tool name (e.g., 'browser_click')
|
|
95
|
+
* @param params - Tool parameters (context_id is auto-injected)
|
|
96
|
+
* @returns Tool execution result
|
|
97
|
+
*/
|
|
98
|
+
async _execute(toolName, params = {}) {
|
|
99
|
+
return this._client.execute(toolName, {
|
|
100
|
+
context_id: this._contextId,
|
|
101
|
+
...params,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// ==================== Navigation ====================
|
|
105
|
+
/**
|
|
106
|
+
* Navigate to a URL.
|
|
107
|
+
*
|
|
108
|
+
* @param url - Target URL (must include protocol)
|
|
109
|
+
* @param options - Navigation options (waitUntil, timeout, referer)
|
|
110
|
+
* @returns Response object or null
|
|
111
|
+
*/
|
|
112
|
+
async goto(url, options) {
|
|
113
|
+
const waitUntil = mapWaitUntil(options?.waitUntil);
|
|
114
|
+
const params = {
|
|
115
|
+
context_id: this._contextId,
|
|
116
|
+
url,
|
|
117
|
+
};
|
|
118
|
+
if (waitUntil) {
|
|
119
|
+
params['wait_until'] = waitUntil;
|
|
120
|
+
}
|
|
121
|
+
if (options?.timeout !== undefined) {
|
|
122
|
+
params['timeout'] = String(options.timeout);
|
|
123
|
+
}
|
|
124
|
+
const result = await this._client.execute('browser_navigate', params);
|
|
125
|
+
this._url = url;
|
|
126
|
+
await this._syncUrl();
|
|
127
|
+
return createResponse(result);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Navigate back in history.
|
|
131
|
+
*
|
|
132
|
+
* @param options - Navigation options
|
|
133
|
+
* @returns Response object or null
|
|
134
|
+
*/
|
|
135
|
+
async goBack(options) {
|
|
136
|
+
const params = {
|
|
137
|
+
context_id: this._contextId,
|
|
138
|
+
};
|
|
139
|
+
if (options?.waitUntil) {
|
|
140
|
+
params['wait_until'] = mapWaitUntil(options.waitUntil);
|
|
141
|
+
}
|
|
142
|
+
if (options?.timeout !== undefined) {
|
|
143
|
+
params['timeout'] = String(options.timeout);
|
|
144
|
+
}
|
|
145
|
+
const result = await this._client.execute('browser_go_back', params);
|
|
146
|
+
await this._syncUrl();
|
|
147
|
+
return createResponse(result);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Navigate forward in history.
|
|
151
|
+
*
|
|
152
|
+
* @param options - Navigation options
|
|
153
|
+
* @returns Response object or null
|
|
154
|
+
*/
|
|
155
|
+
async goForward(options) {
|
|
156
|
+
const params = {
|
|
157
|
+
context_id: this._contextId,
|
|
158
|
+
};
|
|
159
|
+
if (options?.waitUntil) {
|
|
160
|
+
params['wait_until'] = mapWaitUntil(options.waitUntil);
|
|
161
|
+
}
|
|
162
|
+
if (options?.timeout !== undefined) {
|
|
163
|
+
params['timeout'] = String(options.timeout);
|
|
164
|
+
}
|
|
165
|
+
const result = await this._client.execute('browser_go_forward', params);
|
|
166
|
+
await this._syncUrl();
|
|
167
|
+
return createResponse(result);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Reload the page.
|
|
171
|
+
*
|
|
172
|
+
* @param options - Navigation options
|
|
173
|
+
* @returns Response object or null
|
|
174
|
+
*/
|
|
175
|
+
async reload(options) {
|
|
176
|
+
const params = {
|
|
177
|
+
context_id: this._contextId,
|
|
178
|
+
};
|
|
179
|
+
if (options?.waitUntil) {
|
|
180
|
+
params['wait_until'] = mapWaitUntil(options.waitUntil);
|
|
181
|
+
}
|
|
182
|
+
if (options?.timeout !== undefined) {
|
|
183
|
+
params['timeout'] = String(options.timeout);
|
|
184
|
+
}
|
|
185
|
+
const result = await this._client.execute('browser_reload', params);
|
|
186
|
+
return createResponse(result);
|
|
187
|
+
}
|
|
188
|
+
// ==================== Content ====================
|
|
189
|
+
/** Get the page title. */
|
|
190
|
+
async title() {
|
|
191
|
+
const result = await this._client.execute('browser_get_page_info', {
|
|
192
|
+
context_id: this._contextId,
|
|
193
|
+
});
|
|
194
|
+
const res = result;
|
|
195
|
+
return String(res['title'] ?? '');
|
|
196
|
+
}
|
|
197
|
+
/** Get the full HTML content of the page. */
|
|
198
|
+
async content() {
|
|
199
|
+
const result = await this._client.execute('browser_get_html', {
|
|
200
|
+
context_id: this._contextId,
|
|
201
|
+
});
|
|
202
|
+
// Result is a raw HTML string
|
|
203
|
+
if (typeof result === 'string')
|
|
204
|
+
return result;
|
|
205
|
+
const res = result;
|
|
206
|
+
return String(res['html'] ?? res['result'] ?? '');
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Set the HTML content of the page.
|
|
210
|
+
*
|
|
211
|
+
* @param html - HTML string to load
|
|
212
|
+
* @param options - Set content options
|
|
213
|
+
*/
|
|
214
|
+
async setContent(html, options) {
|
|
215
|
+
void options;
|
|
216
|
+
await this._client.execute('browser_set_content', {
|
|
217
|
+
context_id: this._contextId,
|
|
218
|
+
html,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
// ==================== Schema-Driven Extraction ====================
|
|
222
|
+
/**
|
|
223
|
+
* Extract structured data from all elements matching a CSS selector.
|
|
224
|
+
*
|
|
225
|
+
* Fetches the page HTML once and parses it SDK-side using cheerio.
|
|
226
|
+
* Each matching container element has the given fields extracted from it.
|
|
227
|
+
*
|
|
228
|
+
* Field syntax:
|
|
229
|
+
* `"selector"` → textContent of the matched child element
|
|
230
|
+
* `"selector@attr"` → attribute value of the matched child element
|
|
231
|
+
* `"@attr"` → attribute on the container element itself
|
|
232
|
+
*
|
|
233
|
+
* @param selector - CSS selector for repeating container elements
|
|
234
|
+
* @param fields - Mapping of output names to extraction specs
|
|
235
|
+
* @returns Array of objects with extracted values
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```typescript
|
|
239
|
+
* const products = await page.queryAll('.product-card', {
|
|
240
|
+
* name: 'h2',
|
|
241
|
+
* price: '.price',
|
|
242
|
+
* image: 'img@src',
|
|
243
|
+
* link: 'a@href',
|
|
244
|
+
* });
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
async queryAll(selector, fields) {
|
|
248
|
+
const html = await this.content();
|
|
249
|
+
return extractQueryAll(html, selector, fields);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Extract structured data from the first element matching a CSS selector.
|
|
253
|
+
*
|
|
254
|
+
* @param selector - CSS selector for the container element
|
|
255
|
+
* @param fields - Mapping of output names to extraction specs
|
|
256
|
+
* @returns Single record or null if no match
|
|
257
|
+
*/
|
|
258
|
+
async queryFirst(selector, fields) {
|
|
259
|
+
const html = await this.content();
|
|
260
|
+
return extractQueryFirst(html, selector, fields);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Extract a <table> as an array of records.
|
|
264
|
+
*
|
|
265
|
+
* @param selector - CSS selector for the table (default: 'table')
|
|
266
|
+
* @param options - Optional headers override
|
|
267
|
+
* @returns Array of records with header keys
|
|
268
|
+
*/
|
|
269
|
+
async extractTable(selector, options) {
|
|
270
|
+
const html = await this.content();
|
|
271
|
+
return extractTableFn(html, selector ?? 'table', options);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Extract JSON-LD structured data from the page.
|
|
275
|
+
*
|
|
276
|
+
* @returns Array of parsed JSON-LD objects
|
|
277
|
+
*/
|
|
278
|
+
async extractStructuredData() {
|
|
279
|
+
const html = await this.content();
|
|
280
|
+
return extractStructuredDataFn(html);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Extract meta tags from the page.
|
|
284
|
+
*
|
|
285
|
+
* @returns MetaData with title, description, canonical, og, twitter, other
|
|
286
|
+
*/
|
|
287
|
+
async extractMeta() {
|
|
288
|
+
const html = await this.content();
|
|
289
|
+
return extractMetaFn(html);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Count elements matching a CSS selector.
|
|
293
|
+
*
|
|
294
|
+
* @param selector - CSS selector
|
|
295
|
+
* @returns Number of matching elements
|
|
296
|
+
*/
|
|
297
|
+
async count(selector) {
|
|
298
|
+
const html = await this.content();
|
|
299
|
+
return countElementsFn(html, selector);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Extract structured data across multiple pages or scroll loads.
|
|
303
|
+
*
|
|
304
|
+
* Supports two pagination modes:
|
|
305
|
+
* - **Click-next**: clicks a "next" button and waits for page update.
|
|
306
|
+
* - **Infinite scroll**: scrolls to bottom and waits for new content.
|
|
307
|
+
*
|
|
308
|
+
* Automatically detects end of data: next button gone/disabled/hidden,
|
|
309
|
+
* no new items after dedup, or max pages/scrolls reached.
|
|
310
|
+
*
|
|
311
|
+
* @param selector - CSS selector for repeating container elements
|
|
312
|
+
* @param options - Scrape options (fields, next, scroll, limits)
|
|
313
|
+
* @returns Deduplicated array of all extracted records across pages
|
|
314
|
+
*/
|
|
315
|
+
async scrape(selector, options) {
|
|
316
|
+
const { fields, next, maxPages = 10, wait = 2000, scroll = false, maxScrolls = 20, scrollWait = 2000, urls, urlPattern, startPage = 1, endPage, pageStep = 1, follow, onPageDone, retries = 0, retryDelay = 1000, } = options;
|
|
317
|
+
const allItems = [];
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
// Helper: extract and dedup items from current page
|
|
320
|
+
const extractPage = async (pageNum) => {
|
|
321
|
+
const html = await this.content();
|
|
322
|
+
const items = extractQueryAll(html, selector, fields);
|
|
323
|
+
let newCount = 0;
|
|
324
|
+
for (const item of items) {
|
|
325
|
+
const key = JSON.stringify(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)));
|
|
326
|
+
if (!seen.has(key)) {
|
|
327
|
+
seen.add(key);
|
|
328
|
+
allItems.push(item);
|
|
329
|
+
newCount++;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
onPageDone?.({ page: pageNum, newItems: newCount, totalItems: allItems.length });
|
|
333
|
+
return newCount;
|
|
334
|
+
};
|
|
335
|
+
// Helper: retry a function
|
|
336
|
+
const withRetry = async (fn) => {
|
|
337
|
+
let lastError;
|
|
338
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
339
|
+
try {
|
|
340
|
+
return await fn();
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
lastError = e;
|
|
344
|
+
if (attempt < retries) {
|
|
345
|
+
await this.waitForTimeout(retryDelay);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
throw lastError;
|
|
350
|
+
};
|
|
351
|
+
// Mode 3: URL pagination
|
|
352
|
+
if (urls || urlPattern) {
|
|
353
|
+
const urlList = [];
|
|
354
|
+
if (urls) {
|
|
355
|
+
urlList.push(...urls);
|
|
356
|
+
}
|
|
357
|
+
else if (urlPattern) {
|
|
358
|
+
const end = endPage ?? (startPage + maxPages - 1);
|
|
359
|
+
for (let p = startPage; p <= end; p += pageStep) {
|
|
360
|
+
const offset = (p - 1) * pageStep;
|
|
361
|
+
urlList.push(urlPattern
|
|
362
|
+
.replace(/\{page\}/g, String(p))
|
|
363
|
+
.replace(/\{offset\}/g, String(offset)));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (let i = 0; i < urlList.length; i++) {
|
|
367
|
+
await withRetry(async () => {
|
|
368
|
+
await this.goto(urlList[i]);
|
|
369
|
+
await this.waitForLoadState();
|
|
370
|
+
if (wait > 0)
|
|
371
|
+
await this.waitForTimeout(wait);
|
|
372
|
+
});
|
|
373
|
+
const newCount = await extractPage(i + 1);
|
|
374
|
+
if (newCount === 0)
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Mode 1 & 2: Click-next or Infinite scroll
|
|
379
|
+
else {
|
|
380
|
+
const limit = scroll ? maxScrolls : maxPages;
|
|
381
|
+
for (let i = 0; i < limit; i++) {
|
|
382
|
+
const newCount = await extractPage(i + 1);
|
|
383
|
+
if (newCount === 0)
|
|
384
|
+
break;
|
|
385
|
+
if (scroll) {
|
|
386
|
+
await this.scrollToBottom();
|
|
387
|
+
await this.waitForTimeout(scrollWait);
|
|
388
|
+
}
|
|
389
|
+
else if (next) {
|
|
390
|
+
const loc = this.locator(next);
|
|
391
|
+
try {
|
|
392
|
+
const count = await loc.count();
|
|
393
|
+
if (count === 0)
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
if (!(await loc.isVisible()) || !(await loc.isEnabled()))
|
|
400
|
+
break;
|
|
401
|
+
await loc.click();
|
|
402
|
+
await this.waitForLoadState();
|
|
403
|
+
await this.waitForTimeout(wait);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Detail page following
|
|
411
|
+
if (follow) {
|
|
412
|
+
const followWait = follow.wait ?? 1000;
|
|
413
|
+
for (const item of allItems) {
|
|
414
|
+
// Extract the URL from the item using the urlSelector as a string spec
|
|
415
|
+
const detailUrl = item[follow.urlSelector]
|
|
416
|
+
?? extractQueryFirst(await this.content(), selector, { _url: follow.urlSelector })?.['_url'];
|
|
417
|
+
if (!detailUrl || typeof detailUrl !== 'string')
|
|
418
|
+
continue;
|
|
419
|
+
// Resolve relative URLs
|
|
420
|
+
let fullUrl = detailUrl;
|
|
421
|
+
if (!detailUrl.startsWith('http://') && !detailUrl.startsWith('https://')) {
|
|
422
|
+
try {
|
|
423
|
+
fullUrl = new URL(detailUrl, this.url).href;
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const prevUrl = this.url;
|
|
430
|
+
try {
|
|
431
|
+
await withRetry(async () => {
|
|
432
|
+
await this.goto(fullUrl);
|
|
433
|
+
await this.waitForLoadState();
|
|
434
|
+
if (followWait > 0)
|
|
435
|
+
await this.waitForTimeout(followWait);
|
|
436
|
+
});
|
|
437
|
+
const detailHtml = await this.content();
|
|
438
|
+
const detailData = extractQueryFirst(detailHtml, 'body', follow.fields)
|
|
439
|
+
?? extractQueryFirst(detailHtml, selector, follow.fields);
|
|
440
|
+
if (detailData) {
|
|
441
|
+
Object.assign(item, detailData);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// Skip failed detail pages
|
|
446
|
+
}
|
|
447
|
+
// Navigate back
|
|
448
|
+
try {
|
|
449
|
+
await this.goto(prevUrl);
|
|
450
|
+
await this.waitForLoadState();
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// Best effort
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return allItems;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Get the page content as Markdown.
|
|
461
|
+
*
|
|
462
|
+
* @returns Markdown representation of the page
|
|
463
|
+
*/
|
|
464
|
+
async markdown() {
|
|
465
|
+
const result = await this._client.execute('browser_get_markdown', {
|
|
466
|
+
context_id: this._contextId,
|
|
467
|
+
});
|
|
468
|
+
// Result is a raw markdown string
|
|
469
|
+
if (typeof result === 'string')
|
|
470
|
+
return result;
|
|
471
|
+
const res = result;
|
|
472
|
+
return String(res['markdown'] ?? res['result'] ?? '');
|
|
473
|
+
}
|
|
474
|
+
// ==================== Interaction ====================
|
|
475
|
+
/**
|
|
476
|
+
* Click an element.
|
|
477
|
+
*
|
|
478
|
+
* @param selector - CSS selector, XY coordinates, or natural language description
|
|
479
|
+
* @param options - Click options (button, clickCount, etc.)
|
|
480
|
+
*/
|
|
481
|
+
async click(selector, options) {
|
|
482
|
+
// Ensure dialog setup is complete before interacting (prevents race condition)
|
|
483
|
+
if (this._dialogSetupReady) {
|
|
484
|
+
await this._dialogSetupReady;
|
|
485
|
+
this._dialogSetupReady = null;
|
|
486
|
+
}
|
|
487
|
+
if (options?.button === 'right') {
|
|
488
|
+
await this._client.execute('browser_right_click', {
|
|
489
|
+
context_id: this._contextId,
|
|
490
|
+
selector,
|
|
491
|
+
});
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (options?.clickCount === 2) {
|
|
495
|
+
await this._client.execute('browser_double_click', {
|
|
496
|
+
context_id: this._contextId,
|
|
497
|
+
selector,
|
|
498
|
+
});
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
await this._client.execute('browser_click', {
|
|
502
|
+
context_id: this._contextId,
|
|
503
|
+
selector,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
/** Double-click an element. */
|
|
507
|
+
async dblclick(selector, options) {
|
|
508
|
+
void options;
|
|
509
|
+
await this._client.execute('browser_double_click', {
|
|
510
|
+
context_id: this._contextId,
|
|
511
|
+
selector,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Fill an input element (clears existing content first).
|
|
516
|
+
*
|
|
517
|
+
* @param selector - Target input element
|
|
518
|
+
* @param value - Text to fill
|
|
519
|
+
* @param options - Fill options
|
|
520
|
+
*/
|
|
521
|
+
async fill(selector, value, options) {
|
|
522
|
+
void options;
|
|
523
|
+
await this._client.execute('browser_clear_input', {
|
|
524
|
+
context_id: this._contextId,
|
|
525
|
+
selector,
|
|
526
|
+
});
|
|
527
|
+
await this._client.execute('browser_type', {
|
|
528
|
+
context_id: this._contextId,
|
|
529
|
+
selector,
|
|
530
|
+
text: value,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Type text into an element (does NOT clear existing content).
|
|
535
|
+
*
|
|
536
|
+
* @param selector - Target input element
|
|
537
|
+
* @param text - Text to type
|
|
538
|
+
* @param options - Type options
|
|
539
|
+
*/
|
|
540
|
+
async type(selector, text, options) {
|
|
541
|
+
void options;
|
|
542
|
+
await this._client.execute('browser_type', {
|
|
543
|
+
context_id: this._contextId,
|
|
544
|
+
selector,
|
|
545
|
+
text,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Focus an element and press a key.
|
|
550
|
+
*
|
|
551
|
+
* For special keys (Enter, Tab, Escape, etc.) uses browser_press_key.
|
|
552
|
+
* For single character keys (a-z, 0-9, etc.) uses browser_type to simulate typing.
|
|
553
|
+
*/
|
|
554
|
+
async press(selector, key, options) {
|
|
555
|
+
void options;
|
|
556
|
+
await this._client.execute('browser_focus', {
|
|
557
|
+
context_id: this._contextId,
|
|
558
|
+
selector,
|
|
559
|
+
});
|
|
560
|
+
// Delegate to keyboard which handles the special-key vs character distinction
|
|
561
|
+
await this._keyboard.press(key);
|
|
562
|
+
}
|
|
563
|
+
/** Hover over an element. */
|
|
564
|
+
async hover(selector, options) {
|
|
565
|
+
void options;
|
|
566
|
+
await this._client.execute('browser_hover', {
|
|
567
|
+
context_id: this._contextId,
|
|
568
|
+
selector,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/** Focus an element. */
|
|
572
|
+
async focus(selector, options) {
|
|
573
|
+
void options;
|
|
574
|
+
await this._client.execute('browser_focus', {
|
|
575
|
+
context_id: this._contextId,
|
|
576
|
+
selector,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/** Blur the currently focused element. */
|
|
580
|
+
async blur(selector) {
|
|
581
|
+
await this._client.execute('browser_blur', {
|
|
582
|
+
context_id: this._contextId,
|
|
583
|
+
selector,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
/** Select option(s) from a dropdown. Calls browser_pick once per value. */
|
|
587
|
+
async selectOption(selector, values, options) {
|
|
588
|
+
void options;
|
|
589
|
+
const valueList = extractSelectValues(values);
|
|
590
|
+
for (const value of valueList) {
|
|
591
|
+
await this._client.execute('browser_pick', {
|
|
592
|
+
context_id: this._contextId,
|
|
593
|
+
selector,
|
|
594
|
+
value,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return valueList;
|
|
598
|
+
}
|
|
599
|
+
/** Check a checkbox (no-op if already checked). */
|
|
600
|
+
async check(selector, options) {
|
|
601
|
+
void options;
|
|
602
|
+
const checked = await this.isChecked(selector);
|
|
603
|
+
if (!checked) {
|
|
604
|
+
await this.click(selector);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/** Uncheck a checkbox (no-op if already unchecked). */
|
|
608
|
+
async uncheck(selector, options) {
|
|
609
|
+
void options;
|
|
610
|
+
const checked = await this.isChecked(selector);
|
|
611
|
+
if (checked) {
|
|
612
|
+
await this.click(selector);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/** Drag an element and drop it onto another element. */
|
|
616
|
+
async dragAndDrop(source, target, options) {
|
|
617
|
+
void options;
|
|
618
|
+
// Use browser_html5_drag_drop which accepts CSS selectors.
|
|
619
|
+
// browser_drag_drop requires pixel coordinates and is for non-HTML5 drag.
|
|
620
|
+
await this._client.execute('browser_html5_drag_drop', {
|
|
621
|
+
context_id: this._contextId,
|
|
622
|
+
source_selector: source,
|
|
623
|
+
target_selector: target,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Upload files to a file input element.
|
|
628
|
+
*
|
|
629
|
+
* @param selector - CSS selector for the file input element
|
|
630
|
+
* @param files - File path(s) to upload
|
|
631
|
+
* @param options - Upload options
|
|
632
|
+
*/
|
|
633
|
+
async setInputFiles(selector, files, options) {
|
|
634
|
+
void options;
|
|
635
|
+
const filePaths = Array.isArray(files) ? files : [files];
|
|
636
|
+
await this._client.execute('browser_upload_file', {
|
|
637
|
+
context_id: this._contextId,
|
|
638
|
+
selector,
|
|
639
|
+
file_paths: JSON.stringify(filePaths),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
/** Submit a form. */
|
|
643
|
+
async submitForm(selector) {
|
|
644
|
+
await this._client.execute('browser_submit_form', {
|
|
645
|
+
context_id: this._contextId,
|
|
646
|
+
selector,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
/** Select all text in the focused element. */
|
|
650
|
+
async selectAll() {
|
|
651
|
+
await this._client.execute('browser_select_all', {
|
|
652
|
+
context_id: this._contextId,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
// ==================== Queries ====================
|
|
656
|
+
/** Get the text content of an element. */
|
|
657
|
+
async textContent(selector) {
|
|
658
|
+
const result = await this._client.execute('browser_extract_text', {
|
|
659
|
+
context_id: this._contextId,
|
|
660
|
+
selector,
|
|
661
|
+
});
|
|
662
|
+
// Result is a raw string (the text content)
|
|
663
|
+
if (result === null || result === undefined)
|
|
664
|
+
return null;
|
|
665
|
+
if (typeof result === 'string')
|
|
666
|
+
return result;
|
|
667
|
+
const res = result;
|
|
668
|
+
return res['text'] ?? String(res['result'] ?? '');
|
|
669
|
+
}
|
|
670
|
+
/** Get the inner HTML of an element. */
|
|
671
|
+
async innerHTML(selector) {
|
|
672
|
+
const result = await this._client.execute('browser_get_html', {
|
|
673
|
+
context_id: this._contextId,
|
|
674
|
+
selector,
|
|
675
|
+
});
|
|
676
|
+
if (typeof result === 'string')
|
|
677
|
+
return result;
|
|
678
|
+
const res = result;
|
|
679
|
+
return String(res['html'] ?? res['result'] ?? '');
|
|
680
|
+
}
|
|
681
|
+
/** Get the inner text of an element (visible text only). */
|
|
682
|
+
async innerText(selector) {
|
|
683
|
+
const text = await this.textContent(selector);
|
|
684
|
+
return text ?? '';
|
|
685
|
+
}
|
|
686
|
+
/** Get an attribute value from an element. */
|
|
687
|
+
async getAttribute(selector, name) {
|
|
688
|
+
const result = await this._client.execute('browser_get_attribute', {
|
|
689
|
+
context_id: this._contextId,
|
|
690
|
+
selector,
|
|
691
|
+
attribute: name,
|
|
692
|
+
});
|
|
693
|
+
if (result === null || result === undefined)
|
|
694
|
+
return null;
|
|
695
|
+
if (typeof result === 'string')
|
|
696
|
+
return result.length > 0 ? result : null;
|
|
697
|
+
const res = result;
|
|
698
|
+
const val = res['value'] ?? res['result'];
|
|
699
|
+
if (val === undefined || val === null)
|
|
700
|
+
return null;
|
|
701
|
+
return String(val);
|
|
702
|
+
}
|
|
703
|
+
/** Get the bounding box of an element. */
|
|
704
|
+
async boundingBox(selector) {
|
|
705
|
+
try {
|
|
706
|
+
const result = await this._client.execute('browser_get_bounding_box', {
|
|
707
|
+
context_id: this._contextId,
|
|
708
|
+
selector,
|
|
709
|
+
});
|
|
710
|
+
const res = result;
|
|
711
|
+
return {
|
|
712
|
+
x: Number(res['x'] ?? 0),
|
|
713
|
+
y: Number(res['y'] ?? 0),
|
|
714
|
+
width: Number(res['width'] ?? 0),
|
|
715
|
+
height: Number(res['height'] ?? 0),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// ==================== Element State ====================
|
|
723
|
+
/** Check if an element is visible. */
|
|
724
|
+
async isVisible(selector) {
|
|
725
|
+
const result = await this._client.execute('browser_is_visible', {
|
|
726
|
+
context_id: this._contextId,
|
|
727
|
+
selector,
|
|
728
|
+
});
|
|
729
|
+
const res = result;
|
|
730
|
+
if (res['error_code'] === 'visible' || res['visible'] === true)
|
|
731
|
+
return true;
|
|
732
|
+
if (res['error_code'] === 'hidden')
|
|
733
|
+
return false;
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
/** Check if an element is enabled. */
|
|
737
|
+
async isEnabled(selector) {
|
|
738
|
+
const result = await this._client.execute('browser_is_enabled', {
|
|
739
|
+
context_id: this._contextId,
|
|
740
|
+
selector,
|
|
741
|
+
});
|
|
742
|
+
const res = result;
|
|
743
|
+
if (res['error_code'] === 'enabled' || res['enabled'] === true)
|
|
744
|
+
return true;
|
|
745
|
+
if (res['error_code'] === 'disabled')
|
|
746
|
+
return false;
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
/** Check if a checkbox/radio is checked. */
|
|
750
|
+
async isChecked(selector) {
|
|
751
|
+
const result = await this._client.execute('browser_is_checked', {
|
|
752
|
+
context_id: this._contextId,
|
|
753
|
+
selector,
|
|
754
|
+
});
|
|
755
|
+
const res = result;
|
|
756
|
+
if (res['error_code'] === 'checked' || res['checked'] === true)
|
|
757
|
+
return true;
|
|
758
|
+
if (res['error_code'] === 'unchecked')
|
|
759
|
+
return false;
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
// ==================== Screenshots & PDF ====================
|
|
763
|
+
/**
|
|
764
|
+
* Take a screenshot of the page.
|
|
765
|
+
*
|
|
766
|
+
* @param options - Screenshot options (path, type, fullPage, etc.)
|
|
767
|
+
* @returns PNG image data as Buffer
|
|
768
|
+
*/
|
|
769
|
+
async screenshot(options) {
|
|
770
|
+
const params = {
|
|
771
|
+
context_id: this._contextId,
|
|
772
|
+
};
|
|
773
|
+
if (options?.fullPage) {
|
|
774
|
+
params['full_page'] = true;
|
|
775
|
+
}
|
|
776
|
+
const result = await this._client.execute('browser_screenshot', params);
|
|
777
|
+
// Result is a raw base64 string (not an object)
|
|
778
|
+
const base64Data = typeof result === 'string'
|
|
779
|
+
? result
|
|
780
|
+
: String(result['data'] ?? result['screenshot'] ?? result['image'] ?? '');
|
|
781
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
782
|
+
if (options?.path) {
|
|
783
|
+
await writeFile(options.path, buffer);
|
|
784
|
+
}
|
|
785
|
+
return buffer;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Generate a PDF of the page.
|
|
789
|
+
*
|
|
790
|
+
* Stub: Owl Browser does not directly support PDF generation.
|
|
791
|
+
* Returns an empty buffer.
|
|
792
|
+
*
|
|
793
|
+
* @param options - PDF options
|
|
794
|
+
* @returns Empty Buffer
|
|
795
|
+
*/
|
|
796
|
+
async pdf(options) {
|
|
797
|
+
void options;
|
|
798
|
+
return Buffer.alloc(0);
|
|
799
|
+
}
|
|
800
|
+
// ==================== Evaluate ====================
|
|
801
|
+
/**
|
|
802
|
+
* Evaluate JavaScript in the page context.
|
|
803
|
+
*
|
|
804
|
+
* @param pageFunction - Function or expression string to evaluate
|
|
805
|
+
* @param arg - Optional argument to pass to the function
|
|
806
|
+
* @returns The result of the evaluation
|
|
807
|
+
*/
|
|
808
|
+
async evaluate(pageFunction, arg) {
|
|
809
|
+
// Ensure dialog setup is complete before evaluating (prevents race condition)
|
|
810
|
+
if (this._dialogSetupReady) {
|
|
811
|
+
await this._dialogSetupReady;
|
|
812
|
+
this._dialogSetupReady = null;
|
|
813
|
+
}
|
|
814
|
+
const expression = typeof pageFunction === 'function'
|
|
815
|
+
? `(${pageFunction.toString()})(${arg !== undefined ? JSON.stringify(arg) : ''})`
|
|
816
|
+
: pageFunction;
|
|
817
|
+
const result = await this._client.execute('browser_evaluate', {
|
|
818
|
+
context_id: this._contextId,
|
|
819
|
+
expression,
|
|
820
|
+
});
|
|
821
|
+
return result;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Evaluate JavaScript and return a handle (simplified).
|
|
825
|
+
*
|
|
826
|
+
* @param pageFunction - Function or expression string
|
|
827
|
+
* @param arg - Optional argument
|
|
828
|
+
* @returns Evaluation result
|
|
829
|
+
*/
|
|
830
|
+
async evaluateHandle(pageFunction, arg) {
|
|
831
|
+
return this.evaluate(pageFunction, arg);
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Add a script to evaluate when a new document is created.
|
|
835
|
+
*
|
|
836
|
+
* @param script - JavaScript code or path to script file
|
|
837
|
+
* @param arg - Optional argument
|
|
838
|
+
*/
|
|
839
|
+
async addInitScript(script, arg) {
|
|
840
|
+
void arg;
|
|
841
|
+
const code = typeof script === 'string' ? script : '';
|
|
842
|
+
if (code) {
|
|
843
|
+
await this._client.execute('browser_evaluate', {
|
|
844
|
+
context_id: this._contextId,
|
|
845
|
+
script: code,
|
|
846
|
+
return_value: false,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Expose a function in the page's global scope.
|
|
852
|
+
*
|
|
853
|
+
* Stub: Creates a global function that logs calls via console.
|
|
854
|
+
*
|
|
855
|
+
* @param name - Function name to expose
|
|
856
|
+
* @param callback - Function implementation
|
|
857
|
+
*/
|
|
858
|
+
async exposeFunction(name, callback) {
|
|
859
|
+
void callback;
|
|
860
|
+
await this._client.execute('browser_evaluate', {
|
|
861
|
+
context_id: this._contextId,
|
|
862
|
+
script: `window['${name}'] = function() { console.log('${name} called', ...arguments); }`,
|
|
863
|
+
return_value: false,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
// ==================== Waiting ====================
|
|
867
|
+
/** Wait for a selector to appear in the DOM. */
|
|
868
|
+
async waitForSelector(selector, options) {
|
|
869
|
+
const params = {
|
|
870
|
+
context_id: this._contextId,
|
|
871
|
+
selector,
|
|
872
|
+
};
|
|
873
|
+
if (options?.timeout !== undefined) {
|
|
874
|
+
params['timeout'] = options.timeout;
|
|
875
|
+
}
|
|
876
|
+
await this._client.execute('browser_wait_for_selector', params);
|
|
877
|
+
}
|
|
878
|
+
/** Wait for a fixed amount of time. */
|
|
879
|
+
async waitForTimeout(timeout) {
|
|
880
|
+
await this._client.execute('browser_wait', {
|
|
881
|
+
context_id: this._contextId,
|
|
882
|
+
timeout,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
/** Wait for the page URL to match. */
|
|
886
|
+
async waitForURL(url, options) {
|
|
887
|
+
const isRegex = url instanceof RegExp;
|
|
888
|
+
const urlString = typeof url === 'string'
|
|
889
|
+
? url
|
|
890
|
+
: url instanceof RegExp
|
|
891
|
+
? url.source
|
|
892
|
+
: '*';
|
|
893
|
+
const params = {
|
|
894
|
+
context_id: this._contextId,
|
|
895
|
+
url_pattern: urlString,
|
|
896
|
+
};
|
|
897
|
+
if (isRegex) {
|
|
898
|
+
params['is_regex'] = true;
|
|
899
|
+
}
|
|
900
|
+
if (options?.timeout !== undefined) {
|
|
901
|
+
params['timeout'] = options.timeout;
|
|
902
|
+
}
|
|
903
|
+
await this._client.execute('browser_wait_for_url', params);
|
|
904
|
+
await this._syncUrl();
|
|
905
|
+
}
|
|
906
|
+
/** Wait for a JavaScript function to return truthy. */
|
|
907
|
+
async waitForFunction(pageFunction, arg, options) {
|
|
908
|
+
const fn = typeof pageFunction === 'function'
|
|
909
|
+
? `(${pageFunction.toString()})(${arg !== undefined ? JSON.stringify(arg) : ''})`
|
|
910
|
+
: pageFunction;
|
|
911
|
+
const params = {
|
|
912
|
+
context_id: this._contextId,
|
|
913
|
+
js_function: fn,
|
|
914
|
+
};
|
|
915
|
+
if (options?.timeout !== undefined) {
|
|
916
|
+
params['timeout'] = String(options.timeout);
|
|
917
|
+
}
|
|
918
|
+
if (options?.polling !== undefined && options.polling !== 'raf') {
|
|
919
|
+
params['polling'] = String(options.polling);
|
|
920
|
+
}
|
|
921
|
+
await this._client.execute('browser_wait_for_function', params);
|
|
922
|
+
}
|
|
923
|
+
/** Wait for the page to reach a specific load state. */
|
|
924
|
+
async waitForLoadState(state, options) {
|
|
925
|
+
const params = {
|
|
926
|
+
context_id: this._contextId,
|
|
927
|
+
};
|
|
928
|
+
if (options?.timeout !== undefined) {
|
|
929
|
+
params['timeout'] = options.timeout;
|
|
930
|
+
}
|
|
931
|
+
if (state === 'domcontentloaded') {
|
|
932
|
+
await this._client.execute('browser_wait_for_selector', {
|
|
933
|
+
...params,
|
|
934
|
+
selector: 'body',
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
else if (state === 'load') {
|
|
938
|
+
await this._client.execute('browser_wait_for_function', {
|
|
939
|
+
...params,
|
|
940
|
+
js_function: 'document.readyState === "complete"',
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
else {
|
|
944
|
+
// 'networkidle' or undefined — default behavior
|
|
945
|
+
await this._client.execute('browser_wait_for_network_idle', params);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/** Wait for a specific event to fire. */
|
|
949
|
+
async waitForEvent(event, optionsOrPredicate) {
|
|
950
|
+
const options = typeof optionsOrPredicate === 'function'
|
|
951
|
+
? { predicate: optionsOrPredicate }
|
|
952
|
+
: optionsOrPredicate;
|
|
953
|
+
const timeout = options?.timeout ?? 30000;
|
|
954
|
+
if (event === 'download') {
|
|
955
|
+
return this._waitForDownload(timeout);
|
|
956
|
+
}
|
|
957
|
+
if (event === 'dialog') {
|
|
958
|
+
return this._waitForDialog(timeout);
|
|
959
|
+
}
|
|
960
|
+
if (event === 'console') {
|
|
961
|
+
return this._waitForConsole(timeout);
|
|
962
|
+
}
|
|
963
|
+
// Generic wait fallback
|
|
964
|
+
await this.waitForTimeout(Math.min(timeout, 1000));
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Wait for a network response matching the given URL/predicate.
|
|
969
|
+
*
|
|
970
|
+
* Enables network logging and polls browser_get_network_log for matches.
|
|
971
|
+
*
|
|
972
|
+
* @param urlOrPredicate - URL string, RegExp, or predicate function
|
|
973
|
+
* @param options - Wait options (timeout)
|
|
974
|
+
* @returns A Response object for the matching network entry
|
|
975
|
+
*/
|
|
976
|
+
async waitForResponse(urlOrPredicate, options) {
|
|
977
|
+
const timeout = options?.timeout ?? 30000;
|
|
978
|
+
await this._client.execute('browser_enable_network_logging', {
|
|
979
|
+
context_id: this._contextId,
|
|
980
|
+
enable: true,
|
|
981
|
+
});
|
|
982
|
+
const start = Date.now();
|
|
983
|
+
while (Date.now() - start < timeout) {
|
|
984
|
+
const result = await this._client.execute('browser_get_network_log', {
|
|
985
|
+
context_id: this._contextId,
|
|
986
|
+
});
|
|
987
|
+
const res = result;
|
|
988
|
+
// browser_get_network_log returns { requests: [...], responses: [...] }
|
|
989
|
+
const entries = (res['responses'] ?? []);
|
|
990
|
+
if (Array.isArray(entries)) {
|
|
991
|
+
for (const entry of entries) {
|
|
992
|
+
const entryUrl = String(entry['url'] ?? '');
|
|
993
|
+
const status = Number(entry['status'] ?? entry['statusCode'] ?? 0);
|
|
994
|
+
if (typeof urlOrPredicate === 'string') {
|
|
995
|
+
if (entryUrl.includes(urlOrPredicate) && status > 0) {
|
|
996
|
+
return createResponse({ url: entryUrl, status });
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
else if (urlOrPredicate instanceof RegExp) {
|
|
1000
|
+
if (urlOrPredicate.test(entryUrl) && status > 0) {
|
|
1001
|
+
return createResponse({ url: entryUrl, status });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// Predicate support would require constructing a full Response first
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
await this.waitForTimeout(200);
|
|
1008
|
+
}
|
|
1009
|
+
throw new Error(`Timeout waiting for response (${timeout}ms)`);
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Wait for a network request matching the given URL/predicate.
|
|
1013
|
+
*
|
|
1014
|
+
* @param urlOrPredicate - URL string or RegExp to match
|
|
1015
|
+
* @param options - Wait options (timeout)
|
|
1016
|
+
*/
|
|
1017
|
+
async waitForRequest(urlOrPredicate, options) {
|
|
1018
|
+
const timeout = options?.timeout ?? 30000;
|
|
1019
|
+
await this._client.execute('browser_enable_network_logging', {
|
|
1020
|
+
context_id: this._contextId,
|
|
1021
|
+
enable: true,
|
|
1022
|
+
});
|
|
1023
|
+
const start = Date.now();
|
|
1024
|
+
while (Date.now() - start < timeout) {
|
|
1025
|
+
const result = await this._client.execute('browser_get_network_log', {
|
|
1026
|
+
context_id: this._contextId,
|
|
1027
|
+
});
|
|
1028
|
+
const res = result;
|
|
1029
|
+
// browser_get_network_log returns { requests: [...], responses: [...] }
|
|
1030
|
+
const entries = (res['requests'] ?? []);
|
|
1031
|
+
if (Array.isArray(entries)) {
|
|
1032
|
+
for (const entry of entries) {
|
|
1033
|
+
const entryUrl = String(entry['url'] ?? '');
|
|
1034
|
+
const method = String(entry['method'] ?? 'GET');
|
|
1035
|
+
if (typeof urlOrPredicate === 'string') {
|
|
1036
|
+
if (entryUrl.includes(urlOrPredicate)) {
|
|
1037
|
+
return { url: () => entryUrl, method: () => method };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
else if (urlOrPredicate instanceof RegExp) {
|
|
1041
|
+
if (urlOrPredicate.test(entryUrl)) {
|
|
1042
|
+
return { url: () => entryUrl, method: () => method };
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
await this.waitForTimeout(200);
|
|
1048
|
+
}
|
|
1049
|
+
throw new Error(`Timeout waiting for request (${timeout}ms)`);
|
|
1050
|
+
}
|
|
1051
|
+
// ==================== Network Interception ====================
|
|
1052
|
+
/**
|
|
1053
|
+
* Intercept network requests matching a URL pattern.
|
|
1054
|
+
*
|
|
1055
|
+
* @param url - URL pattern (glob) or RegExp to match
|
|
1056
|
+
* @param handler - Handler function to process intercepted requests
|
|
1057
|
+
* @param options - Route options (e.g., times)
|
|
1058
|
+
*/
|
|
1059
|
+
async route(url, handler, options) {
|
|
1060
|
+
const urlPattern = url instanceof RegExp ? url.source : url;
|
|
1061
|
+
// Enable network interception
|
|
1062
|
+
await this._client.execute('browser_enable_network_interception', {
|
|
1063
|
+
context_id: this._contextId,
|
|
1064
|
+
enable: true,
|
|
1065
|
+
});
|
|
1066
|
+
// Add a block rule first (handler will override with fulfill/abort/continue)
|
|
1067
|
+
const result = await this._client.execute('browser_add_network_rule', {
|
|
1068
|
+
context_id: this._contextId,
|
|
1069
|
+
url_pattern: urlPattern,
|
|
1070
|
+
action: 'block',
|
|
1071
|
+
is_regex: url instanceof RegExp,
|
|
1072
|
+
});
|
|
1073
|
+
const res = result;
|
|
1074
|
+
const ruleId = String(res['rule_id'] ?? '');
|
|
1075
|
+
this._routes.push({
|
|
1076
|
+
urlPattern,
|
|
1077
|
+
ruleId,
|
|
1078
|
+
handler,
|
|
1079
|
+
remaining: options?.times ?? null,
|
|
1080
|
+
});
|
|
1081
|
+
// Invoke handler with a Route instance
|
|
1082
|
+
const route = new Route(this._client, this._contextId, ruleId, urlPattern);
|
|
1083
|
+
await handler(route);
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Remove a previously registered route handler.
|
|
1087
|
+
*
|
|
1088
|
+
* @param url - URL pattern that was registered
|
|
1089
|
+
* @param handler - Optional specific handler to remove
|
|
1090
|
+
*/
|
|
1091
|
+
async unroute(url, handler) {
|
|
1092
|
+
const urlPattern = url instanceof RegExp ? url.source : url;
|
|
1093
|
+
for (let i = this._routes.length - 1; i >= 0; i--) {
|
|
1094
|
+
const entry = this._routes[i];
|
|
1095
|
+
if (entry && entry.urlPattern === urlPattern) {
|
|
1096
|
+
if (handler === undefined || entry.handler === handler) {
|
|
1097
|
+
try {
|
|
1098
|
+
await this._client.execute('browser_remove_network_rule', {
|
|
1099
|
+
context_id: this._contextId,
|
|
1100
|
+
rule_id: entry.ruleId,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
// Rule may already be removed
|
|
1105
|
+
}
|
|
1106
|
+
this._routes.splice(i, 1);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/** Remove all route handlers. */
|
|
1112
|
+
async unrouteAll() {
|
|
1113
|
+
for (const entry of this._routes) {
|
|
1114
|
+
try {
|
|
1115
|
+
await this._client.execute('browser_remove_network_rule', {
|
|
1116
|
+
context_id: this._contextId,
|
|
1117
|
+
rule_id: entry.ruleId,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
// Ignore
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
this._routes.length = 0;
|
|
1125
|
+
}
|
|
1126
|
+
// ==================== Events ====================
|
|
1127
|
+
/**
|
|
1128
|
+
* Register an event handler.
|
|
1129
|
+
*
|
|
1130
|
+
* @param event - Event name ('dialog', 'console', 'download', etc.)
|
|
1131
|
+
* @param handler - Event handler function
|
|
1132
|
+
*/
|
|
1133
|
+
on(event, handler) {
|
|
1134
|
+
const handlers = this._eventHandlers.get(event) ?? [];
|
|
1135
|
+
handlers.push(handler);
|
|
1136
|
+
this._eventHandlers.set(event, handlers);
|
|
1137
|
+
// Start background polling when relevant event listeners are registered
|
|
1138
|
+
if (event === 'dialog' && !this._dialogPollTimer) {
|
|
1139
|
+
this._startDialogPolling();
|
|
1140
|
+
}
|
|
1141
|
+
if (event === 'console' && !this._consolePollTimer) {
|
|
1142
|
+
this._startConsolePolling();
|
|
1143
|
+
}
|
|
1144
|
+
return this;
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Register a one-time event handler.
|
|
1148
|
+
*
|
|
1149
|
+
* @param event - Event name
|
|
1150
|
+
* @param handler - Event handler function (called once then removed)
|
|
1151
|
+
*/
|
|
1152
|
+
once(event, handler) {
|
|
1153
|
+
const wrapper = (...args) => {
|
|
1154
|
+
this.off(event, wrapper);
|
|
1155
|
+
return handler(...args);
|
|
1156
|
+
};
|
|
1157
|
+
return this.on(event, wrapper);
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Remove an event handler.
|
|
1161
|
+
*
|
|
1162
|
+
* @param event - Event name
|
|
1163
|
+
* @param handler - Handler to remove
|
|
1164
|
+
*/
|
|
1165
|
+
off(event, handler) {
|
|
1166
|
+
const handlers = this._eventHandlers.get(event);
|
|
1167
|
+
if (handlers) {
|
|
1168
|
+
const idx = handlers.indexOf(handler);
|
|
1169
|
+
if (idx >= 0) {
|
|
1170
|
+
handlers.splice(idx, 1);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return this;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Remove all event handlers for an event (or all events).
|
|
1177
|
+
*
|
|
1178
|
+
* @param event - Optional event name
|
|
1179
|
+
*/
|
|
1180
|
+
removeAllListeners(event) {
|
|
1181
|
+
if (event) {
|
|
1182
|
+
this._eventHandlers.delete(event);
|
|
1183
|
+
}
|
|
1184
|
+
else {
|
|
1185
|
+
this._eventHandlers.clear();
|
|
1186
|
+
}
|
|
1187
|
+
return this;
|
|
1188
|
+
}
|
|
1189
|
+
// ==================== Viewport & Emulation ====================
|
|
1190
|
+
/** Set the viewport size. */
|
|
1191
|
+
async setViewportSize(size) {
|
|
1192
|
+
await this._client.execute('browser_set_viewport', {
|
|
1193
|
+
context_id: this._contextId,
|
|
1194
|
+
width: size.width,
|
|
1195
|
+
height: size.height,
|
|
1196
|
+
});
|
|
1197
|
+
this._viewport = { ...size };
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Emulate media features.
|
|
1201
|
+
*
|
|
1202
|
+
* @param options - Media emulation options (colorScheme, reducedMotion)
|
|
1203
|
+
*/
|
|
1204
|
+
async emulateMedia(options) {
|
|
1205
|
+
if (options?.colorScheme) {
|
|
1206
|
+
await this.evaluate(`document.documentElement.style.colorScheme = '${options.colorScheme}'`);
|
|
1207
|
+
}
|
|
1208
|
+
// Media type and other options require CDP which Owl wraps differently
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Set extra HTTP headers for all requests.
|
|
1212
|
+
*
|
|
1213
|
+
* No-op: Owl Browser does not support runtime header injection.
|
|
1214
|
+
* Custom headers should be configured at context creation via VM profiles.
|
|
1215
|
+
*/
|
|
1216
|
+
async setExtraHTTPHeaders(headers) {
|
|
1217
|
+
void headers;
|
|
1218
|
+
// Not supported — requires VM profile configuration at context creation
|
|
1219
|
+
}
|
|
1220
|
+
// ==================== Scrolling & Zoom ====================
|
|
1221
|
+
/** Scroll by pixel delta. */
|
|
1222
|
+
async scrollBy(deltaX, deltaY) {
|
|
1223
|
+
const params = {
|
|
1224
|
+
context_id: this._contextId,
|
|
1225
|
+
y: Math.round(deltaY),
|
|
1226
|
+
};
|
|
1227
|
+
if (deltaX !== 0) {
|
|
1228
|
+
params['x'] = Math.round(deltaX);
|
|
1229
|
+
}
|
|
1230
|
+
await this._client.execute('browser_scroll_by', params);
|
|
1231
|
+
}
|
|
1232
|
+
/** Scroll an element into view. */
|
|
1233
|
+
async scrollToElement(selector) {
|
|
1234
|
+
await this._client.execute('browser_scroll_to_element', {
|
|
1235
|
+
context_id: this._contextId,
|
|
1236
|
+
selector,
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
/** Scroll to the top of the page. */
|
|
1240
|
+
async scrollToTop() {
|
|
1241
|
+
await this._client.execute('browser_scroll_to_top', {
|
|
1242
|
+
context_id: this._contextId,
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
/** Scroll to the bottom of the page. */
|
|
1246
|
+
async scrollToBottom() {
|
|
1247
|
+
await this._client.execute('browser_scroll_to_bottom', {
|
|
1248
|
+
context_id: this._contextId,
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
/** Zoom in the page. */
|
|
1252
|
+
async zoomIn() {
|
|
1253
|
+
await this._client.execute('browser_zoom_in', {
|
|
1254
|
+
context_id: this._contextId,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
/** Zoom out the page. */
|
|
1258
|
+
async zoomOut() {
|
|
1259
|
+
await this._client.execute('browser_zoom_out', {
|
|
1260
|
+
context_id: this._contextId,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
/** Reset zoom to 100%. */
|
|
1264
|
+
async zoomReset() {
|
|
1265
|
+
await this._client.execute('browser_zoom_reset', {
|
|
1266
|
+
context_id: this._contextId,
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
// ==================== Clipboard ====================
|
|
1270
|
+
/** Read text from the clipboard. */
|
|
1271
|
+
async clipboardRead() {
|
|
1272
|
+
const result = await this._client.execute('browser_clipboard_read', {
|
|
1273
|
+
context_id: this._contextId,
|
|
1274
|
+
});
|
|
1275
|
+
const res = result;
|
|
1276
|
+
return String(res['text'] ?? res['content'] ?? '');
|
|
1277
|
+
}
|
|
1278
|
+
/** Write text to the clipboard. */
|
|
1279
|
+
async clipboardWrite(text) {
|
|
1280
|
+
await this._client.execute('browser_clipboard_write', {
|
|
1281
|
+
context_id: this._contextId,
|
|
1282
|
+
text,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
/** Clear the clipboard. */
|
|
1286
|
+
async clipboardClear() {
|
|
1287
|
+
await this._client.execute('browser_clipboard_clear', {
|
|
1288
|
+
context_id: this._contextId,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
// ==================== Console ====================
|
|
1292
|
+
/**
|
|
1293
|
+
* Get console log entries.
|
|
1294
|
+
*
|
|
1295
|
+
* @param options - Filter options (level, filter text, limit)
|
|
1296
|
+
* @returns Array of ConsoleMessage objects
|
|
1297
|
+
*/
|
|
1298
|
+
async getConsoleMessages(options) {
|
|
1299
|
+
const params = {
|
|
1300
|
+
context_id: this._contextId,
|
|
1301
|
+
};
|
|
1302
|
+
if (options?.level)
|
|
1303
|
+
params['level'] = options.level;
|
|
1304
|
+
if (options?.filter)
|
|
1305
|
+
params['filter'] = options.filter;
|
|
1306
|
+
if (options?.limit !== undefined)
|
|
1307
|
+
params['limit'] = String(options.limit);
|
|
1308
|
+
const result = await this._client.execute('browser_get_console_log', params);
|
|
1309
|
+
const res = result;
|
|
1310
|
+
const logs = res['logs'] ?? res['entries'] ?? [];
|
|
1311
|
+
if (!Array.isArray(logs))
|
|
1312
|
+
return [];
|
|
1313
|
+
return logs.map((entry) => {
|
|
1314
|
+
const e = entry;
|
|
1315
|
+
return new ConsoleMessage(String(e['level'] ?? 'log'), String(e['message'] ?? e['text'] ?? ''), e['url'], e['line'], e['column']);
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
/** Clear console logs. */
|
|
1319
|
+
async clearConsole() {
|
|
1320
|
+
await this._client.execute('browser_clear_console_log', {
|
|
1321
|
+
context_id: this._contextId,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
// ==================== Video ====================
|
|
1325
|
+
/**
|
|
1326
|
+
* Get the Video instance for this page (if recording is active).
|
|
1327
|
+
*
|
|
1328
|
+
* @returns Video instance or null
|
|
1329
|
+
*/
|
|
1330
|
+
video() {
|
|
1331
|
+
return this._video;
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Start video recording for this page.
|
|
1335
|
+
*
|
|
1336
|
+
* @param options - Recording options (fps, codec)
|
|
1337
|
+
*/
|
|
1338
|
+
async startVideoRecording(options) {
|
|
1339
|
+
const params = {
|
|
1340
|
+
context_id: this._contextId,
|
|
1341
|
+
};
|
|
1342
|
+
if (options?.fps !== undefined)
|
|
1343
|
+
params['fps'] = String(options.fps);
|
|
1344
|
+
if (options?.codec)
|
|
1345
|
+
params['codec'] = options.codec;
|
|
1346
|
+
await this._client.execute('browser_start_video_recording', params);
|
|
1347
|
+
this._video = new Video(this._client, this._contextId);
|
|
1348
|
+
return this._video;
|
|
1349
|
+
}
|
|
1350
|
+
/** Stop video recording. */
|
|
1351
|
+
async stopVideoRecording() {
|
|
1352
|
+
await this._client.execute('browser_stop_video_recording', {
|
|
1353
|
+
context_id: this._contextId,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
// ==================== Locators ====================
|
|
1357
|
+
/** Create a Locator for the given selector. */
|
|
1358
|
+
locator(selector) {
|
|
1359
|
+
return new Locator(this, selector);
|
|
1360
|
+
}
|
|
1361
|
+
/** Query a single element (alias for locator). */
|
|
1362
|
+
$(selector) {
|
|
1363
|
+
return new Locator(this, selector);
|
|
1364
|
+
}
|
|
1365
|
+
/** Query all matching elements (returns locators for each match). */
|
|
1366
|
+
async $$(selector) {
|
|
1367
|
+
const loc = new Locator(this, selector);
|
|
1368
|
+
const n = await loc.count();
|
|
1369
|
+
const locators = [];
|
|
1370
|
+
for (let i = 0; i < n; i++) {
|
|
1371
|
+
locators.push(new NthLocator(this, loc._selector, i));
|
|
1372
|
+
}
|
|
1373
|
+
return locators;
|
|
1374
|
+
}
|
|
1375
|
+
/** Get a locator by text content. */
|
|
1376
|
+
getByText(text, options) {
|
|
1377
|
+
void options;
|
|
1378
|
+
const textStr = text instanceof RegExp ? text.source : text;
|
|
1379
|
+
return new Locator(this, `text=${textStr}`);
|
|
1380
|
+
}
|
|
1381
|
+
/** Get a locator by role. */
|
|
1382
|
+
getByRole(role, options) {
|
|
1383
|
+
if (options?.name) {
|
|
1384
|
+
const name = options.name instanceof RegExp ? options.name.source : options.name;
|
|
1385
|
+
return new Locator(this, `role=${role}[name="${name}"]`);
|
|
1386
|
+
}
|
|
1387
|
+
return new Locator(this, `role=${role}`);
|
|
1388
|
+
}
|
|
1389
|
+
/** Get a locator by placeholder text. */
|
|
1390
|
+
getByPlaceholder(text, options) {
|
|
1391
|
+
void options;
|
|
1392
|
+
const textStr = text instanceof RegExp ? text.source : text;
|
|
1393
|
+
return new Locator(this, `placeholder=${textStr}`);
|
|
1394
|
+
}
|
|
1395
|
+
/** Get a locator by label text. */
|
|
1396
|
+
getByLabel(text, options) {
|
|
1397
|
+
void options;
|
|
1398
|
+
const textStr = text instanceof RegExp ? text.source : text;
|
|
1399
|
+
return new Locator(this, `label=${textStr}`);
|
|
1400
|
+
}
|
|
1401
|
+
/** Get a locator by alt text. */
|
|
1402
|
+
getByAltText(text, options) {
|
|
1403
|
+
void options;
|
|
1404
|
+
const textStr = text instanceof RegExp ? text.source : text;
|
|
1405
|
+
return new Locator(this, `alt=${textStr}`);
|
|
1406
|
+
}
|
|
1407
|
+
/** Get a locator by title attribute. */
|
|
1408
|
+
getByTitle(text, options) {
|
|
1409
|
+
void options;
|
|
1410
|
+
const textStr = text instanceof RegExp ? text.source : text;
|
|
1411
|
+
return new Locator(this, `title=${textStr}`);
|
|
1412
|
+
}
|
|
1413
|
+
/** Get a locator by data-testid attribute. */
|
|
1414
|
+
getByTestId(testId) {
|
|
1415
|
+
const idStr = testId instanceof RegExp ? testId.source : testId;
|
|
1416
|
+
return new Locator(this, `data-testid=${idStr}`);
|
|
1417
|
+
}
|
|
1418
|
+
// ==================== Frames ====================
|
|
1419
|
+
/** Get all frames on the page (cached, returns main frame). */
|
|
1420
|
+
frames() {
|
|
1421
|
+
return [this._mainFrame];
|
|
1422
|
+
}
|
|
1423
|
+
/** Get all frames by querying the browser. */
|
|
1424
|
+
async framesAsync() {
|
|
1425
|
+
try {
|
|
1426
|
+
const result = await this._client.execute('browser_list_frames', {
|
|
1427
|
+
context_id: this._contextId,
|
|
1428
|
+
});
|
|
1429
|
+
// browser_list_frames returns a JSON array directly
|
|
1430
|
+
const frames = Array.isArray(result) ? result : [];
|
|
1431
|
+
if (frames.length === 0)
|
|
1432
|
+
return [this._mainFrame];
|
|
1433
|
+
return frames.map((f) => {
|
|
1434
|
+
const entry = f;
|
|
1435
|
+
const id = String(entry['frame_id'] ?? entry['id'] ?? '');
|
|
1436
|
+
const name = String(entry['name'] ?? '');
|
|
1437
|
+
return new Frame(this._client, this._contextId, id, name || id);
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
return [this._mainFrame];
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/** Get a frame by name or URL. */
|
|
1445
|
+
frame(nameOrUrl) {
|
|
1446
|
+
if (typeof nameOrUrl === 'string') {
|
|
1447
|
+
return new Frame(this._client, this._contextId, nameOrUrl, nameOrUrl);
|
|
1448
|
+
}
|
|
1449
|
+
const selector = nameOrUrl.name ?? '';
|
|
1450
|
+
return new Frame(this._client, this._contextId, selector, selector);
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Get a FrameLocator for interacting with iframe content.
|
|
1454
|
+
*
|
|
1455
|
+
* @param selector - CSS selector for the iframe element
|
|
1456
|
+
* @returns A FrameLocator scoped to the iframe
|
|
1457
|
+
*/
|
|
1458
|
+
frameLocator(selector) {
|
|
1459
|
+
return new FrameLocator(this._client, this._contextId, selector, this);
|
|
1460
|
+
}
|
|
1461
|
+
// ==================== Tab Management ====================
|
|
1462
|
+
/** Close the page/tab. */
|
|
1463
|
+
async close() {
|
|
1464
|
+
this._closed = true;
|
|
1465
|
+
if (this._dialogPollTimer) {
|
|
1466
|
+
clearTimeout(this._dialogPollTimer);
|
|
1467
|
+
this._dialogPollTimer = null;
|
|
1468
|
+
}
|
|
1469
|
+
if (this._consolePollTimer) {
|
|
1470
|
+
clearTimeout(this._consolePollTimer);
|
|
1471
|
+
this._consolePollTimer = null;
|
|
1472
|
+
}
|
|
1473
|
+
if (this._tabId) {
|
|
1474
|
+
try {
|
|
1475
|
+
await this._client.execute('browser_close_tab', {
|
|
1476
|
+
context_id: this._contextId,
|
|
1477
|
+
tab_id: this._tabId,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
catch {
|
|
1481
|
+
// Tab may already be closed
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
/** Bring the page tab to front. */
|
|
1486
|
+
async bringToFront() {
|
|
1487
|
+
if (this._tabId) {
|
|
1488
|
+
await this._client.execute('browser_switch_tab', {
|
|
1489
|
+
context_id: this._contextId,
|
|
1490
|
+
tab_id: this._tabId,
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// ==================== Downloads ====================
|
|
1495
|
+
/**
|
|
1496
|
+
* Set the download directory path.
|
|
1497
|
+
*
|
|
1498
|
+
* @param path - Absolute directory path for downloads
|
|
1499
|
+
*/
|
|
1500
|
+
async setDownloadPath(path) {
|
|
1501
|
+
await this._client.execute('browser_set_download_path', {
|
|
1502
|
+
context_id: this._contextId,
|
|
1503
|
+
path,
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Get all downloads for this context.
|
|
1508
|
+
*
|
|
1509
|
+
* @returns Array of Download objects
|
|
1510
|
+
*/
|
|
1511
|
+
async getDownloads() {
|
|
1512
|
+
const result = await this._client.execute('browser_get_downloads', {
|
|
1513
|
+
context_id: this._contextId,
|
|
1514
|
+
});
|
|
1515
|
+
const res = result;
|
|
1516
|
+
const downloads = res['downloads'] ?? [];
|
|
1517
|
+
if (!Array.isArray(downloads))
|
|
1518
|
+
return [];
|
|
1519
|
+
return downloads.map((d) => {
|
|
1520
|
+
const entry = d;
|
|
1521
|
+
return new Download(this._client, this._contextId, String(entry['id'] ?? entry['download_id'] ?? ''), String(entry['url'] ?? ''), String(entry['filename'] ?? entry['suggested_filename'] ?? ''));
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
// ==================== Dialog Handling ====================
|
|
1525
|
+
/**
|
|
1526
|
+
* Configure automatic dialog handling.
|
|
1527
|
+
*
|
|
1528
|
+
* @param dialogType - Dialog type ('alert', 'confirm', 'prompt', 'beforeunload')
|
|
1529
|
+
* @param action - Action ('accept', 'dismiss', 'accept_with_text')
|
|
1530
|
+
* @param promptText - Text for prompt dialogs
|
|
1531
|
+
*/
|
|
1532
|
+
async setDialogAction(dialogType, action, promptText) {
|
|
1533
|
+
const params = {
|
|
1534
|
+
context_id: this._contextId,
|
|
1535
|
+
dialog_type: dialogType,
|
|
1536
|
+
action,
|
|
1537
|
+
};
|
|
1538
|
+
if (promptText !== undefined) {
|
|
1539
|
+
params['prompt_text'] = promptText;
|
|
1540
|
+
}
|
|
1541
|
+
await this._client.execute('browser_set_dialog_action', params);
|
|
1542
|
+
}
|
|
1543
|
+
// ==================== Internal Helpers ====================
|
|
1544
|
+
/** Sync the internal URL cache with the actual page URL. */
|
|
1545
|
+
async _syncUrl() {
|
|
1546
|
+
try {
|
|
1547
|
+
const result = await this._client.execute('browser_get_page_info', {
|
|
1548
|
+
context_id: this._contextId,
|
|
1549
|
+
});
|
|
1550
|
+
const res = result;
|
|
1551
|
+
if (typeof res['url'] === 'string') {
|
|
1552
|
+
this._url = res['url'];
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
catch {
|
|
1556
|
+
// Non-critical; URL cache may be stale
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
/** Start polling for dialogs and dispatching to event handlers. */
|
|
1560
|
+
_startDialogPolling() {
|
|
1561
|
+
// Set all dialog types to "wait" mode so dialogs stay pending for our poll
|
|
1562
|
+
this._dialogSetupReady = Promise.allSettled(['alert', 'confirm', 'prompt', 'beforeunload'].map(dt => this._client.execute('browser_set_dialog_action', {
|
|
1563
|
+
context_id: this._contextId,
|
|
1564
|
+
dialog_type: dt,
|
|
1565
|
+
action: 'wait',
|
|
1566
|
+
}))).then(() => { });
|
|
1567
|
+
const poll = async () => {
|
|
1568
|
+
if (this._closed)
|
|
1569
|
+
return;
|
|
1570
|
+
try {
|
|
1571
|
+
const result = await this._client.execute('browser_get_pending_dialog', {
|
|
1572
|
+
context_id: this._contextId,
|
|
1573
|
+
});
|
|
1574
|
+
const res = result;
|
|
1575
|
+
const dialogType = res['dialog_type'] ?? res['type'];
|
|
1576
|
+
if (dialogType) {
|
|
1577
|
+
const dialog = new Dialog(this._client, String(dialogType), String(res['message'] ?? ''), String(res['default_value'] ?? ''), String(res['dialog_id'] ?? res['id'] ?? ''));
|
|
1578
|
+
const handlers = this._eventHandlers.get('dialog') ?? [];
|
|
1579
|
+
for (const handler of handlers) {
|
|
1580
|
+
try {
|
|
1581
|
+
await handler(dialog);
|
|
1582
|
+
}
|
|
1583
|
+
catch {
|
|
1584
|
+
// Handler error; ignore
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
catch {
|
|
1590
|
+
// No dialog pending or error; ignore
|
|
1591
|
+
}
|
|
1592
|
+
if (!this._closed) {
|
|
1593
|
+
this._dialogPollTimer = setTimeout(poll, 250);
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
this._dialogPollTimer = setTimeout(poll, 250);
|
|
1597
|
+
}
|
|
1598
|
+
/** Start polling for console messages and dispatching to event handlers. */
|
|
1599
|
+
_startConsolePolling() {
|
|
1600
|
+
const poll = async () => {
|
|
1601
|
+
if (this._closed)
|
|
1602
|
+
return;
|
|
1603
|
+
try {
|
|
1604
|
+
const messages = await this.getConsoleMessages();
|
|
1605
|
+
const newMessages = messages.slice(this._consoleLogOffset);
|
|
1606
|
+
this._consoleLogOffset = messages.length;
|
|
1607
|
+
const handlers = this._eventHandlers.get('console') ?? [];
|
|
1608
|
+
for (const msg of newMessages) {
|
|
1609
|
+
for (const handler of handlers) {
|
|
1610
|
+
try {
|
|
1611
|
+
await handler(msg);
|
|
1612
|
+
}
|
|
1613
|
+
catch {
|
|
1614
|
+
// Handler error; ignore
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
catch {
|
|
1620
|
+
// Error polling console; ignore
|
|
1621
|
+
}
|
|
1622
|
+
if (!this._closed) {
|
|
1623
|
+
this._consolePollTimer = setTimeout(poll, 500);
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1626
|
+
this._consolePollTimer = setTimeout(poll, 500);
|
|
1627
|
+
}
|
|
1628
|
+
/** Wait for a download event. */
|
|
1629
|
+
async _waitForDownload(timeout) {
|
|
1630
|
+
const start = Date.now();
|
|
1631
|
+
while (Date.now() - start < timeout) {
|
|
1632
|
+
const result = await this._client.execute('browser_get_downloads', {
|
|
1633
|
+
context_id: this._contextId,
|
|
1634
|
+
});
|
|
1635
|
+
const res = result;
|
|
1636
|
+
const downloads = res['downloads'];
|
|
1637
|
+
if (Array.isArray(downloads) && downloads.length > 0) {
|
|
1638
|
+
const latest = downloads[downloads.length - 1];
|
|
1639
|
+
return new Download(this._client, this._contextId, String(latest['id'] ?? latest['download_id'] ?? ''), String(latest['url'] ?? ''), String(latest['filename'] ?? latest['suggested_filename'] ?? ''));
|
|
1640
|
+
}
|
|
1641
|
+
await this.waitForTimeout(200);
|
|
1642
|
+
}
|
|
1643
|
+
throw new Error(`Timeout waiting for download (${timeout}ms)`);
|
|
1644
|
+
}
|
|
1645
|
+
/** Wait for a dialog event. */
|
|
1646
|
+
async _waitForDialog(timeout) {
|
|
1647
|
+
const params = {
|
|
1648
|
+
context_id: this._contextId,
|
|
1649
|
+
};
|
|
1650
|
+
if (timeout) {
|
|
1651
|
+
params['timeout'] = String(timeout);
|
|
1652
|
+
}
|
|
1653
|
+
const result = await this._client.execute('browser_wait_for_dialog', params);
|
|
1654
|
+
const res = result;
|
|
1655
|
+
return new Dialog(this._client, String(res['dialog_type'] ?? res['type'] ?? 'alert'), String(res['message'] ?? ''), String(res['default_value'] ?? ''), String(res['dialog_id'] ?? res['id'] ?? ''));
|
|
1656
|
+
}
|
|
1657
|
+
/** Wait for a console message event. */
|
|
1658
|
+
async _waitForConsole(timeout) {
|
|
1659
|
+
const start = Date.now();
|
|
1660
|
+
while (Date.now() - start < timeout) {
|
|
1661
|
+
const messages = await this.getConsoleMessages({ limit: 1 });
|
|
1662
|
+
if (messages.length > 0 && messages[0]) {
|
|
1663
|
+
return messages[0];
|
|
1664
|
+
}
|
|
1665
|
+
await this.waitForTimeout(200);
|
|
1666
|
+
}
|
|
1667
|
+
throw new Error(`Timeout waiting for console message (${timeout}ms)`);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
/** Map Playwright waitUntil values to Owl Browser equivalents. */
|
|
1671
|
+
function mapWaitUntil(value) {
|
|
1672
|
+
switch (value) {
|
|
1673
|
+
case 'load':
|
|
1674
|
+
return 'load';
|
|
1675
|
+
case 'domcontentloaded':
|
|
1676
|
+
return 'domcontentloaded';
|
|
1677
|
+
case 'networkidle':
|
|
1678
|
+
return 'networkidle';
|
|
1679
|
+
case 'commit':
|
|
1680
|
+
return '';
|
|
1681
|
+
default:
|
|
1682
|
+
return 'load';
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
/** Extract all option values from SelectOptionValue as an array. */
|
|
1686
|
+
function extractSelectValues(values) {
|
|
1687
|
+
if (typeof values === 'string')
|
|
1688
|
+
return [values];
|
|
1689
|
+
if (Array.isArray(values)) {
|
|
1690
|
+
return values.map((v) => {
|
|
1691
|
+
if (typeof v === 'string')
|
|
1692
|
+
return v;
|
|
1693
|
+
return v.value ?? v.label ?? '';
|
|
1694
|
+
}).filter(Boolean);
|
|
1695
|
+
}
|
|
1696
|
+
return [values.value ?? values.label ?? ''].filter(Boolean);
|
|
1697
|
+
}
|
|
1698
|
+
//# sourceMappingURL=page.js.map
|