@anweat/dsh-browser 0.1.6 → 0.1.8
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 +306 -229
- package/cordis.patch.yml +26 -15
- package/lib/approval-policy.d.ts +2 -1
- package/lib/approval-policy.js +46 -1
- package/lib/approval-policy.js.map +1 -1
- package/lib/browser-service.d.ts +69 -19
- package/lib/browser-service.js +231 -64
- package/lib/browser-service.js.map +1 -1
- package/lib/client/SettingsCard.d.ts +2 -0
- package/lib/client/SettingsCard.js +21 -0
- package/lib/client/SettingsCard.js.map +1 -0
- package/lib/client/context-types.d.ts +8 -0
- package/lib/client/context-types.js +2 -0
- package/lib/client/context-types.js.map +1 -0
- package/lib/client/form.d.ts +62 -0
- package/lib/client/form.js +198 -0
- package/lib/client/form.js.map +1 -0
- package/lib/client/index.d.ts +16 -0
- package/lib/client/index.js +18 -0
- package/lib/client/index.js.map +1 -0
- package/lib/client/locales.d.ts +58 -0
- package/lib/client/locales.js +55 -0
- package/lib/client/locales.js.map +1 -0
- package/lib/client/settings-namespace.d.ts +2 -0
- package/lib/client/settings-namespace.js +3 -0
- package/lib/client/settings-namespace.js.map +1 -0
- package/lib/client/styles.d.ts +37 -0
- package/lib/client/styles.js +25 -0
- package/lib/client/styles.js.map +1 -0
- package/lib/client.js +753 -0
- package/lib/client.js.map +1 -0
- package/lib/config.d.ts +14 -0
- package/lib/config.js +25 -0
- package/lib/config.js.map +1 -1
- package/lib/deps.d.ts +7 -1
- package/lib/deps.js +22 -6
- package/lib/deps.js.map +1 -1
- package/lib/freedom.d.ts +8 -0
- package/lib/freedom.js +52 -0
- package/lib/freedom.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +19 -6
- package/lib/index.js.map +1 -1
- package/lib/opencli-catalog.d.ts +21 -0
- package/lib/opencli-catalog.js +49 -0
- package/lib/opencli-catalog.js.map +1 -0
- package/lib/scripts.js +27 -27
- package/lib/tools.js +111 -22
- package/lib/tools.js.map +1 -1
- package/lib/usage-policy.d.ts +49 -0
- package/lib/usage-policy.js +171 -0
- package/lib/usage-policy.js.map +1 -0
- package/package.json +133 -78
- package/scripts/check-client-bundle.mjs +15 -0
- package/scripts/clean-lib.mjs +3 -0
package/lib/approval-policy.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
/** Approval classification for multi-action and arbitrary-code browser tools. */
|
|
2
2
|
import { recipeNeedsApproval } from "./automation.js";
|
|
3
3
|
import { validateUserscript } from "./scripts.js";
|
|
4
|
-
|
|
4
|
+
import { isBrowserToolExposed } from "./freedom.js";
|
|
5
|
+
const DIRECT_INTERACTIONS = new Set(['browser_click', 'browser_type', 'browser_scroll']);
|
|
6
|
+
const WEB_LOCAL_MUTATIONS = new Set(['web_cache_clear']);
|
|
7
|
+
export function browserPolicyDecision(name, args, mode = 'standard') {
|
|
8
|
+
if (!isBrowserToolExposed(name, mode) && name.startsWith('browser_')) {
|
|
9
|
+
return { kind: 'deny', reason: `Browser tool ${name} is disabled by automationMode=${mode}` };
|
|
10
|
+
}
|
|
11
|
+
if (DIRECT_INTERACTIONS.has(name)) {
|
|
12
|
+
if (mode === 'read-only')
|
|
13
|
+
return { kind: 'deny', reason: `Page interaction is disabled by automationMode=${mode}` };
|
|
14
|
+
if (mode === 'standard')
|
|
15
|
+
return { kind: 'ask', reason: 'Run a direct Playwright page interaction: ' + name };
|
|
16
|
+
}
|
|
17
|
+
if (name === 'browser_install') {
|
|
18
|
+
if (mode === 'read-only')
|
|
19
|
+
return { kind: 'deny', reason: `Browser installation is disabled by automationMode=${mode}` };
|
|
20
|
+
if (mode === 'unrestricted')
|
|
21
|
+
return { kind: 'allow' };
|
|
22
|
+
return { kind: 'ask', reason: 'Install Playwright Chromium into the shared browser cache' };
|
|
23
|
+
}
|
|
5
24
|
if (name === 'browser_userscript_run') {
|
|
6
25
|
const input = args;
|
|
7
26
|
if (typeof input.source !== 'string' || typeof input.url !== 'string')
|
|
@@ -9,6 +28,10 @@ export function browserPolicyDecision(name, args) {
|
|
|
9
28
|
const validation = validateUserscript(input.source, input.url);
|
|
10
29
|
if (!validation.valid)
|
|
11
30
|
return { kind: 'deny', reason: 'invalid external userscript: ' + validation.errors.join('; ') };
|
|
31
|
+
if (mode === 'read-only')
|
|
32
|
+
return { kind: 'deny', reason: `External userscripts are disabled by automationMode=${mode}` };
|
|
33
|
+
if (mode === 'unrestricted')
|
|
34
|
+
return { kind: 'allow' };
|
|
12
35
|
const host = new URL(input.url).hostname;
|
|
13
36
|
return {
|
|
14
37
|
kind: 'ask',
|
|
@@ -18,6 +41,10 @@ export function browserPolicyDecision(name, args) {
|
|
|
18
41
|
if (name === 'browser_opencli_run') {
|
|
19
42
|
const input = args;
|
|
20
43
|
const argv = Array.isArray(input.args) ? input.args.filter(value => typeof value === 'string') : [];
|
|
44
|
+
if (mode === 'read-only')
|
|
45
|
+
return { kind: 'deny', reason: `General OpenCLI commands are disabled by automationMode=${mode}` };
|
|
46
|
+
if (mode === 'unrestricted')
|
|
47
|
+
return { kind: 'allow' };
|
|
21
48
|
return { kind: 'ask', reason: 'Run a general OpenCLI command with the logged-in Chrome profile: ' + (argv.slice(0, 3).join(' ') || '(empty)') };
|
|
22
49
|
}
|
|
23
50
|
if (name === 'browser_recipe_run') {
|
|
@@ -25,9 +52,27 @@ export function browserPolicyDecision(name, args) {
|
|
|
25
52
|
const steps = Array.isArray(input.steps) ? input.steps : [];
|
|
26
53
|
if (recipeNeedsApproval(steps)) {
|
|
27
54
|
const actions = [...new Set(steps.map(step => step.type).filter(type => !['wait', 'extract', 'assert', 'screenshot'].includes(type)))];
|
|
55
|
+
if (mode === 'read-only')
|
|
56
|
+
return { kind: 'deny', reason: `Mutating recipes are disabled by automationMode=${mode}: ` + actions.join(', ') };
|
|
57
|
+
if (mode === 'autonomous' || mode === 'unrestricted')
|
|
58
|
+
return { kind: 'allow' };
|
|
28
59
|
return { kind: 'ask', reason: 'Run a multi-step Playwright recipe with page mutations: ' + actions.join(', ') };
|
|
29
60
|
}
|
|
30
61
|
}
|
|
62
|
+
if (name === 'web_deps' && args?.action === 'install') {
|
|
63
|
+
if (mode === 'read-only')
|
|
64
|
+
return { kind: 'deny', reason: `Dependency installation is disabled by automationMode=${mode}` };
|
|
65
|
+
if (mode === 'unrestricted')
|
|
66
|
+
return { kind: 'allow' };
|
|
67
|
+
return { kind: 'ask', reason: 'Install an external Web Search Pro backend dependency' };
|
|
68
|
+
}
|
|
69
|
+
const webRuleAction = name === 'web_rule' ? args?.action : undefined;
|
|
70
|
+
if (WEB_LOCAL_MUTATIONS.has(name) || (name === 'web_rule' && ['upsert', 'remove', 'import'].includes(String(webRuleAction)))) {
|
|
71
|
+
if (mode === 'read-only')
|
|
72
|
+
return { kind: 'deny', reason: `Web Search Pro mutations are disabled by automationMode=${mode}` };
|
|
73
|
+
if (mode === 'standard')
|
|
74
|
+
return { kind: 'ask', reason: 'Modify Web Search Pro local state: ' + name };
|
|
75
|
+
}
|
|
31
76
|
return { kind: 'allow' };
|
|
32
77
|
}
|
|
33
78
|
//# sourceMappingURL=approval-policy.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"approval-policy.js","sourceRoot":"","sources":["../src/approval-policy.ts"],"names":[],"mappings":"AAAA,iFAAiF;AAEjF,OAAO,EAAE,mBAAmB,EAA0B,MAAM,iBAAiB,CAAA;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"approval-policy.js","sourceRoot":"","sources":["../src/approval-policy.ts"],"names":[],"mappings":"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"}
|
package/lib/browser-service.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ import { type CliResult } from './deps.ts';
|
|
|
22
22
|
import type { ResolvedConfig } from './config.ts';
|
|
23
23
|
import { type BrowserRecipeStep, type RecipeStepResult } from './automation.ts';
|
|
24
24
|
import { type UserscriptValidation } from './scripts.ts';
|
|
25
|
+
import { type AutomationMode } from './freedom.ts';
|
|
26
|
+
import { type OpencliCatalogFilter, type OpencliCatalogItem } from './opencli-catalog.ts';
|
|
27
|
+
import { UsageGovernor } from './usage-policy.ts';
|
|
25
28
|
export interface RenderRule {
|
|
26
29
|
hostname: string;
|
|
27
30
|
contentSelectors: string[];
|
|
@@ -36,7 +39,7 @@ export interface RenderResult {
|
|
|
36
39
|
export interface SnapshotResult {
|
|
37
40
|
title: string;
|
|
38
41
|
text: string;
|
|
39
|
-
screenshotPath
|
|
42
|
+
screenshotPath?: string;
|
|
40
43
|
htmlPath: string;
|
|
41
44
|
usedRule?: string;
|
|
42
45
|
}
|
|
@@ -69,6 +72,58 @@ export interface ScriptRunResult {
|
|
|
69
72
|
resultJson: string;
|
|
70
73
|
truncated: boolean;
|
|
71
74
|
}
|
|
75
|
+
export interface CrawlPage {
|
|
76
|
+
url: string;
|
|
77
|
+
title: string;
|
|
78
|
+
text: string;
|
|
79
|
+
depth: number;
|
|
80
|
+
status: number;
|
|
81
|
+
}
|
|
82
|
+
export interface CrawlResult {
|
|
83
|
+
pages: CrawlPage[];
|
|
84
|
+
errors: {
|
|
85
|
+
url: string;
|
|
86
|
+
depth: number;
|
|
87
|
+
error: string;
|
|
88
|
+
status?: number;
|
|
89
|
+
}[];
|
|
90
|
+
stats: {
|
|
91
|
+
pagesVisited: number;
|
|
92
|
+
queued: number;
|
|
93
|
+
elapsedMs: number;
|
|
94
|
+
waitMs: number;
|
|
95
|
+
backoffEvents: number;
|
|
96
|
+
};
|
|
97
|
+
warnings: string[];
|
|
98
|
+
}
|
|
99
|
+
export interface BrowserStatus {
|
|
100
|
+
enabled: boolean;
|
|
101
|
+
channel: string;
|
|
102
|
+
browserRuntime: 'playwright' | 'patchright';
|
|
103
|
+
runtimeWarnings: string[];
|
|
104
|
+
headless: boolean;
|
|
105
|
+
opencliEnabled: boolean;
|
|
106
|
+
automationMode: AutomationMode;
|
|
107
|
+
exposedTools: string[];
|
|
108
|
+
directInteractionPolicy: 'deny' | 'ask' | 'allow';
|
|
109
|
+
mutatingRecipePolicy: 'deny' | 'ask' | 'allow';
|
|
110
|
+
externalUserscriptPolicy: 'deny' | 'ask' | 'allow';
|
|
111
|
+
opencliRunPolicy: 'deny' | 'ask' | 'allow';
|
|
112
|
+
chromiumInstalled: boolean;
|
|
113
|
+
usagePolicy: ResolvedConfig['usagePolicy'];
|
|
114
|
+
usageGovernor: ReturnType<UsageGovernor['snapshot']>;
|
|
115
|
+
authProfiles: {
|
|
116
|
+
id: string;
|
|
117
|
+
allowedDomains: string[];
|
|
118
|
+
persistState: boolean;
|
|
119
|
+
}[];
|
|
120
|
+
rulePacks: string[];
|
|
121
|
+
builtinScripts: string[];
|
|
122
|
+
externalUserscriptsRequireApproval: boolean;
|
|
123
|
+
mutatingRecipesRequireApproval: boolean;
|
|
124
|
+
activeUrl?: string;
|
|
125
|
+
activeAuthProfile?: string;
|
|
126
|
+
}
|
|
72
127
|
export declare class BrowserService {
|
|
73
128
|
private readonly config;
|
|
74
129
|
private browser;
|
|
@@ -78,11 +133,14 @@ export declare class BrowserService {
|
|
|
78
133
|
private activeProfile?;
|
|
79
134
|
private activeRulePack?;
|
|
80
135
|
private readonly authProfiles;
|
|
136
|
+
private readonly usageGovernor;
|
|
137
|
+
private opencliCatalogCache?;
|
|
81
138
|
constructor(config: ResolvedConfig);
|
|
82
139
|
available(): boolean;
|
|
83
140
|
private ensure;
|
|
84
141
|
/** Run `playwright install chromium` from the bundled playwright CLI. */
|
|
85
142
|
installChromium(): Promise<CliResult>;
|
|
143
|
+
private navigate;
|
|
86
144
|
private transientContext;
|
|
87
145
|
private persistAndClose;
|
|
88
146
|
render(url: string, rules: readonly RenderRule[], opts?: {
|
|
@@ -98,6 +156,7 @@ export declare class BrowserService {
|
|
|
98
156
|
maxChars?: number;
|
|
99
157
|
authProfile?: string;
|
|
100
158
|
rulePack?: string;
|
|
159
|
+
screenshot?: boolean;
|
|
101
160
|
}): Promise<SnapshotResult>;
|
|
102
161
|
searchResults(url: string, spec: PlatformSpec, opts?: {
|
|
103
162
|
signal?: AbortSignal;
|
|
@@ -118,6 +177,14 @@ export declare class BrowserService {
|
|
|
118
177
|
signal?: AbortSignal;
|
|
119
178
|
}): Promise<CliResult>;
|
|
120
179
|
opencliDoctor(signal?: AbortSignal): Promise<CliResult>;
|
|
180
|
+
opencliCatalog(filter?: OpencliCatalogFilter, signal?: AbortSignal): Promise<OpencliCatalogItem[]>;
|
|
181
|
+
crawl(startUrls: readonly string[], opts?: {
|
|
182
|
+
maxPages?: number;
|
|
183
|
+
maxDepth?: number;
|
|
184
|
+
sameOrigin?: boolean;
|
|
185
|
+
maxCharsPerPage?: number;
|
|
186
|
+
signal?: AbortSignal;
|
|
187
|
+
}): Promise<CrawlResult>;
|
|
121
188
|
scriptCatalog(): {
|
|
122
189
|
id: string;
|
|
123
190
|
name: string;
|
|
@@ -168,23 +235,6 @@ export declare class BrowserService {
|
|
|
168
235
|
signal?: AbortSignal;
|
|
169
236
|
}): Promise<RecipeRunResult>;
|
|
170
237
|
closePage(): Promise<void>;
|
|
171
|
-
status(): Promise<
|
|
172
|
-
enabled: boolean;
|
|
173
|
-
channel: string;
|
|
174
|
-
headless: boolean;
|
|
175
|
-
opencliEnabled: boolean;
|
|
176
|
-
chromiumInstalled: boolean;
|
|
177
|
-
authProfiles: {
|
|
178
|
-
id: string;
|
|
179
|
-
allowedDomains: string[];
|
|
180
|
-
persistState: boolean;
|
|
181
|
-
}[];
|
|
182
|
-
rulePacks: string[];
|
|
183
|
-
builtinScripts: string[];
|
|
184
|
-
externalUserscriptsRequireApproval: true;
|
|
185
|
-
mutatingRecipesRequireApproval: true;
|
|
186
|
-
activeUrl?: string;
|
|
187
|
-
activeAuthProfile?: string;
|
|
188
|
-
}>;
|
|
238
|
+
status(): Promise<BrowserStatus>;
|
|
189
239
|
close(): Promise<void>;
|
|
190
240
|
}
|
package/lib/browser-service.js
CHANGED
|
@@ -21,59 +21,75 @@
|
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import crypto from 'node:crypto';
|
|
24
|
-
import {
|
|
24
|
+
import { browserRuntimeCliPath, loadBrowserRuntime, runOpencli, runNode } from "./deps.js";
|
|
25
25
|
import { AuthProfileStore } from "./auth-profiles.js";
|
|
26
26
|
import { applyRuleSteps, resolveRulePack } from "./rule-packs.js";
|
|
27
27
|
import { runRecipe } from "./automation.js";
|
|
28
28
|
import { BUILTIN_SCRIPTS, builtinScript, executeUserscript, validateUserscript } from "./scripts.js";
|
|
29
|
+
import { browserToolsForMode } from "./freedom.js";
|
|
30
|
+
import { filterOpencliCatalog, parseOpencliCatalog } from "./opencli-catalog.js";
|
|
31
|
+
import { UsageGovernor } from "./usage-policy.js";
|
|
29
32
|
/** Rules-aware content extractor (runs in the page). */
|
|
30
|
-
const EXTRACTOR_FN = `(ruleList) => {
|
|
31
|
-
const doc = document
|
|
32
|
-
const title = doc.title ? doc.title.trim() : ''
|
|
33
|
-
let host = ''
|
|
34
|
-
try { host = location.hostname } catch (e) {}
|
|
35
|
-
const norm = (h) => { const x = h.toLowerCase(); return x.startsWith('www.') ? x.slice(4) : x }
|
|
36
|
-
let rule = null
|
|
37
|
-
for (const r of ruleList) {
|
|
38
|
-
const rh = norm(r.hostname)
|
|
39
|
-
if (host === rh || host.endsWith('.' + rh)) rule = r
|
|
40
|
-
}
|
|
41
|
-
const pick = (selectors) => {
|
|
42
|
-
for (const sel of selectors) {
|
|
43
|
-
try {
|
|
44
|
-
const el = doc.querySelector(sel)
|
|
45
|
-
if (el && (el.textContent || '').trim().length > 40) return el
|
|
46
|
-
} catch (e) {}
|
|
47
|
-
}
|
|
48
|
-
return null
|
|
49
|
-
}
|
|
50
|
-
const content = rule ? pick(rule.contentSelectors) : (pick(['article', 'main', '[role="main"]']) || doc.body)
|
|
51
|
-
if (content && rule && rule.removeSelectors) {
|
|
52
|
-
for (const sel of rule.removeSelectors) {
|
|
53
|
-
try { content.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) {}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const text = content ? (content.innerText || content.textContent || '') : ''
|
|
57
|
-
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 }
|
|
58
61
|
}`;
|
|
59
62
|
/** Platform search-page list extractor (runs in the page). */
|
|
60
|
-
const LIST_EXTRACTOR = `(spec) => {
|
|
61
|
-
const items = []
|
|
62
|
-
const nodes = document.querySelectorAll(spec.item)
|
|
63
|
-
for (let i = 0; i < nodes.length && items.length < 20; i++) {
|
|
64
|
-
const el = nodes[i]
|
|
65
|
-
const titleEl = spec.title ? el.querySelector(spec.title) : null
|
|
66
|
-
const linkEl = spec.link ? el.querySelector(spec.link) : null
|
|
67
|
-
const textEl = spec.text ? el.querySelector(spec.text) : null
|
|
68
|
-
const title = (titleEl ? titleEl.textContent : el.textContent || '').trim().replace(/\\s+/g, ' ')
|
|
69
|
-
let url = linkEl ? (linkEl.href || linkEl.getAttribute('href') || '') : ''
|
|
70
|
-
if (url && url.startsWith('/')) url = location.origin + url
|
|
71
|
-
if (!url && linkEl === null && titleEl) { const a = titleEl.closest ? titleEl.closest('a') : null; if (a) url = a.href }
|
|
72
|
-
const snippet = textEl ? (textEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 300) : ''
|
|
73
|
-
if (!url || !title || title.length < 2) continue
|
|
74
|
-
items.push({ url, title, snippet })
|
|
75
|
-
}
|
|
76
|
-
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
|
+
}`;
|
|
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 }
|
|
77
93
|
}`;
|
|
78
94
|
function uid() {
|
|
79
95
|
return crypto.randomUUID();
|
|
@@ -98,9 +114,12 @@ export class BrowserService {
|
|
|
98
114
|
activeProfile;
|
|
99
115
|
activeRulePack;
|
|
100
116
|
authProfiles;
|
|
117
|
+
usageGovernor;
|
|
118
|
+
opencliCatalogCache;
|
|
101
119
|
constructor(config) {
|
|
102
120
|
this.config = config;
|
|
103
121
|
this.authProfiles = new AuthProfileStore(config.authProfiles);
|
|
122
|
+
this.usageGovernor = new UsageGovernor(config.usagePolicy);
|
|
104
123
|
}
|
|
105
124
|
available() {
|
|
106
125
|
return this.config.enabled;
|
|
@@ -110,7 +129,7 @@ export class BrowserService {
|
|
|
110
129
|
return this.browser;
|
|
111
130
|
if (!this.launching) {
|
|
112
131
|
this.launching = (async () => {
|
|
113
|
-
const pw =
|
|
132
|
+
const pw = loadBrowserRuntime(this.config.browserRuntime);
|
|
114
133
|
const launchOptions = { headless: this.config.headless };
|
|
115
134
|
if (this.config.channel)
|
|
116
135
|
launchOptions.channel = this.config.channel;
|
|
@@ -127,7 +146,7 @@ export class BrowserService {
|
|
|
127
146
|
this.browser = await pw.chromium.launch(launchOptions);
|
|
128
147
|
}
|
|
129
148
|
else {
|
|
130
|
-
throw new Error('dsh-browser: chromium is not installed. Run the browser_install tool, or: node "' +
|
|
149
|
+
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');
|
|
131
150
|
}
|
|
132
151
|
}
|
|
133
152
|
else {
|
|
@@ -144,16 +163,35 @@ export class BrowserService {
|
|
|
144
163
|
}
|
|
145
164
|
/** Run `playwright install chromium` from the bundled playwright CLI. */
|
|
146
165
|
installChromium() {
|
|
147
|
-
return runNode(
|
|
166
|
+
return runNode(browserRuntimeCliPath(this.config.browserRuntime), ['install', 'chromium'], { timeoutMs: 600_000, signal: undefined, maxOutput: 256 * 1024 });
|
|
167
|
+
}
|
|
168
|
+
async navigate(page, url, options, signal) {
|
|
169
|
+
let response;
|
|
170
|
+
for (let attempt = 0; attempt <= this.config.usagePolicy.retryLimit; attempt++) {
|
|
171
|
+
response = await this.usageGovernor.run(url, () => page.goto(url, options), signal);
|
|
172
|
+
const status = Number(response?.status?.() ?? 0);
|
|
173
|
+
if (status >= 200 && status < 400)
|
|
174
|
+
this.usageGovernor.noteResponse(url, status);
|
|
175
|
+
if (![429, 502, 503, 504].includes(status))
|
|
176
|
+
return response;
|
|
177
|
+
const rawRetryAfter = String(response?.headers?.()?.['retry-after'] ?? '');
|
|
178
|
+
const retryAfterMs = /^\d+(?:\.\d+)?$/.test(rawRetryAfter)
|
|
179
|
+
? Number(rawRetryAfter) * 1000
|
|
180
|
+
: (Number.isFinite(Date.parse(rawRetryAfter)) ? Math.max(Date.parse(rawRetryAfter) - Date.now(), 0) : undefined);
|
|
181
|
+
this.usageGovernor.noteResponse(url, status, retryAfterMs);
|
|
182
|
+
if (attempt === this.config.usagePolicy.retryLimit)
|
|
183
|
+
return response;
|
|
184
|
+
}
|
|
185
|
+
return response;
|
|
148
186
|
}
|
|
149
187
|
async transientContext(url, opts = {}) {
|
|
150
188
|
const browser = await this.ensure();
|
|
151
|
-
const profileId = opts.authProfile ?? this.config.defaultAuthProfile;
|
|
189
|
+
const profileId = opts.anonymous ? undefined : (opts.authProfile ?? this.config.defaultAuthProfile);
|
|
152
190
|
const profile = profileId ? this.authProfiles.resolve(profileId, url) : undefined;
|
|
153
191
|
const rulePack = resolveRulePack(this.config.rulePacks, opts.rulePack, url);
|
|
154
192
|
const context = await browser.newContext(profile?.storageStatePath
|
|
155
193
|
? { storageState: profile.storageStatePath }
|
|
156
|
-
: (this.config.storageStatePath ? { storageState: this.config.storageStatePath } : {}));
|
|
194
|
+
: (!opts.anonymous && this.config.storageStatePath ? { storageState: this.config.storageStatePath } : {}));
|
|
157
195
|
try {
|
|
158
196
|
if (rulePack?.initScriptPath)
|
|
159
197
|
await context.addInitScript({ path: rulePack.initScriptPath });
|
|
@@ -191,7 +229,7 @@ export class BrowserService {
|
|
|
191
229
|
signal?.addEventListener('abort', onAbort);
|
|
192
230
|
try {
|
|
193
231
|
page.setDefaultTimeout(20_000);
|
|
194
|
-
await
|
|
232
|
+
await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 25_000 }, signal);
|
|
195
233
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
196
234
|
await applyRuleSteps(page, session.rulePack);
|
|
197
235
|
if (opts.waitMs)
|
|
@@ -224,21 +262,22 @@ export class BrowserService {
|
|
|
224
262
|
signal?.addEventListener('abort', onAbort);
|
|
225
263
|
fs.mkdirSync(opts.outDir, { recursive: true });
|
|
226
264
|
const stamp = Date.now() + '-' + uid().slice(0, 8);
|
|
227
|
-
const screenshotPath = path.join(opts.outDir, stamp + '.png');
|
|
265
|
+
const screenshotPath = opts.screenshot === false ? undefined : path.join(opts.outDir, stamp + '.png');
|
|
228
266
|
const htmlPath = path.join(opts.outDir, stamp + '.html');
|
|
229
267
|
try {
|
|
230
268
|
page.setDefaultTimeout(25_000);
|
|
231
|
-
await
|
|
269
|
+
await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, signal);
|
|
232
270
|
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => { });
|
|
233
271
|
await applyRuleSteps(page, session.rulePack);
|
|
234
|
-
|
|
272
|
+
if (screenshotPath)
|
|
273
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
235
274
|
const html = await page.content();
|
|
236
275
|
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
237
276
|
const data = await evaluateExtractor(page, rules);
|
|
238
277
|
return {
|
|
239
278
|
title: String(data.title ?? ''),
|
|
240
279
|
text: capText(String(data.text ?? '').replace(/\n{3,}/g, '\n\n').trim(), opts.maxChars ?? 200_000),
|
|
241
|
-
screenshotPath,
|
|
280
|
+
...screenshotPath ? { screenshotPath } : {},
|
|
242
281
|
htmlPath,
|
|
243
282
|
...data.usedRule ? { usedRule: String(data.usedRule) } : {},
|
|
244
283
|
};
|
|
@@ -265,7 +304,7 @@ export class BrowserService {
|
|
|
265
304
|
signal?.addEventListener('abort', onAbort);
|
|
266
305
|
try {
|
|
267
306
|
page.setDefaultTimeout(25_000);
|
|
268
|
-
await
|
|
307
|
+
await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, signal);
|
|
269
308
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
270
309
|
await applyRuleSteps(page, session.rulePack);
|
|
271
310
|
if (opts.waitMs)
|
|
@@ -298,11 +337,121 @@ export class BrowserService {
|
|
|
298
337
|
if (args.length < 1 || args.length > 40 || args.some(arg => typeof arg !== 'string' || arg.length > 2_000)) {
|
|
299
338
|
return Promise.resolve({ code: -1, stdout: '', stderr: 'dsh-browser: OpenCLI requires 1 to 40 arguments, each at most 2000 characters', timedOut: false });
|
|
300
339
|
}
|
|
301
|
-
|
|
340
|
+
// OpenCLI adapters can issue real site traffic outside Playwright, so they
|
|
341
|
+
// share the same approval-independent concurrency and burst buffer.
|
|
342
|
+
return this.usageGovernor.run('https://opencli.local/', () => runOpencli(args, { ...opts, signal: opts.signal }), opts.signal);
|
|
302
343
|
}
|
|
303
344
|
opencliDoctor(signal) {
|
|
304
345
|
return this.opencli(['doctor'], { timeoutMs: 30_000, signal });
|
|
305
346
|
}
|
|
347
|
+
async opencliCatalog(filter = {}, signal) {
|
|
348
|
+
if (!this.config.opencliEnabled)
|
|
349
|
+
throw new Error('dsh-browser: OpenCLI is disabled');
|
|
350
|
+
if (!this.opencliCatalogCache) {
|
|
351
|
+
const result = await this.opencli(['list', '-f', 'json'], { timeoutMs: 60_000, signal });
|
|
352
|
+
if (result.code !== 0 || result.timedOut)
|
|
353
|
+
throw new Error('OpenCLI catalog failed: ' + (result.stderr || result.stdout).slice(0, 500));
|
|
354
|
+
this.opencliCatalogCache = parseOpencliCatalog(result.stdout);
|
|
355
|
+
}
|
|
356
|
+
return filterOpencliCatalog(this.opencliCatalogCache, filter);
|
|
357
|
+
}
|
|
358
|
+
async crawl(startUrls, opts = {}) {
|
|
359
|
+
if (startUrls.length < 1 || startUrls.length > 5)
|
|
360
|
+
throw new Error('browser crawl requires 1 to 5 start URLs');
|
|
361
|
+
const normalized = startUrls.map(value => {
|
|
362
|
+
const url = new URL(value);
|
|
363
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
364
|
+
throw new Error('browser crawl only supports HTTP(S) URLs');
|
|
365
|
+
url.hash = '';
|
|
366
|
+
return url.href;
|
|
367
|
+
});
|
|
368
|
+
const maxPages = opts.maxPages ?? this.config.usagePolicy.maxPagesPerRun;
|
|
369
|
+
const maxDepth = opts.maxDepth ?? this.config.usagePolicy.maxDepth;
|
|
370
|
+
if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > this.config.usagePolicy.maxPagesPerRun) {
|
|
371
|
+
throw new Error('browser crawl maxPages must be from 1 to configured usagePolicy.maxPagesPerRun (' + this.config.usagePolicy.maxPagesPerRun + ')');
|
|
372
|
+
}
|
|
373
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 0 || maxDepth > this.config.usagePolicy.maxDepth) {
|
|
374
|
+
throw new Error('browser crawl maxDepth must be from 0 to configured usagePolicy.maxDepth (' + this.config.usagePolicy.maxDepth + ')');
|
|
375
|
+
}
|
|
376
|
+
const maxCharsPerPage = Math.min(Math.max(opts.maxCharsPerPage ?? 20_000, 1_000), 50_000);
|
|
377
|
+
const sameOrigin = opts.sameOrigin ?? true;
|
|
378
|
+
const allowedOrigins = new Set(normalized.map(value => new URL(value).origin));
|
|
379
|
+
const queue = normalized.map(url => ({ url, depth: 0 }));
|
|
380
|
+
const seen = new Set(normalized);
|
|
381
|
+
const pages = [];
|
|
382
|
+
const errors = [];
|
|
383
|
+
const before = this.usageGovernor.snapshot();
|
|
384
|
+
const started = Date.now();
|
|
385
|
+
const session = await this.transientContext(normalized[0], { anonymous: true });
|
|
386
|
+
try {
|
|
387
|
+
while (queue.length && pages.length + errors.length < maxPages) {
|
|
388
|
+
if (opts.signal?.aborted)
|
|
389
|
+
throw new Error('browser crawl aborted');
|
|
390
|
+
const item = queue.shift();
|
|
391
|
+
const page = await session.context.newPage();
|
|
392
|
+
try {
|
|
393
|
+
page.setDefaultTimeout(30_000);
|
|
394
|
+
const response = await this.navigate(page, item.url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, opts.signal);
|
|
395
|
+
const status = Number(response?.status?.() ?? 0);
|
|
396
|
+
if (sameOrigin && !allowedOrigins.has(new URL(page.url()).origin)) {
|
|
397
|
+
errors.push({ url: item.url, depth: item.depth, status, error: 'cross-origin redirect blocked: ' + page.url() });
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (status >= 400) {
|
|
401
|
+
errors.push({ url: item.url, depth: item.depth, status, error: 'HTTP ' + status });
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
|
|
405
|
+
const data = await page.evaluate('(' + CRAWL_EXTRACTOR + ')(' + maxCharsPerPage + ')');
|
|
406
|
+
pages.push({ url: page.url(), title: String(data.title ?? ''), text: String(data.text ?? ''), depth: item.depth, status });
|
|
407
|
+
if (item.depth >= maxDepth)
|
|
408
|
+
continue;
|
|
409
|
+
for (const rawLink of Array.isArray(data.links) ? data.links : []) {
|
|
410
|
+
let link;
|
|
411
|
+
try {
|
|
412
|
+
link = new URL(rawLink);
|
|
413
|
+
link.hash = '';
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (sameOrigin && !allowedOrigins.has(link.origin))
|
|
419
|
+
continue;
|
|
420
|
+
const href = link.href;
|
|
421
|
+
if (seen.has(href) || seen.size >= maxPages * 25)
|
|
422
|
+
continue;
|
|
423
|
+
seen.add(href);
|
|
424
|
+
queue.push({ url: href, depth: item.depth + 1 });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
errors.push({ url: item.url, depth: item.depth, error: String(error).slice(0, 500) });
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
await page.close().catch(() => { });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await this.persistAndClose(session);
|
|
437
|
+
}
|
|
438
|
+
const after = this.usageGovernor.snapshot();
|
|
439
|
+
return {
|
|
440
|
+
pages,
|
|
441
|
+
errors,
|
|
442
|
+
stats: {
|
|
443
|
+
pagesVisited: pages.length + errors.length,
|
|
444
|
+
queued: queue.length,
|
|
445
|
+
elapsedMs: Date.now() - started,
|
|
446
|
+
waitMs: after.totalWaitMs - before.totalWaitMs,
|
|
447
|
+
backoffEvents: after.backoffEvents - before.backoffEvents,
|
|
448
|
+
},
|
|
449
|
+
warnings: [
|
|
450
|
+
'Bounded crawl: respect each site\'s terms, robots directives, copyright, privacy, and applicable law.',
|
|
451
|
+
'No-approval mode skips human confirmation only; concurrency, burst, page/depth budgets, and server-pressure backoff remain active.',
|
|
452
|
+
],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
306
455
|
scriptCatalog() {
|
|
307
456
|
return BUILTIN_SCRIPTS.map(script => ({
|
|
308
457
|
id: script.id,
|
|
@@ -329,7 +478,7 @@ export class BrowserService {
|
|
|
329
478
|
let timer;
|
|
330
479
|
try {
|
|
331
480
|
page.setDefaultTimeout(30_000);
|
|
332
|
-
await
|
|
481
|
+
await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, signal);
|
|
333
482
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
334
483
|
await applyRuleSteps(page, session.rulePack);
|
|
335
484
|
const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? 15_000, 1_000), 30_000);
|
|
@@ -409,7 +558,7 @@ export class BrowserService {
|
|
|
409
558
|
async open(url, opts = {}) {
|
|
410
559
|
const page = await this.ensureActivePage(url, opts);
|
|
411
560
|
page.setDefaultTimeout(30_000);
|
|
412
|
-
await
|
|
561
|
+
await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
413
562
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
414
563
|
await applyRuleSteps(page, this.activeRulePack);
|
|
415
564
|
if (opts.waitMs)
|
|
@@ -452,7 +601,7 @@ export class BrowserService {
|
|
|
452
601
|
const page = await this.ensureActivePage(opts.url, opts);
|
|
453
602
|
if (opts.url) {
|
|
454
603
|
page.setDefaultTimeout(30_000);
|
|
455
|
-
await
|
|
604
|
+
await this.navigate(page, opts.url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, opts.signal);
|
|
456
605
|
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
457
606
|
await applyRuleSteps(page, this.activeRulePack);
|
|
458
607
|
if (opts.waitMs)
|
|
@@ -485,23 +634,41 @@ export class BrowserService {
|
|
|
485
634
|
async status() {
|
|
486
635
|
let chromiumInstalled = false;
|
|
487
636
|
try {
|
|
488
|
-
const pw =
|
|
637
|
+
const pw = loadBrowserRuntime(this.config.browserRuntime);
|
|
489
638
|
chromiumInstalled = !!pw.chromium.executablePath();
|
|
490
639
|
}
|
|
491
640
|
catch {
|
|
492
641
|
chromiumInstalled = false;
|
|
493
642
|
}
|
|
643
|
+
const runtimeWarnings = this.config.browserRuntime === 'patchright'
|
|
644
|
+
? [
|
|
645
|
+
'Patchright is Chromium-only and disables Playwright console APIs to avoid Runtime.enable detection.',
|
|
646
|
+
...(this.config.channel !== 'chrome' || this.config.headless
|
|
647
|
+
? ['Patchright stealth is strongest with channel=chrome and headless=false; current settings favor automation/test compatibility.']
|
|
648
|
+
: []),
|
|
649
|
+
]
|
|
650
|
+
: [];
|
|
494
651
|
return {
|
|
495
652
|
enabled: this.config.enabled,
|
|
496
653
|
channel: this.config.channel,
|
|
654
|
+
browserRuntime: this.config.browserRuntime,
|
|
655
|
+
runtimeWarnings,
|
|
497
656
|
headless: this.config.headless,
|
|
498
657
|
opencliEnabled: this.config.opencliEnabled,
|
|
658
|
+
automationMode: this.config.automationMode,
|
|
659
|
+
exposedTools: browserToolsForMode(this.config.automationMode),
|
|
660
|
+
directInteractionPolicy: this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'standard' ? 'ask' : 'allow',
|
|
661
|
+
mutatingRecipePolicy: this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'standard' ? 'ask' : 'allow',
|
|
662
|
+
externalUserscriptPolicy: this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'unrestricted' ? 'allow' : 'ask',
|
|
663
|
+
opencliRunPolicy: this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'unrestricted' ? 'allow' : 'ask',
|
|
499
664
|
chromiumInstalled,
|
|
665
|
+
usagePolicy: this.config.usagePolicy,
|
|
666
|
+
usageGovernor: this.usageGovernor.snapshot(),
|
|
500
667
|
authProfiles: this.authProfiles.list(),
|
|
501
668
|
rulePacks: Object.keys(this.config.rulePacks).sort(),
|
|
502
669
|
builtinScripts: BUILTIN_SCRIPTS.map(script => script.id),
|
|
503
|
-
externalUserscriptsRequireApproval:
|
|
504
|
-
mutatingRecipesRequireApproval:
|
|
670
|
+
externalUserscriptsRequireApproval: ['standard', 'autonomous'].includes(this.config.automationMode),
|
|
671
|
+
mutatingRecipesRequireApproval: this.config.automationMode === 'standard',
|
|
505
672
|
...(this.activePage && !this.activePage.isClosed() ? { activeUrl: this.activePage.url() } : {}),
|
|
506
673
|
...this.activeProfile ? { activeAuthProfile: this.activeProfile.id } : {},
|
|
507
674
|
};
|