feat: add userAgent parameter for custom User-Agent headers
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
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:
parent
a17f492cc3
commit
9290c759da
5 changed files with 313 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue