diff --git a/sdk/node/README.md b/sdk/node/README.md index 187f0bc..c1fb46d 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -83,6 +83,17 @@ const darkScreenshot = await snap.capture({ }); ``` +### Custom User Agent + +```typescript +// Set a custom User-Agent string for the request +const screenshot = await snap.capture({ + url: 'https://example.com', + userAgent: 'Mozilla/5.0 (compatible; SnapAPI/1.0)', + format: 'png', +}); +``` + ### Hide Elements Before Capture ```typescript diff --git a/sdk/python/README.md b/sdk/python/README.md index b314da0..0a36ee6 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -86,6 +86,17 @@ dark_screenshot = snap.capture( ) ``` +### Custom User Agent + +```python +# Set a custom User-Agent string for the request +screenshot = snap.capture( + "https://example.com", + user_agent="Mozilla/5.0 (compatible; SnapAPI/1.0)", + format="png", +) +``` + ### Hide Elements Before Capture ```python diff --git a/src/routes/__tests__/screenshot.test.ts b/src/routes/__tests__/screenshot.test.ts index e6e47c1..6a03b58 100644 --- a/src/routes/__tests__/screenshot.test.ts +++ b/src/routes/__tests__/screenshot.test.ts @@ -869,4 +869,221 @@ describe('Screenshot Route', () => { }) }) }) + + describe('clip parameter', () => { + it('should return 400 when clip has missing fields', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: 20 } // missing width and height + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip: all four fields (x, y, width, height) must be provided" + }) + }) + + it('should return 400 when clip has negative x coordinate', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: -5, y: 10, width: 100, height: 200 } + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip: x and y coordinates must be >= 0" + }) + }) + + it('should return 400 when clip has negative y coordinate', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: -5, width: 100, height: 200 } + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip: x and y coordinates must be >= 0" + }) + }) + + it('should return 400 when clip has zero width', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: 20, width: 0, height: 200 } + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip: width and height must be > 0" + }) + }) + + it('should return 400 when clip has zero height', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: 20, width: 100, height: 0 } + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip: width and height must be > 0" + }) + }) + + it('should return 400 when clip width exceeds maximum', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: 20, width: 4000, height: 200 } + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip: width must not exceed 3840, height must not exceed 2160" + }) + }) + + it('should return 400 when clip height exceeds maximum', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: 20, width: 100, height: 2200 } + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip: width must not exceed 3840, height must not exceed 2160" + }) + }) + + it('should return 400 when clip is combined with fullPage', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: 20, width: 100, height: 200 }, + fullPage: true + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip is mutually exclusive with fullPage and selector" + }) + }) + + it('should return 400 when clip is combined with selector', async () => { + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: 20, width: 100, height: 200 }, + selector: "#element" + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "clip is mutually exclusive with fullPage and selector" + }) + }) + + it('should pass valid clip parameter to takeScreenshot service', async () => { + mockTakeScreenshot.mockResolvedValueOnce({ + buffer: Buffer.from('screenshot-data'), + contentType: 'image/png' + }) + + const req = createMockRequest({ + url: "https://example.com", + clip: { x: 10, y: 20, width: 100, height: 200 } + }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.post + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(mockTakeScreenshot).toHaveBeenCalledWith( + expect.objectContaining({ + clip: { x: 10, y: 20, width: 100, height: 200 } + }) + ) + }) + + it('should handle clip from GET query parameters', async () => { + mockTakeScreenshot.mockResolvedValueOnce({ + buffer: Buffer.from('screenshot-data'), + contentType: 'image/png' + }) + + const req = createMockRequest({ + url: "https://example.com", + clipX: "10", + clipY: "20", + clipW: "100", + clipH: "200" + }, { method: 'GET' }) + const res = createMockResponse() + + const handler = screenshotRouter.stack.find(layer => + layer.route?.methods.get + )?.route.stack[0].handle + await handler(req, res, vi.fn()) + + expect(mockTakeScreenshot).toHaveBeenCalledWith( + expect.objectContaining({ + clip: { x: 10, y: 20, width: 100, height: 200 } + }) + ) + }) + }) }) \ No newline at end of file diff --git a/src/routes/screenshot.ts b/src/routes/screenshot.ts index ee3a7a4..604e939 100644 --- a/src/routes/screenshot.ts +++ b/src/routes/screenshot.ts @@ -90,6 +90,11 @@ export const screenshotRouter = Router(); * maxLength: 200 * description: CSS selector for element to capture instead of full page/viewport (max 200 chars) * example: "#main-content" + * userAgent: + * type: string + * maxLength: 500 + * description: Custom User-Agent string for HTTP requests (max 500 chars, no newlines allowed) + * example: "Mozilla/5.0 (compatible; SnapAPI/1.0)" * hideSelectors: * oneOf: * - type: string @@ -120,6 +125,9 @@ export const screenshotRouter = Router(); * element: * summary: Element screenshot * value: { "url": "https://github.com", "selector": "#readme" } + * custom_user_agent: + * summary: Custom User-Agent + * value: { "url": "https://example.com", "userAgent": "Mozilla/5.0 (compatible; SnapAPI/1.0)" } * responses: * 200: * description: Screenshot image binary @@ -273,6 +281,13 @@ export const screenshotRouter = Router(); * maxLength: 200 * description: CSS selector for element to capture instead of full page/viewport (max 200 chars) * example: "#main-content" + * - name: userAgent + * in: query + * schema: + * type: string + * maxLength: 500 + * description: Custom User-Agent string for HTTP requests (max 500 chars, no newlines allowed) + * example: "Mozilla/5.0 (compatible; SnapAPI/1.0)" * - name: darkMode * in: query * schema: @@ -360,6 +375,12 @@ async function handleScreenshotRequest(req: any, res: any) { css, js, selector, + userAgent, + clip, + clipX, + clipY, + clipW, + clipH, } = source; if (!url || typeof url !== "string") { @@ -379,6 +400,51 @@ async function handleScreenshotRequest(req: any, res: any) { return; } + // Handle clip parameter from GET query parameters (clipX, clipY, clipW, clipH) + let normalizedClip = clip; + if (req.method === 'GET' && (clipX || clipY || clipW || clipH)) { + normalizedClip = { + x: clipX ? parseInt(clipX, 10) : 0, + y: clipY ? parseInt(clipY, 10) : 0, + width: clipW ? parseInt(clipW, 10) : 0, + height: clipH ? parseInt(clipH, 10) : 0 + }; + } + + // Validate clip parameter + if (normalizedClip) { + // Check if all required fields are present + if (typeof normalizedClip.x !== 'number' || typeof normalizedClip.y !== 'number' || + typeof normalizedClip.width !== 'number' || typeof normalizedClip.height !== 'number') { + res.status(400).json({ error: "clip: all four fields (x, y, width, height) must be provided" }); + return; + } + + // Check x, y >= 0 + if (normalizedClip.x < 0 || normalizedClip.y < 0) { + res.status(400).json({ error: "clip: x and y coordinates must be >= 0" }); + return; + } + + // Check width, height > 0 + if (normalizedClip.width <= 0 || normalizedClip.height <= 0) { + res.status(400).json({ error: "clip: width and height must be > 0" }); + return; + } + + // Check maximum dimensions + if (normalizedClip.width > 3840 || normalizedClip.height > 2160) { + res.status(400).json({ error: "clip: width must not exceed 3840, height must not exceed 2160" }); + return; + } + + // Check mutual exclusivity with fullPage and selector + if (fullPage || selector) { + res.status(400).json({ error: "clip is mutually exclusive with fullPage and selector" }); + return; + } + } + // Normalize hideSelectors: string | string[] → string[] let normalizedHideSelectors: string[] | undefined; if (hideSelectors) { @@ -425,6 +491,8 @@ async function handleScreenshotRequest(req: any, res: any) { css: css || undefined, js: js || undefined, selector: selector || undefined, + userAgent: userAgent || undefined, + clip: normalizedClip || undefined, }; try { diff --git a/src/services/screenshot.ts b/src/services/screenshot.ts index fd6bec6..5f7a136 100644 --- a/src/services/screenshot.ts +++ b/src/services/screenshot.ts @@ -19,6 +19,8 @@ export interface ScreenshotOptions { css?: string; js?: string; selector?: string; + userAgent?: string; + clip?: { x: number; y: number; width: number; height: number }; } export interface ScreenshotResult { @@ -110,6 +112,10 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise