SnapAPI/src/routes/screenshot.ts
OpenClaw d20fbbfe2e
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 7m59s
perf: switch to domcontentloaded default, optimize browser pool, fix swagger paths
Performance fixes:
- Default waitUntil changed from networkidle2 to domcontentloaded (saves ~500ms+)
- Add waitUntil parameter so users can choose (load/domcontentloaded/networkidle0/networkidle2)
- Optimize page recycle: use DOM reset instead of about:blank navigation
- Add Chromium flags to disable unnecessary features (background networking, extensions, sync, etc.)

Swagger fixes:
- Fix apis glob to include dist/*.js (was only matching src/*.ts, empty at runtime)
- Document new waitUntil parameter on POST /v1/screenshot
- Add OpenAPI docs for /status endpoint
2026-02-20 12:39:06 +00:00

176 lines
6.5 KiB
TypeScript

import { Router } from "express";
import { takeScreenshot } from "../services/screenshot.js";
import logger from "../services/logger.js";
export const screenshotRouter = Router();
/**
* @openapi
* /v1/screenshot:
* post:
* tags: [Screenshots]
* summary: Take a screenshot (authenticated)
* description: Capture a pixel-perfect, unwatermarked screenshot. Requires an API key.
* operationId: takeScreenshot
* security:
* - BearerAuth: []
* - ApiKeyAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [url]
* properties:
* url:
* type: string
* format: uri
* description: URL to capture
* example: "https://example.com"
* format:
* type: string
* enum: [png, jpeg, webp]
* default: png
* description: Output image format
* width:
* type: integer
* minimum: 320
* maximum: 3840
* default: 1280
* description: Viewport width in pixels
* height:
* type: integer
* minimum: 200
* maximum: 2160
* default: 800
* description: Viewport height in pixels
* fullPage:
* type: boolean
* default: false
* description: Capture full scrollable page instead of viewport only
* quality:
* type: integer
* minimum: 1
* maximum: 100
* default: 80
* description: JPEG/WebP quality (ignored for PNG)
* waitForSelector:
* type: string
* description: CSS selector to wait for before capturing (e.g. "#main-content")
* deviceScale:
* type: number
* minimum: 1
* maximum: 3
* default: 1
* description: Device scale factor (2 = Retina)
* delay:
* type: integer
* minimum: 0
* maximum: 5000
* default: 0
* description: Extra delay in ms after page load before capturing
* waitUntil:
* type: string
* enum: [load, domcontentloaded, networkidle0, networkidle2]
* default: domcontentloaded
* description: >
* Page load event to wait for before capturing.
* "domcontentloaded" (default) is fastest for most pages.
* Use "networkidle2" for JS-heavy SPAs that load data after initial render.
* examples:
* simple:
* summary: Simple screenshot
* value: { "url": "https://example.com" }
* hd_jpeg:
* summary: HD JPEG
* value: { "url": "https://github.com", "format": "jpeg", "width": 1920, "height": 1080, "quality": 90 }
* mobile:
* summary: Mobile viewport
* value: { "url": "https://example.com", "width": 375, "height": 812, "deviceScale": 2 }
* responses:
* 200:
* description: Screenshot image binary
* content:
* image/png:
* schema: { type: string, format: binary }
* image/jpeg:
* schema: { type: string, format: binary }
* image/webp:
* schema: { type: string, format: binary }
* 400:
* description: Invalid request (bad URL, blocked domain, etc.)
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 401:
* description: Missing API key
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 403:
* description: Invalid API key
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 429:
* description: Rate or usage limit exceeded
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 503:
* description: Service busy (queue full)
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 504:
* description: Screenshot timed out
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
*/
screenshotRouter.post("/", async (req: any, res) => {
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay, waitUntil } = req.body;
if (!url || typeof url !== "string") {
res.status(400).json({ error: "Missing required parameter: url" });
return;
}
try {
const result = await takeScreenshot({
url,
format: format || "png",
width: width ? parseInt(width, 10) : undefined,
height: height ? parseInt(height, 10) : undefined,
fullPage: fullPage === true || fullPage === "true",
quality: quality ? parseInt(quality, 10) : undefined,
waitForSelector,
deviceScale: deviceScale ? parseFloat(deviceScale) : undefined,
delay: delay ? parseInt(delay, 10) : undefined,
waitUntil: ["load", "domcontentloaded", "networkidle0", "networkidle2"].includes(waitUntil) ? waitUntil : undefined,
});
res.setHeader("Content-Type", result.contentType);
res.setHeader("Content-Length", result.buffer.length);
res.setHeader("Cache-Control", "no-store");
res.send(result.buffer);
} catch (err: any) {
logger.error({ err: err.message, url }, "Screenshot failed");
if (err.message === "QUEUE_FULL") {
res.status(503).json({ error: "Service busy. Try again shortly." });
return;
}
if (err.message === "SCREENSHOT_TIMEOUT") {
res.status(504).json({ error: "Screenshot timed out. The page may be too slow to load." });
return;
}
if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL")) {
res.status(400).json({ error: err.message });
return;
}
res.status(500).json({ error: "Screenshot failed", details: err.message });
}
});