@znmin/claude-remote-cli 0.3.2 → 0.4.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.
@@ -1,290 +1,290 @@
1
- import * as fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { createRequire } from 'node:module';
5
- import { randomUUID } from 'node:crypto';
6
- import { chromium } from 'playwright';
7
- import { execute, exists, withLock } from '../system.js';
8
-
9
- const require = createRequire(import.meta.url);
10
- const PLAYWRIGHT_VERSION = require('playwright/package.json').version;
11
- let preparation;
12
-
13
- const cacheDirectory = () => process.env.CLAUDE_REMOTE_CACHE_DIR || path.join(os.homedir(), '.cache', 'claude-remote');
14
- export const browserMarker = () => path.join(cacheDirectory(), `browser-ready-${PLAYWRIGHT_VERSION}`);
15
-
16
- // The marker records which executable a completed install produced, so a
17
- // warm start skips spawning the Playwright installer entirely.
18
- export async function browserReady() {
19
- const executable = chromium.executablePath();
20
- try { return (await fs.readFile(browserMarker(), 'utf8')).trim() === executable && await exists(executable); }
21
- catch (error) { if (error.code === 'ENOENT') return false; throw error; }
22
- }
23
-
24
- const BROWSER_NAMES = { chrome: 'Google Chrome', msedge: 'Microsoft Edge', chromium: 'Chromium(Playwright 自带)' };
25
-
26
- // Standard install locations of Chrome and Edge. Windows checks per-machine
27
- // and per-user installs; Edge ships with Windows 10/11.
28
- export function systemBrowserCandidates(kind, env = process.env, platform = process.platform, home = os.homedir()) {
29
- if (platform === 'win32') {
30
- const relative = kind === 'chrome' ? ['Google', 'Chrome', 'Application', 'chrome.exe'] : ['Microsoft', 'Edge', 'Application', 'msedge.exe'];
31
- const bases = [env.ProgramFiles, env.ProgramW6432, env['ProgramFiles(x86)'], env.LOCALAPPDATA].filter(Boolean);
32
- return [...new Set(bases.map(base => path.win32.join(base, ...relative)))];
33
- }
34
- if (platform === 'darwin') {
35
- const app = kind === 'chrome' ? 'Google Chrome' : 'Microsoft Edge';
36
- return ['/Applications', path.join(home, 'Applications')].map(directory => path.join(directory, app + '.app', 'Contents', 'MacOS', app));
37
- }
38
- return kind === 'chrome' ? ['/opt/google/chrome/chrome'] : ['/opt/microsoft/msedge/msedge'];
39
- }
40
-
41
- // CLAUDE_REMOTE_BROWSER picks one explicitly; otherwise an installed Chrome,
42
- // then Edge, and only without either the downloaded Chromium.
43
- export async function chooseBrowser(env = process.env, { platform = process.platform, home = os.homedir(),
44
- fileExists = file => exists(file).catch(() => false) } = {}) {
45
- const wanted = (env.CLAUDE_REMOTE_BROWSER || '').trim().toLowerCase();
46
- if (wanted && !(wanted in BROWSER_NAMES)) throw new Error('CLAUDE_REMOTE_BROWSER 只能是 chrome、msedge 或 chromium。');
47
- if (wanted === 'chromium') return { kind: 'chromium', explicit: true };
48
- for (const kind of wanted ? [wanted] : ['chrome', 'msedge'])
49
- for (const file of systemBrowserCandidates(kind, env, platform, home))
50
- if (await fileExists(file)) return { kind, file, explicit: Boolean(wanted) };
51
- if (wanted) throw new Error(`找不到 ${BROWSER_NAMES[wanted]}。请安装后重试,或去掉 CLAUDE_REMOTE_BROWSER 改为自动选择。`);
52
- return { kind: 'chromium', explicit: false };
53
- }
54
-
55
- export const browserName = choice => BROWSER_NAMES[choice.kind];
56
-
57
- // Returns the browser to launch. A system browser needs no download.
58
- export async function installBrowser(log = console.error, { debug = () => {}, force = false, env = process.env } = {}) {
59
- const choice = await chooseBrowser(env);
60
- if (choice.kind !== 'chromium') { debug(`使用系统浏览器:${browserName(choice)}(${choice.file})`); return choice; }
61
- await installChromium(log, { debug, force });
62
- return { ...choice, file: chromium.executablePath() };
63
- }
64
-
65
- export async function installChromium(log = console.error, { debug = () => {}, force = false } = {}) {
66
- if (!force && await browserReady()) { debug('浏览器缓存可用:' + chromium.executablePath()); return; }
67
- if (force) preparation = null;
68
- if (!preparation) preparation = (async () => {
69
- const cli = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
70
- // Keep the lock outside Playwright's cache: its installer replaces browser directories.
71
- await withLock(path.join(cacheDirectory(), 'browser-install'), async () => {
72
- if (!force && await browserReady()) return;
73
- if (!await exists(chromium.executablePath())) log('正在下载浏览器…');
74
- else debug('检查浏览器缓存:' + chromium.executablePath());
75
- // The CLI checks completion markers and prepares headed AND headless binaries.
76
- const result = await execute(process.execPath, [cli, 'install', 'chromium'], { capture: true, timeout: 600000 });
77
- if (!await exists(chromium.executablePath())) throw new Error('浏览器安装未完成:' + result.stderr);
78
- await fs.mkdir(cacheDirectory(), { recursive: true });
79
- await fs.writeFile(browserMarker(), chromium.executablePath() + '\n');
80
- });
81
- })().catch(error => { preparation = null; throw error; });
82
- await preparation;
83
- }
84
-
85
- // Runs inside each frame. Refs persist on elements across snapshots; the
86
- // per-document token keeps refs from a previous page from matching new ones.
87
- function collectElements({ limit, textLimit }) {
88
- const attribute = 'data-claude-remote-ref';
89
- window.__claudeRemote ||= { token: Math.random().toString(36).slice(2, 7), next: 0 };
90
- const state = window.__claudeRemote;
91
- const query = 'a,button,input,textarea,select,summary,[role="button"],[role="link"],[role="checkbox"],' +
92
- '[role="radio"],[role="tab"],[role="menuitem"],[role="option"],[role="switch"],[contenteditable="true"],[onclick]';
93
- const found = [];
94
- const visit = root => {
95
- for (const element of root.querySelectorAll(query)) found.push(element);
96
- for (const element of root.querySelectorAll('*')) if (element.shadowRoot) visit(element.shadowRoot);
97
- };
98
- visit(document);
99
- const elements = [];
100
- for (const element of found) {
101
- if (elements.length >= limit) break;
102
- if (!element.getClientRects().length) continue;
103
- let ref = element.getAttribute(attribute);
104
- if (!ref || !ref.startsWith(state.token + '-')) {
105
- ref = `${state.token}-${++state.next}`;
106
- element.setAttribute(attribute, ref);
107
- }
108
- const item = { selector: `[${attribute}="${ref}"]`, tag: element.tagName.toLowerCase(),
109
- text: (element.innerText || element.getAttribute('aria-label') || element.getAttribute('placeholder') ||
110
- element.getAttribute('title') || '').trim().slice(0, 200) };
111
- const type = element.getAttribute('type'); if (type) item.type = type;
112
- const name = element.getAttribute('name'); if (name) item.name = name;
113
- if (element.tagName === 'A' && element.getAttribute('href')) item.href = element.getAttribute('href').slice(0, 200);
114
- if ('value' in element && typeof element.value === 'string' && element.value && type !== 'password')
115
- item.value = element.value.slice(0, 200);
116
- if (element.disabled) item.disabled = true;
117
- if (element.checked) item.checked = true;
118
- elements.push(item);
119
- }
120
- return { text: (document.body?.innerText || '').slice(0, textLimit), elements, more: found.length > elements.length };
121
- }
122
-
123
- export class Browser {
124
- constructor({ headless = false, log = console.error, env = process.env } = {}) {
125
- this.headless = headless; this.log = log; this.env = env; this.pages = new Map(); this.logs = new Map();
126
- }
127
-
128
- async launchChromium() {
129
- await installChromium(this.log);
130
- try { return await chromium.launch({ headless: this.headless }); }
131
- catch (error) {
132
- // The cache marker may be stale (e.g. browsers removed by hand): reinstall once.
133
- if (!/Executable doesn't exist|executable/i.test(error.message)) throw error;
134
- await installChromium(this.log, { force: true });
135
- return chromium.launch({ headless: this.headless });
136
- }
137
- }
138
-
139
- async start() {
140
- if (!this.starting) this.starting = (async () => {
141
- const choice = await installBrowser(this.log, { env: this.env });
142
- if (choice.kind === 'chromium') this.browser = await this.launchChromium();
143
- else {
144
- // Each launch uses a fresh temporary profile, never the user's own.
145
- try { this.browser = await chromium.launch({ headless: this.headless, executablePath: choice.file }); }
146
- catch (error) {
147
- const reason = error.message.split('\n')[0];
148
- if (choice.explicit) throw new Error(`无法启动 ${browserName(choice)}:${reason}`);
149
- this.log(`无法启动 ${browserName(choice)},改用 Playwright 自带 Chromium:${reason}`);
150
- this.browser = await this.launchChromium();
151
- }
152
- }
153
- this.browser.on('disconnected', () => { this.starting = null; this.context = null; this.browser = null; this.pages.clear(); this.logs.clear(); });
154
- this.context = await this.browser.newContext({ viewport: { width: 1280, height: 800 } });
155
- })().catch(error => { this.starting = null; throw error; });
156
- await this.starting;
157
- }
158
-
159
- page(id) {
160
- const page = this.pages.get(id);
161
- if (!page || page.isClosed()) throw new Error('找不到浏览器页面,请先调用 browser_open。');
162
- return page;
163
- }
164
-
165
- // Selectors from iframes look like "frame=2 >> [data-claude-remote-ref=...]".
166
- locator(page, selector) {
167
- const match = selector.match(/^frame=(\d+) >> ([\s\S]+)$/);
168
- if (!match) return page.locator(selector);
169
- const frame = page.frames()[Number(match[1])];
170
- if (!frame || frame.isDetached()) throw new Error('找不到对应 iframe,请重新调用 browser_snapshot。');
171
- return frame.locator(match[2]);
172
- }
173
-
174
- async open(url = 'about:blank') {
175
- await this.start();
176
- if (this.pages.size >= 16) throw new Error('浏览器页面过多,请先调用 browser_close 关闭部分页面。');
177
- const page = await this.context.newPage(), id = randomUUID();
178
- this.pages.set(id, page); this.logs.set(id, []);
179
- const record = message => {
180
- const entries = this.logs.get(id); if (!entries) return;
181
- entries.push(message.slice(0, 1000)); if (entries.length > 200) entries.shift();
182
- };
183
- page.on('console', message => record(`${message.type()}: ${message.text()}`));
184
- page.on('pageerror', error => record('pageerror: ' + error.message));
185
- page.on('requestfailed', request => record(`requestfailed: ${request.url()} ${request.failure()?.errorText}`));
186
- let navigationError;
187
- try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); }
188
- catch (error) { navigationError = error.message; record(error.message); }
189
- return { ...await this.snapshot(id), ...(navigationError ? { navigation_error: navigationError } : {}) };
190
- }
191
-
192
- async navigate(id, { url, action }) {
193
- const page = this.page(id);
194
- const options = { waitUntil: 'domcontentloaded', timeout: 30000 };
195
- let navigationError;
196
- try {
197
- if (url) await page.goto(url, options);
198
- else if (action === 'back') await page.goBack(options);
199
- else if (action === 'forward') await page.goForward(options);
200
- else if (action === 'reload') await page.reload(options);
201
- else throw new Error('请提供 url,或 action 为 back、forward、reload 之一。');
202
- } catch (error) {
203
- if (!url && !action) throw error;
204
- navigationError = error.message;
205
- }
206
- return { ...await this.snapshot(id), ...(navigationError ? { navigation_error: navigationError } : {}) };
207
- }
208
-
209
- async snapshot(id) {
210
- const page = this.page(id);
211
- const main = await page.mainFrame().evaluate(collectElements, { limit: 200, textLimit: 12000 });
212
- const frames = [];
213
- const all = page.frames();
214
- for (let index = 0; index < all.length && frames.length < 10; index++) {
215
- const frame = all[index];
216
- if (frame === page.mainFrame() || frame.isDetached()) continue;
217
- try {
218
- const state = await frame.evaluate(collectElements, { limit: 50, textLimit: 2000 });
219
- frames.push({ frame: index, url: frame.url(), text: state.text,
220
- elements: state.elements.map(element => ({ ...element, selector: `frame=${index} >> ${element.selector}` })) });
221
- } catch { /* detached or not yet loaded */ }
222
- }
223
- return { page_id: id, url: page.url(), title: await page.title(), text: main.text, elements: main.elements,
224
- ...(main.more ? { more_elements: true } : {}), ...(frames.length ? { frames } : {}) };
225
- }
226
-
227
- async action(id, action, selector, value) {
228
- const page = this.page(id);
229
- const options = { timeout: 15000 };
230
- if (action === 'click') await this.locator(page, selector).click(options);
231
- else if (action === 'fill') await this.locator(page, selector).fill(value, options);
232
- else if (action === 'hover') await this.locator(page, selector).hover(options);
233
- else if (action === 'select') await this.locator(page, selector).selectOption(value, options);
234
- else if (action === 'press') {
235
- if (selector) await this.locator(page, selector).press(value, options);
236
- else await page.keyboard.press(value);
237
- }
238
- return this.snapshot(id);
239
- }
240
-
241
- async scroll(id, { selector, x = 0, y = 0 }) {
242
- const page = this.page(id);
243
- if (selector) await this.locator(page, selector).scrollIntoViewIfNeeded({ timeout: 15000 });
244
- else {
245
- // mouse.wheel does not wait for the scroll to happen; give it a moment.
246
- await page.mouse.wheel(x, y);
247
- await page.waitForTimeout(200);
248
- }
249
- return this.snapshot(id);
250
- }
251
-
252
- async waitFor(id, { selector, text, state = 'visible', timeout = 10000 }) {
253
- const page = this.page(id);
254
- if (selector) await this.locator(page, selector).first().waitFor({ state, timeout });
255
- else if (text) await page.getByText(text).first().waitFor({ state, timeout });
256
- else await page.waitForLoadState('load', { timeout });
257
- return this.snapshot(id);
258
- }
259
-
260
- async evaluate(id, expression) {
261
- const result = await this.page(id).evaluate(expression);
262
- return { page_id: id, result: result === undefined ? null : result };
263
- }
264
-
265
- async screenshot(id, { fullPage = false, selector } = {}) {
266
- const page = this.page(id);
267
- const options = { type: 'png', timeout: 15000 };
268
- const buffer = selector ? await this.locator(page, selector).screenshot(options)
269
- : await page.screenshot({ ...options, fullPage });
270
- return { content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/png' }] };
271
- }
272
-
273
- readLogs(id, limit = 20) {
274
- this.page(id);
275
- const logs = this.logs.get(id);
276
- return { logs: logs.slice(-limit), total: logs.length, truncated: logs.length > limit };
277
- }
278
-
279
- async closePage(id) {
280
- await this.page(id).close(); this.pages.delete(id); this.logs.delete(id);
281
- return { closed: id };
282
- }
283
-
284
- async close() {
285
- if (this.starting) await this.starting.catch(() => {});
286
- await this.browser?.close();
287
- this.browser = null; this.context = null; this.starting = null;
288
- this.pages.clear(); this.logs.clear();
289
- }
290
- }
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { createRequire } from 'node:module';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { chromium } from 'playwright';
7
+ import { execute, exists, withLock } from '../system.js';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const PLAYWRIGHT_VERSION = require('playwright/package.json').version;
11
+ let preparation;
12
+
13
+ const cacheDirectory = () => process.env.CLAUDE_REMOTE_CACHE_DIR || path.join(os.homedir(), '.cache', 'claude-remote');
14
+ export const browserMarker = () => path.join(cacheDirectory(), `browser-ready-${PLAYWRIGHT_VERSION}`);
15
+
16
+ // The marker records which executable a completed install produced, so a
17
+ // warm start skips spawning the Playwright installer entirely.
18
+ export async function browserReady() {
19
+ const executable = chromium.executablePath();
20
+ try { return (await fs.readFile(browserMarker(), 'utf8')).trim() === executable && await exists(executable); }
21
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
22
+ }
23
+
24
+ const BROWSER_NAMES = { chrome: 'Google Chrome', msedge: 'Microsoft Edge', chromium: 'Chromium(Playwright 自带)' };
25
+
26
+ // Standard install locations of Chrome and Edge. Windows checks per-machine
27
+ // and per-user installs; Edge ships with Windows 10/11.
28
+ export function systemBrowserCandidates(kind, env = process.env, platform = process.platform, home = os.homedir()) {
29
+ if (platform === 'win32') {
30
+ const relative = kind === 'chrome' ? ['Google', 'Chrome', 'Application', 'chrome.exe'] : ['Microsoft', 'Edge', 'Application', 'msedge.exe'];
31
+ const bases = [env.ProgramFiles, env.ProgramW6432, env['ProgramFiles(x86)'], env.LOCALAPPDATA].filter(Boolean);
32
+ return [...new Set(bases.map(base => path.win32.join(base, ...relative)))];
33
+ }
34
+ if (platform === 'darwin') {
35
+ const app = kind === 'chrome' ? 'Google Chrome' : 'Microsoft Edge';
36
+ return ['/Applications', path.join(home, 'Applications')].map(directory => path.join(directory, app + '.app', 'Contents', 'MacOS', app));
37
+ }
38
+ return kind === 'chrome' ? ['/opt/google/chrome/chrome'] : ['/opt/microsoft/msedge/msedge'];
39
+ }
40
+
41
+ // CLAUDE_REMOTE_BROWSER picks one explicitly; otherwise an installed Chrome,
42
+ // then Edge, and only without either the downloaded Chromium.
43
+ export async function chooseBrowser(env = process.env, { platform = process.platform, home = os.homedir(),
44
+ fileExists = file => exists(file).catch(() => false) } = {}) {
45
+ const wanted = (env.CLAUDE_REMOTE_BROWSER || '').trim().toLowerCase();
46
+ if (wanted && !(wanted in BROWSER_NAMES)) throw new Error('CLAUDE_REMOTE_BROWSER 只能是 chrome、msedge 或 chromium。');
47
+ if (wanted === 'chromium') return { kind: 'chromium', explicit: true };
48
+ for (const kind of wanted ? [wanted] : ['chrome', 'msedge'])
49
+ for (const file of systemBrowserCandidates(kind, env, platform, home))
50
+ if (await fileExists(file)) return { kind, file, explicit: Boolean(wanted) };
51
+ if (wanted) throw new Error(`找不到 ${BROWSER_NAMES[wanted]}。请安装后重试,或去掉 CLAUDE_REMOTE_BROWSER 改为自动选择。`);
52
+ return { kind: 'chromium', explicit: false };
53
+ }
54
+
55
+ export const browserName = choice => BROWSER_NAMES[choice.kind];
56
+
57
+ // Returns the browser to launch. A system browser needs no download.
58
+ export async function installBrowser(log = console.error, { debug = () => {}, force = false, env = process.env } = {}) {
59
+ const choice = await chooseBrowser(env);
60
+ if (choice.kind !== 'chromium') { debug(`使用系统浏览器:${browserName(choice)}(${choice.file})`); return choice; }
61
+ await installChromium(log, { debug, force });
62
+ return { ...choice, file: chromium.executablePath() };
63
+ }
64
+
65
+ export async function installChromium(log = console.error, { debug = () => {}, force = false } = {}) {
66
+ if (!force && await browserReady()) { debug('浏览器缓存可用:' + chromium.executablePath()); return; }
67
+ if (force) preparation = null;
68
+ if (!preparation) preparation = (async () => {
69
+ const cli = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
70
+ // Keep the lock outside Playwright's cache: its installer replaces browser directories.
71
+ await withLock(path.join(cacheDirectory(), 'browser-install'), async () => {
72
+ if (!force && await browserReady()) return;
73
+ if (!await exists(chromium.executablePath())) log('正在下载浏览器…');
74
+ else debug('检查浏览器缓存:' + chromium.executablePath());
75
+ // The CLI checks completion markers and prepares headed AND headless binaries.
76
+ const result = await execute(process.execPath, [cli, 'install', 'chromium'], { capture: true, timeout: 600000 });
77
+ if (!await exists(chromium.executablePath())) throw new Error('浏览器安装未完成:' + result.stderr);
78
+ await fs.mkdir(cacheDirectory(), { recursive: true });
79
+ await fs.writeFile(browserMarker(), chromium.executablePath() + '\n');
80
+ });
81
+ })().catch(error => { preparation = null; throw error; });
82
+ await preparation;
83
+ }
84
+
85
+ // Runs inside each frame. Refs persist on elements across snapshots; the
86
+ // per-document token keeps refs from a previous page from matching new ones.
87
+ function collectElements({ limit, textLimit }) {
88
+ const attribute = 'data-claude-remote-ref';
89
+ window.__claudeRemote ||= { token: Math.random().toString(36).slice(2, 7), next: 0 };
90
+ const state = window.__claudeRemote;
91
+ const query = 'a,button,input,textarea,select,summary,[role="button"],[role="link"],[role="checkbox"],' +
92
+ '[role="radio"],[role="tab"],[role="menuitem"],[role="option"],[role="switch"],[contenteditable="true"],[onclick]';
93
+ const found = [];
94
+ const visit = root => {
95
+ for (const element of root.querySelectorAll(query)) found.push(element);
96
+ for (const element of root.querySelectorAll('*')) if (element.shadowRoot) visit(element.shadowRoot);
97
+ };
98
+ visit(document);
99
+ const elements = [];
100
+ for (const element of found) {
101
+ if (elements.length >= limit) break;
102
+ if (!element.getClientRects().length) continue;
103
+ let ref = element.getAttribute(attribute);
104
+ if (!ref || !ref.startsWith(state.token + '-')) {
105
+ ref = `${state.token}-${++state.next}`;
106
+ element.setAttribute(attribute, ref);
107
+ }
108
+ const item = { selector: `[${attribute}="${ref}"]`, tag: element.tagName.toLowerCase(),
109
+ text: (element.innerText || element.getAttribute('aria-label') || element.getAttribute('placeholder') ||
110
+ element.getAttribute('title') || '').trim().slice(0, 200) };
111
+ const type = element.getAttribute('type'); if (type) item.type = type;
112
+ const name = element.getAttribute('name'); if (name) item.name = name;
113
+ if (element.tagName === 'A' && element.getAttribute('href')) item.href = element.getAttribute('href').slice(0, 200);
114
+ if ('value' in element && typeof element.value === 'string' && element.value && type !== 'password')
115
+ item.value = element.value.slice(0, 200);
116
+ if (element.disabled) item.disabled = true;
117
+ if (element.checked) item.checked = true;
118
+ elements.push(item);
119
+ }
120
+ return { text: (document.body?.innerText || '').slice(0, textLimit), elements, more: found.length > elements.length };
121
+ }
122
+
123
+ export class Browser {
124
+ constructor({ headless = false, log = console.error, env = process.env } = {}) {
125
+ this.headless = headless; this.log = log; this.env = env; this.pages = new Map(); this.logs = new Map();
126
+ }
127
+
128
+ async launchChromium() {
129
+ await installChromium(this.log);
130
+ try { return await chromium.launch({ headless: this.headless }); }
131
+ catch (error) {
132
+ // The cache marker may be stale (e.g. browsers removed by hand): reinstall once.
133
+ if (!/Executable doesn't exist|executable/i.test(error.message)) throw error;
134
+ await installChromium(this.log, { force: true });
135
+ return chromium.launch({ headless: this.headless });
136
+ }
137
+ }
138
+
139
+ async start() {
140
+ if (!this.starting) this.starting = (async () => {
141
+ const choice = await installBrowser(this.log, { env: this.env });
142
+ if (choice.kind === 'chromium') this.browser = await this.launchChromium();
143
+ else {
144
+ // Each launch uses a fresh temporary profile, never the user's own.
145
+ try { this.browser = await chromium.launch({ headless: this.headless, executablePath: choice.file }); }
146
+ catch (error) {
147
+ const reason = error.message.split('\n')[0];
148
+ if (choice.explicit) throw new Error(`无法启动 ${browserName(choice)}:${reason}`);
149
+ this.log(`无法启动 ${browserName(choice)},改用 Playwright 自带 Chromium:${reason}`);
150
+ this.browser = await this.launchChromium();
151
+ }
152
+ }
153
+ this.browser.on('disconnected', () => { this.starting = null; this.context = null; this.browser = null; this.pages.clear(); this.logs.clear(); });
154
+ this.context = await this.browser.newContext({ viewport: { width: 1280, height: 800 } });
155
+ })().catch(error => { this.starting = null; throw error; });
156
+ await this.starting;
157
+ }
158
+
159
+ page(id) {
160
+ const page = this.pages.get(id);
161
+ if (!page || page.isClosed()) throw new Error('找不到浏览器页面,请先调用 browser_open。');
162
+ return page;
163
+ }
164
+
165
+ // Selectors from iframes look like "frame=2 >> [data-claude-remote-ref=...]".
166
+ locator(page, selector) {
167
+ const match = selector.match(/^frame=(\d+) >> ([\s\S]+)$/);
168
+ if (!match) return page.locator(selector);
169
+ const frame = page.frames()[Number(match[1])];
170
+ if (!frame || frame.isDetached()) throw new Error('找不到对应 iframe,请重新调用 browser_snapshot。');
171
+ return frame.locator(match[2]);
172
+ }
173
+
174
+ async open(url = 'about:blank') {
175
+ await this.start();
176
+ if (this.pages.size >= 16) throw new Error('浏览器页面过多,请先调用 browser_close 关闭部分页面。');
177
+ const page = await this.context.newPage(), id = randomUUID();
178
+ this.pages.set(id, page); this.logs.set(id, []);
179
+ const record = message => {
180
+ const entries = this.logs.get(id); if (!entries) return;
181
+ entries.push(message.slice(0, 1000)); if (entries.length > 200) entries.shift();
182
+ };
183
+ page.on('console', message => record(`${message.type()}: ${message.text()}`));
184
+ page.on('pageerror', error => record('pageerror: ' + error.message));
185
+ page.on('requestfailed', request => record(`requestfailed: ${request.url()} ${request.failure()?.errorText}`));
186
+ let navigationError;
187
+ try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); }
188
+ catch (error) { navigationError = error.message; record(error.message); }
189
+ return { ...await this.snapshot(id), ...(navigationError ? { navigation_error: navigationError } : {}) };
190
+ }
191
+
192
+ async navigate(id, { url, action }) {
193
+ const page = this.page(id);
194
+ const options = { waitUntil: 'domcontentloaded', timeout: 30000 };
195
+ let navigationError;
196
+ try {
197
+ if (url) await page.goto(url, options);
198
+ else if (action === 'back') await page.goBack(options);
199
+ else if (action === 'forward') await page.goForward(options);
200
+ else if (action === 'reload') await page.reload(options);
201
+ else throw new Error('请提供 url,或 action 为 back、forward、reload 之一。');
202
+ } catch (error) {
203
+ if (!url && !action) throw error;
204
+ navigationError = error.message;
205
+ }
206
+ return { ...await this.snapshot(id), ...(navigationError ? { navigation_error: navigationError } : {}) };
207
+ }
208
+
209
+ async snapshot(id) {
210
+ const page = this.page(id);
211
+ const main = await page.mainFrame().evaluate(collectElements, { limit: 200, textLimit: 12000 });
212
+ const frames = [];
213
+ const all = page.frames();
214
+ for (let index = 0; index < all.length && frames.length < 10; index++) {
215
+ const frame = all[index];
216
+ if (frame === page.mainFrame() || frame.isDetached()) continue;
217
+ try {
218
+ const state = await frame.evaluate(collectElements, { limit: 50, textLimit: 2000 });
219
+ frames.push({ frame: index, url: frame.url(), text: state.text,
220
+ elements: state.elements.map(element => ({ ...element, selector: `frame=${index} >> ${element.selector}` })) });
221
+ } catch { /* detached or not yet loaded */ }
222
+ }
223
+ return { page_id: id, url: page.url(), title: await page.title(), text: main.text, elements: main.elements,
224
+ ...(main.more ? { more_elements: true } : {}), ...(frames.length ? { frames } : {}) };
225
+ }
226
+
227
+ async action(id, action, selector, value) {
228
+ const page = this.page(id);
229
+ const options = { timeout: 15000 };
230
+ if (action === 'click') await this.locator(page, selector).click(options);
231
+ else if (action === 'fill') await this.locator(page, selector).fill(value, options);
232
+ else if (action === 'hover') await this.locator(page, selector).hover(options);
233
+ else if (action === 'select') await this.locator(page, selector).selectOption(value, options);
234
+ else if (action === 'press') {
235
+ if (selector) await this.locator(page, selector).press(value, options);
236
+ else await page.keyboard.press(value);
237
+ }
238
+ return this.snapshot(id);
239
+ }
240
+
241
+ async scroll(id, { selector, x = 0, y = 0 }) {
242
+ const page = this.page(id);
243
+ if (selector) await this.locator(page, selector).scrollIntoViewIfNeeded({ timeout: 15000 });
244
+ else {
245
+ // mouse.wheel does not wait for the scroll to happen; give it a moment.
246
+ await page.mouse.wheel(x, y);
247
+ await page.waitForTimeout(200);
248
+ }
249
+ return this.snapshot(id);
250
+ }
251
+
252
+ async waitFor(id, { selector, text, state = 'visible', timeout = 10000 }) {
253
+ const page = this.page(id);
254
+ if (selector) await this.locator(page, selector).first().waitFor({ state, timeout });
255
+ else if (text) await page.getByText(text).first().waitFor({ state, timeout });
256
+ else await page.waitForLoadState('load', { timeout });
257
+ return this.snapshot(id);
258
+ }
259
+
260
+ async evaluate(id, expression) {
261
+ const result = await this.page(id).evaluate(expression);
262
+ return { page_id: id, result: result === undefined ? null : result };
263
+ }
264
+
265
+ async screenshot(id, { fullPage = false, selector } = {}) {
266
+ const page = this.page(id);
267
+ const options = { type: 'png', timeout: 15000 };
268
+ const buffer = selector ? await this.locator(page, selector).screenshot(options)
269
+ : await page.screenshot({ ...options, fullPage });
270
+ return { content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/png' }] };
271
+ }
272
+
273
+ readLogs(id, limit = 20) {
274
+ this.page(id);
275
+ const logs = this.logs.get(id);
276
+ return { logs: logs.slice(-limit), total: logs.length, truncated: logs.length > limit };
277
+ }
278
+
279
+ async closePage(id) {
280
+ await this.page(id).close(); this.pages.delete(id); this.logs.delete(id);
281
+ return { closed: id };
282
+ }
283
+
284
+ async close() {
285
+ if (this.starting) await this.starting.catch(() => {});
286
+ await this.browser?.close();
287
+ this.browser = null; this.context = null; this.starting = null;
288
+ this.pages.clear(); this.logs.clear();
289
+ }
290
+ }