feat: add POST /v1/screenshots/batch endpoint
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
- Batch screenshot endpoint: take 1-10 screenshots in a single request - Concurrent processing with Promise.allSettled (partial success support) - Upfront quota check for all URLs before processing - Per-URL SSRF validation via existing takeScreenshot() - Added incrementUsage() to usage middleware for granular tracking - 10 new tests covering all edge cases - Updated OpenAPI docs (JSDoc on route) - Updated Node.js and Python SDK READMEs with batch method docs
This commit is contained in:
parent
65d2fd38cc
commit
8a36826e35
6 changed files with 506 additions and 0 deletions
235
src/routes/batch.ts
Normal file
235
src/routes/batch.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { Router } from "express";
|
||||
import { takeScreenshot } from "../services/screenshot.js";
|
||||
import { getUsageForKey, incrementUsage } from "../middleware/usage.js";
|
||||
import { getTierLimit } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
export const batchRouter = Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/screenshots/batch:
|
||||
* post:
|
||||
* tags: [Screenshots]
|
||||
* summary: Take multiple screenshots in a single request
|
||||
* description: >
|
||||
* Capture multiple URLs in one API call. Each URL counts as one screenshot
|
||||
* toward your usage limits. All parameters except `urls` are shared across
|
||||
* all screenshots. Returns partial results if some URLs fail.
|
||||
* operationId: batchScreenshots
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* - ApiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [urls]
|
||||
* properties:
|
||||
* urls:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: uri
|
||||
* minItems: 1
|
||||
* maxItems: 10
|
||||
* description: Array of URLs to capture (1-10)
|
||||
* example: ["https://example.com", "https://example.org"]
|
||||
* format:
|
||||
* type: string
|
||||
* enum: [png, jpeg, webp]
|
||||
* default: png
|
||||
* width:
|
||||
* type: integer
|
||||
* minimum: 320
|
||||
* maximum: 3840
|
||||
* default: 1280
|
||||
* height:
|
||||
* type: integer
|
||||
* minimum: 200
|
||||
* maximum: 2160
|
||||
* default: 800
|
||||
* fullPage:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* quality:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 80
|
||||
* darkMode:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* css:
|
||||
* type: string
|
||||
* maxLength: 5000
|
||||
* js:
|
||||
* type: string
|
||||
* maxLength: 5000
|
||||
* selector:
|
||||
* type: string
|
||||
* maxLength: 200
|
||||
* userAgent:
|
||||
* type: string
|
||||
* maxLength: 500
|
||||
* delay:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* maximum: 5000
|
||||
* waitForSelector:
|
||||
* type: string
|
||||
* hideSelectors:
|
||||
* oneOf:
|
||||
* - type: string
|
||||
* - type: array
|
||||
* items:
|
||||
* type: string
|
||||
* maxItems: 10
|
||||
* clip:
|
||||
* type: object
|
||||
* properties:
|
||||
* x:
|
||||
* type: integer
|
||||
* y:
|
||||
* type: integer
|
||||
* width:
|
||||
* type: integer
|
||||
* height:
|
||||
* type: integer
|
||||
* examples:
|
||||
* basic:
|
||||
* summary: Two URLs
|
||||
* value:
|
||||
* urls: ["https://example.com", "https://example.org"]
|
||||
* with_options:
|
||||
* summary: With shared options
|
||||
* value:
|
||||
* urls: ["https://example.com", "https://example.org"]
|
||||
* format: jpeg
|
||||
* width: 1920
|
||||
* height: 1080
|
||||
* quality: 90
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Batch results (may include partial failures)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [success, error]
|
||||
* image:
|
||||
* type: string
|
||||
* description: Base64-encoded image (only on success)
|
||||
* error:
|
||||
* type: string
|
||||
* description: Error message (only on error)
|
||||
* 400:
|
||||
* description: Invalid request
|
||||
* content:
|
||||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
* 401:
|
||||
* description: Missing API key
|
||||
* 429:
|
||||
* description: Usage limit exceeded
|
||||
*/
|
||||
batchRouter.post("/", async (req: any, res: any) => {
|
||||
const { urls, ...sharedParams } = req.body;
|
||||
|
||||
// Validate urls
|
||||
if (!urls || !Array.isArray(urls) || urls.length === 0) {
|
||||
res.status(400).json({ error: "Missing required parameter: urls (array of 1-10 URLs)" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.length > 10) {
|
||||
res.status(400).json({ error: "Maximum 10 URLs per batch request" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check usage quota for all URLs before starting
|
||||
const keyInfo = req.apiKeyInfo;
|
||||
if (keyInfo) {
|
||||
const key = keyInfo.key;
|
||||
const limit = getTierLimit(keyInfo.tier);
|
||||
const currentUsage = getUsageForKey(key);
|
||||
const monthKey = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
|
||||
const currentCount = (currentUsage && currentUsage.monthKey === monthKey) ? currentUsage.count : 0;
|
||||
|
||||
if (currentCount + urls.length > limit) {
|
||||
res.status(429).json({
|
||||
error: `Monthly limit would be exceeded. Need ${urls.length} screenshots but only ${limit - currentCount} remaining (${limit} limit for ${keyInfo.tier} tier).`,
|
||||
usage: currentCount,
|
||||
limit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract shared screenshot params
|
||||
const {
|
||||
format, width, height, fullPage, quality, darkMode, css, js,
|
||||
selector, userAgent, delay, waitForSelector, hideSelectors, clip,
|
||||
waitUntil, deviceScale,
|
||||
} = sharedParams;
|
||||
|
||||
// Process all URLs concurrently
|
||||
const settled = await Promise.allSettled(
|
||||
urls.map((url: string) =>
|
||||
takeScreenshot({
|
||||
url,
|
||||
format: format || undefined,
|
||||
width: width ? parseInt(width, 10) || width : undefined,
|
||||
height: height ? parseInt(height, 10) || height : undefined,
|
||||
fullPage,
|
||||
quality: quality ? parseInt(quality, 10) || quality : undefined,
|
||||
darkMode,
|
||||
css,
|
||||
js,
|
||||
selector,
|
||||
userAgent,
|
||||
delay: delay ? parseInt(delay, 10) || delay : undefined,
|
||||
waitForSelector,
|
||||
hideSelectors,
|
||||
clip,
|
||||
waitUntil,
|
||||
deviceScale,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Build results and track usage
|
||||
const results = settled.map((result, i) => {
|
||||
if (result.status === "fulfilled") {
|
||||
// Increment usage for successful screenshot
|
||||
if (keyInfo) {
|
||||
incrementUsage(keyInfo.key);
|
||||
}
|
||||
return {
|
||||
url: urls[i],
|
||||
status: "success" as const,
|
||||
image: result.value.buffer.toString("base64"),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
url: urls[i],
|
||||
status: "error" as const,
|
||||
error: result.reason?.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ results });
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue