feat: add css parameter for custom CSS injection in screenshots
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m33s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m33s
This commit is contained in:
parent
1b7251fbcb
commit
0999474fbd
8 changed files with 176 additions and 17 deletions
|
|
@ -134,6 +134,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
<div class="release-body">
|
||||
<ul>
|
||||
<li>✨ New: <code>css</code> parameter — inject custom CSS into the page before capture (max 5000 chars). Perfect for custom fonts, color overrides, or complex layout adjustments</li>
|
||||
<li>✨ New: <code>darkMode</code> parameter — emulate prefers-color-scheme: dark for dark mode screenshots</li>
|
||||
<li>✨ New: <code>hideSelectors</code> parameter — hide elements by CSS selector before capture (max 10, 200 chars each)</li>
|
||||
<li>🔒 Fixed: Cancelled subscriptions now properly blocked (was incorrectly getting free tier)</li>
|
||||
|
|
@ -141,7 +142,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<li>🛡️ Added: Rate limiting on billing endpoints (10 req/15min)</li>
|
||||
<li>🐛 Fixed: FAQ accordion double-toggle</li>
|
||||
<li>🐛 Fixed: Privacy/Terms/Impressum 404s on extensionless URLs</li>
|
||||
<li>📊 360 tests passing</li>
|
||||
<li>📊 366 tests passing</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -301,6 +301,12 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<span class="flag">-H</span> <span class="str">"Authorization: Bearer YOUR_API_KEY"</span> \
|
||||
<span class="flag">-H</span> <span class="str">"Content-Type: application/json"</span> \
|
||||
<span class="flag">-d</span> <span class="str">'{"url":"https://example.com","hideSelectors":["#cookie-banner",".popup"]}'</span>
|
||||
|
||||
<span class="cmt"># Inject custom CSS</span>
|
||||
<span class="kw">curl</span> <span class="flag">-X POST</span> <span class="url">https://snapapi.eu/v1/screenshot</span> \
|
||||
<span class="flag">-H</span> <span class="str">"Authorization: Bearer YOUR_API_KEY"</span> \
|
||||
<span class="flag">-H</span> <span class="str">"Content-Type: application/json"</span> \
|
||||
<span class="flag">-d</span> <span class="str">'{"url":"https://example.com","css":"body { background: #1a1a2e !important }"}'</span>
|
||||
</div>
|
||||
<div class="code-body" id="code-get" style="display:none">
|
||||
<span class="cmt"># GET request with query parameters</span>
|
||||
|
|
@ -327,6 +333,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<span class="prop">fullPage</span>: <span class="kw">true</span>,
|
||||
<span class="prop">darkMode</span>: <span class="kw">true</span>,
|
||||
<span class="prop">hideSelectors</span>: [<span class="str">'#cookie-banner'</span>, <span class="str">'.popup'</span>],
|
||||
<span class="prop">css</span>: <span class="str">'body { background: #1a1a2e !important }'</span>,
|
||||
});
|
||||
</div>
|
||||
<div class="code-body" id="code-python" style="display:none">
|
||||
|
|
@ -342,6 +349,7 @@ screenshot = snap.<span class="fn">capture</span>(
|
|||
<span class="prop">full_page</span>=<span class="kw">True</span>,
|
||||
<span class="prop">dark_mode</span>=<span class="kw">True</span>,
|
||||
<span class="prop">hide_selectors</span>=[<span class="str">"#cookie-banner"</span>, <span class="str">".popup"</span>],
|
||||
<span class="prop">css</span>=<span class="str">"body { background: #1a1a2e !important }"</span>,
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<Buffer>` 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()`
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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<Screensho
|
|||
await new Promise(r => 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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue