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
|
### Hide Elements Before Capture
|
||||||
|
|
||||||
```typescript
|
```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
|
### Hide Elements Before Capture
|
||||||
|
|
||||||
```python
|
```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
|
* maxLength: 200
|
||||||
* description: CSS selector for element to capture instead of full page/viewport (max 200 chars)
|
* description: CSS selector for element to capture instead of full page/viewport (max 200 chars)
|
||||||
* example: "#main-content"
|
* 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:
|
* hideSelectors:
|
||||||
* oneOf:
|
* oneOf:
|
||||||
* - type: string
|
* - type: string
|
||||||
|
|
@ -120,6 +125,9 @@ export const screenshotRouter = Router();
|
||||||
* element:
|
* element:
|
||||||
* summary: Element screenshot
|
* summary: Element screenshot
|
||||||
* value: { "url": "https://github.com", "selector": "#readme" }
|
* 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:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Screenshot image binary
|
* description: Screenshot image binary
|
||||||
|
|
@ -273,6 +281,13 @@ export const screenshotRouter = Router();
|
||||||
* maxLength: 200
|
* maxLength: 200
|
||||||
* description: CSS selector for element to capture instead of full page/viewport (max 200 chars)
|
* description: CSS selector for element to capture instead of full page/viewport (max 200 chars)
|
||||||
* example: "#main-content"
|
* 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
|
* - name: darkMode
|
||||||
* in: query
|
* in: query
|
||||||
* schema:
|
* schema:
|
||||||
|
|
@ -360,6 +375,12 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
css,
|
css,
|
||||||
js,
|
js,
|
||||||
selector,
|
selector,
|
||||||
|
userAgent,
|
||||||
|
clip,
|
||||||
|
clipX,
|
||||||
|
clipY,
|
||||||
|
clipW,
|
||||||
|
clipH,
|
||||||
} = source;
|
} = source;
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
|
|
@ -379,6 +400,51 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
return;
|
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[]
|
// Normalize hideSelectors: string | string[] → string[]
|
||||||
let normalizedHideSelectors: string[] | undefined;
|
let normalizedHideSelectors: string[] | undefined;
|
||||||
if (hideSelectors) {
|
if (hideSelectors) {
|
||||||
|
|
@ -425,6 +491,8 @@ async function handleScreenshotRequest(req: any, res: any) {
|
||||||
css: css || undefined,
|
css: css || undefined,
|
||||||
js: js || undefined,
|
js: js || undefined,
|
||||||
selector: selector || undefined,
|
selector: selector || undefined,
|
||||||
|
userAgent: userAgent || undefined,
|
||||||
|
clip: normalizedClip || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export interface ScreenshotOptions {
|
||||||
css?: string;
|
css?: string;
|
||||||
js?: string;
|
js?: string;
|
||||||
selector?: string;
|
selector?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
clip?: { x: number; y: number; width: number; height: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScreenshotResult {
|
export interface ScreenshotResult {
|
||||||
|
|
@ -110,6 +112,10 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise<Screensho
|
||||||
try {
|
try {
|
||||||
await page.setViewport({ width, height, deviceScaleFactor: deviceScale });
|
await page.setViewport({ width, height, deviceScaleFactor: deviceScale });
|
||||||
|
|
||||||
|
if (opts.userAgent) {
|
||||||
|
await page.setUserAgent(opts.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.darkMode) {
|
if (opts.darkMode) {
|
||||||
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
|
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue