Add GET endpoint support, response caching, and update landing page
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m11s

- Add GET /v1/screenshot endpoint with query parameter support
- Support API key authentication via ?key= query parameter
- Implement in-memory LRU cache with configurable TTL (5min) and size limits (100MB)
- Add X-Cache headers (HIT/MISS) to indicate cache status
- Add cache bypass option via ?cache=false parameter
- Update OpenAPI documentation with GET endpoint and caching info
- Add GET/Embed code examples to landing page hero section
- Add Response Caching and GET Request Support feature cards
- Update features grid layout to accommodate new features
This commit is contained in:
OpenClaw Agent 2026-02-24 08:05:43 +00:00
parent 609e7d0808
commit 44e31e355c
5 changed files with 433 additions and 18 deletions

View file

@ -10,7 +10,10 @@ const options: swaggerJsdoc.Options = {
"## Authentication\n" +
"API screenshot requests require an API key:\n" +
"- `Authorization: Bearer YOUR_API_KEY` header, or\n" +
"- `X-API-Key: YOUR_API_KEY` header\n\n" +
"- `X-API-Key: YOUR_API_KEY` header, or\n" +
"- `?key=YOUR_API_KEY` query parameter (for GET requests)\n\n" +
"## Response Caching\n" +
"Screenshot responses are cached for 5 minutes (authenticated requests only). Add `?cache=false` or `\"cache\": false` to bypass cache. Cache status is indicated via `X-Cache` header (HIT/MISS).\n\n" +
"## Playground\n" +
"The `/v1/playground` endpoint requires no authentication but returns watermarked screenshots (5 requests/hour per IP).\n\n" +
"## Rate Limits\n" +
@ -37,6 +40,7 @@ const options: swaggerJsdoc.Options = {
securitySchemes: {
BearerAuth: { type: "http", scheme: "bearer" },
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
QueryKeyAuth: { type: "apiKey", in: "query", name: "key" },
},
schemas: {
Error: {

View file

@ -4,13 +4,15 @@ import { isValidKey, getKeyInfo } from "../services/keys.js";
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization;
const xApiKey = req.headers["x-api-key"] as string | undefined;
const queryKey = req.query.key as string | undefined;
let key: string | undefined;
if (header?.startsWith("Bearer ")) key = header.slice(7);
else if (xApiKey) key = xApiKey;
else if (queryKey) key = queryKey;
if (!key) {
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key> or X-API-Key: <key>" });
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key>, X-API-Key: <key>, or ?key=<key>" });
return;
}
if (!(await isValidKey(key))) {

View file

@ -1,5 +1,6 @@
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();
@ -128,32 +129,217 @@ export const screenshotRouter = Router();
* 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: 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" }
*/
screenshotRouter.post("/", async (req: any, res) => {
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay, waitUntil } = req.body;
// 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,
} = source;
if (!url || typeof url !== "string") {
res.status(400).json({ error: "Missing required parameter: url" });
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,
};
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,
});
// 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");
@ -173,4 +359,8 @@ screenshotRouter.post("/", async (req: any, res) => {
res.status(500).json({ error: "Screenshot failed", details: err.message });
}
});
}
// Register both GET and POST routes
screenshotRouter.get("/", handleScreenshotRequest);
screenshotRouter.post("/", handleScreenshotRequest);

195
src/services/cache.ts Normal file
View file

@ -0,0 +1,195 @@
import crypto from "crypto";
import logger from "./logger.js";
interface CacheItem {
buffer: Buffer;
contentType: string;
timestamp: number;
lastAccessed: number;
size: number;
}
export class ScreenshotCache {
private cache = new Map<string, CacheItem>();
private ttlMs: number;
private maxSizeBytes: number;
private currentSizeBytes = 0;
constructor() {
// Default TTL: 5 minutes (configurable via env)
this.ttlMs = parseInt(process.env.CACHE_TTL_MS || "300000", 10);
// Default max size: 100MB (configurable via env)
this.maxSizeBytes = parseInt(process.env.CACHE_MAX_MB || "100", 10) * 1024 * 1024;
// Start cleanup interval every minute
setInterval(() => this.cleanup(), 60 * 1000);
}
/**
* Generate cache key from URL and screenshot parameters
*/
private generateKey(params: any): string {
// Hash all parameters that affect the screenshot result
const keyData = {
url: params.url,
format: params.format || "png",
width: params.width,
height: params.height,
fullPage: params.fullPage,
quality: params.quality,
waitForSelector: params.waitForSelector,
deviceScale: params.deviceScale,
delay: params.delay,
waitUntil: params.waitUntil,
};
const hash = crypto.createHash("sha256");
hash.update(JSON.stringify(keyData));
return hash.digest("hex");
}
/**
* Get cached screenshot if available and not expired
*/
get(params: any): CacheItem | null {
const key = this.generateKey(params);
const item = this.cache.get(key);
if (!item) {
return null;
}
// Check if expired
if (Date.now() - item.timestamp > this.ttlMs) {
this.cache.delete(key);
this.currentSizeBytes -= item.size;
return null;
}
// Update last accessed time for LRU
item.lastAccessed = Date.now();
return item;
}
/**
* Store screenshot in cache
*/
put(params: any, buffer: Buffer, contentType: string): void {
const key = this.generateKey(params);
const size = buffer.length;
const now = Date.now();
// Don't cache if item is larger than 50% of max cache size
if (size > this.maxSizeBytes * 0.5) {
logger.warn({ size, maxSize: this.maxSizeBytes }, "Screenshot too large to cache");
return;
}
// Make room if needed
this.evictToFit(size);
const item: CacheItem = {
buffer,
contentType,
timestamp: now,
lastAccessed: now,
size,
};
this.cache.set(key, item);
this.currentSizeBytes += size;
logger.debug({
key: key.substring(0, 8),
size,
totalItems: this.cache.size,
totalSize: this.currentSizeBytes
}, "Screenshot cached");
}
/**
* Check if caching should be bypassed
*/
shouldBypass(params: any): boolean {
// Check for cache=false in params (both GET query and POST body)
return params.cache === false || params.cache === "false";
}
/**
* Evict items to make room for new item
*/
private evictToFit(newItemSize: number): void {
// Calculate how much space we need
const availableSpace = this.maxSizeBytes - this.currentSizeBytes;
if (availableSpace >= newItemSize) {
return; // No eviction needed
}
const spaceNeeded = newItemSize - availableSpace;
let spaceFreed = 0;
// Sort items by last accessed time (oldest first)
const items = Array.from(this.cache.entries()).sort(
([, a], [, b]) => a.lastAccessed - b.lastAccessed
);
for (const [key, item] of items) {
if (spaceFreed >= spaceNeeded) {
break;
}
this.cache.delete(key);
this.currentSizeBytes -= item.size;
spaceFreed += item.size;
logger.debug({
key: key.substring(0, 8),
size: item.size,
spaceFreed,
spaceNeeded
}, "Evicted cache item");
}
}
/**
* Clean up expired items
*/
private cleanup(): void {
const now = Date.now();
let expiredCount = 0;
let freedBytes = 0;
for (const [key, item] of this.cache.entries()) {
if (now - item.timestamp > this.ttlMs) {
this.cache.delete(key);
this.currentSizeBytes -= item.size;
expiredCount++;
freedBytes += item.size;
}
}
if (expiredCount > 0) {
logger.debug({
expiredCount,
freedBytes,
remainingItems: this.cache.size,
totalSize: this.currentSizeBytes
}, "Cache cleanup completed");
}
}
/**
* Get cache statistics
*/
getStats() {
return {
items: this.cache.size,
sizeBytes: this.currentSizeBytes,
maxSizeBytes: this.maxSizeBytes,
ttlMs: this.ttlMs,
};
}
}
// Export singleton instance
export const screenshotCache = new ScreenshotCache();