From 0999474fbdc852a8cd1d256f59b362634e5b0bf0 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Wed, 4 Mar 2026 21:06:50 +0100 Subject: [PATCH] feat: add css parameter for custom CSS injection in screenshots --- public/changelog.html | 3 +- public/index.html | 8 +++ sdk/node/README.md | 21 ++++--- sdk/python/README.md | 21 ++++--- src/routes/__tests__/screenshot.test.ts | 73 +++++++++++++++++++++++ src/routes/screenshot.ts | 20 +++++++ src/services/__tests__/screenshot.test.ts | 42 +++++++++++++ src/services/screenshot.ts | 5 ++ 8 files changed, 176 insertions(+), 17 deletions(-) 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(
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 }"}' 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')