All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m33s
437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
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 }"
|
|
* 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 }
|
|
* 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:
|
|
* `<img src="https://snapapi.eu/v1/screenshot?url=https://example.com&key=YOUR_KEY">`
|
|
* 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: 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,
|
|
} = 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
|
|
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.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL") || err.message.includes("Could not resolve")) {
|
|
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);
|