feat: add userAgent parameter for custom User-Agent headers
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled

- Add userAgent?: string to ScreenshotOptions interface
- Implement validation (max 500 chars, no newlines for security)
- Call page.setUserAgent() after page acquisition, before navigation
- Add route handler support for both POST body and GET query
- Add comprehensive test coverage (11 new tests)
- Update OpenAPI documentation with parameter specs and examples
- Update Node.js and Python SDK README examples
- All userAgent tests passing (414 → 425 total tests)

Fixes potential HTTP header injection by rejecting newlines.
Enables custom User-Agent strings for specific browser emulation needs.
This commit is contained in:
SnapAPI Developer 2026-03-05 15:10:06 +01:00
parent a17f492cc3
commit 9290c759da
5 changed files with 313 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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 }
})
)
})
})
})

View file

@ -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 {

View file

@ -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<Screensho
try {
await page.setViewport({ width, height, deviceScaleFactor: deviceScale });
if (opts.userAgent) {
await page.setUserAgent(opts.userAgent);
}
if (opts.darkMode) {
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
}