@auxiora/browser 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -0
- package/dist/browser-manager.d.ts +41 -0
- package/dist/browser-manager.d.ts.map +1 -0
- package/dist/browser-manager.js +227 -0
- package/dist/browser-manager.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/dist/url-validator.d.ts +37 -0
- package/dist/url-validator.d.ts.map +1 -0
- package/dist/url-validator.js +189 -0
- package/dist/url-validator.js.map +1 -0
- package/package.json +25 -0
- package/src/browser-manager.ts +284 -0
- package/src/index.ts +13 -0
- package/src/types.ts +49 -0
- package/src/url-validator.ts +198 -0
- package/tests/browser-actions.test.ts +185 -0
- package/tests/browser-manager.test.ts +142 -0
- package/tests/integration.test.ts +141 -0
- package/tests/types.test.ts +72 -0
- package/tests/url-validator.test.ts +151 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { BLOCKED_PROTOCOLS } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Check if an IP address (v4 or v6) falls within private/internal ranges.
|
|
5
|
+
* Uses numeric comparison instead of string prefix matching to prevent
|
|
6
|
+
* bypass via decimal, hex, or octal IP encodings.
|
|
7
|
+
*/
|
|
8
|
+
function isPrivateIP(ip) {
|
|
9
|
+
// IPv6 checks
|
|
10
|
+
if (ip.includes(':')) {
|
|
11
|
+
const normalized = normalizeIPv6(ip);
|
|
12
|
+
// ::1 (loopback)
|
|
13
|
+
if (normalized === '0000:0000:0000:0000:0000:0000:0000:0001')
|
|
14
|
+
return true;
|
|
15
|
+
// :: (unspecified)
|
|
16
|
+
if (normalized === '0000:0000:0000:0000:0000:0000:0000:0000')
|
|
17
|
+
return true;
|
|
18
|
+
// fe80::/10 (link-local)
|
|
19
|
+
if (normalized.startsWith('fe8') || normalized.startsWith('fe9') ||
|
|
20
|
+
normalized.startsWith('fea') || normalized.startsWith('feb'))
|
|
21
|
+
return true;
|
|
22
|
+
// fc00::/7 (unique local)
|
|
23
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd'))
|
|
24
|
+
return true;
|
|
25
|
+
// ::ffff:x.x.x.x (IPv4-mapped IPv6)
|
|
26
|
+
if (normalized.startsWith('0000:0000:0000:0000:0000:ffff:')) {
|
|
27
|
+
const lastTwo = normalized.slice(30); // e.g., "7f00:0001"
|
|
28
|
+
const hi = parseInt(lastTwo.slice(0, 4), 16);
|
|
29
|
+
const lo = parseInt(lastTwo.slice(5, 9), 16);
|
|
30
|
+
const ipv4 = ((hi << 16) | lo) >>> 0;
|
|
31
|
+
return isPrivateIPv4Numeric(ipv4);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
// IPv4: parse to numeric
|
|
36
|
+
const num = parseIPv4ToNumber(ip);
|
|
37
|
+
if (num === null)
|
|
38
|
+
return false;
|
|
39
|
+
return isPrivateIPv4Numeric(num);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parse an IPv4 address string to a 32-bit unsigned number.
|
|
43
|
+
* Handles standard dotted-decimal (e.g. "127.0.0.1").
|
|
44
|
+
*/
|
|
45
|
+
function parseIPv4ToNumber(ip) {
|
|
46
|
+
const parts = ip.split('.');
|
|
47
|
+
if (parts.length !== 4)
|
|
48
|
+
return null;
|
|
49
|
+
let result = 0;
|
|
50
|
+
for (const part of parts) {
|
|
51
|
+
const n = parseInt(part, 10);
|
|
52
|
+
if (isNaN(n) || n < 0 || n > 255)
|
|
53
|
+
return null;
|
|
54
|
+
result = (result << 8) | n;
|
|
55
|
+
}
|
|
56
|
+
return result >>> 0; // unsigned
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Check if a numeric IPv4 address is in a private/internal range.
|
|
60
|
+
*/
|
|
61
|
+
function isPrivateIPv4Numeric(ip) {
|
|
62
|
+
// 127.0.0.0/8 (loopback)
|
|
63
|
+
if ((ip >>> 24) === 127)
|
|
64
|
+
return true;
|
|
65
|
+
// 10.0.0.0/8
|
|
66
|
+
if ((ip >>> 24) === 10)
|
|
67
|
+
return true;
|
|
68
|
+
// 172.16.0.0/12
|
|
69
|
+
if ((ip >>> 20) === (172 << 4 | 1))
|
|
70
|
+
return true; // 0xAC1 = 172.16-31
|
|
71
|
+
// 192.168.0.0/16
|
|
72
|
+
if ((ip >>> 16) === (192 << 8 | 168))
|
|
73
|
+
return true; // 0xC0A8
|
|
74
|
+
// 169.254.0.0/16 (link-local)
|
|
75
|
+
if ((ip >>> 16) === (169 << 8 | 254))
|
|
76
|
+
return true; // 0xA9FE
|
|
77
|
+
// 0.0.0.0
|
|
78
|
+
if (ip === 0)
|
|
79
|
+
return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Normalize an IPv6 address to its full expanded form.
|
|
84
|
+
*/
|
|
85
|
+
function normalizeIPv6(ip) {
|
|
86
|
+
// Handle IPv4-mapped addresses like ::ffff:127.0.0.1
|
|
87
|
+
const v4MappedMatch = ip.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
88
|
+
if (v4MappedMatch) {
|
|
89
|
+
const v4num = parseIPv4ToNumber(v4MappedMatch[1]);
|
|
90
|
+
if (v4num !== null) {
|
|
91
|
+
const hi = (v4num >>> 16) & 0xffff;
|
|
92
|
+
const lo = v4num & 0xffff;
|
|
93
|
+
return `0000:0000:0000:0000:0000:ffff:${hi.toString(16).padStart(4, '0')}:${lo.toString(16).padStart(4, '0')}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let parts = ip.split(':');
|
|
97
|
+
// Handle :: expansion
|
|
98
|
+
const emptyIndex = parts.indexOf('');
|
|
99
|
+
if (ip.includes('::')) {
|
|
100
|
+
const before = ip.split('::')[0].split(':').filter(Boolean);
|
|
101
|
+
const after = ip.split('::')[1].split(':').filter(Boolean);
|
|
102
|
+
const missing = 8 - before.length - after.length;
|
|
103
|
+
parts = [...before, ...Array(missing).fill('0'), ...after];
|
|
104
|
+
}
|
|
105
|
+
return parts.map((p) => (p || '0').padStart(4, '0').toLowerCase()).join(':');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Check if a hostname looks like a numeric IP (decimal, hex, octal)
|
|
109
|
+
* that could bypass string-based checks.
|
|
110
|
+
*/
|
|
111
|
+
function isNumericHostname(hostname) {
|
|
112
|
+
// Pure decimal number (e.g., 2130706433 for 127.0.0.1)
|
|
113
|
+
if (/^\d+$/.test(hostname))
|
|
114
|
+
return true;
|
|
115
|
+
// Hex number (e.g., 0x7f000001)
|
|
116
|
+
if (/^0x[0-9a-fA-F]+$/i.test(hostname))
|
|
117
|
+
return true;
|
|
118
|
+
// Octal parts (e.g., 0177.0.0.1)
|
|
119
|
+
if (/^[0-7]+\./.test(hostname) && hostname.startsWith('0') && !hostname.startsWith('0.'))
|
|
120
|
+
return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Validates a URL for browser navigation.
|
|
125
|
+
* Returns null if valid, or an error message string.
|
|
126
|
+
*
|
|
127
|
+
* Blocks:
|
|
128
|
+
* - Non http/https protocols (file:, javascript:, data:, blob:)
|
|
129
|
+
* - Private/internal IP addresses (127.x, 10.x, 192.168.x, 172.16-31.x, 169.254.x, localhost)
|
|
130
|
+
* - Numeric IP bypasses (decimal, hex, octal encodings)
|
|
131
|
+
* - IPv6-mapped IPv4 addresses (::ffff:127.0.0.1)
|
|
132
|
+
*/
|
|
133
|
+
export function validateUrl(url, options) {
|
|
134
|
+
if (!url || typeof url !== 'string') {
|
|
135
|
+
return 'URL must be a non-empty string';
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = new URL(url);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return 'Invalid URL format';
|
|
143
|
+
}
|
|
144
|
+
// Check blocked protocols
|
|
145
|
+
if (BLOCKED_PROTOCOLS.includes(parsed.protocol)) {
|
|
146
|
+
return `Blocked protocol: ${parsed.protocol} — only http: and https: are allowed`;
|
|
147
|
+
}
|
|
148
|
+
// Only allow http and https
|
|
149
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
150
|
+
return `Blocked protocol: ${parsed.protocol} — only http: and https: are allowed`;
|
|
151
|
+
}
|
|
152
|
+
const hostname = parsed.hostname;
|
|
153
|
+
// Check blocklist first (takes priority)
|
|
154
|
+
if (options?.blockedUrls?.some((pattern) => hostname.includes(pattern))) {
|
|
155
|
+
return `URL blocked by blocklist: ${hostname}`;
|
|
156
|
+
}
|
|
157
|
+
// Check if in allowlist (skip private IP check if allowed)
|
|
158
|
+
const isAllowed = options?.allowedUrls?.some((pattern) => hostname.includes(pattern));
|
|
159
|
+
if (isAllowed) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
// Block numeric IP encodings (decimal, hex, octal) that could bypass string checks
|
|
163
|
+
if (isNumericHostname(hostname)) {
|
|
164
|
+
return `Blocked numeric IP encoding: ${hostname}`;
|
|
165
|
+
}
|
|
166
|
+
// Block localhost
|
|
167
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
168
|
+
return `Blocked private/internal address: ${hostname}`;
|
|
169
|
+
}
|
|
170
|
+
// Check if hostname is an IP address
|
|
171
|
+
const ipVersion = isIP(hostname);
|
|
172
|
+
if (ipVersion > 0) {
|
|
173
|
+
// It's a direct IP — check against private ranges numerically
|
|
174
|
+
if (isPrivateIP(hostname)) {
|
|
175
|
+
return `Blocked private/internal address: ${hostname}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Check hostnames that resolve to IPv6 loopback patterns
|
|
179
|
+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
180
|
+
const innerIP = hostname.slice(1, -1);
|
|
181
|
+
if (isPrivateIP(innerIP)) {
|
|
182
|
+
return `Blocked private/internal address: ${hostname}`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
// Export for testing
|
|
188
|
+
export { isPrivateIP, parseIPv4ToNumber, isNumericHostname, normalizeIPv6 };
|
|
189
|
+
//# sourceMappingURL=url-validator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url-validator.js","sourceRoot":"","sources":["../src/url-validator.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@auxiora/browser",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.0.0"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"playwright": "^1.50.0",
|
|
18
|
+
"@auxiora/logger": "1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"clean": "rm -rf dist",
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import type { Browser, Page, BrowserContext } from 'playwright';
|
|
2
|
+
import { getLogger } from '@auxiora/logger';
|
|
3
|
+
import type { BrowserConfig, ScreenshotOptions, ExtractResult, BrowseStep, PageInfo } from './types.js';
|
|
4
|
+
import { DEFAULT_BROWSER_CONFIG } from './types.js';
|
|
5
|
+
import { validateUrl } from './url-validator.js';
|
|
6
|
+
|
|
7
|
+
const logger = getLogger('browser:manager');
|
|
8
|
+
|
|
9
|
+
type BrowserFactory = (config: BrowserConfig) => Promise<Browser>;
|
|
10
|
+
|
|
11
|
+
export interface BrowserManagerOptions {
|
|
12
|
+
config?: BrowserConfig;
|
|
13
|
+
browserFactory?: BrowserFactory;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_SCREENSHOT_SIZE = 5 * 1024 * 1024; // 5MB
|
|
17
|
+
const MAX_RESULT_SIZE = 100 * 1024; // 100KB
|
|
18
|
+
|
|
19
|
+
export class BrowserManager {
|
|
20
|
+
private config: BrowserConfig;
|
|
21
|
+
private browser: Browser | null = null;
|
|
22
|
+
private context: BrowserContext | null = null;
|
|
23
|
+
private pages = new Map<string, Page>();
|
|
24
|
+
private browserFactory: BrowserFactory;
|
|
25
|
+
|
|
26
|
+
constructor(options: BrowserManagerOptions = {}) {
|
|
27
|
+
this.config = { ...DEFAULT_BROWSER_CONFIG, ...options.config };
|
|
28
|
+
this.browserFactory = options.browserFactory || defaultBrowserFactory;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async launch(): Promise<void> {
|
|
32
|
+
if (this.browser?.isConnected()) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
logger.info('Launching browser', { headless: this.config.headless });
|
|
37
|
+
this.browser = await this.browserFactory(this.config);
|
|
38
|
+
this.context = await this.browser.newContext();
|
|
39
|
+
logger.info('Browser launched');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async ensureBrowser(): Promise<void> {
|
|
43
|
+
if (!this.browser || !this.browser.isConnected()) {
|
|
44
|
+
this.pages.clear();
|
|
45
|
+
this.context = null;
|
|
46
|
+
this.browser = null;
|
|
47
|
+
|
|
48
|
+
logger.warn('Browser disconnected, re-launching');
|
|
49
|
+
await this.launch();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getPage(sessionId: string): Promise<Page> {
|
|
54
|
+
await this.ensureBrowser();
|
|
55
|
+
|
|
56
|
+
const existing = this.pages.get(sessionId);
|
|
57
|
+
if (existing && !existing.isClosed()) {
|
|
58
|
+
return existing;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (this.pages.size >= this.config.maxConcurrentPages) {
|
|
62
|
+
throw new Error(`Max concurrent pages (${this.config.maxConcurrentPages}) reached`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const page = await this.context!.newPage();
|
|
66
|
+
await page.setViewportSize(this.config.viewport);
|
|
67
|
+
page.setDefaultNavigationTimeout(this.config.navigationTimeout);
|
|
68
|
+
page.setDefaultTimeout(this.config.actionTimeout);
|
|
69
|
+
|
|
70
|
+
this.pages.set(sessionId, page);
|
|
71
|
+
logger.info('Page created', { sessionId });
|
|
72
|
+
return page;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async closePage(sessionId: string): Promise<void> {
|
|
76
|
+
const page = this.pages.get(sessionId);
|
|
77
|
+
if (page) {
|
|
78
|
+
await page.close();
|
|
79
|
+
this.pages.delete(sessionId);
|
|
80
|
+
logger.info('Page closed', { sessionId });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async shutdown(): Promise<void> {
|
|
85
|
+
for (const [sessionId, page] of this.pages) {
|
|
86
|
+
try {
|
|
87
|
+
if (!page.isClosed()) {
|
|
88
|
+
await page.close();
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.warn('Error closing page', { sessionId, error: error instanceof Error ? error : new Error(String(error)) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this.pages.clear();
|
|
95
|
+
|
|
96
|
+
if (this.browser) {
|
|
97
|
+
try {
|
|
98
|
+
await this.browser.close();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
logger.warn('Error closing browser', { error: error instanceof Error ? error : new Error(String(error)) });
|
|
101
|
+
}
|
|
102
|
+
this.browser = null;
|
|
103
|
+
this.context = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
logger.info('Browser shutdown complete');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async navigate(sessionId: string, url: string): Promise<PageInfo> {
|
|
110
|
+
const validationError = validateUrl(url, {
|
|
111
|
+
allowedUrls: this.config.allowedUrls,
|
|
112
|
+
blockedUrls: this.config.blockedUrls,
|
|
113
|
+
});
|
|
114
|
+
if (validationError) {
|
|
115
|
+
throw new Error(validationError);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const page = await this.getPage(sessionId);
|
|
119
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
120
|
+
|
|
121
|
+
const title = await page.title();
|
|
122
|
+
const content = await this.getPageMarkdown(page);
|
|
123
|
+
|
|
124
|
+
return { url: page.url(), title, content };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async click(sessionId: string, selector: string): Promise<void> {
|
|
128
|
+
const page = await this.getPage(sessionId);
|
|
129
|
+
await page.click(selector, { timeout: this.config.actionTimeout });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async type(sessionId: string, selector: string, text: string, pressEnter?: boolean): Promise<void> {
|
|
133
|
+
const page = await this.getPage(sessionId);
|
|
134
|
+
await page.fill(selector, text);
|
|
135
|
+
if (pressEnter) {
|
|
136
|
+
await page.keyboard.press('Enter');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async screenshot(sessionId: string, options?: ScreenshotOptions): Promise<{ base64: string; path?: string }> {
|
|
141
|
+
const page = await this.getPage(sessionId);
|
|
142
|
+
|
|
143
|
+
const screenshotOptions: any = {
|
|
144
|
+
fullPage: options?.fullPage ?? true,
|
|
145
|
+
type: 'png',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (options?.selector) {
|
|
149
|
+
const element = await page.$(options.selector);
|
|
150
|
+
if (!element) {
|
|
151
|
+
throw new Error(`Element not found: ${options.selector}`);
|
|
152
|
+
}
|
|
153
|
+
const buffer = await element.screenshot(screenshotOptions);
|
|
154
|
+
return this.processScreenshot(buffer, sessionId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const buffer = await page.screenshot(screenshotOptions);
|
|
158
|
+
return this.processScreenshot(buffer, sessionId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async processScreenshot(buffer: Buffer, sessionId: string): Promise<{ base64: string; path?: string }> {
|
|
162
|
+
if (buffer.length > MAX_SCREENSHOT_SIZE) {
|
|
163
|
+
logger.warn('Screenshot exceeds size limit', { size: buffer.length });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const base64 = buffer.toString('base64');
|
|
167
|
+
|
|
168
|
+
let filePath: string | undefined;
|
|
169
|
+
if (this.config.screenshotDir) {
|
|
170
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
171
|
+
const { join } = await import('node:path');
|
|
172
|
+
const dir = this.config.screenshotDir;
|
|
173
|
+
await mkdir(dir, { recursive: true });
|
|
174
|
+
const timestamp = Date.now();
|
|
175
|
+
filePath = join(dir, `${timestamp}-${sessionId}.png`);
|
|
176
|
+
await writeFile(filePath, buffer);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { base64, path: filePath };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async extract(sessionId: string, selector: string): Promise<ExtractResult> {
|
|
183
|
+
const page = await this.getPage(sessionId);
|
|
184
|
+
|
|
185
|
+
const elements = await page.$$eval(selector, (els) =>
|
|
186
|
+
els.map((el) => ({
|
|
187
|
+
text: (el as any).innerText || el.textContent || '',
|
|
188
|
+
tagName: el.tagName.toLowerCase(),
|
|
189
|
+
attributes: Object.fromEntries(
|
|
190
|
+
Array.from(el.attributes).map((attr: any) => [attr.name, attr.value])
|
|
191
|
+
),
|
|
192
|
+
}))
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return { selector, elements };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async wait(sessionId: string, selectorOrMs: string | number): Promise<void> {
|
|
199
|
+
const page = await this.getPage(sessionId);
|
|
200
|
+
|
|
201
|
+
if (typeof selectorOrMs === 'number') {
|
|
202
|
+
const delay = Math.min(selectorOrMs, 30_000);
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
204
|
+
} else {
|
|
205
|
+
await page.waitForSelector(selectorOrMs, { timeout: this.config.actionTimeout });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async runScript(sessionId: string, script: string): Promise<string> {
|
|
210
|
+
const page = await this.getPage(sessionId);
|
|
211
|
+
// Using Playwright's page.evaluate API to run script in browser context
|
|
212
|
+
const result = await page.evaluate(script);
|
|
213
|
+
const json = JSON.stringify(result);
|
|
214
|
+
|
|
215
|
+
if (json.length > MAX_RESULT_SIZE) {
|
|
216
|
+
throw new Error(`Result too large (${json.length} bytes, max ${MAX_RESULT_SIZE})`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return json;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async browse(sessionId: string, task: string): Promise<{ result: string; steps: BrowseStep[] }> {
|
|
223
|
+
const steps: BrowseStep[] = [];
|
|
224
|
+
|
|
225
|
+
const mutationKeywords = ['click', 'type', 'fill', 'submit', 'login', 'sign in', 'purchase', 'buy', 'send', 'post', 'delete'];
|
|
226
|
+
const needsMutation = mutationKeywords.some((kw) => task.toLowerCase().includes(kw));
|
|
227
|
+
|
|
228
|
+
if (needsMutation) {
|
|
229
|
+
return {
|
|
230
|
+
result: 'This task requires page interactions (clicking, typing). Please use the primitive browser tools (browser_click, browser_type) for these actions, which require user approval for safety.',
|
|
231
|
+
steps: [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
result: `Browse task queued: "${task}". Use browser_navigate to go to a page, then browser_extract to get data.`,
|
|
237
|
+
steps,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async getPageMarkdown(page: Page): Promise<string> {
|
|
242
|
+
const html = await page.content();
|
|
243
|
+
return this.htmlToMarkdown(html);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private htmlToMarkdown(html: string): string {
|
|
247
|
+
let text = html;
|
|
248
|
+
text = text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
|
249
|
+
text = text.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
|
|
250
|
+
text = text.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '\n# $1\n');
|
|
251
|
+
text = text.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '\n## $1\n');
|
|
252
|
+
text = text.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '\n### $1\n');
|
|
253
|
+
text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
|
254
|
+
text = text.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
|
|
255
|
+
text = text.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
|
|
256
|
+
text = text.replace(/<br\s*\/?>/gi, '\n');
|
|
257
|
+
text = text.replace(/<[^>]+>/g, '');
|
|
258
|
+
text = text.replace(/ /g, ' ');
|
|
259
|
+
text = text.replace(/&/g, '&');
|
|
260
|
+
text = text.replace(/</g, '<');
|
|
261
|
+
text = text.replace(/>/g, '>');
|
|
262
|
+
text = text.replace(/"/g, '"');
|
|
263
|
+
text = text.replace(/'/g, "'");
|
|
264
|
+
text = text.replace(/\n{3,}/g, '\n\n');
|
|
265
|
+
text = text.replace(/[ \t]{2,}/g, ' ');
|
|
266
|
+
return text.trim();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getActiveSessionIds(): string[] {
|
|
270
|
+
return Array.from(this.pages.keys());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
getPageCount(): number {
|
|
274
|
+
return this.pages.size;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function defaultBrowserFactory(config: BrowserConfig): Promise<Browser> {
|
|
279
|
+
const { chromium } = await import('playwright');
|
|
280
|
+
return chromium.launch({
|
|
281
|
+
headless: config.headless,
|
|
282
|
+
args: ['--disable-extensions', '--disable-dev-shm-usage', '--no-sandbox'],
|
|
283
|
+
});
|
|
284
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
BrowserConfig,
|
|
3
|
+
PageInfo,
|
|
4
|
+
BrowseStep,
|
|
5
|
+
ScreenshotOptions,
|
|
6
|
+
ExtractResult,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
export {
|
|
9
|
+
DEFAULT_BROWSER_CONFIG,
|
|
10
|
+
BLOCKED_PROTOCOLS,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
export { validateUrl } from './url-validator.js';
|
|
13
|
+
export { BrowserManager, type BrowserManagerOptions } from './browser-manager.js';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface BrowserConfig {
|
|
2
|
+
headless: boolean;
|
|
3
|
+
viewport: { width: number; height: number };
|
|
4
|
+
navigationTimeout: number;
|
|
5
|
+
actionTimeout: number;
|
|
6
|
+
maxConcurrentPages: number;
|
|
7
|
+
screenshotDir: string;
|
|
8
|
+
allowedUrls?: string[];
|
|
9
|
+
blockedUrls?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
|
|
13
|
+
headless: true,
|
|
14
|
+
viewport: { width: 1280, height: 720 },
|
|
15
|
+
navigationTimeout: 30_000,
|
|
16
|
+
actionTimeout: 10_000,
|
|
17
|
+
maxConcurrentPages: 10,
|
|
18
|
+
screenshotDir: 'screenshots',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const BLOCKED_PROTOCOLS = ['file:', 'javascript:', 'data:', 'blob:'];
|
|
22
|
+
|
|
23
|
+
export interface PageInfo {
|
|
24
|
+
url: string;
|
|
25
|
+
title: string;
|
|
26
|
+
content?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BrowseStep {
|
|
30
|
+
action: string;
|
|
31
|
+
params: Record<string, unknown>;
|
|
32
|
+
result?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ScreenshotOptions {
|
|
37
|
+
fullPage?: boolean;
|
|
38
|
+
selector?: string;
|
|
39
|
+
quality?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExtractResult {
|
|
43
|
+
selector: string;
|
|
44
|
+
elements: Array<{
|
|
45
|
+
text: string;
|
|
46
|
+
attributes: Record<string, string>;
|
|
47
|
+
tagName: string;
|
|
48
|
+
}>;
|
|
49
|
+
}
|