💬 Commit message: Update 2026-02-11 04:16:38, 12 files, 588 lines
📁 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
This commit is contained in:
+145
-1
@@ -1,5 +1,5 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { type Browser, type BrowserContext, type Page, webkit } from 'playwright';
|
||||
import { type Browser, type BrowserContext, type Page, type Route, webkit } from 'playwright';
|
||||
import * as image from './image.js';
|
||||
import type {
|
||||
BrowserCommand,
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CommandResponse,
|
||||
ConsoleMessage,
|
||||
ElementInfo,
|
||||
NetworkEntry,
|
||||
} from './types.js';
|
||||
|
||||
export class ClaudeBrowser {
|
||||
@@ -15,6 +16,14 @@ export class ClaudeBrowser {
|
||||
private page: Page | null = null;
|
||||
private options: Required<BrowserOptions>;
|
||||
private consoleMessages: ConsoleMessage[] = [];
|
||||
private networkEntries: NetworkEntry[] = [];
|
||||
private interceptPatterns: Map<
|
||||
string,
|
||||
{
|
||||
action: 'block' | 'mock';
|
||||
response?: { status?: number; body?: string; contentType?: string };
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
constructor(options: BrowserOptions = {}) {
|
||||
this.options = {
|
||||
@@ -34,6 +43,7 @@ export class ClaudeBrowser {
|
||||
});
|
||||
this.page = await this.context.newPage();
|
||||
this.setupConsoleListener(this.page);
|
||||
this.setupNetworkListener(this.page);
|
||||
}
|
||||
|
||||
private setupConsoleListener(page: Page): void {
|
||||
@@ -48,6 +58,58 @@ export class ClaudeBrowser {
|
||||
});
|
||||
}
|
||||
|
||||
private setupNetworkListener(page: Page): void {
|
||||
const pendingRequests = new Map<string, NetworkEntry>();
|
||||
|
||||
page.on('request', (request) => {
|
||||
const entry: NetworkEntry = {
|
||||
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(): Promise<void> {
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
@@ -156,6 +218,7 @@ export class ClaudeBrowser {
|
||||
}
|
||||
this.page = await this.context.newPage();
|
||||
this.setupConsoleListener(this.page);
|
||||
this.setupNetworkListener(this.page);
|
||||
}
|
||||
|
||||
async eval(script: string): Promise<unknown> {
|
||||
@@ -178,6 +241,68 @@ export class ClaudeBrowser {
|
||||
this.consoleMessages = [];
|
||||
}
|
||||
|
||||
getNetwork(filter?: string, clear = false): NetworkEntry[] {
|
||||
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(): void {
|
||||
this.networkEntries = [];
|
||||
}
|
||||
|
||||
async addIntercept(
|
||||
pattern: string,
|
||||
action: 'block' | 'mock',
|
||||
response?: { status?: number; body?: string; contentType?: string }
|
||||
): Promise<void> {
|
||||
const page = this.ensurePage();
|
||||
this.interceptPatterns.set(pattern, { action, response });
|
||||
await page.route(pattern, (route) => this.handleIntercept(pattern, route));
|
||||
}
|
||||
|
||||
private async handleIntercept(pattern: string, route: Route): Promise<void> {
|
||||
const config = this.interceptPatterns.get(pattern);
|
||||
if (!config) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
if (config.action === 'block') {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
if (config.action === 'mock' && config.response) {
|
||||
await route.fulfill({
|
||||
status: config.response.status || 200,
|
||||
contentType: config.response.contentType || 'application/json',
|
||||
body: config.response.body || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
}
|
||||
|
||||
async clearIntercepts(): Promise<void> {
|
||||
const page = this.ensurePage();
|
||||
for (const pattern of this.interceptPatterns.keys()) {
|
||||
await page.unroute(pattern);
|
||||
}
|
||||
this.interceptPatterns.clear();
|
||||
}
|
||||
|
||||
getInterceptPatterns(): string[] {
|
||||
return Array.from(this.interceptPatterns.keys());
|
||||
}
|
||||
|
||||
async executeCommand(cmd: BrowserCommand): Promise<CommandResponse> {
|
||||
try {
|
||||
switch (cmd.cmd) {
|
||||
@@ -241,6 +366,25 @@ export class ClaudeBrowser {
|
||||
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 };
|
||||
|
||||
+120
@@ -202,6 +202,63 @@ server.tool(
|
||||
})
|
||||
);
|
||||
|
||||
// Network monitoring
|
||||
server.tool(
|
||||
'network',
|
||||
'Get captured network requests and responses',
|
||||
{
|
||||
filter: z
|
||||
.enum(['all', 'failed', 'xhr', 'fetch', 'document', 'script', 'stylesheet', 'image'])
|
||||
.optional()
|
||||
.default('all')
|
||||
.describe('Filter by request type or status'),
|
||||
clear: z.boolean().optional().default(false).describe('Clear entries after retrieving'),
|
||||
},
|
||||
withLogging('network', async ({ filter, clear }) => {
|
||||
await ensureLaunched();
|
||||
const requests = browser.getNetwork(filter, clear);
|
||||
return textResult(JSON.stringify({ ok: true, count: requests.length, requests }));
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'intercept',
|
||||
'Block or mock network requests matching a pattern',
|
||||
{
|
||||
action: z.enum(['block', 'mock', 'list', 'clear']).describe('Action to perform'),
|
||||
pattern: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('URL pattern to match (glob syntax, e.g., "**/api/*" or "**/*.png")'),
|
||||
status: z.number().optional().describe('HTTP status code for mock response'),
|
||||
body: z.string().optional().describe('Response body for mock'),
|
||||
contentType: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('application/json')
|
||||
.describe('Content-Type for mock response'),
|
||||
},
|
||||
withLogging('intercept', async ({ action, pattern, status, body, contentType }) => {
|
||||
await ensureLaunched();
|
||||
if (action === 'list') {
|
||||
const patterns = browser.getInterceptPatterns();
|
||||
return textResult(JSON.stringify({ ok: true, count: patterns.length, patterns }));
|
||||
}
|
||||
if (action === 'clear') {
|
||||
await browser.clearIntercepts();
|
||||
return textResult(JSON.stringify({ ok: true, message: 'All intercepts cleared' }));
|
||||
}
|
||||
if (!pattern) {
|
||||
return textResult(JSON.stringify({ ok: false, error: 'Pattern required for block/mock' }));
|
||||
}
|
||||
const response = action === 'mock' ? { status, body, contentType } : undefined;
|
||||
await browser.addIntercept(pattern, action, response);
|
||||
return textResult(
|
||||
JSON.stringify({ ok: true, action, pattern, patterns: browser.getInterceptPatterns() })
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Utility
|
||||
server.tool(
|
||||
'wait',
|
||||
@@ -561,6 +618,69 @@ server.resource(
|
||||
}
|
||||
);
|
||||
|
||||
// Resource: browser://network - All captured network requests
|
||||
server.resource(
|
||||
'Network Requests',
|
||||
'browser://network',
|
||||
{ description: 'All network requests captured from the browser', mimeType: 'application/json' },
|
||||
async () => {
|
||||
if (!launched) {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'browser://network',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ launched: false, requests: [] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const requests = browser.getNetwork('all', false);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'browser://network',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ launched: true, count: requests.length, requests }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Resource: browser://network/failed - Failed network requests only
|
||||
server.resource(
|
||||
'Failed Requests',
|
||||
'browser://network/failed',
|
||||
{
|
||||
description: 'Failed network requests (errors and 4xx/5xx status codes)',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
async () => {
|
||||
if (!launched) {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'browser://network/failed',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ launched: false, requests: [] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const requests = browser.getNetwork('failed', false);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'browser://network/failed',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ launched: true, count: requests.length, requests }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Resource: browser://screenshot - Current page screenshot (base64)
|
||||
server.resource(
|
||||
'Page Screenshot',
|
||||
|
||||
+40
-1
@@ -137,6 +137,40 @@ export interface ConsoleMessage {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface NetworkEntry {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
responseHeaders?: Record<string, string>;
|
||||
timing?: {
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
};
|
||||
size?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NetworkCommand {
|
||||
cmd: 'network';
|
||||
clear?: boolean;
|
||||
filter?: 'all' | 'failed' | 'xhr' | 'fetch' | 'document' | 'script' | 'stylesheet' | 'image';
|
||||
}
|
||||
|
||||
export interface InterceptCommand {
|
||||
cmd: 'intercept';
|
||||
action: 'block' | 'mock' | 'list' | 'clear';
|
||||
pattern?: string;
|
||||
response?: {
|
||||
status?: number;
|
||||
body?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type BrowserCommand =
|
||||
| GotoCommand
|
||||
| ClickCommand
|
||||
@@ -158,7 +192,9 @@ export type BrowserCommand =
|
||||
| CropCommand
|
||||
| CompressCommand
|
||||
| ThumbnailCommand
|
||||
| ConsoleCommand;
|
||||
| ConsoleCommand
|
||||
| NetworkCommand
|
||||
| InterceptCommand;
|
||||
|
||||
// Response types
|
||||
export interface SuccessResponse {
|
||||
@@ -179,6 +215,9 @@ export interface SuccessResponse {
|
||||
size?: number;
|
||||
// Console fields
|
||||
messages?: ConsoleMessage[];
|
||||
// Network fields
|
||||
requests?: NetworkEntry[];
|
||||
patterns?: string[];
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
|
||||
Reference in New Issue
Block a user