import { Router } from "express"; import { takeScreenshot } from "../services/screenshot.js"; import { screenshotCache } from "../services/cache.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 * darkMode: * type: boolean * default: false * description: Emulate prefers-color-scheme dark mode * css: * type: string * maxLength: 5000 * description: Custom CSS to inject into the page before capture (max 5000 chars) * example: "body { background: #1a1a2e !important; color: #eee !important }" * js: * type: string * maxLength: 5000 * description: Custom JavaScript code to execute on the page before capture (max 5000 chars, 5-second timeout) * example: "document.querySelector('.modal').remove(); window.scrollTo(0, 500);" * selector: * type: string * maxLength: 200 * description: CSS selector for element to capture instead of full page/viewport (max 200 chars) * 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: * oneOf: * - type: string * description: Single CSS selector or comma-separated list * - type: array * items: * type: string * maxItems: 10 * description: CSS selectors to hide before capture (max 10 items, each max 200 chars) * 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 } * element: * summary: Element screenshot * 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: * 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" } * get: * tags: [Screenshots] * summary: Take a screenshot via GET request (authenticated) * description: > * Capture a pixel-perfect, unwatermarked screenshot using GET request. * All parameters are passed via query string. Perfect for image embeds: * `` * operationId: takeScreenshotGet * security: * - BearerAuth: [] * - ApiKeyAuth: [] * - QueryKeyAuth: [] * parameters: * - name: url * in: query * required: true * schema: * type: string * format: uri * description: URL to capture * example: https://example.com * - name: key * in: query * schema: * type: string * description: API key (alternative to header auth) * example: sk_test_1234567890abcdef * - name: format * in: query * schema: * type: string * enum: [png, jpeg, webp] * default: png * description: Output image format * - name: width * in: query * schema: * type: integer * minimum: 320 * maximum: 3840 * default: 1280 * description: Viewport width in pixels * - name: height * in: query * schema: * type: integer * minimum: 200 * maximum: 2160 * default: 800 * description: Viewport height in pixels * - name: fullPage * in: query * schema: * type: boolean * default: false * description: Capture full scrollable page instead of viewport only * - name: quality * in: query * schema: * type: integer * minimum: 1 * maximum: 100 * default: 80 * description: JPEG/WebP quality (ignored for PNG) * - name: waitForSelector * in: query * schema: * type: string * description: CSS selector to wait for before capturing * - name: deviceScale * in: query * schema: * type: number * minimum: 1 * maximum: 3 * default: 1 * description: Device scale factor (2 = Retina) * - name: delay * in: query * schema: * type: integer * minimum: 0 * maximum: 5000 * default: 0 * description: Extra delay in ms after page load before capturing * - name: waitUntil * in: query * schema: * type: string * enum: [load, domcontentloaded, networkidle0, networkidle2] * default: domcontentloaded * description: Page load event to wait for before capturing * - name: css * in: query * schema: * type: string * maxLength: 5000 * description: Custom CSS to inject into the page before capture (max 5000 chars) * example: "body { background: #1a1a2e !important }" * - name: js * in: query * schema: * type: string * maxLength: 5000 * description: Custom JavaScript code to execute on the page before capture (max 5000 chars, 5-second timeout) * example: "document.querySelector('.modal').remove();" * - name: selector * in: query * schema: * type: string * maxLength: 200 * description: CSS selector for element to capture instead of full page/viewport (max 200 chars) * 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 * in: query * schema: * type: boolean * default: false * description: Emulate prefers-color-scheme dark mode * - name: hideSelectors * in: query * schema: * type: string * description: Comma-separated CSS selectors to hide before capture (max 10 items, each max 200 chars) * example: ".ad,#cookie-banner,.popup" * - name: cache * in: query * schema: * type: boolean * default: true * description: Enable response caching (5-minute TTL) * responses: * 200: * description: Screenshot image binary * headers: * X-Cache: * description: Cache status * schema: * type: string * enum: [HIT, MISS] * 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" } */ // Shared handler for both GET and POST requests async function handleScreenshotRequest(req: any, res: any) { // Extract parameters from both query (GET) and body (POST) const source = req.method === "GET" ? req.query : req.body; const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay, waitUntil, cache, darkMode, hideSelectors, css, js, selector, userAgent, clip, clipX, clipY, clipW, clipH, } = source; if (!url || typeof url !== "string") { res.status(400).json({ error: "Missing required parameter: url" }); return; } // Validate css parameter if (css && typeof css === 'string' && css.length > 5000) { res.status(400).json({ error: "css: maximum 5000 characters allowed" }); return; } // Validate js parameter if (js && typeof js === 'string' && js.length > 5000) { res.status(400).json({ error: "js: maximum 5000 characters allowed" }); 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[] let normalizedHideSelectors: string[] | undefined; if (hideSelectors) { if (typeof hideSelectors === 'string') { normalizedHideSelectors = hideSelectors.split(',').map((s: string) => s.trim()).filter(Boolean); } else if (Array.isArray(hideSelectors)) { normalizedHideSelectors = hideSelectors; } } // Validate hideSelectors if (normalizedHideSelectors) { if (normalizedHideSelectors.length > 10) { res.status(400).json({ error: "hideSelectors: maximum 10 selectors allowed" }); return; } if (normalizedHideSelectors.some((s: string) => s.length > 200)) { res.status(400).json({ error: "hideSelectors: each selector must be 200 characters or less" }); return; } } // Check mutual exclusivity of selector and fullPage if (selector && (fullPage === true || fullPage === "true")) { res.status(400).json({ error: "selector and fullPage are mutually exclusive" }); return; } // Normalize parameters const params = { 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, cache, darkMode: darkMode === true || darkMode === "true", hideSelectors: normalizedHideSelectors, css: css || undefined, js: js || undefined, selector: selector || undefined, userAgent: userAgent || undefined, clip: normalizedClip || undefined, }; try { // Check cache first (if not bypassed) let cacheHit = false; if (!screenshotCache.shouldBypass(params)) { const cached = screenshotCache.get(params); if (cached) { res.setHeader("Content-Type", cached.contentType); res.setHeader("Content-Length", cached.buffer.length); res.setHeader("Cache-Control", "no-store"); res.setHeader("X-Cache", "HIT"); res.send(cached.buffer); return; } } // Take new screenshot const result = await takeScreenshot(params); // Cache the result (if not bypassed) if (!screenshotCache.shouldBypass(params)) { screenshotCache.put(params, result.buffer, result.contentType); } res.setHeader("Content-Type", result.contentType); res.setHeader("Content-Length", result.buffer.length); res.setHeader("Cache-Control", "no-store"); res.setHeader("X-Cache", "MISS"); 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 === "SELECTOR_NOT_FOUND") { res.status(400).json({ error: `Element not found: ${selector}` }); return; } if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL") || err.message.includes("Could not resolve") || err.message.includes("JS_EXECUTION_ERROR")) { res.status(400).json({ error: err.message }); return; } res.status(500).json({ error: "Screenshot failed", details: err.message }); } } // Register both GET and POST routes screenshotRouter.get("/", handleScreenshotRequest); screenshotRouter.post("/", handleScreenshotRequest);