SnapAPI/src/routes/batch.ts
Hoid 8a36826e35
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
feat: add POST /v1/screenshots/batch endpoint
- 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
2026-03-06 09:09:27 +01:00

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