diff --git a/public/changelog.html b/public/changelog.html
index f6d2968..7f6d779 100644
--- a/public/changelog.html
+++ b/public/changelog.html
@@ -134,6 +134,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
+ - ✨ New:
css parameter — inject custom CSS into the page before capture (max 5000 chars). Perfect for custom fonts, color overrides, or complex layout adjustments
- ✨ New:
darkMode parameter — emulate prefers-color-scheme: dark for dark mode screenshots
- ✨ New:
hideSelectors parameter — hide elements by CSS selector before capture (max 10, 200 chars each)
- 🔒 Fixed: Cancelled subscriptions now properly blocked (was incorrectly getting free tier)
@@ -141,7 +142,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
- 🛡️ Added: Rate limiting on billing endpoints (10 req/15min)
- 🐛 Fixed: FAQ accordion double-toggle
- 🐛 Fixed: Privacy/Terms/Impressum 404s on extensionless URLs
- - 📊 360 tests passing
+ - 📊 366 tests passing
diff --git a/public/index.html b/public/index.html
index c955018..ae532e8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -301,6 +301,12 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","hideSelectors":["#cookie-banner",".popup"]}'
+
+# Inject custom CSS
+curl -X POST https://snapapi.eu/v1/screenshot \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"url":"https://example.com","css":"body { background: #1a1a2e !important }"}'
# GET request with query parameters
@@ -327,6 +333,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
fullPage: true,
darkMode: true,
hideSelectors: ['#cookie-banner', '.popup'],
+ css: 'body { background: #1a1a2e !important }',
});
@@ -342,6 +349,7 @@ screenshot = snap.capture(
full_page=True,
dark_mode=True,
hide_selectors=["#cookie-banner", ".popup"],
+ css="body { background: #1a1a2e !important }",
)
diff --git a/sdk/node/README.md b/sdk/node/README.md
index 140c030..ac9377f 100644
--- a/sdk/node/README.md
+++ b/sdk/node/README.md
@@ -103,17 +103,21 @@ const singleHide = await snap.capture('https://example.com', {
});
```
-### Combined Dark Mode + Element Hiding
+### Custom CSS Injection
```typescript
-// Perfect for clean marketing screenshots
-const marketingShot = await snap.capture({
- url: 'https://your-saas-app.com',
+// Inject custom CSS before capture
+const styled = await snap.capture({
+ url: 'https://example.com',
+ css: 'body { background: #1a1a2e !important; color: #eee !important; font-family: "Comic Sans MS" }',
+});
+
+// Combine with other options
+const combined = await snap.capture({
+ url: 'https://example.com',
+ css: '.hero { padding: 80px 0 } h1 { font-size: 48px }',
darkMode: true,
- hideSelectors: ['.dev-banner', '.beta-notice'],
- width: 1920,
- height: 1080,
- deviceScale: 2,
+ hideSelectors: ['.cookie-banner'],
});
```
@@ -145,6 +149,7 @@ Returns a `Promise` containing the screenshot image.
| `waitUntil` | `string` | `'domcontentloaded'` | Load event to wait for |
| `darkMode` | `boolean` | `false` | Emulate prefers-color-scheme: dark |
| `hideSelectors` | `string \| string[]` | — | CSS selectors to hide before capture |
+| `css` | `string` | — | Custom CSS to inject before capture (max 5000 chars) |
### `snap.health()`
diff --git a/sdk/python/README.md b/sdk/python/README.md
index ef0dd82..1874b4b 100644
--- a/sdk/python/README.md
+++ b/sdk/python/README.md
@@ -107,17 +107,21 @@ single_hide = snap.capture(
)
```
-### Combined Dark Mode + Element Hiding
+### Custom CSS Injection
```python
-# Perfect for clean marketing screenshots
-marketing_shot = snap.capture(
- "https://your-saas-app.com",
+# Inject custom CSS before capture
+styled = snap.capture(
+ "https://example.com",
+ css='body { background: #1a1a2e !important; color: #eee !important }',
+)
+
+# Combine with other options
+combined = snap.capture(
+ "https://example.com",
+ css=".hero { padding: 80px 0 } h1 { font-size: 48px }",
dark_mode=True,
- hide_selectors=[".dev-banner", ".beta-notice"],
- width=1920,
- height=1080,
- device_scale=2,
+ hide_selectors=[".cookie-banner"],
)
```
@@ -154,6 +158,7 @@ except SnapAPIError as e:
| `wait_until` | `str` | `"domcontentloaded"` | Load event |
| `dark_mode` | `bool` | `False` | Emulate prefers-color-scheme: dark |
| `hide_selectors` | `list` | — | CSS selectors to hide before capture |
+| `css` | `str` | — | Custom CSS to inject before capture (max 5000 chars) |
### `snap.health() -> dict`
diff --git a/src/routes/__tests__/screenshot.test.ts b/src/routes/__tests__/screenshot.test.ts
index 531eb00..cc43b64 100644
--- a/src/routes/__tests__/screenshot.test.ts
+++ b/src/routes/__tests__/screenshot.test.ts
@@ -135,6 +135,7 @@ describe('Screenshot Route', () => {
cache: undefined,
darkMode: false,
hideSelectors: undefined,
+ css: undefined,
})
expect(mockCache.put).toHaveBeenCalledWith(expect.any(Object), mockBuffer, 'image/png')
@@ -291,6 +292,7 @@ describe('Screenshot Route', () => {
cache: undefined,
darkMode: false,
hideSelectors: undefined,
+ css: undefined,
})
})
@@ -498,6 +500,77 @@ describe('Screenshot Route', () => {
})
})
+ describe('css parameter', () => {
+ it('should pass css to takeScreenshot via POST', async () => {
+ const mockBuffer = Buffer.from('fake-screenshot-data')
+ mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' })
+
+ const req = createMockRequest({ url: "https://example.com", css: "body { background: red !important }" })
+ const res = createMockResponse()
+ const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
+ await handler(req, res, vi.fn())
+
+ expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({ css: "body { background: red !important }" }))
+ })
+
+ it('should pass css to takeScreenshot via GET', async () => {
+ const mockBuffer = Buffer.from('fake-screenshot-data')
+ mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' })
+
+ const req = createMockRequest({ url: "https://example.com", css: "body { background: red !important }" }, { method: 'GET' })
+ const res = createMockResponse()
+ const handler = screenshotRouter.stack.find(layer => layer.route?.methods.get)?.route.stack[0].handle
+ await handler(req, res, vi.fn())
+
+ expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({ css: "body { background: red !important }" }))
+ })
+
+ it('should return 400 when css exceeds 5000 characters', async () => {
+ const longCss = 'a'.repeat(5001)
+ const req = createMockRequest({ url: "https://example.com", css: longCss })
+ const res = createMockResponse()
+ const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
+ await handler(req, res, vi.fn())
+
+ expect(res.status).toHaveBeenCalledWith(400)
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('css') }))
+ })
+
+ it('should accept css at exactly 5000 characters', async () => {
+ const mockBuffer = Buffer.from('fake-screenshot-data')
+ mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' })
+
+ const exactCss = 'a'.repeat(5000)
+ const req = createMockRequest({ url: "https://example.com", css: exactCss })
+ const res = createMockResponse()
+ const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
+ await handler(req, res, vi.fn())
+
+ expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({ css: exactCss }))
+ })
+
+ it('should work alongside darkMode and hideSelectors', async () => {
+ const mockBuffer = Buffer.from('fake-screenshot-data')
+ mockTakeScreenshot.mockResolvedValueOnce({ buffer: mockBuffer, contentType: 'image/png' })
+
+ const req = createMockRequest({
+ url: "https://example.com",
+ css: "body { font-family: Arial }",
+ darkMode: true,
+ hideSelectors: [".ad"]
+ })
+ const res = createMockResponse()
+ const handler = screenshotRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[0].handle
+ await handler(req, res, vi.fn())
+
+ expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
+ css: "body { font-family: Arial }",
+ darkMode: true,
+ hideSelectors: [".ad"]
+ }))
+ })
+ })
+
describe('Parameter normalization', () => {
it('should parse integer parameters correctly', async () => {
const mockBuffer = Buffer.from('fake-screenshot-data')
diff --git a/src/routes/screenshot.ts b/src/routes/screenshot.ts
index ef246b6..bc167bb 100644
--- a/src/routes/screenshot.ts
+++ b/src/routes/screenshot.ts
@@ -75,6 +75,11 @@ export const screenshotRouter = Router();
* 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
@@ -234,6 +239,13 @@ export const screenshotRouter = Router();
* 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:
@@ -318,6 +330,7 @@ async function handleScreenshotRequest(req: any, res: any) {
cache,
darkMode,
hideSelectors,
+ css,
} = source;
if (!url || typeof url !== "string") {
@@ -325,6 +338,12 @@ async function handleScreenshotRequest(req: any, res: any) {
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) {
@@ -362,6 +381,7 @@ async function handleScreenshotRequest(req: any, res: any) {
cache,
darkMode: darkMode === true || darkMode === "true",
hideSelectors: normalizedHideSelectors,
+ css: css || undefined,
};
try {
diff --git a/src/services/__tests__/screenshot.test.ts b/src/services/__tests__/screenshot.test.ts
index d7c3b67..8fe0f45 100644
--- a/src/services/__tests__/screenshot.test.ts
+++ b/src/services/__tests__/screenshot.test.ts
@@ -240,6 +240,48 @@ describe('Screenshot Service', () => {
})
})
+ describe('css parameter', () => {
+ it('injects custom CSS via addStyleTag', async () => {
+ await takeScreenshot({ url: 'https://example.com', css: 'body { background: red !important }' })
+ expect(mockPage.addStyleTag).toHaveBeenCalledWith({
+ content: 'body { background: red !important }'
+ })
+ })
+
+ it('injects css after goto and waitForSelector', async () => {
+ const callOrder: string[] = []
+ mockPage.goto.mockImplementation(async () => { callOrder.push('goto') })
+ mockPage.waitForSelector.mockImplementation(async () => { callOrder.push('waitForSelector') })
+ mockPage.addStyleTag.mockImplementation(async () => { callOrder.push('addStyleTag') })
+ await takeScreenshot({ url: 'https://example.com', css: 'body { color: blue }', waitForSelector: '#main' })
+ expect(callOrder.indexOf('goto')).toBeLessThan(callOrder.indexOf('addStyleTag'))
+ expect(callOrder.indexOf('waitForSelector')).toBeLessThan(callOrder.indexOf('addStyleTag'))
+ })
+
+ it('works alongside hideSelectors', async () => {
+ await takeScreenshot({ url: 'https://example.com', css: 'body { color: blue }', hideSelectors: ['.ad'] })
+ // Both should result in addStyleTag calls
+ expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: 'body { color: blue }' })
+ expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: '.ad { display: none !important }' })
+ })
+
+ it('works alongside darkMode', async () => {
+ await takeScreenshot({ url: 'https://example.com', css: 'h1 { font-size: 48px }', darkMode: true })
+ expect(mockPage.emulateMediaFeatures).toHaveBeenCalledWith([{ name: 'prefers-color-scheme', value: 'dark' }])
+ expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: 'h1 { font-size: 48px }' })
+ })
+
+ it('does not inject style tag when css is not set', async () => {
+ await takeScreenshot({ url: 'https://example.com' })
+ expect(mockPage.addStyleTag).not.toHaveBeenCalled()
+ })
+
+ it('does not inject style tag when css is empty string', async () => {
+ await takeScreenshot({ url: 'https://example.com', css: '' })
+ expect(mockPage.addStyleTag).not.toHaveBeenCalled()
+ })
+ })
+
describe('Page lifecycle', () => {
it('always releases page after successful screenshot', async () => {
await takeScreenshot({ url: 'https://example.com' })
diff --git a/src/services/screenshot.ts b/src/services/screenshot.ts
index abbb1e8..2894118 100644
--- a/src/services/screenshot.ts
+++ b/src/services/screenshot.ts
@@ -16,6 +16,7 @@ export interface ScreenshotOptions {
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
darkMode?: boolean;
hideSelectors?: string[];
+ css?: string;
}
export interface ScreenshotResult {
@@ -60,6 +61,10 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise setTimeout(r, Math.min(opts.delay!, 5000)));
}
+ if (opts.css) {
+ await page.addStyleTag({ content: opts.css });
+ }
+
if (opts.hideSelectors && opts.hideSelectors.length > 0) {
await page.addStyleTag({
content: opts.hideSelectors.map(s => s + ' { display: none !important }').join('\n')