@anweat/dsh-browser 0.1.13-alpha.1 → 0.1.14-alpha.2
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 -21
- package/README.md +393 -382
- package/cordis.patch.yml +26 -26
- package/lib/approval-policy.js +1 -1
- package/lib/approval-policy.js.map +1 -1
- package/lib/browser-service.d.ts +90 -5
- package/lib/browser-service.js +410 -100
- package/lib/browser-service.js.map +1 -1
- package/lib/client/styles.js +12 -12
- package/lib/freedom.d.ts +1 -1
- package/lib/freedom.js +9 -0
- package/lib/freedom.js.map +1 -1
- package/lib/index.js +8 -14
- package/lib/index.js.map +1 -1
- package/lib/scripts.js +27 -27
- package/lib/tools.js +188 -28
- package/lib/tools.js.map +1 -1
- package/package.json +147 -150
- package/scripts/check-client-bundle.mjs +15 -15
- package/scripts/clean-lib.mjs +3 -3
- package/scripts/install-browser.mjs +18 -18
package/lib/browser-service.js
CHANGED
|
@@ -30,66 +30,66 @@ import { configuredBrowserTools } from "./freedom.js";
|
|
|
30
30
|
import { filterOpencliCatalog, parseOpencliCatalog } from "./opencli-catalog.js";
|
|
31
31
|
import { UsageGovernor } from "./usage-policy.js";
|
|
32
32
|
/** Rules-aware content extractor (runs in the page). */
|
|
33
|
-
const EXTRACTOR_FN = `(ruleList) => {
|
|
34
|
-
const doc = document
|
|
35
|
-
const title = doc.title ? doc.title.trim() : ''
|
|
36
|
-
let host = ''
|
|
37
|
-
try { host = location.hostname } catch (e) {}
|
|
38
|
-
const norm = (h) => { const x = h.toLowerCase(); return x.startsWith('www.') ? x.slice(4) : x }
|
|
39
|
-
let rule = null
|
|
40
|
-
for (const r of ruleList) {
|
|
41
|
-
const rh = norm(r.hostname)
|
|
42
|
-
if (host === rh || host.endsWith('.' + rh)) rule = r
|
|
43
|
-
}
|
|
44
|
-
const pick = (selectors) => {
|
|
45
|
-
for (const sel of selectors) {
|
|
46
|
-
try {
|
|
47
|
-
const el = doc.querySelector(sel)
|
|
48
|
-
if (el && (el.textContent || '').trim().length > 40) return el
|
|
49
|
-
} catch (e) {}
|
|
50
|
-
}
|
|
51
|
-
return null
|
|
52
|
-
}
|
|
53
|
-
const content = rule ? pick(rule.contentSelectors) : (pick(['article', 'main', '[role="main"]']) || doc.body)
|
|
54
|
-
if (content && rule && rule.removeSelectors) {
|
|
55
|
-
for (const sel of rule.removeSelectors) {
|
|
56
|
-
try { content.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) {}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
const text = content ? (content.innerText || content.textContent || '') : ''
|
|
60
|
-
return { title, text, html: doc.documentElement.outerHTML.slice(0, 2000000), usedRule: rule ? rule.hostname : null }
|
|
33
|
+
const EXTRACTOR_FN = `(ruleList) => {
|
|
34
|
+
const doc = document
|
|
35
|
+
const title = doc.title ? doc.title.trim() : ''
|
|
36
|
+
let host = ''
|
|
37
|
+
try { host = location.hostname } catch (e) {}
|
|
38
|
+
const norm = (h) => { const x = h.toLowerCase(); return x.startsWith('www.') ? x.slice(4) : x }
|
|
39
|
+
let rule = null
|
|
40
|
+
for (const r of ruleList) {
|
|
41
|
+
const rh = norm(r.hostname)
|
|
42
|
+
if (host === rh || host.endsWith('.' + rh)) rule = r
|
|
43
|
+
}
|
|
44
|
+
const pick = (selectors) => {
|
|
45
|
+
for (const sel of selectors) {
|
|
46
|
+
try {
|
|
47
|
+
const el = doc.querySelector(sel)
|
|
48
|
+
if (el && (el.textContent || '').trim().length > 40) return el
|
|
49
|
+
} catch (e) {}
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
const content = rule ? pick(rule.contentSelectors) : (pick(['article', 'main', '[role="main"]']) || doc.body)
|
|
54
|
+
if (content && rule && rule.removeSelectors) {
|
|
55
|
+
for (const sel of rule.removeSelectors) {
|
|
56
|
+
try { content.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const text = content ? (content.innerText || content.textContent || '') : ''
|
|
60
|
+
return { title, text, html: doc.documentElement.outerHTML.slice(0, 2000000), usedRule: rule ? rule.hostname : null }
|
|
61
61
|
}`;
|
|
62
62
|
/** Platform search-page list extractor (runs in the page). */
|
|
63
|
-
const LIST_EXTRACTOR = `(spec) => {
|
|
64
|
-
const items = []
|
|
65
|
-
const nodes = document.querySelectorAll(spec.item)
|
|
66
|
-
for (let i = 0; i < nodes.length && items.length < 20; i++) {
|
|
67
|
-
const el = nodes[i]
|
|
68
|
-
const titleEl = spec.title ? el.querySelector(spec.title) : null
|
|
69
|
-
const linkEl = spec.link ? el.querySelector(spec.link) : null
|
|
70
|
-
const textEl = spec.text ? el.querySelector(spec.text) : null
|
|
71
|
-
const title = (titleEl ? titleEl.textContent : el.textContent || '').trim().replace(/\\s+/g, ' ')
|
|
72
|
-
let url = linkEl ? (linkEl.href || linkEl.getAttribute('href') || '') : ''
|
|
73
|
-
if (url && url.startsWith('/')) url = location.origin + url
|
|
74
|
-
if (!url && linkEl === null && titleEl) { const a = titleEl.closest ? titleEl.closest('a') : null; if (a) url = a.href }
|
|
75
|
-
const snippet = textEl ? (textEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 300) : ''
|
|
76
|
-
if (!url || !title || title.length < 2) continue
|
|
77
|
-
items.push({ url, title, snippet })
|
|
78
|
-
}
|
|
79
|
-
return items
|
|
63
|
+
const LIST_EXTRACTOR = `(spec) => {
|
|
64
|
+
const items = []
|
|
65
|
+
const nodes = document.querySelectorAll(spec.item)
|
|
66
|
+
for (let i = 0; i < nodes.length && items.length < 20; i++) {
|
|
67
|
+
const el = nodes[i]
|
|
68
|
+
const titleEl = spec.title ? el.querySelector(spec.title) : null
|
|
69
|
+
const linkEl = spec.link ? el.querySelector(spec.link) : null
|
|
70
|
+
const textEl = spec.text ? el.querySelector(spec.text) : null
|
|
71
|
+
const title = (titleEl ? titleEl.textContent : el.textContent || '').trim().replace(/\\s+/g, ' ')
|
|
72
|
+
let url = linkEl ? (linkEl.href || linkEl.getAttribute('href') || '') : ''
|
|
73
|
+
if (url && url.startsWith('/')) url = location.origin + url
|
|
74
|
+
if (!url && linkEl === null && titleEl) { const a = titleEl.closest ? titleEl.closest('a') : null; if (a) url = a.href }
|
|
75
|
+
const snippet = textEl ? (textEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 300) : ''
|
|
76
|
+
if (!url || !title || title.length < 2) continue
|
|
77
|
+
items.push({ url, title, snippet })
|
|
78
|
+
}
|
|
79
|
+
return items
|
|
80
80
|
}`;
|
|
81
|
-
const CRAWL_EXTRACTOR = `(maxChars) => {
|
|
82
|
-
const root = document.querySelector('article, main, [role="main"]') || document.body
|
|
83
|
-
const text = ((root && (root.innerText || root.textContent)) || '').replace(/\\n{3,}/g, '\\n\\n').trim().slice(0, maxChars)
|
|
84
|
-
const links = []
|
|
85
|
-
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
86
|
-
try {
|
|
87
|
-
const url = new URL(anchor.href, location.href)
|
|
88
|
-
if ((url.protocol === 'http:' || url.protocol === 'https:') && !links.includes(url.href)) links.push(url.href)
|
|
89
|
-
if (links.length >= 500) break
|
|
90
|
-
} catch (e) {}
|
|
91
|
-
}
|
|
92
|
-
return { title: document.title || '', text, links }
|
|
81
|
+
const CRAWL_EXTRACTOR = `(maxChars) => {
|
|
82
|
+
const root = document.querySelector('article, main, [role="main"]') || document.body
|
|
83
|
+
const text = ((root && (root.innerText || root.textContent)) || '').replace(/\\n{3,}/g, '\\n\\n').trim().slice(0, maxChars)
|
|
84
|
+
const links = []
|
|
85
|
+
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
86
|
+
try {
|
|
87
|
+
const url = new URL(anchor.href, location.href)
|
|
88
|
+
if ((url.protocol === 'http:' || url.protocol === 'https:') && !links.includes(url.href)) links.push(url.href)
|
|
89
|
+
if (links.length >= 500) break
|
|
90
|
+
} catch (e) {}
|
|
91
|
+
}
|
|
92
|
+
return { title: document.title || '', text, links }
|
|
93
93
|
}`;
|
|
94
94
|
function uid() {
|
|
95
95
|
return crypto.randomUUID();
|
|
@@ -117,6 +117,35 @@ function storageStateOptions(statePath, label, allowMissing = false) {
|
|
|
117
117
|
throw new Error(`dsh-browser: ${label} storageState path is not a file: ${statePath}`);
|
|
118
118
|
return { storageState: statePath };
|
|
119
119
|
}
|
|
120
|
+
function boundedString(value, label, max) {
|
|
121
|
+
if (!value || value.length > max)
|
|
122
|
+
throw new Error(`${label} must contain 1 to ${max} characters`);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function boundedTimeout(value, label) {
|
|
126
|
+
const resolved = value ?? 15_000;
|
|
127
|
+
if (!Number.isFinite(resolved) || resolved < 1 || resolved > 30_000)
|
|
128
|
+
throw new Error(`${label} must be between 1 and 30,000 ms`);
|
|
129
|
+
return resolved;
|
|
130
|
+
}
|
|
131
|
+
function redactCaptureText(value, max = 2_000) {
|
|
132
|
+
return value
|
|
133
|
+
.replace(/\b(authorization|cookie|set-cookie|password|passwd|secret|token|api[-_]?key|session[-_]?id)\b\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
|
|
134
|
+
.slice(0, max);
|
|
135
|
+
}
|
|
136
|
+
function redactCaptureUrl(value) {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = new URL(value);
|
|
139
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
140
|
+
if (/token|key|auth|session|cookie|password|secret/i.test(key))
|
|
141
|
+
parsed.searchParams.set(key, '[redacted]');
|
|
142
|
+
}
|
|
143
|
+
return parsed.toString().slice(0, 2_000);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return redactCaptureText(value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
120
149
|
export class BrowserService {
|
|
121
150
|
config;
|
|
122
151
|
browser;
|
|
@@ -128,6 +157,10 @@ export class BrowserService {
|
|
|
128
157
|
authProfiles;
|
|
129
158
|
usageGovernor;
|
|
130
159
|
opencliCatalogCache;
|
|
160
|
+
captureConsoleEnabled = false;
|
|
161
|
+
captureNetworkEnabled = false;
|
|
162
|
+
capturedConsole = [];
|
|
163
|
+
capturedRequests = [];
|
|
131
164
|
constructor(config) {
|
|
132
165
|
this.config = config;
|
|
133
166
|
this.authProfiles = new AuthProfileStore(config.authProfiles);
|
|
@@ -140,10 +173,35 @@ export class BrowserService {
|
|
|
140
173
|
if (!this.config.enabled)
|
|
141
174
|
throw new Error('dsh-browser: browser service is disabled');
|
|
142
175
|
}
|
|
176
|
+
browserConnected(browser = this.browser) {
|
|
177
|
+
return !!browser && (typeof browser.isConnected !== 'function' || browser.isConnected());
|
|
178
|
+
}
|
|
179
|
+
clearActiveState() {
|
|
180
|
+
this.activeContext = undefined;
|
|
181
|
+
this.activePage = undefined;
|
|
182
|
+
this.activeProfile = undefined;
|
|
183
|
+
this.activeRulePack = undefined;
|
|
184
|
+
this.resetCapture();
|
|
185
|
+
}
|
|
186
|
+
handleBrowserDisconnected(browser) {
|
|
187
|
+
// A late event from an older process must not invalidate its replacement.
|
|
188
|
+
if (this.browser !== browser)
|
|
189
|
+
return;
|
|
190
|
+
this.browser = undefined;
|
|
191
|
+
this.launching = undefined;
|
|
192
|
+
this.clearActiveState();
|
|
193
|
+
}
|
|
194
|
+
trackBrowser(browser) {
|
|
195
|
+
this.browser = browser;
|
|
196
|
+
browser.on?.('disconnected', () => this.handleBrowserDisconnected(browser));
|
|
197
|
+
return browser;
|
|
198
|
+
}
|
|
143
199
|
async ensure() {
|
|
144
200
|
this.assertEnabled();
|
|
145
|
-
if (this.
|
|
201
|
+
if (this.browserConnected())
|
|
146
202
|
return this.browser;
|
|
203
|
+
if (this.browser)
|
|
204
|
+
this.handleBrowserDisconnected(this.browser);
|
|
147
205
|
if (!this.launching) {
|
|
148
206
|
this.launching = (async () => {
|
|
149
207
|
const pw = loadBrowserRuntime(this.config.browserRuntime);
|
|
@@ -153,14 +211,14 @@ export class BrowserService {
|
|
|
153
211
|
if (this.config.executablePath)
|
|
154
212
|
launchOptions.executablePath = this.config.executablePath;
|
|
155
213
|
try {
|
|
156
|
-
this.
|
|
214
|
+
return this.trackBrowser(await pw.chromium.launch(launchOptions));
|
|
157
215
|
}
|
|
158
216
|
catch (error) {
|
|
159
217
|
const msg = String(error);
|
|
160
218
|
if (/Executable doesn't exist|playwright install|not found/i.test(msg)) {
|
|
161
219
|
if (this.config.autoInstall) {
|
|
162
220
|
await this.installChromium();
|
|
163
|
-
this.
|
|
221
|
+
return this.trackBrowser(await pw.chromium.launch(launchOptions));
|
|
164
222
|
}
|
|
165
223
|
else {
|
|
166
224
|
throw new Error('dsh-browser: chromium is not installed for ' + this.config.browserRuntime + '. Run the browser_install tool, or: node "' + browserRuntimeCliPath(this.config.browserRuntime) + '" install chromium');
|
|
@@ -170,13 +228,16 @@ export class BrowserService {
|
|
|
170
228
|
throw error;
|
|
171
229
|
}
|
|
172
230
|
}
|
|
173
|
-
|
|
174
|
-
|
|
231
|
+
})();
|
|
232
|
+
}
|
|
233
|
+
const launching = this.launching;
|
|
234
|
+
try {
|
|
235
|
+
return await launching;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
if (this.launching === launching)
|
|
175
239
|
this.launching = undefined;
|
|
176
|
-
throw error;
|
|
177
|
-
});
|
|
178
240
|
}
|
|
179
|
-
return this.launching;
|
|
180
241
|
}
|
|
181
242
|
/** Run `playwright install chromium` from the bundled playwright CLI. */
|
|
182
243
|
installChromium() {
|
|
@@ -203,14 +264,27 @@ export class BrowserService {
|
|
|
203
264
|
return response;
|
|
204
265
|
}
|
|
205
266
|
async transientContext(url, opts = {}) {
|
|
206
|
-
const browser = await this.ensure();
|
|
207
267
|
const profileId = opts.anonymous ? undefined : (opts.authProfile ?? this.config.defaultAuthProfile);
|
|
208
268
|
const profile = profileId ? this.authProfiles.resolve(profileId, url) : undefined;
|
|
209
269
|
const rulePack = resolveRulePack(this.config.rulePacks, opts.rulePack, url);
|
|
210
270
|
const stateOptions = profile
|
|
211
271
|
? storageStateOptions(profile.storageStatePath, `auth profile ${profile.id}`, profile.persistState)
|
|
212
272
|
: (!opts.anonymous ? storageStateOptions(this.config.storageStatePath, 'global') : {});
|
|
213
|
-
|
|
273
|
+
let context;
|
|
274
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
275
|
+
const browser = await this.ensure();
|
|
276
|
+
try {
|
|
277
|
+
context = await browser.newContext(stateOptions);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (attempt > 0 || this.browserConnected(browser))
|
|
282
|
+
throw error;
|
|
283
|
+
this.handleBrowserDisconnected(browser);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!context)
|
|
287
|
+
throw new Error('dsh-browser: browser context could not be created after reconnecting');
|
|
214
288
|
try {
|
|
215
289
|
if (rulePack?.initScriptPath)
|
|
216
290
|
await context.addInitScript({ path: rulePack.initScriptPath });
|
|
@@ -224,7 +298,17 @@ export class BrowserService {
|
|
|
224
298
|
async persistAndClose(session) {
|
|
225
299
|
try {
|
|
226
300
|
if (session.profile?.persistState) {
|
|
227
|
-
|
|
301
|
+
let state;
|
|
302
|
+
try {
|
|
303
|
+
state = await session.context.storageState();
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// A crashed browser cannot provide state. Filesystem failures below
|
|
307
|
+
// still propagate so failed persistence is never reported as success.
|
|
308
|
+
if (/target page, context or browser has been closed|browser has been closed|browser disconnected/i.test(String(error)))
|
|
309
|
+
return;
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
228
312
|
fs.mkdirSync(path.dirname(session.profile.storageStatePath), { recursive: true });
|
|
229
313
|
const temporary = session.profile.storageStatePath + '.tmp-' + uid().slice(0, 8);
|
|
230
314
|
fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
@@ -543,6 +627,8 @@ export class BrowserService {
|
|
|
543
627
|
}
|
|
544
628
|
// ── interactive surface (one persistent context + page) ──────────────────
|
|
545
629
|
async ensureActivePage(targetUrl, opts = {}) {
|
|
630
|
+
if (this.activePage && (this.activePage.isClosed() || !this.browserConnected()))
|
|
631
|
+
await this.closePage();
|
|
546
632
|
if (this.activePage && !this.activePage.isClosed()) {
|
|
547
633
|
if (!targetUrl)
|
|
548
634
|
return this.activePage;
|
|
@@ -566,12 +652,150 @@ export class BrowserService {
|
|
|
566
652
|
this.activeContext = await browser.newContext(storageStateOptions(this.config.storageStatePath, 'global'));
|
|
567
653
|
}
|
|
568
654
|
this.activePage = await this.activeContext.newPage();
|
|
655
|
+
this.attachCapture(this.activePage);
|
|
569
656
|
return this.activePage;
|
|
570
657
|
}
|
|
571
|
-
|
|
658
|
+
attachCapture(page) {
|
|
659
|
+
const release = () => {
|
|
660
|
+
if (this.activePage !== page)
|
|
661
|
+
return;
|
|
662
|
+
const context = this.activeContext;
|
|
663
|
+
const profile = this.activeProfile;
|
|
664
|
+
this.clearActiveState();
|
|
665
|
+
if (context)
|
|
666
|
+
void this.persistAndClose({ context, ...profile ? { profile } : {} }).catch(() => { });
|
|
667
|
+
};
|
|
668
|
+
page.on('close', release);
|
|
669
|
+
page.on('crash', release);
|
|
670
|
+
page.on('console', (message) => {
|
|
671
|
+
if (!this.captureConsoleEnabled)
|
|
672
|
+
return;
|
|
673
|
+
const location = message.location?.();
|
|
674
|
+
this.capturedConsole.push({
|
|
675
|
+
type: String(message.type?.() ?? 'log').slice(0, 40),
|
|
676
|
+
text: redactCaptureText(String(message.text?.() ?? '')),
|
|
677
|
+
...location?.url ? { url: redactCaptureUrl(location.url) } : {},
|
|
678
|
+
timestamp: new Date().toISOString(),
|
|
679
|
+
});
|
|
680
|
+
if (this.capturedConsole.length > 200)
|
|
681
|
+
this.capturedConsole.splice(0, this.capturedConsole.length - 200);
|
|
682
|
+
});
|
|
683
|
+
page.on('response', (response) => {
|
|
684
|
+
if (!this.captureNetworkEnabled)
|
|
685
|
+
return;
|
|
686
|
+
const status = Number(response.status?.() ?? 0);
|
|
687
|
+
if (status < 400)
|
|
688
|
+
return;
|
|
689
|
+
const request = response.request?.();
|
|
690
|
+
this.capturedRequests.push({
|
|
691
|
+
method: String(request?.method?.() ?? 'GET').slice(0, 20),
|
|
692
|
+
url: redactCaptureUrl(String(response.url?.() ?? '')),
|
|
693
|
+
status,
|
|
694
|
+
timestamp: new Date().toISOString(),
|
|
695
|
+
});
|
|
696
|
+
if (this.capturedRequests.length > 200)
|
|
697
|
+
this.capturedRequests.splice(0, this.capturedRequests.length - 200);
|
|
698
|
+
});
|
|
699
|
+
page.on('requestfailed', (request) => {
|
|
700
|
+
if (!this.captureNetworkEnabled)
|
|
701
|
+
return;
|
|
702
|
+
this.capturedRequests.push({
|
|
703
|
+
method: String(request.method?.() ?? 'GET').slice(0, 20),
|
|
704
|
+
url: redactCaptureUrl(String(request.url?.() ?? '')),
|
|
705
|
+
failure: redactCaptureText(String(request.failure?.()?.errorText ?? 'request failed'), 500),
|
|
706
|
+
timestamp: new Date().toISOString(),
|
|
707
|
+
});
|
|
708
|
+
if (this.capturedRequests.length > 200)
|
|
709
|
+
this.capturedRequests.splice(0, this.capturedRequests.length - 200);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
resetCapture(capture = []) {
|
|
713
|
+
this.captureConsoleEnabled = capture.includes('console');
|
|
714
|
+
this.captureNetworkEnabled = capture.includes('network');
|
|
715
|
+
this.capturedConsole = [];
|
|
716
|
+
this.capturedRequests = [];
|
|
717
|
+
}
|
|
718
|
+
resolveTarget(page, target) {
|
|
719
|
+
if (!target || (typeof target !== 'string' && typeof target !== 'object'))
|
|
720
|
+
throw new Error('browser target must be a selector string or locator object');
|
|
721
|
+
const spec = typeof target === 'string' ? { selector: target } : target;
|
|
722
|
+
const modes = [spec.selector, spec.role, spec.text, spec.label].filter(value => value !== undefined);
|
|
723
|
+
if (modes.length !== 1)
|
|
724
|
+
throw new Error('browser target requires exactly one of selector, role, text, or label');
|
|
725
|
+
if (spec.name !== undefined && spec.role === undefined)
|
|
726
|
+
throw new Error('browser target name is only valid with role');
|
|
727
|
+
let root = page;
|
|
728
|
+
if (spec.frame) {
|
|
729
|
+
const frameModes = [spec.frame.selector, spec.frame.name, spec.frame.url].filter(value => value !== undefined);
|
|
730
|
+
if (frameModes.length !== 1)
|
|
731
|
+
throw new Error('browser frame requires exactly one of selector, name, or url');
|
|
732
|
+
if (spec.frame.selector)
|
|
733
|
+
root = page.frameLocator(boundedString(spec.frame.selector, 'frame selector', 500));
|
|
734
|
+
else {
|
|
735
|
+
const frame = page.frame(spec.frame.name
|
|
736
|
+
? { name: boundedString(spec.frame.name, 'frame name', 500) }
|
|
737
|
+
: { url: boundedString(spec.frame.url, 'frame url', 2_000) });
|
|
738
|
+
if (!frame)
|
|
739
|
+
throw new Error('browser target frame was not found');
|
|
740
|
+
root = frame;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (spec.selector)
|
|
744
|
+
return root.locator(boundedString(spec.selector, 'selector', 500));
|
|
745
|
+
if (spec.role)
|
|
746
|
+
return root.getByRole(boundedString(spec.role, 'role', 100), {
|
|
747
|
+
...spec.name !== undefined ? { name: boundedString(spec.name, 'role name', 2_000) } : {},
|
|
748
|
+
exact: spec.exact ?? false,
|
|
749
|
+
});
|
|
750
|
+
if (spec.text)
|
|
751
|
+
return root.getByText(boundedString(spec.text, 'text locator', 2_000), { exact: spec.exact ?? false });
|
|
752
|
+
return root.getByLabel(boundedString(spec.label, 'label locator', 2_000), { exact: spec.exact ?? false });
|
|
753
|
+
}
|
|
754
|
+
screenshotFile(options) {
|
|
755
|
+
const filename = options.filename ?? `shot-${Date.now()}-${uid().slice(0, 8)}.${options.format === 'jpeg' ? 'jpg' : 'png'}`;
|
|
756
|
+
if (filename !== path.basename(filename) || !/^[\w.() -]{1,160}$/.test(filename)) {
|
|
757
|
+
throw new Error('browser_screenshot filename must be a plain file name inside snapshotDir');
|
|
758
|
+
}
|
|
759
|
+
const extension = path.extname(filename).toLowerCase();
|
|
760
|
+
const inferred = extension === '.jpg' || extension === '.jpeg' ? 'jpeg' : extension === '.png' ? 'png' : undefined;
|
|
761
|
+
const format = options.format ?? inferred ?? 'png';
|
|
762
|
+
if (inferred && inferred !== format)
|
|
763
|
+
throw new Error('browser_screenshot filename extension does not match format');
|
|
764
|
+
if (!inferred)
|
|
765
|
+
throw new Error('browser_screenshot filename must end in .png, .jpg, or .jpeg');
|
|
766
|
+
return { file: path.join(this.config.snapshotDir, filename), format };
|
|
767
|
+
}
|
|
768
|
+
async captureScreenshot(page, options = {}) {
|
|
572
769
|
fs.mkdirSync(this.config.snapshotDir, { recursive: true });
|
|
573
|
-
|
|
574
|
-
|
|
770
|
+
if (options.target && options.clip)
|
|
771
|
+
throw new Error('browser_screenshot cannot combine target and clip');
|
|
772
|
+
if (options.target && options.fullPage)
|
|
773
|
+
throw new Error('browser_screenshot cannot combine target and fullPage');
|
|
774
|
+
if (options.quality !== undefined && (!Number.isInteger(options.quality) || options.quality < 0 || options.quality > 100)) {
|
|
775
|
+
throw new Error('browser_screenshot quality must be an integer from 0 to 100');
|
|
776
|
+
}
|
|
777
|
+
const { file, format } = this.screenshotFile(options);
|
|
778
|
+
if (format === 'png' && options.quality !== undefined)
|
|
779
|
+
throw new Error('browser_screenshot quality is only supported for jpeg');
|
|
780
|
+
const screenshotOptions = {
|
|
781
|
+
path: file,
|
|
782
|
+
type: format,
|
|
783
|
+
...options.quality !== undefined ? { quality: options.quality } : {},
|
|
784
|
+
};
|
|
785
|
+
if (options.clip) {
|
|
786
|
+
const { x, y, width, height } = options.clip;
|
|
787
|
+
if (![x, y, width, height].every(Number.isFinite) || x < 0 || y < 0 || width <= 0 || height <= 0 || width > 20_000 || height > 20_000) {
|
|
788
|
+
throw new Error('browser_screenshot clip must use finite non-negative coordinates and dimensions from 1 to 20,000');
|
|
789
|
+
}
|
|
790
|
+
screenshotOptions.clip = options.clip;
|
|
791
|
+
}
|
|
792
|
+
else if (!options.target) {
|
|
793
|
+
screenshotOptions.fullPage = options.fullPage ?? true;
|
|
794
|
+
}
|
|
795
|
+
if (options.target)
|
|
796
|
+
await this.resolveTarget(page, options.target).screenshot(screenshotOptions);
|
|
797
|
+
else
|
|
798
|
+
await page.screenshot(screenshotOptions);
|
|
575
799
|
return file;
|
|
576
800
|
}
|
|
577
801
|
async readState(page, includeScreenshot) {
|
|
@@ -587,6 +811,7 @@ export class BrowserService {
|
|
|
587
811
|
}
|
|
588
812
|
async open(url, opts = {}) {
|
|
589
813
|
const page = await this.ensureActivePage(url, opts);
|
|
814
|
+
this.resetCapture(opts.capture);
|
|
590
815
|
page.setDefaultTimeout(30_000);
|
|
591
816
|
await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
592
817
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
@@ -595,32 +820,75 @@ export class BrowserService {
|
|
|
595
820
|
await page.waitForTimeout(opts.waitMs);
|
|
596
821
|
return this.readState(page, true);
|
|
597
822
|
}
|
|
598
|
-
async click(
|
|
823
|
+
async click(target, opts = {}) {
|
|
599
824
|
const page = await this.ensureActivePage();
|
|
600
|
-
await
|
|
601
|
-
await page.click(selector);
|
|
825
|
+
await this.resolveTarget(page, target).click({ timeout: boundedTimeout(opts.timeoutMs, 'browser_click timeoutMs') });
|
|
602
826
|
if (opts.waitMs !== undefined)
|
|
603
827
|
await page.waitForTimeout(opts.waitMs);
|
|
604
828
|
else
|
|
605
829
|
await page.waitForTimeout(500);
|
|
606
830
|
return this.readState(page, true);
|
|
607
831
|
}
|
|
608
|
-
async type(
|
|
832
|
+
async type(target, text, opts = {}) {
|
|
833
|
+
const page = await this.ensureActivePage();
|
|
834
|
+
await this.resolveTarget(page, target).fill(text, { timeout: boundedTimeout(opts.timeoutMs, 'browser_type timeoutMs') });
|
|
835
|
+
return this.readState(page, false);
|
|
836
|
+
}
|
|
837
|
+
async wait(target, opts = {}) {
|
|
609
838
|
const page = await this.ensureActivePage();
|
|
610
|
-
|
|
611
|
-
|
|
839
|
+
const modes = [target !== undefined, opts.urlPattern !== undefined, opts.networkIdle === true, opts.timeMs !== undefined].filter(Boolean);
|
|
840
|
+
if (modes.length !== 1)
|
|
841
|
+
throw new Error('browser_wait requires exactly one target, urlPattern, networkIdle=true, or timeMs');
|
|
842
|
+
const timeout = boundedTimeout(opts.timeoutMs, 'browser_wait timeoutMs');
|
|
843
|
+
if (target !== undefined)
|
|
844
|
+
await this.resolveTarget(page, target).waitFor({ state: opts.state ?? 'visible', timeout });
|
|
845
|
+
else if (opts.urlPattern !== undefined)
|
|
846
|
+
await page.waitForURL(boundedString(opts.urlPattern, 'urlPattern', 2_000), { timeout });
|
|
847
|
+
else if (opts.networkIdle)
|
|
848
|
+
await page.waitForLoadState('networkidle', { timeout });
|
|
849
|
+
else {
|
|
850
|
+
const timeMs = opts.timeMs;
|
|
851
|
+
if (!Number.isFinite(timeMs) || timeMs < 0 || timeMs > 10_000)
|
|
852
|
+
throw new Error('browser_wait timeMs must be between 0 and 10,000');
|
|
853
|
+
await page.waitForTimeout(timeMs);
|
|
854
|
+
}
|
|
612
855
|
return this.readState(page, false);
|
|
613
856
|
}
|
|
614
|
-
async
|
|
857
|
+
async press(target, key, opts = {}) {
|
|
615
858
|
const page = await this.ensureActivePage();
|
|
616
|
-
const
|
|
859
|
+
const value = boundedString(key, 'browser_press key', 100);
|
|
860
|
+
if (target !== undefined)
|
|
861
|
+
await this.resolveTarget(page, target).press(value, { timeout: boundedTimeout(opts.timeoutMs, 'browser_press timeoutMs') });
|
|
862
|
+
else
|
|
863
|
+
await page.keyboard.press(value);
|
|
864
|
+
return this.readState(page, false);
|
|
865
|
+
}
|
|
866
|
+
async select(target, values, opts = {}) {
|
|
867
|
+
if (values.length < 1 || values.length > 20)
|
|
868
|
+
throw new Error('browser_select requires 1 to 20 values');
|
|
869
|
+
values.forEach(value => boundedString(value, 'browser_select value', 2_000));
|
|
870
|
+
const page = await this.ensureActivePage();
|
|
871
|
+
await this.resolveTarget(page, target).selectOption([...values], { timeout: boundedTimeout(opts.timeoutMs, 'browser_select timeoutMs') });
|
|
872
|
+
return this.readState(page, false);
|
|
873
|
+
}
|
|
874
|
+
async check(target, checked = true, opts = {}) {
|
|
875
|
+
const page = await this.ensureActivePage();
|
|
876
|
+
const locator = this.resolveTarget(page, target);
|
|
877
|
+
if (checked)
|
|
878
|
+
await locator.check({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
|
|
879
|
+
else
|
|
880
|
+
await locator.uncheck({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
|
|
881
|
+
return this.readState(page, false);
|
|
882
|
+
}
|
|
883
|
+
async hover(target, opts = {}) {
|
|
884
|
+
const page = await this.ensureActivePage();
|
|
885
|
+
const timeoutMs = boundedTimeout(opts.timeoutMs, 'browser_hover timeoutMs');
|
|
617
886
|
const waitMs = Math.min(Math.max(opts.waitMs ?? 300, 0), timeoutMs);
|
|
618
|
-
await
|
|
619
|
-
await page.hover(selector);
|
|
887
|
+
await this.resolveTarget(page, target).hover({ timeout: timeoutMs });
|
|
620
888
|
await page.waitForTimeout(waitMs);
|
|
621
889
|
return this.readState(page, true);
|
|
622
890
|
}
|
|
623
|
-
async setFiles(
|
|
891
|
+
async setFiles(target, files, opts = {}) {
|
|
624
892
|
if (files.length === 0 || files.length > 20)
|
|
625
893
|
throw new Error('browser_set_files requires 1 to 20 files');
|
|
626
894
|
const resolved = files.map(file => {
|
|
@@ -635,8 +903,7 @@ export class BrowserService {
|
|
|
635
903
|
if (totalBytes > 512 * 1024 * 1024)
|
|
636
904
|
throw new Error('browser_set_files total upload size exceeds 512 MiB');
|
|
637
905
|
const page = await this.ensureActivePage();
|
|
638
|
-
await page.
|
|
639
|
-
await page.setInputFiles(selector, resolved);
|
|
906
|
+
await this.resolveTarget(page, target).setInputFiles(resolved, { timeout: boundedTimeout(opts.timeoutMs, 'browser_set_files timeoutMs') });
|
|
640
907
|
return { ...await this.readState(page, true), files: resolved.map(file => path.basename(file)) };
|
|
641
908
|
}
|
|
642
909
|
async evaluate(expression, opts = {}) {
|
|
@@ -648,15 +915,38 @@ export class BrowserService {
|
|
|
648
915
|
const page = await this.ensureActivePage();
|
|
649
916
|
const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? 15_000, 1_000), 30_000);
|
|
650
917
|
let timer;
|
|
918
|
+
let timedOut = false;
|
|
651
919
|
const timeout = new Promise((_resolve, reject) => {
|
|
652
920
|
timer = setTimeout(() => {
|
|
653
|
-
|
|
654
|
-
|
|
921
|
+
timedOut = true;
|
|
922
|
+
void (async () => {
|
|
923
|
+
try {
|
|
924
|
+
const session = await page.context().newCDPSession(page);
|
|
925
|
+
try {
|
|
926
|
+
await session.send('Runtime.terminateExecution');
|
|
927
|
+
}
|
|
928
|
+
finally {
|
|
929
|
+
await session.detach().catch(() => { });
|
|
930
|
+
}
|
|
931
|
+
reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open'));
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; unable to terminate page JavaScript without closing the active page: ' + String(error).slice(0, 200)));
|
|
935
|
+
}
|
|
936
|
+
})();
|
|
655
937
|
}, timeoutMs);
|
|
656
938
|
});
|
|
657
939
|
try {
|
|
658
940
|
const script = `(async () => {\nconst value = await (${source}\n);\nconst json = JSON.stringify(value);\nif (json === undefined) throw new Error('expression result is not JSON-serializable');\nreturn { resultJson: json.slice(0, 100000), truncated: json.length > 100000 };\n})()`;
|
|
659
|
-
|
|
941
|
+
let result;
|
|
942
|
+
try {
|
|
943
|
+
result = await Promise.race([page.evaluate(script), timeout]);
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
if (timedOut)
|
|
947
|
+
throw new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open');
|
|
948
|
+
throw error;
|
|
949
|
+
}
|
|
660
950
|
return {
|
|
661
951
|
url: page.url(),
|
|
662
952
|
resultJson: result.resultJson,
|
|
@@ -684,9 +974,30 @@ export class BrowserService {
|
|
|
684
974
|
const page = await this.ensureActivePage();
|
|
685
975
|
return this.readState(page, false);
|
|
686
976
|
}
|
|
687
|
-
|
|
977
|
+
consoleMessages(opts = {}) {
|
|
978
|
+
const severities = ['debug', 'log', 'info', 'warning', 'error'];
|
|
979
|
+
const threshold = opts.level ? severities.indexOf(opts.level) : 0;
|
|
980
|
+
if (opts.level && threshold < 0)
|
|
981
|
+
throw new Error('browser_console level must be debug, log, info, warning, or error');
|
|
982
|
+
const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
|
|
983
|
+
const records = this.capturedConsole.filter(record => {
|
|
984
|
+
const index = severities.indexOf(record.type === 'warn' ? 'warning' : record.type);
|
|
985
|
+
return index < 0 || index >= threshold;
|
|
986
|
+
}).slice(-limit);
|
|
987
|
+
if (opts.clear)
|
|
988
|
+
this.capturedConsole = [];
|
|
989
|
+
return { enabled: this.captureConsoleEnabled, records };
|
|
990
|
+
}
|
|
991
|
+
networkRequests(opts = {}) {
|
|
992
|
+
const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
|
|
993
|
+
const records = this.capturedRequests.slice(-limit);
|
|
994
|
+
if (opts.clear)
|
|
995
|
+
this.capturedRequests = [];
|
|
996
|
+
return { enabled: this.captureNetworkEnabled, records };
|
|
997
|
+
}
|
|
998
|
+
async screenshot(options = {}) {
|
|
688
999
|
const page = await this.ensureActivePage();
|
|
689
|
-
return { path: await this.captureScreenshot(page) };
|
|
1000
|
+
return { path: await this.captureScreenshot(page, options) };
|
|
690
1001
|
}
|
|
691
1002
|
async recipe(steps, opts = {}) {
|
|
692
1003
|
if (!opts.url && (!this.activePage || this.activePage.isClosed()))
|
|
@@ -714,15 +1025,14 @@ export class BrowserService {
|
|
|
714
1025
|
}
|
|
715
1026
|
}
|
|
716
1027
|
async closePage() {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
this.
|
|
721
|
-
if (
|
|
722
|
-
await
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
this.activeRulePack = undefined;
|
|
1028
|
+
const page = this.activePage;
|
|
1029
|
+
const context = this.activeContext;
|
|
1030
|
+
const profile = this.activeProfile;
|
|
1031
|
+
this.clearActiveState();
|
|
1032
|
+
if (page)
|
|
1033
|
+
await page.close().catch(() => { });
|
|
1034
|
+
if (context)
|
|
1035
|
+
await this.persistAndClose({ context, ...profile ? { profile } : {} });
|
|
726
1036
|
}
|
|
727
1037
|
async status() {
|
|
728
1038
|
let chromiumInstalled = false;
|
|
@@ -786,8 +1096,8 @@ export class BrowserService {
|
|
|
786
1096
|
builtinScripts: BUILTIN_SCRIPTS.map(script => script.id),
|
|
787
1097
|
externalUserscriptsRequireApproval: ['standard', 'autonomous'].includes(this.config.automationMode),
|
|
788
1098
|
mutatingRecipesRequireApproval: this.config.automationMode === 'standard',
|
|
789
|
-
...(this.activePage && !this.activePage.isClosed() ? { activeUrl: this.activePage.url() } : {}),
|
|
790
|
-
...this.activeProfile ? { activeAuthProfile: this.activeProfile.id } : {},
|
|
1099
|
+
...(this.browserConnected() && this.activePage && !this.activePage.isClosed() ? { activeUrl: this.activePage.url() } : {}),
|
|
1100
|
+
...(this.browserConnected() && this.activePage && !this.activePage.isClosed() && this.activeProfile ? { activeAuthProfile: this.activeProfile.id } : {}),
|
|
791
1101
|
};
|
|
792
1102
|
}
|
|
793
1103
|
async close() {
|