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
235 lines
7.4 KiB
TypeScript
235 lines
7.4 KiB
TypeScript
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 });
|
|
});
|