💬 Commit message: Update 2026-02-07 14:20:25, 8 files, 1060 lines
📁 Files changed: 8 📝 Lines changed: 1060 • package-lock.json • package.json • browser.ts • cli.ts • image.ts • index.ts • mcp.ts • types.ts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { type Browser, type BrowserContext, type Page, webkit } from 'playwright';
|
||||
import * as image from './image.js';
|
||||
import type { BrowserCommand, BrowserOptions, CommandResponse, ElementInfo } from './types.js';
|
||||
|
||||
export class ClaudeBrowser {
|
||||
@@ -190,6 +191,72 @@ export class ClaudeBrowser {
|
||||
const result = await this.eval(cmd.script);
|
||||
return { ok: true, result };
|
||||
}
|
||||
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: never = cmd;
|
||||
return { ok: false, error: `Unknown command: ${(_exhaustive as { cmd: string }).cmd}` };
|
||||
|
||||
+59
@@ -2,6 +2,7 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { ClaudeBrowser } from './browser.js';
|
||||
import * as image from './image.js';
|
||||
import { startServer } from './server.js';
|
||||
import type { ElementInfo } from './types.js';
|
||||
|
||||
@@ -21,6 +22,11 @@ const { values, positionals } = parseArgs({
|
||||
type: { type: 'string', short: 't', multiple: true },
|
||||
help: { type: 'boolean', default: false },
|
||||
version: { type: 'boolean', short: 'v', default: false },
|
||||
// Image processing options
|
||||
favicon: { type: 'string' },
|
||||
convert: { type: 'string' },
|
||||
resize: { type: 'string' },
|
||||
compress: { type: 'string' },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,6 +48,12 @@ Options:
|
||||
-v, --version Show version
|
||||
--help Show this help
|
||||
|
||||
Image Processing:
|
||||
--favicon <dir> Generate favicon set to directory (from screenshot or input)
|
||||
--convert <format> Convert screenshot to format (png, jpeg, webp, avif)
|
||||
--resize <WxH> Resize screenshot (e.g., 800x600 or 800 for width only)
|
||||
--compress <quality> Compress with quality 1-100
|
||||
|
||||
Examples:
|
||||
claude-browse https://example.com
|
||||
claude-browse -o page.png -w 1920 -h 1080 https://example.com
|
||||
@@ -52,6 +64,12 @@ Examples:
|
||||
claude-browse -t "input[name=q]=hello" -c "button[type=submit]" https://google.com
|
||||
claude-browse -c ".cookie-accept" -c "a.nav-link" -q "h1" https://example.com
|
||||
|
||||
Image processing examples:
|
||||
claude-browse https://example.com --favicon ./favicons/
|
||||
claude-browse https://example.com -o page.webp --convert webp
|
||||
claude-browse https://example.com --resize 800x600
|
||||
claude-browse https://example.com --compress 60
|
||||
|
||||
Server mode (default):
|
||||
claude-browse # Start server on port 13373
|
||||
claude-browse --headed # Start with visible browser
|
||||
@@ -65,6 +83,12 @@ Server mode (default):
|
||||
curl -X POST http://localhost:13373 -d '{"cmd":"url"}'
|
||||
curl -X POST http://localhost:13373 -d '{"cmd":"html"}'
|
||||
curl -X POST http://localhost:13373 -d '{"cmd":"close"}'
|
||||
|
||||
# Image processing via server:
|
||||
curl localhost:13373 -d '{"cmd":"favicon","input":"screenshot.png","outputDir":"./favicons"}'
|
||||
curl localhost:13373 -d '{"cmd":"convert","input":"img.png","output":"img.webp","format":"webp"}'
|
||||
curl localhost:13373 -d '{"cmd":"resize","input":"img.png","output":"small.png","width":400}'
|
||||
curl localhost:13373 -d '{"cmd":"compress","input":"img.png","output":"compressed.png","quality":60}'
|
||||
`;
|
||||
|
||||
function getViewportConfig() {
|
||||
@@ -157,10 +181,45 @@ async function runInteractiveMode(browser: ClaudeBrowser): Promise<void> {
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
async function processImageOptions(screenshotPath: string): Promise<void> {
|
||||
// Process image options on the screenshot
|
||||
if (values.favicon) {
|
||||
console.log(`Generating favicon set to: ${values.favicon}`);
|
||||
const result = await image.createFavicon(screenshotPath, values.favicon as string);
|
||||
console.log(`Created ${result.files.length} favicon files`);
|
||||
}
|
||||
|
||||
if (values.convert) {
|
||||
const format = values.convert as 'png' | 'jpeg' | 'webp' | 'avif';
|
||||
const outputPath = screenshotPath.replace(/\.[^.]+$/, `.${format}`);
|
||||
console.log(`Converting to ${format}: ${outputPath}`);
|
||||
await image.convert(screenshotPath, outputPath, format);
|
||||
}
|
||||
|
||||
if (values.resize) {
|
||||
const resizeValue = values.resize as string;
|
||||
const [widthStr, heightStr] = resizeValue.split('x');
|
||||
const width = Number.parseInt(widthStr);
|
||||
const height = heightStr ? Number.parseInt(heightStr) : undefined;
|
||||
console.log(`Resizing to ${width}${height ? `x${height}` : ''}`);
|
||||
await image.resize(screenshotPath, screenshotPath, width, height);
|
||||
}
|
||||
|
||||
if (values.compress) {
|
||||
const quality = Number.parseInt(values.compress as string);
|
||||
console.log(`Compressing with quality ${quality}`);
|
||||
await image.compress(screenshotPath, screenshotPath, quality);
|
||||
}
|
||||
}
|
||||
|
||||
async function runScreenshotMode(browser: ClaudeBrowser): Promise<void> {
|
||||
const outputPath = resolve(values.output as string);
|
||||
console.log(`Saving screenshot to: ${outputPath}`);
|
||||
await browser.screenshot(outputPath, values.fullpage);
|
||||
|
||||
// Process any image options
|
||||
await processImageOptions(outputPath);
|
||||
|
||||
await browser.close();
|
||||
console.log('Done!');
|
||||
}
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export type FitType = 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
|
||||
export type FormatType = 'png' | 'jpeg' | 'webp' | 'avif';
|
||||
export type ThumbnailSize = 'small' | 'medium' | 'large';
|
||||
|
||||
const THUMBNAIL_SIZES: Record<ThumbnailSize, number> = {
|
||||
small: 150,
|
||||
medium: 300,
|
||||
large: 600,
|
||||
};
|
||||
|
||||
const FAVICON_SIZES = [
|
||||
{ name: 'favicon-16x16.png', size: 16 },
|
||||
{ name: 'favicon-32x32.png', size: 32 },
|
||||
{ name: 'favicon-48x48.png', size: 48 },
|
||||
{ name: 'apple-touch-icon.png', size: 180 },
|
||||
{ name: 'android-chrome-192x192.png', size: 192 },
|
||||
{ name: 'android-chrome-512x512.png', size: 512 },
|
||||
];
|
||||
|
||||
export interface FaviconResult {
|
||||
files: string[];
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
export interface ImageResult {
|
||||
path: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
async function ensureDir(filePath: string): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
export async function createFavicon(input: string, outputDir: string): Promise<FaviconResult> {
|
||||
const resolvedDir = resolve(outputDir);
|
||||
await mkdir(resolvedDir, { recursive: true });
|
||||
|
||||
const files: string[] = [];
|
||||
const image = sharp(input);
|
||||
|
||||
for (const { name, size } of FAVICON_SIZES) {
|
||||
const outputPath = join(resolvedDir, name);
|
||||
await image.clone().resize(size, size, { fit: 'cover' }).png().toFile(outputPath);
|
||||
files.push(outputPath);
|
||||
}
|
||||
|
||||
// Create favicon.ico with multiple sizes (16, 32, 48)
|
||||
const icoPath = join(resolvedDir, 'favicon.ico');
|
||||
const sizes = [16, 32, 48];
|
||||
const buffers = await Promise.all(
|
||||
sizes.map((size) => image.clone().resize(size, size, { fit: 'cover' }).png().toBuffer())
|
||||
);
|
||||
|
||||
// ICO format: simple approach - use largest PNG as ICO
|
||||
// For true multi-size ICO, we'd need a dedicated library
|
||||
// Sharp doesn't support ICO output, so we'll use the 32x32 PNG
|
||||
await image.clone().resize(32, 32, { fit: 'cover' }).png().toFile(icoPath);
|
||||
files.push(icoPath);
|
||||
|
||||
return { files, outputDir: resolvedDir };
|
||||
}
|
||||
|
||||
export async function convert(
|
||||
input: string,
|
||||
output: string,
|
||||
format: FormatType
|
||||
): Promise<ImageResult> {
|
||||
const resolvedOutput = resolve(output);
|
||||
await ensureDir(resolvedOutput);
|
||||
|
||||
const image = sharp(input);
|
||||
let result: sharp.Sharp;
|
||||
|
||||
switch (format) {
|
||||
case 'png':
|
||||
result = image.png();
|
||||
break;
|
||||
case 'jpeg':
|
||||
result = image.jpeg();
|
||||
break;
|
||||
case 'webp':
|
||||
result = image.webp();
|
||||
break;
|
||||
case 'avif':
|
||||
result = image.avif();
|
||||
break;
|
||||
}
|
||||
|
||||
const info = await result.toFile(resolvedOutput);
|
||||
|
||||
return {
|
||||
path: resolvedOutput,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
format: info.format,
|
||||
size: info.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resize(
|
||||
input: string,
|
||||
output: string,
|
||||
width: number,
|
||||
height?: number,
|
||||
fit: FitType = 'cover'
|
||||
): Promise<ImageResult> {
|
||||
const resolvedOutput = resolve(output);
|
||||
await ensureDir(resolvedOutput);
|
||||
|
||||
const info = await sharp(input).resize(width, height, { fit }).toFile(resolvedOutput);
|
||||
|
||||
return {
|
||||
path: resolvedOutput,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
format: info.format,
|
||||
size: info.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function crop(
|
||||
input: string,
|
||||
output: string,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<ImageResult> {
|
||||
const resolvedOutput = resolve(output);
|
||||
await ensureDir(resolvedOutput);
|
||||
|
||||
const info = await sharp(input).extract({ left, top, width, height }).toFile(resolvedOutput);
|
||||
|
||||
return {
|
||||
path: resolvedOutput,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
format: info.format,
|
||||
size: info.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function compress(input: string, output: string, quality = 80): Promise<ImageResult> {
|
||||
const resolvedOutput = resolve(output);
|
||||
await ensureDir(resolvedOutput);
|
||||
|
||||
const image = sharp(input);
|
||||
const metadata = await image.metadata();
|
||||
const format = metadata.format;
|
||||
|
||||
let result: sharp.Sharp;
|
||||
switch (format) {
|
||||
case 'png':
|
||||
result = image.png({ quality });
|
||||
break;
|
||||
case 'jpeg':
|
||||
case 'jpg':
|
||||
result = image.jpeg({ quality });
|
||||
break;
|
||||
case 'webp':
|
||||
result = image.webp({ quality });
|
||||
break;
|
||||
case 'avif':
|
||||
result = image.avif({ quality });
|
||||
break;
|
||||
default:
|
||||
// Default to PNG for unknown formats
|
||||
result = image.png({ quality });
|
||||
}
|
||||
|
||||
const info = await result.toFile(resolvedOutput);
|
||||
|
||||
return {
|
||||
path: resolvedOutput,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
format: info.format,
|
||||
size: info.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function thumbnail(
|
||||
input: string,
|
||||
output: string,
|
||||
size: ThumbnailSize = 'medium'
|
||||
): Promise<ImageResult> {
|
||||
const dimension = THUMBNAIL_SIZES[size];
|
||||
return resize(input, output, dimension, dimension, 'cover');
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
export { ClaudeBrowser } from './browser.js';
|
||||
export { BrowserServer, startServer } from './server.js';
|
||||
export {
|
||||
createFavicon,
|
||||
convert,
|
||||
resize,
|
||||
crop,
|
||||
compress,
|
||||
thumbnail,
|
||||
type FaviconResult,
|
||||
type ImageResult,
|
||||
type FitType,
|
||||
type FormatType,
|
||||
type ThumbnailSize,
|
||||
} from './image.js';
|
||||
export type {
|
||||
BrowserOptions,
|
||||
BrowserCommand,
|
||||
@@ -21,5 +34,11 @@ export type {
|
||||
NewPageCommand,
|
||||
CloseCommand,
|
||||
EvalCommand,
|
||||
FaviconCommand,
|
||||
ConvertCommand,
|
||||
ResizeCommand,
|
||||
CropCommand,
|
||||
CompressCommand,
|
||||
ThumbnailCommand,
|
||||
} from './types.js';
|
||||
export type { ServerOptions } from './server.js';
|
||||
|
||||
+103
@@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { ClaudeBrowser } from './browser.js';
|
||||
import * as image from './image.js';
|
||||
import { type CommandLike, type ResultLike, stderrLogger as log } from './logger.js';
|
||||
|
||||
const browser = new ClaudeBrowser({ headless: true, width: 1280, height: 800 });
|
||||
@@ -187,6 +188,108 @@ server.tool(
|
||||
})
|
||||
);
|
||||
|
||||
// Image processing
|
||||
server.tool(
|
||||
'favicon',
|
||||
'Generate a complete favicon set from an image (16x16, 32x32, 48x48, apple-touch-icon 180x180, android-chrome 192x192 and 512x512)',
|
||||
{
|
||||
input: z.string().describe('Path to source image'),
|
||||
outputDir: z.string().describe('Directory to output favicon files'),
|
||||
},
|
||||
withLogging('favicon', async ({ input, outputDir }) => {
|
||||
const result = await image.createFavicon(input, outputDir);
|
||||
return textResult(
|
||||
JSON.stringify({ ok: true, files: result.files, outputDir: result.outputDir })
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'convert',
|
||||
'Convert an image to a different format (png, jpeg, webp, avif)',
|
||||
{
|
||||
input: z.string().describe('Path to source image'),
|
||||
output: z.string().describe('Path for output image'),
|
||||
format: z.enum(['png', 'jpeg', 'webp', 'avif']).describe('Target format'),
|
||||
},
|
||||
withLogging('convert', async ({ input, output, format }) => {
|
||||
const result = await image.convert(input, output, format);
|
||||
return textResult(JSON.stringify({ ok: true, ...result }));
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'resize',
|
||||
'Resize an image to specified dimensions',
|
||||
{
|
||||
input: z.string().describe('Path to source image'),
|
||||
output: z.string().describe('Path for output image'),
|
||||
width: z.number().describe('Target width in pixels'),
|
||||
height: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Target height in pixels (optional, maintains aspect ratio if omitted)'),
|
||||
fit: z
|
||||
.enum(['cover', 'contain', 'fill', 'inside', 'outside'])
|
||||
.optional()
|
||||
.default('cover')
|
||||
.describe('How to fit the image'),
|
||||
},
|
||||
withLogging('resize', async ({ input, output, width, height, fit }) => {
|
||||
const result = await image.resize(input, output, width, height, fit);
|
||||
return textResult(JSON.stringify({ ok: true, ...result }));
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'crop',
|
||||
'Crop a region from an image',
|
||||
{
|
||||
input: z.string().describe('Path to source image'),
|
||||
output: z.string().describe('Path for output image'),
|
||||
left: z.number().describe('Left edge position in pixels'),
|
||||
top: z.number().describe('Top edge position in pixels'),
|
||||
width: z.number().describe('Width of crop region in pixels'),
|
||||
height: z.number().describe('Height of crop region in pixels'),
|
||||
},
|
||||
withLogging('crop', async ({ input, output, left, top, width, height }) => {
|
||||
const result = await image.crop(input, output, left, top, width, height);
|
||||
return textResult(JSON.stringify({ ok: true, ...result }));
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'compress',
|
||||
'Compress an image to reduce file size',
|
||||
{
|
||||
input: z.string().describe('Path to source image'),
|
||||
output: z.string().describe('Path for output image'),
|
||||
quality: z.number().min(1).max(100).optional().default(80).describe('Quality level 1-100'),
|
||||
},
|
||||
withLogging('compress', async ({ input, output, quality }) => {
|
||||
const result = await image.compress(input, output, quality);
|
||||
return textResult(JSON.stringify({ ok: true, ...result }));
|
||||
})
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'thumbnail',
|
||||
'Create a thumbnail from an image',
|
||||
{
|
||||
input: z.string().describe('Path to source image'),
|
||||
output: z.string().describe('Path for output image'),
|
||||
size: z
|
||||
.enum(['small', 'medium', 'large'])
|
||||
.optional()
|
||||
.default('medium')
|
||||
.describe('Thumbnail size preset (small=150px, medium=300px, large=600px)'),
|
||||
},
|
||||
withLogging('thumbnail', async ({ input, output, size }) => {
|
||||
const result = await image.thumbnail(input, output, size);
|
||||
return textResult(JSON.stringify({ ok: true, ...result }));
|
||||
})
|
||||
);
|
||||
|
||||
// Start server
|
||||
async function main(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
+61
-1
@@ -77,6 +77,53 @@ export interface EvalCommand {
|
||||
script: string;
|
||||
}
|
||||
|
||||
// Image processing commands
|
||||
export interface FaviconCommand {
|
||||
cmd: 'favicon';
|
||||
input: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
export interface ConvertCommand {
|
||||
cmd: 'convert';
|
||||
input: string;
|
||||
output: string;
|
||||
format: 'png' | 'jpeg' | 'webp' | 'avif';
|
||||
}
|
||||
|
||||
export interface ResizeCommand {
|
||||
cmd: 'resize';
|
||||
input: string;
|
||||
output: string;
|
||||
width: number;
|
||||
height?: number;
|
||||
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
|
||||
}
|
||||
|
||||
export interface CropCommand {
|
||||
cmd: 'crop';
|
||||
input: string;
|
||||
output: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CompressCommand {
|
||||
cmd: 'compress';
|
||||
input: string;
|
||||
output: string;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export interface ThumbnailCommand {
|
||||
cmd: 'thumbnail';
|
||||
input: string;
|
||||
output: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
export type BrowserCommand =
|
||||
| GotoCommand
|
||||
| ClickCommand
|
||||
@@ -91,7 +138,13 @@ export type BrowserCommand =
|
||||
| WaitCommand
|
||||
| NewPageCommand
|
||||
| CloseCommand
|
||||
| EvalCommand;
|
||||
| EvalCommand
|
||||
| FaviconCommand
|
||||
| ConvertCommand
|
||||
| ResizeCommand
|
||||
| CropCommand
|
||||
| CompressCommand
|
||||
| ThumbnailCommand;
|
||||
|
||||
// Response types
|
||||
export interface SuccessResponse {
|
||||
@@ -103,6 +156,13 @@ export interface SuccessResponse {
|
||||
count?: number;
|
||||
elements?: ElementInfo[];
|
||||
result?: unknown;
|
||||
// Image processing fields
|
||||
files?: string[];
|
||||
outputDir?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
|
||||
Reference in New Issue
Block a user