opencode-webfetch-plugin 0.1.0 → 0.1.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/AGENTS.md +189 -0
- package/bun.lock +327 -11
- package/c1.ts +57 -0
- package/c2.js +28 -0
- package/package.json +13 -5
- package/src/BrowserManager.ts +36 -99
- package/src/BrowserServer.ts +226 -0
- package/src/BrowserWorkerManager.ts +184 -0
- package/src/HumanInteractor.ts +11 -6
- package/src/browser-worker.ts +136 -0
- package/src/index.ts +10 -31
- package/test.md +1 -0
- package/tests/BrowserWorkerManager.test.ts +49 -0
- package/tests/browser.test.ts +84 -0
- package/tests/helpers/cookie-worker.ts +51 -0
- package/vitest.config.ts +17 -0
- package/tsconfig.json +0 -17
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Browser Worker Process
|
|
4
|
+
* Runs in Node.js to avoid Bun's connectOverCDP issues
|
|
5
|
+
* Communicates with parent process via IPC
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { BrowserManager } from './BrowserManager.js';
|
|
9
|
+
|
|
10
|
+
type PlaywrightModule = typeof import('playwright');
|
|
11
|
+
|
|
12
|
+
interface WorkerRequest {
|
|
13
|
+
id: string;
|
|
14
|
+
type: 'fetch' | 'dispose';
|
|
15
|
+
url?: string;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface WorkerResponse {
|
|
20
|
+
id: string;
|
|
21
|
+
success: boolean;
|
|
22
|
+
data?: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let globalManager: BrowserManager | null = null;
|
|
27
|
+
let playwright: PlaywrightModule | null = null;
|
|
28
|
+
|
|
29
|
+
// Mock client for worker process
|
|
30
|
+
const workerClient = {
|
|
31
|
+
logger: {
|
|
32
|
+
info: (msg: string) => console.error(`[Worker Info] ${msg}`),
|
|
33
|
+
warn: (msg: string) => console.error(`[Worker Warn] ${msg}`),
|
|
34
|
+
error: (msg: string, err?: any) => console.error(`[Worker Error] ${msg}`, err),
|
|
35
|
+
},
|
|
36
|
+
tui: {
|
|
37
|
+
showToast: (opts: any) => {
|
|
38
|
+
// Send toast request to parent
|
|
39
|
+
sendToParent({ type: 'toast', data: opts });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Mock ToolContext for worker
|
|
45
|
+
function createMockContext(): any {
|
|
46
|
+
const abortController = new AbortController();
|
|
47
|
+
return {
|
|
48
|
+
abort: abortController.signal,
|
|
49
|
+
metadata: () => {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loadPlaywright(): Promise<PlaywrightModule> {
|
|
54
|
+
try {
|
|
55
|
+
return await import('playwright');
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'webfetch plugin requires Playwright. Install it with: npm install playwright && npx playwright install chromium',
|
|
59
|
+
{ cause: error },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function handleRequest(req: WorkerRequest): Promise<WorkerResponse> {
|
|
65
|
+
try {
|
|
66
|
+
if (req.type === 'fetch') {
|
|
67
|
+
if (!req.url) {
|
|
68
|
+
throw new Error('URL is required for fetch request');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!globalManager) {
|
|
72
|
+
playwright = await loadPlaywright();
|
|
73
|
+
globalManager = new BrowserManager(playwright, workerClient);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const timeout = req.timeout || 30000;
|
|
77
|
+
const ctx = createMockContext();
|
|
78
|
+
const markdown = await globalManager.fetchWebpage(req.url, timeout, ctx);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
id: req.id,
|
|
82
|
+
success: true,
|
|
83
|
+
data: markdown,
|
|
84
|
+
};
|
|
85
|
+
} else if (req.type === 'dispose') {
|
|
86
|
+
if (globalManager) {
|
|
87
|
+
await globalManager.dispose();
|
|
88
|
+
globalManager = null;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
id: req.id,
|
|
92
|
+
success: true,
|
|
93
|
+
};
|
|
94
|
+
} else {
|
|
95
|
+
throw new Error(`Unknown request type: ${(req as any).type}`);
|
|
96
|
+
}
|
|
97
|
+
} catch (error: any) {
|
|
98
|
+
return {
|
|
99
|
+
id: req.id,
|
|
100
|
+
success: false,
|
|
101
|
+
error: error.message || String(error),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sendToParent(message: any): void {
|
|
107
|
+
if (process.send) {
|
|
108
|
+
process.send(message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Listen for messages from parent process
|
|
113
|
+
process.on('message', async (message: any) => {
|
|
114
|
+
if (message && typeof message === 'object' && message.id) {
|
|
115
|
+
const response = await handleRequest(message as WorkerRequest);
|
|
116
|
+
sendToParent(response);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Handle graceful shutdown
|
|
121
|
+
process.on('SIGTERM', async () => {
|
|
122
|
+
if (globalManager) {
|
|
123
|
+
await globalManager.dispose().catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
process.exit(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
process.on('SIGINT', async () => {
|
|
129
|
+
if (globalManager) {
|
|
130
|
+
await globalManager.dispose().catch(() => {});
|
|
131
|
+
}
|
|
132
|
+
process.exit(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Signal ready
|
|
136
|
+
sendToParent({ type: 'ready' });
|
package/src/index.ts
CHANGED
|
@@ -1,32 +1,23 @@
|
|
|
1
1
|
import { type Plugin, tool } from "@opencode-ai/plugin";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
type PlaywrightModule = typeof import("playwright");
|
|
2
|
+
import { BrowserWorkerManager } from './BrowserWorkerManager.js';
|
|
5
3
|
|
|
6
4
|
const DEFAULT_TIMEOUT = 30_000;
|
|
7
5
|
const MAX_TIMEOUT = 120_000;
|
|
8
6
|
|
|
9
|
-
let
|
|
7
|
+
let globalWorkerManager: BrowserWorkerManager | null = null;
|
|
10
8
|
|
|
11
9
|
export const WebfetchPlugin: Plugin = async ({ client }) => {
|
|
12
10
|
const WebfetchTool = tool({
|
|
13
11
|
description: "Fetch a webpage's main content in markdown.",
|
|
14
12
|
args: {
|
|
15
13
|
url: tool.schema.string().describe("The URL to fetch."),
|
|
16
|
-
// timeout: tool.schema
|
|
17
|
-
// .number()
|
|
18
|
-
// .min(5)
|
|
19
|
-
// .max(120)
|
|
20
|
-
// .optional()
|
|
21
|
-
// .describe("Timeout in seconds (default: 30, max: 120)"),
|
|
22
14
|
},
|
|
23
15
|
async execute(params: any, ctx: any) {
|
|
24
|
-
if (!
|
|
25
|
-
|
|
26
|
-
globalManager = new BrowserManager(playwright, client);
|
|
16
|
+
if (!globalWorkerManager) {
|
|
17
|
+
globalWorkerManager = new BrowserWorkerManager(client);
|
|
27
18
|
}
|
|
28
19
|
|
|
29
|
-
const manager =
|
|
20
|
+
const manager = globalWorkerManager;
|
|
30
21
|
const timeoutMs = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT);
|
|
31
22
|
|
|
32
23
|
const abortHandler = () => {
|
|
@@ -41,7 +32,11 @@ export const WebfetchPlugin: Plugin = async ({ client }) => {
|
|
|
41
32
|
targetUrl = `https://www.google.com/search?q=${encodeURIComponent(targetUrl)}`;
|
|
42
33
|
}
|
|
43
34
|
|
|
44
|
-
const markdownResult = await manager.
|
|
35
|
+
const markdownResult = await manager.sendRequest({
|
|
36
|
+
type: 'fetch',
|
|
37
|
+
url: targetUrl,
|
|
38
|
+
timeout: timeoutMs,
|
|
39
|
+
});
|
|
45
40
|
|
|
46
41
|
ctx.metadata({
|
|
47
42
|
title: `Webfetch: ${targetUrl}`,
|
|
@@ -67,20 +62,4 @@ export const WebfetchPlugin: Plugin = async ({ client }) => {
|
|
|
67
62
|
};
|
|
68
63
|
};
|
|
69
64
|
|
|
70
|
-
async function loadPlaywright(): Promise<PlaywrightModule> {
|
|
71
|
-
try {
|
|
72
|
-
return await import("playwright");
|
|
73
|
-
} catch (error) {
|
|
74
|
-
try {
|
|
75
|
-
// @ts-ignore
|
|
76
|
-
return await import("/tmp/node_modules/playwright");
|
|
77
|
-
} catch {
|
|
78
|
-
throw new Error(
|
|
79
|
-
"webfetch plugin requires Playwright. Install it with: bun install playwright && bunx playwright install chromium",
|
|
80
|
-
{ cause: error },
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
65
|
export default WebfetchPlugin;
|
package/test.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
webfetch https://z.weixin.qq.com/ https://bailian.console.aliyun.com/#/home 两个页面,并分别做总结
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { BrowserWorkerManager } from '../src/BrowserWorkerManager.js';
|
|
3
|
+
|
|
4
|
+
describe('BrowserWorkerManager', () => {
|
|
5
|
+
let manager: BrowserWorkerManager | null = null;
|
|
6
|
+
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
if (manager) {
|
|
9
|
+
await manager.dispose().catch(() => {});
|
|
10
|
+
manager = null;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should handle worker lifecycle and concurrent requests', async () => {
|
|
15
|
+
const mockClient = {
|
|
16
|
+
logger: {
|
|
17
|
+
info: () => {},
|
|
18
|
+
warn: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
},
|
|
21
|
+
tui: {
|
|
22
|
+
showToast: () => {},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
manager = new BrowserWorkerManager(mockClient);
|
|
27
|
+
|
|
28
|
+
// Send two concurrent fetch requests
|
|
29
|
+
const [result1, result2] = await Promise.all([
|
|
30
|
+
manager.sendRequest({
|
|
31
|
+
type: 'fetch',
|
|
32
|
+
url: 'https://example.com',
|
|
33
|
+
timeout: 30000,
|
|
34
|
+
}),
|
|
35
|
+
manager.sendRequest({
|
|
36
|
+
type: 'fetch',
|
|
37
|
+
url: 'https://httpbin.org/html',
|
|
38
|
+
timeout: 30000,
|
|
39
|
+
}),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// Verify both requests returned markdown content
|
|
43
|
+
expect(result1).toContain('Example Domain');
|
|
44
|
+
expect(result2).toContain('httpbin');
|
|
45
|
+
|
|
46
|
+
// Verify worker can be disposed
|
|
47
|
+
await manager.dispose();
|
|
48
|
+
}, 60000);
|
|
49
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { BrowserServer } from '../src/BrowserServer.js';
|
|
3
|
+
import { BrowserManager } from '../src/BrowserManager.js';
|
|
4
|
+
import { fork } from 'child_process';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
|
|
7
|
+
async function getPlaywright() {
|
|
8
|
+
return await import('playwright');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resetBrowserServerSingleton() {
|
|
12
|
+
(BrowserServer as any).instance = null;
|
|
13
|
+
(BrowserServer as any).initPromise = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('Browser Tests', () => {
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
resetBrowserServerSingleton();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
resetBrowserServerSingleton();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should fetch two pages concurrently in the same browser instance', async () => {
|
|
26
|
+
const playwright = await getPlaywright();
|
|
27
|
+
const client = { logger: console };
|
|
28
|
+
|
|
29
|
+
const manager = new BrowserManager(playwright, client);
|
|
30
|
+
|
|
31
|
+
const [result1, result2] = await Promise.all([
|
|
32
|
+
manager.fetchWebpage('https://example.com', 30000, { abort: new AbortController().signal } as any),
|
|
33
|
+
manager.fetchWebpage('https://httpbin.org/html', 30000, { abort: new AbortController().signal } as any),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
expect(result1).toContain('Example Domain');
|
|
37
|
+
expect(result2).toContain('httpbin.org');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should share browser between two independent processes', async () => {
|
|
41
|
+
const workerScript = path.join(__dirname, 'helpers', 'cookie-worker.ts');
|
|
42
|
+
|
|
43
|
+
const runWorker = (action: string): Promise<{ content: string }> => {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const child = fork(workerScript, [action], {
|
|
46
|
+
execArgv: ['--import', 'tsx'],
|
|
47
|
+
stdio: ['pipe', 'pipe', 'pipe', 'ipc']
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let stdout = '';
|
|
51
|
+
child.stdout?.on('data', (data) => { stdout += data; });
|
|
52
|
+
child.stderr?.on('data', (data) => { console.log(`[worker stderr] ${data}`); });
|
|
53
|
+
|
|
54
|
+
child.on('message', (result: { content: string }) => {
|
|
55
|
+
resolve(result);
|
|
56
|
+
child.disconnect();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
child.on('error', reject);
|
|
60
|
+
child.on('exit', (code) => {
|
|
61
|
+
if (code !== 0) {
|
|
62
|
+
reject(new Error(`Worker exited with code ${code}: ${stdout}`));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const res = await runWorker('https://example.com');
|
|
69
|
+
expect(res.content).toContain('Example Domain');
|
|
70
|
+
|
|
71
|
+
const [result1, result2] = await Promise.all([runWorker('https://example.com'), runWorker('https://httpbin.org/html')]);
|
|
72
|
+
|
|
73
|
+
// console.log(result1)
|
|
74
|
+
// console.log(result2)
|
|
75
|
+
expect(result1.content).toContain('Example Domain');
|
|
76
|
+
expect(result2.content).toContain('httpbin.org');
|
|
77
|
+
// expect(result1.port).toBe(9222);
|
|
78
|
+
// expect(result1.cookies).toHaveProperty('test_cookie', 'test_value');
|
|
79
|
+
|
|
80
|
+
// // const result2 = await runWorker('get');
|
|
81
|
+
// expect(result2.port).toBe(9222);
|
|
82
|
+
// expect(result2.cookies).toHaveProperty('test_cookie', 'test_value');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { BrowserManager } from '../../src/BrowserManager.js'
|
|
2
|
+
|
|
3
|
+
async function main () {
|
|
4
|
+
const action = process.argv[2]
|
|
5
|
+
|
|
6
|
+
const playwright = await import('playwright')
|
|
7
|
+
const client = { logger: console }
|
|
8
|
+
|
|
9
|
+
const manager = new BrowserManager(playwright, client)
|
|
10
|
+
// const server = await (manager as any).ensureBrowserServer();
|
|
11
|
+
// const context = server.getContext();
|
|
12
|
+
|
|
13
|
+
// if (!context) {
|
|
14
|
+
// process.send?.({ error: 'No context' });
|
|
15
|
+
// process.exit(1);
|
|
16
|
+
// }
|
|
17
|
+
|
|
18
|
+
// const page = await context.newPage();
|
|
19
|
+
|
|
20
|
+
// let cookies: Record<string, string> = {};
|
|
21
|
+
|
|
22
|
+
let content = await manager.fetchWebpage(action, 3000, {
|
|
23
|
+
abort: new AbortController().signal
|
|
24
|
+
} as any)
|
|
25
|
+
|
|
26
|
+
// await page.goto('https://httpbin.org/cookies', { waitUntil: 'domcontentloaded' });
|
|
27
|
+
// await page.waitForTimeout(1000);
|
|
28
|
+
|
|
29
|
+
// const content = await page.content();
|
|
30
|
+
// const match = content.match(/{\s*"cookies":\s*({[^}]*})\s*}/)
|
|
31
|
+
// if (match) {
|
|
32
|
+
// try {
|
|
33
|
+
// cookies = JSON.parse(match[1])
|
|
34
|
+
// } catch (e) {
|
|
35
|
+
// // ignore
|
|
36
|
+
// }
|
|
37
|
+
// }
|
|
38
|
+
|
|
39
|
+
// await page.close()
|
|
40
|
+
|
|
41
|
+
process.send?.({
|
|
42
|
+
content
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
process.exit(0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
main().catch(e => {
|
|
49
|
+
console.error(e)
|
|
50
|
+
process.exit(1)
|
|
51
|
+
})
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
testTimeout: 60000,
|
|
8
|
+
hookTimeout: 60000,
|
|
9
|
+
teardownTimeout: 5000,
|
|
10
|
+
isolate: false,
|
|
11
|
+
maxConcurrency: 1,
|
|
12
|
+
fileParallelism: false,
|
|
13
|
+
sequence: {
|
|
14
|
+
concurrent: false,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
package/tsconfig.json
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"target": "ES2022",
|
|
5
|
-
"module": "ES2022",
|
|
6
|
-
"moduleResolution": "Bundler",
|
|
7
|
-
"lib": ["ES2022", "DOM"],
|
|
8
|
-
"strict": true,
|
|
9
|
-
"declaration": true,
|
|
10
|
-
"declarationMap": true,
|
|
11
|
-
"sourceMap": true,
|
|
12
|
-
"outDir": "dist",
|
|
13
|
-
"esModuleInterop": true,
|
|
14
|
-
"skipLibCheck": true
|
|
15
|
-
},
|
|
16
|
-
"include": ["src/**/*"]
|
|
17
|
-
}
|