f7df5d8f54
📁 Files changed: 12 📝 Lines changed: 588 • TODO.md • browser.d.ts • browser.d.ts.map • browser.js • browser.js.map • mcp.js • mcp.js.map • types.d.ts • types.d.ts.map • browser.ts • mcp.ts • types.ts
405 lines
15 KiB
JavaScript
405 lines
15 KiB
JavaScript
import { resolve } from 'node:path';
|
|
import { webkit } from 'playwright';
|
|
import * as image from './image.js';
|
|
export class ClaudeBrowser {
|
|
browser = null;
|
|
context = null;
|
|
page = null;
|
|
options;
|
|
consoleMessages = [];
|
|
networkEntries = [];
|
|
interceptPatterns = new Map();
|
|
constructor(options = {}) {
|
|
this.options = {
|
|
headless: options.headless ?? true,
|
|
width: options.width ?? 1280,
|
|
height: options.height ?? 800,
|
|
};
|
|
}
|
|
async launch() {
|
|
this.browser = await webkit.launch({ headless: this.options.headless });
|
|
this.context = await this.browser.newContext({
|
|
viewport: {
|
|
width: this.options.width,
|
|
height: this.options.height,
|
|
},
|
|
});
|
|
this.page = await this.context.newPage();
|
|
this.setupConsoleListener(this.page);
|
|
this.setupNetworkListener(this.page);
|
|
}
|
|
setupConsoleListener(page) {
|
|
page.on('console', (msg) => {
|
|
const location = msg.location();
|
|
this.consoleMessages.push({
|
|
level: msg.type(),
|
|
text: msg.text(),
|
|
timestamp: Date.now(),
|
|
location: location.url ? `${location.url}:${location.lineNumber}` : undefined,
|
|
});
|
|
});
|
|
}
|
|
setupNetworkListener(page) {
|
|
const pendingRequests = new Map();
|
|
page.on('request', (request) => {
|
|
const entry = {
|
|
url: request.url(),
|
|
method: request.method(),
|
|
resourceType: request.resourceType(),
|
|
requestHeaders: request.headers(),
|
|
timing: { startTime: Date.now() },
|
|
};
|
|
pendingRequests.set(request.url() + request.method(), entry);
|
|
});
|
|
page.on('response', async (response) => {
|
|
const request = response.request();
|
|
const key = request.url() + request.method();
|
|
const entry = pendingRequests.get(key);
|
|
if (entry) {
|
|
entry.status = response.status();
|
|
entry.statusText = response.statusText();
|
|
entry.responseHeaders = response.headers();
|
|
if (entry.timing) {
|
|
entry.timing.endTime = Date.now();
|
|
entry.timing.duration = entry.timing.endTime - entry.timing.startTime;
|
|
}
|
|
try {
|
|
const body = await response.body();
|
|
entry.size = body.length;
|
|
}
|
|
catch {
|
|
// Body may not be available for some responses
|
|
}
|
|
this.networkEntries.push(entry);
|
|
pendingRequests.delete(key);
|
|
}
|
|
});
|
|
page.on('requestfailed', (request) => {
|
|
const key = request.url() + request.method();
|
|
const entry = pendingRequests.get(key);
|
|
if (entry) {
|
|
entry.error = request.failure()?.errorText || 'Request failed';
|
|
if (entry.timing) {
|
|
entry.timing.endTime = Date.now();
|
|
entry.timing.duration = entry.timing.endTime - entry.timing.startTime;
|
|
}
|
|
this.networkEntries.push(entry);
|
|
pendingRequests.delete(key);
|
|
}
|
|
});
|
|
}
|
|
async close() {
|
|
if (this.browser) {
|
|
await this.browser.close();
|
|
this.browser = null;
|
|
this.context = null;
|
|
this.page = null;
|
|
}
|
|
}
|
|
ensurePage() {
|
|
if (!this.page) {
|
|
throw new Error('Browser not launched. Call launch() first.');
|
|
}
|
|
return this.page;
|
|
}
|
|
/** Get the current page instance (for advanced usage) */
|
|
getPage() {
|
|
return this.page;
|
|
}
|
|
/** Get the browser context (for advanced usage like cookies) */
|
|
getContext() {
|
|
return this.context;
|
|
}
|
|
async goto(url) {
|
|
const page = this.ensurePage();
|
|
await page.goto(url, { waitUntil: 'networkidle' });
|
|
return { url: page.url(), title: await page.title() };
|
|
}
|
|
async click(selector) {
|
|
const page = this.ensurePage();
|
|
await page.click(selector);
|
|
await page.waitForLoadState('networkidle').catch(() => { });
|
|
return { url: page.url() };
|
|
}
|
|
async type(selector, text) {
|
|
const page = this.ensurePage();
|
|
await page.fill(selector, text);
|
|
}
|
|
async query(selector) {
|
|
const page = this.ensurePage();
|
|
return page.$$eval(selector, (nodes) => nodes.map((el) => {
|
|
const attrs = {};
|
|
for (const attr of el.attributes) {
|
|
attrs[attr.name] = attr.value;
|
|
}
|
|
return {
|
|
tag: el.tagName.toLowerCase(),
|
|
text: el.textContent?.trim().slice(0, 200) || '',
|
|
attributes: attrs,
|
|
};
|
|
}));
|
|
}
|
|
async screenshot(path, fullPage = false) {
|
|
const page = this.ensurePage();
|
|
const resolvedPath = resolve(path || 'screenshot.png');
|
|
const buffer = await page.screenshot({ path: resolvedPath, fullPage });
|
|
return { path: resolvedPath, buffer };
|
|
}
|
|
async getUrl() {
|
|
const page = this.ensurePage();
|
|
return { url: page.url(), title: await page.title() };
|
|
}
|
|
async getHtml(full = false) {
|
|
const page = this.ensurePage();
|
|
const html = await page.content();
|
|
return full ? html : html.slice(0, 10000);
|
|
}
|
|
async back() {
|
|
const page = this.ensurePage();
|
|
await page.goBack();
|
|
return { url: page.url() };
|
|
}
|
|
async forward() {
|
|
const page = this.ensurePage();
|
|
await page.goForward();
|
|
return { url: page.url() };
|
|
}
|
|
async reload() {
|
|
const page = this.ensurePage();
|
|
await page.reload();
|
|
return { url: page.url() };
|
|
}
|
|
async wait(ms = 1000) {
|
|
const page = this.ensurePage();
|
|
await page.waitForTimeout(ms);
|
|
}
|
|
async newPage() {
|
|
if (!this.context) {
|
|
throw new Error('Browser not launched. Call launch() first.');
|
|
}
|
|
this.page = await this.context.newPage();
|
|
this.setupConsoleListener(this.page);
|
|
this.setupNetworkListener(this.page);
|
|
}
|
|
async eval(script) {
|
|
const page = this.ensurePage();
|
|
return page.evaluate(script);
|
|
}
|
|
getConsole(level, clear = false) {
|
|
let messages = this.consoleMessages;
|
|
if (level && level !== 'all') {
|
|
messages = messages.filter((m) => m.level === level);
|
|
}
|
|
if (clear) {
|
|
this.consoleMessages = [];
|
|
}
|
|
return messages;
|
|
}
|
|
clearConsole() {
|
|
this.consoleMessages = [];
|
|
}
|
|
getNetwork(filter, clear = false) {
|
|
let entries = this.networkEntries;
|
|
if (filter && filter !== 'all') {
|
|
if (filter === 'failed') {
|
|
entries = entries.filter((e) => e.error || (e.status && e.status >= 400));
|
|
}
|
|
else {
|
|
entries = entries.filter((e) => e.resourceType === filter);
|
|
}
|
|
}
|
|
if (clear) {
|
|
this.networkEntries = [];
|
|
}
|
|
return entries;
|
|
}
|
|
clearNetwork() {
|
|
this.networkEntries = [];
|
|
}
|
|
async addIntercept(pattern, action, response) {
|
|
const page = this.ensurePage();
|
|
this.interceptPatterns.set(pattern, { action, response });
|
|
await page.route(pattern, async (route) => {
|
|
const config = this.interceptPatterns.get(pattern);
|
|
if (!config) {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
if (config.action === 'block') {
|
|
await route.abort();
|
|
}
|
|
else if (config.action === 'mock' && config.response) {
|
|
await route.fulfill({
|
|
status: config.response.status || 200,
|
|
contentType: config.response.contentType || 'application/json',
|
|
body: config.response.body || '',
|
|
});
|
|
}
|
|
else {
|
|
await route.continue();
|
|
}
|
|
});
|
|
}
|
|
async clearIntercepts() {
|
|
const page = this.ensurePage();
|
|
for (const pattern of this.interceptPatterns.keys()) {
|
|
await page.unroute(pattern);
|
|
}
|
|
this.interceptPatterns.clear();
|
|
}
|
|
getInterceptPatterns() {
|
|
return Array.from(this.interceptPatterns.keys());
|
|
}
|
|
async executeCommand(cmd) {
|
|
try {
|
|
switch (cmd.cmd) {
|
|
case 'goto': {
|
|
const result = await this.goto(cmd.url);
|
|
return { ok: true, ...result };
|
|
}
|
|
case 'click': {
|
|
const result = await this.click(cmd.selector);
|
|
return { ok: true, ...result };
|
|
}
|
|
case 'type': {
|
|
await this.type(cmd.selector, cmd.text);
|
|
return { ok: true };
|
|
}
|
|
case 'query': {
|
|
const elements = await this.query(cmd.selector);
|
|
return { ok: true, count: elements.length, elements };
|
|
}
|
|
case 'screenshot': {
|
|
const result = await this.screenshot(cmd.path, cmd.fullPage);
|
|
return { ok: true, path: result.path };
|
|
}
|
|
case 'url': {
|
|
const result = await this.getUrl();
|
|
return { ok: true, ...result };
|
|
}
|
|
case 'html': {
|
|
const html = await this.getHtml(cmd.full);
|
|
return { ok: true, html };
|
|
}
|
|
case 'back': {
|
|
const result = await this.back();
|
|
return { ok: true, ...result };
|
|
}
|
|
case 'forward': {
|
|
const result = await this.forward();
|
|
return { ok: true, ...result };
|
|
}
|
|
case 'reload': {
|
|
const result = await this.reload();
|
|
return { ok: true, ...result };
|
|
}
|
|
case 'wait': {
|
|
await this.wait(cmd.ms);
|
|
return { ok: true };
|
|
}
|
|
case 'newpage': {
|
|
await this.newPage();
|
|
return { ok: true };
|
|
}
|
|
case 'close': {
|
|
await this.close();
|
|
return { ok: true };
|
|
}
|
|
case 'eval': {
|
|
const result = await this.eval(cmd.script);
|
|
return { ok: true, result };
|
|
}
|
|
case 'console': {
|
|
const messages = this.getConsole(cmd.level, cmd.clear);
|
|
return { ok: true, count: messages.length, messages };
|
|
}
|
|
case 'network': {
|
|
const requests = this.getNetwork(cmd.filter, cmd.clear);
|
|
return { ok: true, count: requests.length, requests };
|
|
}
|
|
case 'intercept': {
|
|
if (cmd.action === 'list') {
|
|
const patterns = this.getInterceptPatterns();
|
|
return { ok: true, count: patterns.length, patterns };
|
|
}
|
|
if (cmd.action === 'clear') {
|
|
await this.clearIntercepts();
|
|
return { ok: true };
|
|
}
|
|
if (!cmd.pattern) {
|
|
return { ok: false, error: 'Pattern required for block/mock actions' };
|
|
}
|
|
await this.addIntercept(cmd.pattern, cmd.action, cmd.response);
|
|
return { ok: true, patterns: this.getInterceptPatterns() };
|
|
}
|
|
case 'favicon': {
|
|
const result = await image.createFavicon(cmd.input, cmd.outputDir);
|
|
return { ok: true, files: result.files, outputDir: result.outputDir };
|
|
}
|
|
case 'convert': {
|
|
const result = await image.convert(cmd.input, cmd.output, cmd.format);
|
|
return {
|
|
ok: true,
|
|
path: result.path,
|
|
width: result.width,
|
|
height: result.height,
|
|
format: result.format,
|
|
size: result.size,
|
|
};
|
|
}
|
|
case 'resize': {
|
|
const result = await image.resize(cmd.input, cmd.output, cmd.width, cmd.height, cmd.fit);
|
|
return {
|
|
ok: true,
|
|
path: result.path,
|
|
width: result.width,
|
|
height: result.height,
|
|
format: result.format,
|
|
size: result.size,
|
|
};
|
|
}
|
|
case 'crop': {
|
|
const result = await image.crop(cmd.input, cmd.output, cmd.left, cmd.top, cmd.width, cmd.height);
|
|
return {
|
|
ok: true,
|
|
path: result.path,
|
|
width: result.width,
|
|
height: result.height,
|
|
format: result.format,
|
|
size: result.size,
|
|
};
|
|
}
|
|
case 'compress': {
|
|
const result = await image.compress(cmd.input, cmd.output, cmd.quality);
|
|
return {
|
|
ok: true,
|
|
path: result.path,
|
|
width: result.width,
|
|
height: result.height,
|
|
format: result.format,
|
|
size: result.size,
|
|
};
|
|
}
|
|
case 'thumbnail': {
|
|
const result = await image.thumbnail(cmd.input, cmd.output, cmd.size);
|
|
return {
|
|
ok: true,
|
|
path: result.path,
|
|
width: result.width,
|
|
height: result.height,
|
|
format: result.format,
|
|
size: result.size,
|
|
};
|
|
}
|
|
default: {
|
|
const _exhaustive = cmd;
|
|
return { ok: false, error: `Unknown command: ${_exhaustive.cmd}` };
|
|
}
|
|
}
|
|
}
|
|
catch (err) {
|
|
return { ok: false, error: err.message };
|
|
}
|
|
}
|
|
}
|
|
//# sourceMappingURL=browser.js.map
|