Compare commits
56 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
187f0fd4be | ||
|
|
126490feca | ||
| e11ae1e074 | |||
| af7637027e | |||
|
|
e7ef9d74c4 | ||
|
|
990b6d4f95 | ||
|
|
93dec9765f | ||
| fde5aea324 | |||
| 8a36826e35 | |||
|
|
65d2fd38cc | ||
|
|
4f4139c47e | ||
|
|
3e9336ae67 | ||
|
|
9290c759da | ||
|
|
a17f492cc3 | ||
|
|
c38f702dfa | ||
|
|
f1d63cdc66 | ||
|
|
91a08bab70 | ||
|
|
ba888bb580 | ||
| 0999474fbd | |||
|
|
1b7251fbcb | ||
|
|
e6c34ef760 | ||
|
|
28f4a93dc3 | ||
|
|
90c1e7da44 | ||
| 96d21aa63b | |||
| 9575d312fe | |||
| f3a363fb17 | |||
| 740c70f905 | |||
| 05c91e6747 | |||
| 9fe59d4867 | |||
| e04d0bb283 | |||
| e240d9e30d | |||
| 5137b80a2a | |||
| 01c214e054 | |||
| 56c7a87f3c | |||
| 9609501d7b | |||
| 9d1170fb9a | |||
| e9ee3a6c2c | |||
|
|
195a656a7d | ||
| dfd410f842 | |||
| 2eca4e700b | |||
| 5b59a7a010 | |||
|
|
b2688c0cce | ||
|
|
c32436631a | ||
|
|
a20828b09c | ||
| f696cb36db | |||
| c3dabc2ac6 | |||
| cda259a3c6 | |||
| b07b9cfd25 | |||
| 5ec8c92413 | |||
|
|
44e31e355c | ||
| 609e7d0808 | |||
| 253d03f58a | |||
| 66ecc471cf | |||
|
|
db1fa8d506 | ||
| d20fbbfe2e | |||
| de1215bc32 |
85 changed files with 15836 additions and 221 deletions
|
|
@ -6,39 +6,39 @@ on:
|
|||
|
||||
jobs:
|
||||
promote:
|
||||
name: Promote to Production
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
|
||||
- name: Login to Forgejo Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.cloonar.com
|
||||
username: openclawd
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Build and Push Production
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
no-cache: true
|
||||
tags: |
|
||||
git.cloonar.com/openclawd/snapapi:prod
|
||||
|
||||
- name: Retag staging image for production
|
||||
run: |
|
||||
# Pull the image that staging already built and tested
|
||||
docker pull --platform linux/arm64 git.cloonar.com/openclawd/snapapi:latest
|
||||
docker tag git.cloonar.com/openclawd/snapapi:latest \
|
||||
git.cloonar.com/openclawd/snapapi:${{ github.ref_name }}
|
||||
platforms: linux/arm64
|
||||
docker push git.cloonar.com/openclawd/snapapi:${{ github.ref_name }}
|
||||
|
||||
- name: Deploy to Production
|
||||
run: |
|
||||
curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
echo "${{ secrets.KUBECONFIG }}" | base64 -d > /tmp/kubeconfig.yaml
|
||||
|
||||
./kubectl set image deployment/snapapi \
|
||||
snapapi=git.cloonar.com/openclawd/snapapi:${{ github.ref_name }} \
|
||||
-n snapapi --kubeconfig=/tmp/kubeconfig.yaml
|
||||
|
||||
./kubectl rollout status deployment/snapapi \
|
||||
-n snapapi --kubeconfig=/tmp/kubeconfig.yaml --timeout=180s
|
||||
echo "✅ Production deploy complete!"
|
||||
|
||||
echo "✅ Production deploy complete! Version: ${{ github.ref_name }}"
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -34,4 +34,4 @@ Thumbs.db
|
|||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
.cache/*.pyc
|
||||
|
|
|
|||
1334
package-lock.json
generated
1334
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -1,13 +1,16 @@
|
|||
{
|
||||
"name": "snapapi",
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.0",
|
||||
"description": "URL to Screenshot API — PNG, JPEG, WebP via simple REST API",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"compression": "^1.8.1",
|
||||
|
|
@ -26,8 +29,12 @@
|
|||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.0.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,9 +26,14 @@ nav{padding:16px 24px;border-bottom:1px solid var(--border);display:flex;align-i
|
|||
.btn-secondary{border:1px solid var(--border);color:var(--text-secondary)}
|
||||
.btn-secondary:hover{border-color:var(--primary);color:var(--text)}
|
||||
footer{padding:24px;text-align:center;color:var(--text-secondary);font-size:.8rem;border-top:1px solid var(--border)}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/" class="logo">📸 SnapAPI</a>
|
||||
<div class="nav-links">
|
||||
|
|
@ -37,7 +42,8 @@ footer{padding:24px;text-align:center;color:var(--text-secondary);font-size:.8re
|
|||
<a href="/#pricing">Pricing</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
</header>
|
||||
<main id="main-content" class="content">
|
||||
<div>
|
||||
<div class="error-code">404</div>
|
||||
<h1 class="error-title">Page Not Found</h1>
|
||||
|
|
|
|||
160
public/blog.html
Normal file
160
public/blog.html
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Blog — SnapAPI Developer Blog</title>
|
||||
<meta name="description" content="Technical articles about screenshot APIs, web rendering, performance optimization, and building developer tools. From the SnapAPI engineering team.">
|
||||
<link rel="canonical" href="https://snapapi.eu/blog">
|
||||
<meta property="og:title" content="Blog — SnapAPI Developer Blog">
|
||||
<meta property="og:description" content="Technical articles about screenshot APIs, web rendering, performance optimization, and building developer tools.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://snapapi.eu/blog">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Blog — SnapAPI Developer Blog">
|
||||
<meta name="twitter:description" content="Technical articles about screenshot APIs, web rendering, and performance.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;--border:#1e2a3f;--border-light:#2a3752;--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);--accent:#10b981;--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);--radius:12px;--radius-lg:16px}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
.container{max-width:900px;margin:0 auto;padding:0 24px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);color:#fff}
|
||||
.btn-sm{padding:8px 18px;font-size:.85rem}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
@media(max-width:768px){.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}.nav-links.show{display:flex}.nav-mobile{display:block}}
|
||||
|
||||
.page-header{padding:80px 0 40px;text-align:center}
|
||||
.page-header h1{font-size:3rem;font-weight:900;margin-bottom:16px}
|
||||
.page-header p{font-size:1.15rem;color:var(--text-secondary);max-width:600px;margin:0 auto}
|
||||
|
||||
.blog-grid{display:grid;gap:32px;padding:0 0 100px}
|
||||
.blog-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:36px;transition:all .2s}
|
||||
.blog-card:hover{background:var(--card-hover);border-color:var(--border-light);transform:translateY(-2px)}
|
||||
.blog-card .meta{font-size:.82rem;color:var(--muted);margin-bottom:12px;display:flex;gap:16px}
|
||||
.blog-card h2{font-size:1.5rem;font-weight:700;margin-bottom:12px}
|
||||
.blog-card h2 a{color:var(--text)}
|
||||
.blog-card h2 a:hover{color:var(--primary-light)}
|
||||
.blog-card p{color:var(--text-secondary);line-height:1.7;margin-bottom:16px}
|
||||
.blog-card .read-more{font-weight:600;font-size:.9rem;color:var(--primary-light)}
|
||||
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:768px){.footer-grid{grid-template-columns:1fr}}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Blog","name":"SnapAPI Developer Blog","description":"Technical articles about screenshot APIs, web rendering, and performance optimization.","url":"https://snapapi.eu/blog","publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"}}</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1>Developer Blog</h1>
|
||||
<p>Technical deep-dives on screenshot APIs, web rendering, caching strategies, and building reliable developer tools.</p>
|
||||
</div>
|
||||
|
||||
<div class="blog-grid">
|
||||
<article class="blog-card">
|
||||
<div class="meta"><span>March 6, 2026</span><span>6 min read</span></div>
|
||||
<h2><a href="/blog/dark-mode-screenshots">How to Capture Dark Mode Screenshots Automatically</a></h2>
|
||||
<p>Dark mode isn't a trend anymore — it's the default. Learn how to capture both light and dark screenshots with a single API parameter, inject custom dark themes, and generate dual OG images for social media.</p>
|
||||
<a href="/blog/dark-mode-screenshots" class="read-more">Read article →</a>
|
||||
</article>
|
||||
|
||||
<article class="blog-card">
|
||||
<div class="meta"><span>March 3, 2026</span><span>7 min read</span></div>
|
||||
<h2><a href="/blog/automating-og-images">Automating OG Image Generation with Screenshot APIs</a></h2>
|
||||
<p>Stop designing Open Graph images by hand. Learn how to use screenshot APIs to automatically generate beautiful, dynamic OG images for every page on your site.</p>
|
||||
<a href="/blog/automating-og-images" class="read-more">Read article →</a>
|
||||
</article>
|
||||
|
||||
<article class="blog-card">
|
||||
<div class="meta"><span>March 2, 2026</span><span>8 min read</span></div>
|
||||
<h2><a href="/blog/why-screenshot-api">Why You Need a Screenshot API (And Why Building Your Own Is Harder Than You Think)</a></h2>
|
||||
<p>Self-hosting Puppeteer sounds easy until you're debugging zombie Chrome processes at 3 AM. Here's why dedicated screenshot APIs exist and when they make sense for your project.</p>
|
||||
<a href="/blog/why-screenshot-api" class="read-more">Read article →</a>
|
||||
</article>
|
||||
|
||||
<article class="blog-card">
|
||||
<div class="meta"><span>March 2, 2026</span><span>5 min read</span></div>
|
||||
<h2><a href="/blog/screenshot-api-performance">Screenshot API Performance: Caching Strategies That Actually Work</a></h2>
|
||||
<p>How we reduced p95 response times by 10x with intelligent caching, CDN integration, and browser pool management. A practical guide to making screenshot APIs fast.</p>
|
||||
<a href="/blog/screenshot-api-performance" class="read-more">Read article →</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<h4>📸 SnapAPI</h4>
|
||||
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Product</h5>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Developers</h5>
|
||||
<a href="/docs">Swagger / OpenAPI</a>
|
||||
<a href="/guides/quick-start">Quick Start</a>
|
||||
<a href="/changelog">Changelog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="/impressum.html">Impressum</a>
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
284
public/blog/automating-og-images.html
Normal file
284
public/blog/automating-og-images.html
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Automating OG Image Generation with Screenshot APIs — SnapAPI Blog</title>
|
||||
<meta name="description" content="Stop designing Open Graph images by hand. Learn how to use screenshot APIs to automatically generate beautiful, dynamic OG images for every page on your site.">
|
||||
<link rel="canonical" href="https://snapapi.eu/blog/automating-og-images">
|
||||
<meta property="og:title" content="Automating OG Image Generation with Screenshot APIs">
|
||||
<meta property="og:description" content="Stop designing OG images by hand. Use screenshot APIs to generate dynamic social preview images automatically.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://snapapi.eu/blog/automating-og-images">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Automating OG Image Generation with Screenshot APIs">
|
||||
<meta name="twitter:description" content="How to use screenshot APIs to automatically generate beautiful OG images for every page on your site.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;--border:#1e2a3f;--border-light:#2a3752;--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--accent:#10b981;--purple:#a78bfa;--orange:#f59e0b;--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);--radius:12px;--radius-lg:16px}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);color:#fff}
|
||||
.btn-sm{padding:8px 18px;font-size:.85rem}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
@media(max-width:768px){.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}.nav-links.show{display:flex}.nav-mobile{display:block}}
|
||||
|
||||
.article{max-width:740px;margin:0 auto;padding:80px 24px 100px}
|
||||
.article .breadcrumb{font-size:.85rem;color:var(--muted);margin-bottom:32px}
|
||||
.article .breadcrumb a{color:var(--muted)}
|
||||
.article .breadcrumb a:hover{color:var(--text)}
|
||||
.article h1{font-size:2.5rem;font-weight:900;line-height:1.2;margin-bottom:16px}
|
||||
.article .meta{font-size:.85rem;color:var(--muted);margin-bottom:48px;display:flex;gap:16px}
|
||||
.article h2{font-size:1.5rem;font-weight:700;margin:48px 0 16px;color:var(--text)}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px;color:var(--text)}
|
||||
.article p{color:var(--text-secondary);line-height:1.8;margin-bottom:20px;font-size:1.05rem}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 20px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.article code{font-family:'JetBrains Mono',monospace;background:var(--card);padding:2px 8px;border-radius:4px;font-size:.9rem;color:var(--primary-light)}
|
||||
.article pre{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin:0 0 24px;overflow-x:auto}
|
||||
.article pre code{background:none;padding:0;font-size:.85rem;line-height:1.7;color:var(--text-secondary)}
|
||||
.article blockquote{border-left:3px solid var(--primary);padding:16px 24px;margin:0 0 24px;background:var(--card);border-radius:0 var(--radius) var(--radius) 0}
|
||||
.article blockquote p{margin:0;color:var(--text-secondary);font-style:italic}
|
||||
.article .cta-box{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;margin:48px 0;text-align:center}
|
||||
.article .cta-box h3{margin-top:0;font-size:1.3rem}
|
||||
.article .cta-box p{margin-bottom:20px}
|
||||
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:768px){.footer-grid{grid-template-columns:1fr}}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"Automating OG Image Generation with Screenshot APIs","description":"Stop designing Open Graph images by hand. Learn how to use screenshot APIs to automatically generate beautiful, dynamic OG images for every page on your site.","datePublished":"2026-03-03","author":{"@type":"Organization","name":"SnapAPI"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"url":"https://snapapi.eu/blog/automating-og-images","mainEntityOfPage":"https://snapapi.eu/blog/automating-og-images"}</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<article class="article">
|
||||
<div class="breadcrumb"><a href="/">Home</a> / <a href="/blog">Blog</a> / Automating OG Images</div>
|
||||
|
||||
<h1>Automating OG Image Generation with Screenshot APIs</h1>
|
||||
<div class="meta"><span>March 3, 2026</span><span>7 min read</span></div>
|
||||
|
||||
<p>You've just published a new blog post. You share it on Twitter, LinkedIn, and Slack. But instead of a rich preview with a beautiful image, your link shows up as a sad little text snippet — or worse, a broken image placeholder. Sound familiar?</p>
|
||||
|
||||
<p>Open Graph (OG) images are the social preview cards that appear when someone shares your URL. They're one of those things that seem trivial until you realize they directly impact click-through rates. Posts with compelling preview images get <strong>2-3x more engagement</strong> than plain text links. Yet most developers either skip them entirely or spend hours manually designing them in Figma for each page.</p>
|
||||
|
||||
<p>There's a better way: use a screenshot API to generate OG images automatically, on the fly, for every single page on your site.</p>
|
||||
|
||||
<h2>The OG Image Problem</h2>
|
||||
|
||||
<p>Let's start with why this is harder than it should be. The Open Graph protocol is simple — you add a few meta tags to your HTML, including <code>og:image</code>, and social platforms use that image as a preview. The challenge isn't the protocol. It's producing the images themselves.</p>
|
||||
|
||||
<p>The traditional approaches all have significant drawbacks:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Manual design:</strong> Create each image by hand in Figma or Canva. Looks great, doesn't scale. You'll stop doing it after the fifth blog post.</li>
|
||||
<li><strong>Static templates:</strong> Use the same generic image for everything. Low effort, but also low engagement — every link looks identical.</li>
|
||||
<li><strong>Canvas/SVG generation:</strong> Write code to programmatically compose images using node-canvas, Sharp, or SVG rendering. Works, but you're essentially building a graphics engine. Text wrapping, font rendering, and layout calculations become your problem.</li>
|
||||
<li><strong>Self-hosted Puppeteer:</strong> Spin up a headless browser to screenshot an HTML template. Powerful, but now you're managing Chrome instances in production — the exact problem we discussed in our <a href="/blog/why-screenshot-api">previous article</a>.</li>
|
||||
</ul>
|
||||
|
||||
<p>Each approach trades off between quality, scalability, and engineering effort. What if you could get all three?</p>
|
||||
|
||||
<h2>The Screenshot API Approach</h2>
|
||||
|
||||
<p>The idea is elegant in its simplicity: design your OG image as an HTML page, then use a screenshot API to render it as a PNG. You get the full power of CSS for layout and styling, web fonts for typography, and zero infrastructure to maintain.</p>
|
||||
|
||||
<p>Here's the workflow:</p>
|
||||
|
||||
<ol>
|
||||
<li>Create an HTML template for your OG images (a simple page with your branding, title, and any dynamic content)</li>
|
||||
<li>Host that template at a URL, passing page-specific data via query parameters</li>
|
||||
<li>Call a screenshot API to capture it at 1200×630 pixels (the standard OG image size)</li>
|
||||
<li>Set the resulting image URL as your <code>og:image</code> meta tag</li>
|
||||
</ol>
|
||||
|
||||
<h3>The HTML Template</h3>
|
||||
|
||||
<p>Your OG image template is just HTML and CSS. This means you can use flexbox for layout, Google Fonts for typography, gradients, shadows — anything the browser can render. Here's a minimal example:</p>
|
||||
|
||||
<pre><code><div style="width:1200px;height:630px;display:flex;
|
||||
align-items:center;justify-content:center;
|
||||
background:linear-gradient(135deg,#667eea,#764ba2);
|
||||
font-family:'Inter',sans-serif;padding:60px">
|
||||
<h1 style="color:#fff;font-size:56px;
|
||||
text-align:center;line-height:1.2">
|
||||
{{title}}
|
||||
</h1>
|
||||
</div></code></pre>
|
||||
|
||||
<p>Replace <code>{{title}}</code> with your page title dynamically — either server-side or via query parameters — and you have a unique OG image for every page.</p>
|
||||
|
||||
<h3>Calling the API</h3>
|
||||
|
||||
<p>With SnapAPI, generating the OG image is a single API call:</p>
|
||||
|
||||
<pre><code>curl "https://api.snapapi.eu/v1/screenshot?url=https://yoursite.com/og-template?title=My+Blog+Post&width=1200&height=630&format=png" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
--output og-image.png</code></pre>
|
||||
|
||||
<p>That's it. The API launches a browser, renders your template, captures it at exactly 1200×630, and returns a pixel-perfect PNG. No Chrome processes to manage, no memory leaks to debug, no zombie browsers consuming your server's RAM.</p>
|
||||
|
||||
<h2>Static vs. Dynamic Generation</h2>
|
||||
|
||||
<p>There are two strategies for integrating OG images into your site, and the right choice depends on your content velocity and caching requirements.</p>
|
||||
|
||||
<h3>Build-Time Generation (Static)</h3>
|
||||
|
||||
<p>Generate all OG images during your build step and serve them as static files. This works well for blogs and documentation sites where content changes infrequently. Your CI pipeline calls the screenshot API for each page, saves the PNGs to your public directory, and deploys them alongside your HTML.</p>
|
||||
|
||||
<p>The advantage is zero runtime dependencies — your OG images are just static files on a CDN. The downside is that adding or updating content requires a rebuild.</p>
|
||||
|
||||
<h3>On-Demand Generation (Dynamic)</h3>
|
||||
|
||||
<p>Generate OG images on the fly when they're first requested, then cache them aggressively. This is ideal for sites with user-generated content, e-commerce product pages, or any scenario where you can't enumerate all pages at build time.</p>
|
||||
|
||||
<p>A typical implementation uses a serverless function or edge worker as a proxy:</p>
|
||||
|
||||
<pre><code>// /api/og-image.ts (Edge Function)
|
||||
export default async function handler(req) {
|
||||
const { title, subtitle } = new URL(req.url).searchParams;
|
||||
const templateUrl = `https://yoursite.com/og?title=${title}`;
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.snapapi.eu/v1/screenshot?` +
|
||||
`url=${encodeURIComponent(templateUrl)}` +
|
||||
`&width=1200&height=630&format=png`,
|
||||
{ headers: { "X-API-Key": process.env.SNAPAPI_KEY } }
|
||||
);
|
||||
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=86400, s-maxage=604800",
|
||||
},
|
||||
});
|
||||
}</code></pre>
|
||||
|
||||
<p>With proper cache headers, each OG image is generated exactly once and then served from the CDN for subsequent requests. Your <code>og:image</code> tag simply points to <code>/api/og-image?title=My+Page+Title</code>.</p>
|
||||
|
||||
<h2>Design Tips for Better OG Images</h2>
|
||||
|
||||
<p>Since you're designing with HTML and CSS, you have enormous creative freedom. But social preview images have specific constraints worth respecting:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Size matters:</strong> Always render at 1200×630 pixels. This is the standard that works across Twitter, LinkedIn, Facebook, and Slack. Smaller images get upscaled and look blurry.</li>
|
||||
<li><strong>Keep text large:</strong> Your title should be at least 48px. Remember, these images are often displayed at small sizes on mobile feeds. If you can't read the text at 300px wide, it's too small.</li>
|
||||
<li><strong>Brand consistency:</strong> Include your logo and use your brand colors. OG images are branding real estate — make every share reinforce your visual identity.</li>
|
||||
<li><strong>Contrast is king:</strong> Social feeds are visually noisy. High contrast between text and background ensures your preview stands out. Dark backgrounds with light text tend to perform well.</li>
|
||||
<li><strong>Avoid text near edges:</strong> Some platforms crop OG images slightly. Keep all important content within 90% of the image area.</li>
|
||||
<li><strong>Test everywhere:</strong> Use tools like <a href="https://www.opengraph.xyz/" rel="noopener">opengraph.xyz</a> to preview how your OG images render across different platforms before shipping.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Performance Considerations</h2>
|
||||
|
||||
<p>OG images don't need to be fast for end users — they're fetched by social platform crawlers, not by browsers loading your page. That said, there are performance aspects worth considering:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Cache aggressively:</strong> Set long <code>Cache-Control</code> headers. Social platforms cache OG images themselves, but your CDN should too. A week or more is typical.</li>
|
||||
<li><strong>Use PNG for text-heavy images:</strong> PNG preserves sharp text better than JPEG. The file sizes are larger, but since these images are fetched by crawlers (not loaded on every page view), it's an acceptable tradeoff.</li>
|
||||
<li><strong>Pre-generate when possible:</strong> For content you know about at build time, generate OG images during the build. Save the runtime generation for truly dynamic content.</li>
|
||||
<li><strong>Monitor your API usage:</strong> Each unique OG image is one API call. If you have 1,000 blog posts and rebuild nightly, that's 1,000 calls per day. Most screenshot APIs (including SnapAPI) offer generous free tiers and predictable per-screenshot pricing, but it's worth tracking.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Beyond Social Previews</h2>
|
||||
|
||||
<p>Once you've set up the infrastructure for OG images, you'll find other uses for dynamically generated images. The same template approach works for:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Email headers:</strong> Personalized banner images for marketing emails</li>
|
||||
<li><strong>Certificate generation:</strong> Course completion certificates with the student's name rendered beautifully</li>
|
||||
<li><strong>Invoice previews:</strong> Thumbnail previews of invoices in dashboard UIs</li>
|
||||
<li><strong>Documentation screenshots:</strong> Auto-generated screenshots of your own UI for docs that stay current</li>
|
||||
</ul>
|
||||
|
||||
<p>The pattern is always the same: design it in HTML, screenshot it via API, serve the result. HTML becomes your universal image template language.</p>
|
||||
|
||||
<h2>Conclusion</h2>
|
||||
|
||||
<p>OG images shouldn't be an afterthought, and they shouldn't require a design team to maintain. By combining HTML templates with a screenshot API, you get the visual quality of hand-designed images with the scalability of automation. Every page gets a unique, branded social preview — automatically.</p>
|
||||
|
||||
<p>The best part? Your OG image templates are just code. They live in version control, they're easy to iterate on, and they update everywhere when you change your branding. No more stale Figma exports, no more missing preview images, no more sad text-only link shares.</p>
|
||||
|
||||
<p>Start with a simple template, wire it up to a screenshot API, add caching, and you're done. Your links will never look naked again.</p>
|
||||
|
||||
<div class="cta-box">
|
||||
<h3>Generate OG Images with SnapAPI</h3>
|
||||
<p>100 free screenshots to get started. Pixel-perfect rendering, EU-hosted, and ready in under a minute. Perfect for automated OG image generation.</p>
|
||||
<a href="/#playground" class="btn btn-primary">Try the Playground →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<h4>📸 SnapAPI</h4>
|
||||
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Product</h5>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Developers</h5>
|
||||
<a href="/docs">Swagger / OpenAPI</a>
|
||||
<a href="/guides/quick-start">Quick Start</a>
|
||||
<a href="/changelog">Changelog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="/impressum.html">Impressum</a>
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
274
public/blog/dark-mode-screenshots.html
Normal file
274
public/blog/dark-mode-screenshots.html
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>How to Capture Dark Mode Screenshots Automatically — SnapAPI Blog</title>
|
||||
<meta name="description" content="Learn how to capture dark mode screenshots with a single API parameter. Generate both light and dark screenshots for testing, monitoring, and social media previews.">
|
||||
<link rel="canonical" href="https://snapapi.eu/blog/dark-mode-screenshots">
|
||||
<meta property="og:title" content="How to Capture Dark Mode Screenshots Automatically">
|
||||
<meta property="og:description" content="Capture dark mode screenshots with a single API parameter. Perfect for testing, monitoring, and generating social media previews in both themes.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://snapapi.eu/blog/dark-mode-screenshots">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="How to Capture Dark Mode Screenshots Automatically">
|
||||
<meta name="twitter:description" content="Capture dark mode screenshots with one API parameter. Test both themes, generate dual OG images.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;--border:#1e2a3f;--border-light:#2a3752;--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--accent:#10b981;--purple:#a78bfa;--orange:#f59e0b;--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);--radius:12px;--radius-lg:16px}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);color:#fff}
|
||||
.btn-sm{padding:8px 18px;font-size:.85rem}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
@media(max-width:768px){.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}.nav-links.show{display:flex}.nav-mobile{display:block}}
|
||||
|
||||
.article{max-width:740px;margin:0 auto;padding:80px 24px 100px}
|
||||
.article .breadcrumb{font-size:.85rem;color:var(--muted);margin-bottom:32px}
|
||||
.article .breadcrumb a{color:var(--muted)}
|
||||
.article .breadcrumb a:hover{color:var(--text)}
|
||||
.article h1{font-size:2.5rem;font-weight:900;line-height:1.2;margin-bottom:16px}
|
||||
.article .meta{font-size:.85rem;color:var(--muted);margin-bottom:48px;display:flex;gap:16px}
|
||||
.article h2{font-size:1.5rem;font-weight:700;margin:48px 0 16px;color:var(--text)}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px;color:var(--text)}
|
||||
.article p{color:var(--text-secondary);line-height:1.8;margin-bottom:20px;font-size:1.05rem}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 20px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.article code{font-family:'JetBrains Mono',monospace;background:var(--card);padding:2px 8px;border-radius:4px;font-size:.9rem;color:var(--primary-light)}
|
||||
.article pre{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin:0 0 24px;overflow-x:auto}
|
||||
.article pre code{background:none;padding:0;font-size:.85rem;line-height:1.7;color:var(--text-secondary)}
|
||||
.article blockquote{border-left:3px solid var(--primary);padding:16px 24px;margin:0 0 24px;background:var(--card);border-radius:0 var(--radius) var(--radius) 0}
|
||||
.article blockquote p{margin:0;color:var(--text-secondary);font-style:italic}
|
||||
.article .cta-box{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;margin:48px 0;text-align:center}
|
||||
.article .cta-box h3{margin-top:0;font-size:1.3rem}
|
||||
.article .cta-box p{margin-bottom:20px}
|
||||
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:768px){.footer-grid{grid-template-columns:1fr}}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"How to Capture Dark Mode Screenshots Automatically","description":"Learn how to capture dark mode screenshots with a single API parameter. Generate both light and dark screenshots for testing and social media.","datePublished":"2026-03-06","author":{"@type":"Organization","name":"SnapAPI"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"url":"https://snapapi.eu/blog/dark-mode-screenshots","mainEntityOfPage":"https://snapapi.eu/blog/dark-mode-screenshots"}</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<article class="article">
|
||||
<div class="breadcrumb"><a href="/">Home</a> → <a href="/blog">Blog</a> → Dark Mode Screenshots</div>
|
||||
|
||||
<h1>How to Capture Dark Mode Screenshots Automatically</h1>
|
||||
<div class="meta"><span>March 6, 2026</span><span>6 min read</span></div>
|
||||
|
||||
<p>Dark mode isn't a trend anymore — it's the default for most developers and a growing majority of users. Studies show that over 80% of developers work in dark mode, and roughly 70% of smartphone users have enabled it system-wide. If your app or website supports dark mode, you need to test, monitor, and preview both themes.</p>
|
||||
|
||||
<p>But capturing dark mode screenshots programmatically is surprisingly annoying. Until now.</p>
|
||||
|
||||
<h2>The Problem: Dark Mode Is Harder Than It Looks</h2>
|
||||
|
||||
<p>Websites implement dark mode in different ways. Some use the <code>prefers-color-scheme</code> CSS media query. Others toggle a class on the body element via JavaScript. Some use CSS custom properties that switch based on a data attribute. And some use all three simultaneously.</p>
|
||||
|
||||
<p>If you're running a headless browser to take screenshots, you need to handle all of these. With Puppeteer or Playwright, that means:</p>
|
||||
|
||||
<ul>
|
||||
<li>Emulating the <code>prefers-color-scheme: dark</code> media feature</li>
|
||||
<li>Waiting for JavaScript theme toggles to apply</li>
|
||||
<li>Handling race conditions where CSS transitions haven't finished</li>
|
||||
<li>Managing different browser contexts for light vs. dark screenshots</li>
|
||||
</ul>
|
||||
|
||||
<p>That's a lot of boilerplate for what should be a simple toggle.</p>
|
||||
|
||||
<h2>The Solution: One Parameter</h2>
|
||||
|
||||
<p>SnapAPI's <code>darkMode</code> parameter handles all of this for you. Set it to <code>true</code>, and the browser emulates <code>prefers-color-scheme: dark</code> before loading the page. Any site that respects the system preference will render in dark mode automatically.</p>
|
||||
|
||||
<h3>cURL</h3>
|
||||
<pre><code>curl -X POST https://snapapi.eu/v1/screenshot \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://github.com", "darkMode": true}' \
|
||||
--output github-dark.png</code></pre>
|
||||
|
||||
<h3>Node.js</h3>
|
||||
<pre><code>import SnapAPI from 'snapapi';
|
||||
|
||||
const client = new SnapAPI('YOUR_API_KEY');
|
||||
|
||||
// Light mode (default)
|
||||
const light = await client.screenshot({ url: 'https://github.com' });
|
||||
|
||||
// Dark mode — just add darkMode: true
|
||||
const dark = await client.screenshot({
|
||||
url: 'https://github.com',
|
||||
darkMode: true,
|
||||
});
|
||||
|
||||
fs.writeFileSync('github-light.png', light);
|
||||
fs.writeFileSync('github-dark.png', dark);</code></pre>
|
||||
|
||||
<h3>Python</h3>
|
||||
<pre><code>from snapapi import SnapAPI
|
||||
|
||||
client = SnapAPI("YOUR_API_KEY")
|
||||
|
||||
# Capture both themes
|
||||
light = client.screenshot(url="https://github.com")
|
||||
dark = client.screenshot(url="https://github.com", dark_mode=True)
|
||||
|
||||
with open("github-light.png", "wb") as f:
|
||||
f.write(light)
|
||||
with open("github-dark.png", "wb") as f:
|
||||
f.write(dark)</code></pre>
|
||||
|
||||
<h2>Going Further: Custom Dark Themes</h2>
|
||||
|
||||
<p>Not every site has built-in dark mode support. For those cases, you can combine <code>darkMode</code> with SnapAPI's <code>css</code> parameter to inject your own dark theme:</p>
|
||||
|
||||
<pre><code>curl -X POST https://snapapi.eu/v1/screenshot \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"url": "https://example.com",
|
||||
"darkMode": true,
|
||||
"css": "body { background: #1a1a2e !important; color: #eee !important; } a { color: #6da3ff !important; }"
|
||||
}' \
|
||||
--output example-forced-dark.png</code></pre>
|
||||
|
||||
<p>This is useful for generating consistent dark-themed previews across sites that don't natively support it.</p>
|
||||
|
||||
<h2>Cleaning Up Screenshots</h2>
|
||||
|
||||
<p>Real-world screenshots often include cookie banners, notification popups, and chat widgets that clutter the image. Use <code>hideSelectors</code> to remove them before capture:</p>
|
||||
|
||||
<pre><code>{
|
||||
"url": "https://example.com",
|
||||
"darkMode": true,
|
||||
"hideSelectors": ["#cookie-banner", ".chat-widget", ".notification-popup"],
|
||||
"css": "body { overflow: hidden !important; }"
|
||||
}</code></pre>
|
||||
|
||||
<p>Combined with dark mode, this gives you clean, professional screenshots every time.</p>
|
||||
|
||||
<h2>Use Case: Dual OG Images for Social Media</h2>
|
||||
|
||||
<p>One compelling use case is generating both light and dark Open Graph images for your website. Some platforms and apps display previews differently based on the user's system theme. By generating both variants, you can serve the right one via <code>og:image</code> based on context.</p>
|
||||
|
||||
<pre><code>// Generate OG images for all your pages in both themes
|
||||
const pages = ['/about', '/pricing', '/blog'];
|
||||
|
||||
for (const page of pages) {
|
||||
const url = `https://yoursite.com${page}`;
|
||||
|
||||
// Light variant
|
||||
await client.screenshot({
|
||||
url,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
});
|
||||
|
||||
// Dark variant
|
||||
await client.screenshot({
|
||||
url,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
darkMode: true,
|
||||
});
|
||||
}</code></pre>
|
||||
|
||||
<p>Automate this with a cron job or CI pipeline, and your social previews are always up to date — in both themes.</p>
|
||||
|
||||
<h2>When to Use Each Approach</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong><code>darkMode: true</code></strong> — for sites with native <code>prefers-color-scheme</code> support (GitHub, MDN, most modern sites)</li>
|
||||
<li><strong><code>darkMode</code> + <code>css</code></strong> — for forcing a dark look on sites without dark mode support</li>
|
||||
<li><strong><code>hideSelectors</code></strong> — for removing UI clutter regardless of theme</li>
|
||||
<li><strong><code>js</code> parameter</strong> — for triggering JavaScript-based theme toggles before capture</li>
|
||||
</ul>
|
||||
|
||||
<p>All of these parameters work together. You can combine <code>darkMode</code>, <code>css</code>, <code>hideSelectors</code>, and <code>js</code> in a single request — no multiple API calls needed.</p>
|
||||
|
||||
<p>Check our <a href="/docs">API documentation</a> for the complete parameter reference, or see how we <a href="/compare">compare to other screenshot APIs</a> on feature coverage.</p>
|
||||
|
||||
<div class="cta-box">
|
||||
<h3>Try Dark Mode Screenshots</h3>
|
||||
<p>Test it right now with our <a href="/#playground">playground</a> — no API key needed. When you're ready for clean, unwatermarked screenshots, <a href="/pricing">pick a plan</a> that fits your usage.</p>
|
||||
<a href="/pricing" class="btn btn-primary">View Plans →</a>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<h4>📸 SnapAPI</h4>
|
||||
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Product</h5>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Developers</h5>
|
||||
<a href="/docs">Swagger / OpenAPI</a>
|
||||
<a href="/guides/quick-start">Quick Start</a>
|
||||
<a href="/changelog">Changelog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="/impressum.html">Impressum</a>
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
210
public/blog/screenshot-api-performance.html
Normal file
210
public/blog/screenshot-api-performance.html
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Screenshot API Performance: Caching Strategies That Actually Work — SnapAPI Blog</title>
|
||||
<meta name="description" content="How we reduced p95 response times by 10x with intelligent caching, CDN integration, and browser pool management. A practical guide to fast screenshot APIs.">
|
||||
<link rel="canonical" href="https://snapapi.eu/blog/screenshot-api-performance">
|
||||
<meta property="og:title" content="Screenshot API Performance: Caching Strategies That Actually Work">
|
||||
<meta property="og:description" content="How we reduced p95 response times by 10x with intelligent caching, CDN integration, and browser pool management.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://snapapi.eu/blog/screenshot-api-performance">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Screenshot API Performance: Caching Strategies That Work">
|
||||
<meta name="twitter:description" content="Practical caching strategies for screenshot APIs — browser pooling, content hashing, and CDN delivery.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;--border:#1e2a3f;--border-light:#2a3752;--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--accent:#10b981;--purple:#a78bfa;--orange:#f59e0b;--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);--radius:12px;--radius-lg:16px}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);color:#fff}
|
||||
.btn-sm{padding:8px 18px;font-size:.85rem}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
@media(max-width:768px){.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}.nav-links.show{display:flex}.nav-mobile{display:block}}
|
||||
|
||||
.article{max-width:740px;margin:0 auto;padding:80px 24px 100px}
|
||||
.article .breadcrumb{font-size:.85rem;color:var(--muted);margin-bottom:32px}
|
||||
.article .breadcrumb a{color:var(--muted)}
|
||||
.article .breadcrumb a:hover{color:var(--text)}
|
||||
.article h1{font-size:2.5rem;font-weight:900;line-height:1.2;margin-bottom:16px}
|
||||
.article .meta{font-size:.85rem;color:var(--muted);margin-bottom:48px;display:flex;gap:16px}
|
||||
.article h2{font-size:1.5rem;font-weight:700;margin:48px 0 16px;color:var(--text)}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px;color:var(--text)}
|
||||
.article p{color:var(--text-secondary);line-height:1.8;margin-bottom:20px;font-size:1.05rem}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 20px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.article code{font-family:'JetBrains Mono',monospace;background:var(--card);padding:2px 8px;border-radius:4px;font-size:.9rem;color:var(--primary-light)}
|
||||
.article pre{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin:0 0 24px;overflow-x:auto}
|
||||
.article pre code{background:none;padding:0;font-size:.85rem;line-height:1.7;color:var(--text-secondary)}
|
||||
.article blockquote{border-left:3px solid var(--primary);padding:16px 24px;margin:0 0 24px;background:var(--card);border-radius:0 var(--radius) var(--radius) 0}
|
||||
.article blockquote p{margin:0;color:var(--text-secondary);font-style:italic}
|
||||
.article .cta-box{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;margin:48px 0;text-align:center}
|
||||
.article .cta-box h3{margin-top:0;font-size:1.3rem}
|
||||
.article .cta-box p{margin-bottom:20px}
|
||||
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:768px){.footer-grid{grid-template-columns:1fr}}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"Screenshot API Performance: Caching Strategies That Actually Work","description":"How we reduced p95 response times by 10x with intelligent caching, CDN integration, and browser pool management.","datePublished":"2026-03-02","author":{"@type":"Organization","name":"SnapAPI"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"url":"https://snapapi.eu/blog/screenshot-api-performance","mainEntityOfPage":"https://snapapi.eu/blog/screenshot-api-performance"}</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<article class="article">
|
||||
<div class="breadcrumb"><a href="/">Home</a> / <a href="/blog">Blog</a> / Screenshot API Performance</div>
|
||||
|
||||
<h1>Screenshot API Performance: Caching Strategies That Actually Work</h1>
|
||||
<div class="meta"><span>March 2, 2026</span><span>5 min read</span></div>
|
||||
|
||||
<p>Screenshot generation is inherently expensive. Every request launches a browser context, navigates to a URL, waits for rendering, and captures pixels. Without optimization, you're looking at 3-8 seconds per screenshot — unacceptable for any production API. Here's how we brought our p95 response times down from 6 seconds to under 600 milliseconds.</p>
|
||||
|
||||
<h2>The Three Layers of Screenshot Caching</h2>
|
||||
|
||||
<p>Effective caching for screenshot APIs operates at three distinct layers, each addressing a different performance bottleneck. Getting all three right is what separates a fast API from a sluggish one.</p>
|
||||
|
||||
<h3>Layer 1: Content-Addressable Cache</h3>
|
||||
|
||||
<p>The most impactful optimization is also the most straightforward: don't re-render screenshots you've already taken. We hash the request parameters — URL, viewport dimensions, format, device scale factor, and any custom options — into a cache key. If the same combination was requested recently, we serve the cached result directly.</p>
|
||||
|
||||
<p>The tricky part is cache invalidation. Web pages change, and serving a stale screenshot is worse than serving a slow one. Our approach uses configurable TTLs with smart defaults: 1 hour for most pages, shorter for known-dynamic content, and instant invalidation via a <code>cache: false</code> parameter when freshness is critical.</p>
|
||||
|
||||
<pre><code>GET /v1/screenshot?url=example.com&cache_ttl=3600
|
||||
# First request: ~3s (full render)
|
||||
# Subsequent requests: ~50ms (cache hit)</code></pre>
|
||||
|
||||
<p>This single layer eliminates 60-70% of all rendering work in a typical production workload, because many applications request the same URLs repeatedly — social preview generators, monitoring dashboards, and report builders all exhibit high cache hit ratios.</p>
|
||||
|
||||
<h3>Layer 2: Browser Pool Management</h3>
|
||||
|
||||
<p>For cache misses, the next bottleneck is browser startup time. Launching a fresh Chrome instance takes 1-2 seconds. Multiply that by concurrent requests and you have a performance disaster combined with memory pressure that triggers garbage collection storms.</p>
|
||||
|
||||
<p>Browser pooling solves this by maintaining a warm pool of ready-to-use browser contexts. Instead of launching Chrome per request, we allocate a pre-warmed context from the pool, navigate to the target URL, capture the screenshot, and return the context to the pool for reuse.</p>
|
||||
|
||||
<p>Key considerations for browser pool management:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Pool sizing:</strong> Too small and requests queue up. Too large and you waste memory. We dynamically scale based on request concurrency, maintaining a buffer of 2-3 idle contexts above current demand.</li>
|
||||
<li><strong>Context hygiene:</strong> Each context must be fully isolated — cleared cookies, fresh storage, no shared state. A screenshot of a banking login page shouldn't leak session data to the next request.</li>
|
||||
<li><strong>Health checks:</strong> Browser contexts degrade over time. Memory leaks accumulate. We cycle contexts after a configurable number of uses (default: 50) and immediately replace any that fail health checks.</li>
|
||||
<li><strong>Graceful degradation:</strong> When pool capacity is exhausted, we queue requests with backpressure rather than spawning unbounded Chrome instances. Better to return a slow response than to OOM the host.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Layer 3: CDN and Edge Delivery</h3>
|
||||
|
||||
<p>Once a screenshot is generated, delivering it fast is a standard CDN problem — but with nuances specific to dynamically generated images. We push rendered screenshots to edge locations, so subsequent requests for the same content are served from the nearest point of presence.</p>
|
||||
|
||||
<p>For our EU-hosted infrastructure, this means edge nodes across European cities. A user in Berlin gets their screenshot from Frankfurt, not from the origin server in Nuremberg. The latency difference is 5-10ms versus 30-50ms — small in absolute terms, but it compounds when your API is called in a rendering pipeline.</p>
|
||||
|
||||
<h2>Beyond Caching: Render Path Optimization</h2>
|
||||
|
||||
<p>Caching handles repeated requests, but the cold-start path still needs to be fast. Several techniques reduce raw rendering time:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Eager navigation:</strong> Start loading the page before all parameters are validated. By the time we've checked auth and parsed options, the page is already halfway loaded.</li>
|
||||
<li><strong>Network idle detection:</strong> Instead of waiting a fixed duration, we monitor network activity and capture as soon as the page stabilizes. This adapts to fast-loading static sites (200ms) and heavy SPAs (2-3s) automatically.</li>
|
||||
<li><strong>Resource blocking:</strong> Optional blocking of ads, trackers, and analytics scripts that slow page loads without affecting visual output. This alone can save 500ms-1s on ad-heavy pages.</li>
|
||||
<li><strong>Viewport pre-configuration:</strong> Setting viewport dimensions before navigation avoids layout recalculation after the page loads, eliminating a common source of visual inconsistency and wasted time.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Measuring What Matters</h2>
|
||||
|
||||
<p>Performance optimization without measurement is guesswork. The metrics that actually matter for a screenshot API are:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>p50/p95/p99 response times</strong> — split by cache hit vs. miss. Your p95 cache-miss time is the number your users feel most.</li>
|
||||
<li><strong>Cache hit ratio</strong> — anything below 50% suggests your TTL strategy needs work or your traffic patterns are unusually diverse.</li>
|
||||
<li><strong>Pool utilization</strong> — consistently above 80% means you need more capacity. Consistently below 30% means you're wasting memory.</li>
|
||||
<li><strong>Error rate by type</strong> — timeouts, rendering failures, and OOM kills each have different root causes and different fixes.</li>
|
||||
</ul>
|
||||
|
||||
<p>We expose these metrics via a <code>/health</code> endpoint and internal dashboards, making it easy to spot performance regressions before they affect users.</p>
|
||||
|
||||
<h2>Results</h2>
|
||||
|
||||
<p>With all three caching layers active plus render path optimizations, our production numbers look like this: cache-hit responses average 45ms at p50 and 120ms at p95. Cache-miss responses average 1.8s at p50 and 2.9s at p95. The overall cache hit ratio sits at 72% across all customers, meaning nearly three-quarters of all screenshot requests are served without touching a browser.</p>
|
||||
|
||||
<p>For most API consumers, the performance feels instant — because for the majority of their requests, it is. That's the power of treating caching as a first-class architectural concern rather than an afterthought bolted on later.</p>
|
||||
|
||||
<div class="cta-box">
|
||||
<h3>Experience the Speed</h3>
|
||||
<p>Try SnapAPI's playground and see sub-second screenshot delivery in action. No signup required for your first request.</p>
|
||||
<a href="/#playground" class="btn btn-primary">Open Playground →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<h4>📸 SnapAPI</h4>
|
||||
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Product</h5>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Developers</h5>
|
||||
<a href="/docs">Swagger / OpenAPI</a>
|
||||
<a href="/guides/quick-start">Quick Start</a>
|
||||
<a href="/changelog">Changelog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="/impressum.html">Impressum</a>
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
226
public/blog/why-screenshot-api.html
Normal file
226
public/blog/why-screenshot-api.html
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Why You Need a Screenshot API — SnapAPI Blog</title>
|
||||
<meta name="description" content="Self-hosting Puppeteer sounds easy until you're debugging zombie Chrome processes at 3 AM. Learn why dedicated screenshot APIs exist and when they make sense.">
|
||||
<link rel="canonical" href="https://snapapi.eu/blog/why-screenshot-api">
|
||||
<meta property="og:title" content="Why You Need a Screenshot API">
|
||||
<meta property="og:description" content="Self-hosting Puppeteer sounds easy until you're debugging zombie Chrome processes at 3 AM. Learn why dedicated screenshot APIs exist.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://snapapi.eu/blog/why-screenshot-api">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Why You Need a Screenshot API">
|
||||
<meta name="twitter:description" content="Why dedicated screenshot APIs exist and when they make sense for your project.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;--border:#1e2a3f;--border-light:#2a3752;--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--accent:#10b981;--purple:#a78bfa;--orange:#f59e0b;--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);--radius:12px;--radius-lg:16px}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);color:#fff}
|
||||
.btn-sm{padding:8px 18px;font-size:.85rem}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
@media(max-width:768px){.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}.nav-links.show{display:flex}.nav-mobile{display:block}}
|
||||
|
||||
.article{max-width:740px;margin:0 auto;padding:80px 24px 100px}
|
||||
.article .breadcrumb{font-size:.85rem;color:var(--muted);margin-bottom:32px}
|
||||
.article .breadcrumb a{color:var(--muted)}
|
||||
.article .breadcrumb a:hover{color:var(--text)}
|
||||
.article h1{font-size:2.5rem;font-weight:900;line-height:1.2;margin-bottom:16px}
|
||||
.article .meta{font-size:.85rem;color:var(--muted);margin-bottom:48px;display:flex;gap:16px}
|
||||
.article h2{font-size:1.5rem;font-weight:700;margin:48px 0 16px;color:var(--text)}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px;color:var(--text)}
|
||||
.article p{color:var(--text-secondary);line-height:1.8;margin-bottom:20px;font-size:1.05rem}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 20px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.article code{font-family:'JetBrains Mono',monospace;background:var(--card);padding:2px 8px;border-radius:4px;font-size:.9rem;color:var(--primary-light)}
|
||||
.article pre{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin:0 0 24px;overflow-x:auto}
|
||||
.article pre code{background:none;padding:0;font-size:.85rem;line-height:1.7;color:var(--text-secondary)}
|
||||
.article blockquote{border-left:3px solid var(--primary);padding:16px 24px;margin:0 0 24px;background:var(--card);border-radius:0 var(--radius) var(--radius) 0}
|
||||
.article blockquote p{margin:0;color:var(--text-secondary);font-style:italic}
|
||||
.article .cta-box{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;margin:48px 0;text-align:center}
|
||||
.article .cta-box h3{margin-top:0;font-size:1.3rem}
|
||||
.article .cta-box p{margin-bottom:20px}
|
||||
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:768px){.footer-grid{grid-template-columns:1fr}}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"Why You Need a Screenshot API (And Why Building Your Own Is Harder Than You Think)","description":"Self-hosting Puppeteer sounds easy until you're debugging zombie Chrome processes at 3 AM.","datePublished":"2026-03-02","author":{"@type":"Organization","name":"SnapAPI"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"url":"https://snapapi.eu/blog/why-screenshot-api","mainEntityOfPage":"https://snapapi.eu/blog/why-screenshot-api"}</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<article class="article">
|
||||
<div class="breadcrumb"><a href="/">Home</a> / <a href="/blog">Blog</a> / Why You Need a Screenshot API</div>
|
||||
|
||||
<h1>Why You Need a Screenshot API (And Why Building Your Own Is Harder Than You Think)</h1>
|
||||
<div class="meta"><span>March 2, 2026</span><span>8 min read</span></div>
|
||||
|
||||
<p>Every developer has the same thought at some point: "I just need a screenshot of a webpage. How hard can it be?" You spin up a quick Node.js script with Puppeteer, take a screenshot, and it works. Ship it. Done. Right?</p>
|
||||
|
||||
<p>Not quite. What starts as a ten-line script inevitably grows into a sprawling system that consumes more engineering time than the feature it was supposed to support. Let's talk about why screenshot APIs exist, what makes them genuinely hard to build, and when it makes sense to use one instead of rolling your own.</p>
|
||||
|
||||
<h2>The Puppeteer Trap</h2>
|
||||
|
||||
<p>Puppeteer is an excellent tool. It gives you full control over a headless Chrome instance, and for local development or one-off scripts, it's perfect. The problem isn't Puppeteer itself — it's what happens when you try to run it in production at any meaningful scale.</p>
|
||||
|
||||
<p>Here's what your "simple screenshot service" needs to handle in the real world:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Resource management:</strong> Each Chrome instance consumes 200-500 MB of RAM. Running ten concurrent screenshots means you need several gigabytes of memory just for the browser processes. Without careful pooling, you'll OOM your server in hours.</li>
|
||||
<li><strong>Zombie processes:</strong> Chrome tabs crash. Pages hang on infinite loops. Scripts run forever. You need process monitoring, timeouts, and cleanup routines that kill orphaned processes before they eat your server alive.</li>
|
||||
<li><strong>Font rendering:</strong> That page looks perfect on your MacBook but renders with missing glyphs on your Ubuntu server. You need a curated font library — CJK fonts alone add hundreds of megabytes to your Docker image.</li>
|
||||
<li><strong>Network reliability:</strong> Target sites go down, return 503s, redirect endlessly, or serve CAPTCHAs. Your service needs retry logic, circuit breakers, and meaningful error reporting.</li>
|
||||
<li><strong>Security:</strong> Accepting arbitrary URLs means you're opening your server to SSRF attacks. Without proper validation, someone will use your screenshot service to probe your internal network, access cloud metadata endpoints, or worse.</li>
|
||||
</ul>
|
||||
|
||||
<p>Each of these is a project in itself. Together, they represent weeks of engineering work that has nothing to do with your actual product.</p>
|
||||
|
||||
<h2>The Hidden Costs of Self-Hosting</h2>
|
||||
|
||||
<p>Beyond the initial implementation, self-hosted screenshot services have ongoing costs that are easy to underestimate:</p>
|
||||
|
||||
<h3>Infrastructure</h3>
|
||||
<p>Chrome is resource-hungry. A production screenshot service needs dedicated compute resources — you can't just tack it onto your existing application server. You're looking at dedicated containers or VMs with enough CPU and RAM to handle concurrent rendering, plus autoscaling if your traffic is bursty.</p>
|
||||
|
||||
<h3>Maintenance</h3>
|
||||
<p>Chrome updates break things. Puppeteer version X works with Chrome version Y but not Z. Dependencies shift. Security patches need applying. Someone on your team needs to own this service, monitor it, and fix it when it breaks at 3 AM on a Saturday — because it will break at 3 AM on a Saturday.</p>
|
||||
|
||||
<h3>Edge Cases</h3>
|
||||
<p>The long tail of web rendering is brutal. SPAs that need JavaScript execution. Pages with lazy-loaded content that require scroll simulation. Cookie consent banners that block the entire viewport. Dark mode detection. Viewport emulation for mobile screenshots. Each edge case is another conditional in your codebase, another test to maintain, another thing that can break.</p>
|
||||
|
||||
<blockquote>
|
||||
<p>"The first 80% of a screenshot service takes a weekend. The remaining 20% takes six months."</p>
|
||||
</blockquote>
|
||||
|
||||
<h2>When a Screenshot API Makes Sense</h2>
|
||||
|
||||
<p>A dedicated screenshot API isn't always the right choice. Here's a framework for deciding:</p>
|
||||
|
||||
<p><strong>Use a screenshot API when:</strong></p>
|
||||
<ul>
|
||||
<li>Screenshots aren't your core product — they support a feature (social previews, PDF reports, monitoring dashboards)</li>
|
||||
<li>You need reliability at scale without dedicating engineering resources to browser infrastructure</li>
|
||||
<li>You need consistent rendering across different types of pages without handling every edge case yourself</li>
|
||||
<li>Compliance matters — EU hosting, GDPR, data residency requirements are handled for you</li>
|
||||
<li>You want to move fast and ship the actual feature instead of building plumbing</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Build your own when:</strong></p>
|
||||
<ul>
|
||||
<li>Screenshots are your core product and you need deep customization</li>
|
||||
<li>You're taking screenshots of your own application (not arbitrary URLs) in a controlled environment</li>
|
||||
<li>Volume is very low (a few screenshots per day) and reliability isn't critical</li>
|
||||
<li>You have specific security requirements that prevent using third-party services</li>
|
||||
</ul>
|
||||
|
||||
<h2>What to Look for in a Screenshot API</h2>
|
||||
|
||||
<p>Not all screenshot APIs are created equal. Here are the things that actually matter when evaluating options:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Rendering quality:</strong> Does it handle SPAs, web fonts, and modern CSS? Can it wait for dynamic content to load? The difference between a screenshot API that renders the loading spinner and one that waits for the actual content is everything.</li>
|
||||
<li><strong>Response time:</strong> Screenshot generation is inherently slow (browsers are complex), but good APIs use caching, browser pooling, and CDN delivery to minimize latency. Look for p95 response times under 3 seconds for cached content.</li>
|
||||
<li><strong>Output formats:</strong> PNG for quality, JPEG for size, WebP for the best of both. Full-page capture, viewport-only, or element-specific — flexibility matters when your use case evolves.</li>
|
||||
<li><strong>SSRF protection:</strong> Any API that accepts arbitrary URLs must validate them against internal network ranges, metadata endpoints, and redirect chains. This isn't optional — it's a security fundamental.</li>
|
||||
<li><strong>Data residency:</strong> If you're building for European users, your screenshots shouldn't be rendered on servers in Virginia. Look for APIs with explicit EU hosting and GDPR compliance documentation.</li>
|
||||
<li><strong>Transparent pricing:</strong> Per-screenshot pricing is the standard, but watch for hidden costs — bandwidth charges, storage fees, or premium features locked behind enterprise tiers.</li>
|
||||
</ul>
|
||||
|
||||
<h2>The Build vs. Buy Calculation</h2>
|
||||
|
||||
<p>Let's do the math. A senior developer costs roughly €80-120/hour fully loaded. Building a production-ready screenshot service takes 2-4 weeks minimum. That's €6,400-€19,200 in development costs alone, before ongoing maintenance.</p>
|
||||
|
||||
<p>A screenshot API at typical pricing (€9-79/month depending on volume) would need to run for years before matching the upfront development cost. And during those years, someone else handles the infrastructure, the Chrome updates, and the 3 AM incidents.</p>
|
||||
|
||||
<p>The calculation becomes even clearer when you factor in opportunity cost. Those 2-4 weeks of engineering time could go toward features that actually differentiate your product.</p>
|
||||
|
||||
<h2>Conclusion</h2>
|
||||
|
||||
<p>Screenshot APIs exist because taking screenshots of web pages at scale is a deceptively hard infrastructure problem. The initial implementation is easy; the production hardening is where the real work lives. For most teams, delegating this to a specialized service is the pragmatic choice — it lets you ship the feature your users actually care about instead of maintaining browser infrastructure.</p>
|
||||
|
||||
<p>The best engineering decisions aren't about what you <em>can</em> build. They're about what you <em>should</em> build. Save your complexity budget for the things that make your product unique.</p>
|
||||
|
||||
<div class="cta-box">
|
||||
<h3>Try SnapAPI Free</h3>
|
||||
<p>Get your first 100 screenshots free. No credit card required. EU-hosted, GDPR compliant, and ready in under a minute.</p>
|
||||
<a href="/#playground" class="btn btn-primary">Open Playground →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<h4>📸 SnapAPI</h4>
|
||||
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Product</h5>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Developers</h5>
|
||||
<a href="/docs">Swagger / OpenAPI</a>
|
||||
<a href="/guides/quick-start">Quick Start</a>
|
||||
<a href="/changelog">Changelog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="/impressum.html">Impressum</a>
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
267
public/changelog.html
Normal file
267
public/changelog.html
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>API Changelog — SnapAPI Updates & Release History</title>
|
||||
<meta name="description" content="SnapAPI changelog: track every update, new feature, and improvement to the screenshot API. See what's new in each release.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="canonical" href="https://snapapi.eu/changelog">
|
||||
<meta property="og:title" content="API Changelog — SnapAPI Updates & Release History">
|
||||
<meta property="og:description" content="Track every update and new feature added to SnapAPI's screenshot API.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://snapapi.eu/changelog">
|
||||
<meta property="og:image" content="https://snapapi.eu/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="API Changelog — SnapAPI Updates & Release History">
|
||||
<meta name="twitter:description" content="Track every update and new feature added to SnapAPI's screenshot API.">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Blog","name":"SnapAPI Changelog","description":"Release history and updates for SnapAPI screenshot API.","url":"https://snapapi.eu/changelog","publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"}}
|
||||
</script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);
|
||||
--accent:#10b981;--accent-glow:rgba(16,185,129,0.15);
|
||||
--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;--radius-lg:16px;--radius-xl:24px;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
::selection{background:var(--primary);color:#fff}
|
||||
.container{max-width:800px;margin:0 auto;padding:0 24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4);color:#fff}
|
||||
.btn-sm{padding:8px 18px;font-size:.85rem;border-radius:8px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.nav-cta{margin-left:8px}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
|
||||
.hero-section{padding:80px 0 40px;text-align:center}
|
||||
.hero-section h1{font-size:2.5rem;font-weight:800;line-height:1.2;margin-bottom:16px}
|
||||
.hero-section p{color:var(--text-secondary);font-size:1.1rem;max-width:600px;margin:0 auto}
|
||||
|
||||
.timeline{margin:48px 0;position:relative}
|
||||
.timeline::before{content:'';position:absolute;left:20px;top:0;bottom:0;width:2px;background:var(--border)}
|
||||
.release{position:relative;padding-left:56px;margin-bottom:48px}
|
||||
.release::before{content:'';position:absolute;left:13px;top:6px;width:16px;height:16px;border-radius:50%;background:var(--primary);border:3px solid var(--bg)}
|
||||
.release.latest::before{background:var(--accent);box-shadow:0 0 12px rgba(16,185,129,0.4)}
|
||||
.release-header{display:flex;align-items:baseline;gap:16px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.release-version{font-size:1.4rem;font-weight:800;color:var(--text)}
|
||||
.release-date{font-size:.85rem;color:var(--muted);font-weight:500}
|
||||
.release-tag{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:1px;padding:3px 10px;border-radius:20px;background:var(--accent-glow);color:var(--accent)}
|
||||
.release-body{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px}
|
||||
.release-body ul{list-style:none;margin:0;padding:0}
|
||||
.release-body li{padding:8px 0;font-size:.92rem;color:var(--text-secondary);border-bottom:1px solid var(--border)}
|
||||
.release-body li:last-child{border-bottom:none}
|
||||
.release-body li::before{content:"• ";color:var(--primary);font-weight:700}
|
||||
|
||||
.cta-box{background:linear-gradient(135deg,rgba(79,143,255,0.1) 0%,rgba(167,139,250,0.1) 100%);border:1px solid rgba(79,143,255,0.2);border-radius:var(--radius-xl);padding:48px;text-align:center;margin:48px 0}
|
||||
.cta-box h2{font-size:1.8rem;font-weight:800;margin-bottom:12px}
|
||||
.cta-box p{color:var(--text-secondary);margin-bottom:24px}
|
||||
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;color:var(--muted);font-size:.8rem}
|
||||
|
||||
@media(max-width:768px){
|
||||
.hero-section h1{font-size:1.8rem}
|
||||
.footer-grid{grid-template-columns:1fr}
|
||||
.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}
|
||||
.nav-links.show{display:flex}
|
||||
.nav-mobile{display:block}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#playground">Try It Free</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/compare">Compare</a>
|
||||
<a href="/guides/quick-start">Quick Start</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<section class="hero-section">
|
||||
<div class="container">
|
||||
<h1>Changelog</h1>
|
||||
<p>Every update, feature, and improvement to SnapAPI. Follow our progress as we build the best EU-hosted screenshot API.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container">
|
||||
<div class="timeline">
|
||||
|
||||
<div class="release latest">
|
||||
<div class="release-header">
|
||||
<span class="release-version">v0.7.0</span>
|
||||
<span class="release-date">March 4, 2026</span>
|
||||
<span class="release-tag">Latest</span>
|
||||
</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>
|
||||
<li>🔒 Fixed: Recovery endpoint no longer logs full API keys</li>
|
||||
<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>📊 366 tests passing</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="release">
|
||||
<div class="release-header">
|
||||
<span class="release-version">v0.6.0</span>
|
||||
<span class="release-date">March 2026</span>
|
||||
</div>
|
||||
<div class="release-body">
|
||||
<ul>
|
||||
<li>GET endpoint for direct image embedding in <code><img></code> tags</li>
|
||||
<li>Response caching with X-Cache headers and 5-minute TTL</li>
|
||||
<li>Usage dashboard for tracking API consumption</li>
|
||||
<li>Customer portal for managing subscriptions</li>
|
||||
<li>API key recovery flow</li>
|
||||
<li>SEO pages: comparison page, quick-start guide</li>
|
||||
<li>157 tests covering all functionality</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="release">
|
||||
<div class="release-header">
|
||||
<span class="release-version">v0.5.0</span>
|
||||
<span class="release-date">February 2026</span>
|
||||
</div>
|
||||
<div class="release-body">
|
||||
<ul>
|
||||
<li>Stripe billing integration</li>
|
||||
<li>3 paid plans: Starter (€9/mo), Pro (€29/mo), Business (€79/mo)</li>
|
||||
<li>Checkout flow with automatic API key provisioning</li>
|
||||
<li>Stripe webhook handling for subscription lifecycle</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="release">
|
||||
<div class="release-header">
|
||||
<span class="release-version">v0.4.0</span>
|
||||
<span class="release-date">February 2026</span>
|
||||
</div>
|
||||
<div class="release-body">
|
||||
<ul>
|
||||
<li>Playground endpoint — try the API without authentication</li>
|
||||
<li>Watermarked output for playground screenshots</li>
|
||||
<li>IP-based rate limiting (5 requests/hour)</li>
|
||||
<li>Official Node.js SDK</li>
|
||||
<li>Official Python SDK</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="release">
|
||||
<div class="release-header">
|
||||
<span class="release-version">v0.3.0</span>
|
||||
<span class="release-date">February 2026</span>
|
||||
</div>
|
||||
<div class="release-body">
|
||||
<ul>
|
||||
<li>Redesigned landing page with dark theme</li>
|
||||
<li>Removed free tier — playground replaces it</li>
|
||||
<li>Interactive Swagger documentation at <a href="/docs">/docs</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="release">
|
||||
<div class="release-header">
|
||||
<span class="release-version">v0.2.0</span>
|
||||
<span class="release-date">February 2026</span>
|
||||
</div>
|
||||
<div class="release-body">
|
||||
<ul>
|
||||
<li>SSRF protection — blocks private/internal IP ranges</li>
|
||||
<li>Browser pool with automatic recycling</li>
|
||||
<li>PostgreSQL integration for persistent data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="release">
|
||||
<div class="release-header">
|
||||
<span class="release-version">v0.1.0</span>
|
||||
<span class="release-date">February 2026</span>
|
||||
</div>
|
||||
<div class="release-body">
|
||||
<ul>
|
||||
<li>Initial release</li>
|
||||
<li>POST /v1/screenshot endpoint</li>
|
||||
<li>Health check endpoint</li>
|
||||
<li>Basic API key authentication</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="cta-box">
|
||||
<h2>Start building with SnapAPI</h2>
|
||||
<p>Get your API key in 60 seconds and start capturing screenshots.</p>
|
||||
<a href="/pricing" class="btn btn-primary btn-lg">View Pricing →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand"><h4>📸 SnapAPI</h4><p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p></div>
|
||||
<div class="footer-col"><h5>Product</h5><a href="/#features">Features</a><a href="/pricing">Pricing</a><a href="/#playground">Playground</a><a href="/docs">API Docs</a></div>
|
||||
<div class="footer-col"><h5>Developers</h5><a href="/docs">Swagger / OpenAPI</a><a href="/health">Status</a><a href="/guides/quick-start">Quick-Start Guide</a><a href="/changelog">Changelog</a></div>
|
||||
<div class="footer-col"><h5>Legal</h5><a href="/impressum.html">Impressum</a><a href="/privacy.html">Privacy Policy</a><a href="/terms.html">Terms of Service</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>Linzer Straße 192/1/2, 1140 Wien, Austria 🇦🇹</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
226
public/compare.html
Normal file
226
public/compare.html
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Screenshot API Comparison 2026 — SnapAPI vs Alternatives | SnapAPI</title>
|
||||
<meta name="description" content="Compare screenshot APIs: SnapAPI vs ScreenshotOne, URLBox, ApiFlash, CaptureKit, and GetScreenshot. Find the best screenshot API for your project.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="canonical" href="https://snapapi.eu/compare">
|
||||
<meta property="og:title" content="Screenshot API Comparison 2026 — SnapAPI vs Alternatives">
|
||||
<meta property="og:description" content="Compare screenshot APIs: SnapAPI vs ScreenshotOne, URLBox, ApiFlash, CaptureKit, and GetScreenshot.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://snapapi.eu/compare">
|
||||
<meta property="og:image" content="https://snapapi.eu/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Screenshot API Comparison 2026 — SnapAPI vs Alternatives">
|
||||
<meta name="twitter:description" content="Compare screenshot APIs: SnapAPI vs ScreenshotOne, URLBox, ApiFlash, CaptureKit, and GetScreenshot.">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"WebPage","name":"Screenshot API Comparison 2026","description":"Compare screenshot APIs: SnapAPI vs ScreenshotOne, URLBox, ApiFlash, CaptureKit, and GetScreenshot.","url":"https://snapapi.eu/compare","publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"}}
|
||||
</script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);
|
||||
--accent:#10b981;--accent-glow:rgba(16,185,129,0.15);
|
||||
--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;--radius-lg:16px;--radius-xl:24px;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
::selection{background:var(--primary);color:#fff}
|
||||
.container{max-width:800px;margin:0 auto;padding:0 24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4);color:#fff}
|
||||
.btn-lg{padding:16px 36px;font-size:1.05rem;border-radius:12px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.nav-cta{margin-left:8px}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
.article{padding:80px 0 60px}
|
||||
.article h1{font-size:2.5rem;font-weight:800;line-height:1.2;margin-bottom:24px}
|
||||
.article h2{font-size:1.6rem;font-weight:700;margin:48px 0 16px}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px}
|
||||
.article p{color:var(--text-secondary);font-size:1rem;line-height:1.8;margin-bottom:16px}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 16px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.compare-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:32px 0}
|
||||
.compare-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;transition:border-color .2s}
|
||||
.compare-card:hover{border-color:var(--border-light)}
|
||||
.compare-card h3{margin:0 0 12px;font-size:1.05rem;font-weight:700;color:var(--text)}
|
||||
.compare-card p{font-size:.9rem;color:var(--text-secondary);margin:0 0 12px}
|
||||
.compare-card ul{margin:0;padding:0;list-style:none}
|
||||
.compare-card ul li{padding:4px 0;font-size:.88rem;color:var(--text-secondary)}
|
||||
.compare-card ul li::before{content:"✓ ";color:var(--accent)}
|
||||
.compare-card.highlight{border-color:var(--primary);background:linear-gradient(135deg,rgba(79,143,255,0.05),rgba(167,139,250,0.05))}
|
||||
.feature-list{list-style:none;padding:0;margin:24px 0}
|
||||
.feature-list li{padding:12px 0;border-bottom:1px solid var(--border);font-size:.95rem;color:var(--text-secondary);display:flex;align-items:center;gap:12px}
|
||||
.feature-list li .icon{font-size:1.2rem}
|
||||
.cta-box{background:linear-gradient(135deg,rgba(79,143,255,0.1) 0%,rgba(167,139,250,0.1) 100%);border:1px solid rgba(79,143,255,0.2);border-radius:var(--radius-xl);padding:48px;text-align:center;margin:48px 0}
|
||||
.cta-box h2{font-size:1.8rem;font-weight:800;margin-bottom:12px}
|
||||
.cta-box p{color:var(--text-secondary);margin-bottom:24px}
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:640px){
|
||||
.article{padding:40px 0 30px}
|
||||
.article h1{font-size:1.8rem}
|
||||
.compare-grid{grid-template-columns:1fr}
|
||||
.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}
|
||||
.nav-links.show{display:flex}
|
||||
.nav-mobile{display:block}
|
||||
.footer-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#playground">Try It Free</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<article class="article">
|
||||
<div class="container">
|
||||
<h1>Screenshot API Comparison 2026</h1>
|
||||
|
||||
<p>Choosing the right screenshot API depends on your requirements — pricing, data residency, features, and developer experience. Here's an honest look at how the major screenshot APIs compare so you can pick the best fit for your project.</p>
|
||||
|
||||
<h2>The Contenders</h2>
|
||||
|
||||
<div class="compare-grid">
|
||||
<div class="compare-card highlight">
|
||||
<h3>📸 SnapAPI</h3>
|
||||
<p>EU-hosted screenshot API with simple EUR pricing.</p>
|
||||
<ul>
|
||||
<li>EU data residency (Germany)</li>
|
||||
<li>POST & GET endpoints</li>
|
||||
<li>Dark mode capture support</li>
|
||||
<li>Element hiding (CSS selectors)</li>
|
||||
<li>Built-in response caching</li>
|
||||
<li>Free playground, no signup</li>
|
||||
<li>Node.js & Python SDKs</li>
|
||||
<li>Pricing in EUR</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="compare-card">
|
||||
<h3>ScreenshotOne</h3>
|
||||
<p>Feature-rich API with global CDN.</p>
|
||||
<ul>
|
||||
<li>Extensive rendering options</li>
|
||||
<li>US-based infrastructure</li>
|
||||
<li>USD pricing</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="compare-card">
|
||||
<h3>URLBox</h3>
|
||||
<p>Established screenshot service with retina support.</p>
|
||||
<ul>
|
||||
<li>Retina rendering</li>
|
||||
<li>Webhook notifications</li>
|
||||
<li>USD pricing</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="compare-card">
|
||||
<h3>ApiFlash</h3>
|
||||
<p>Chrome-based screenshot API with CDN caching.</p>
|
||||
<ul>
|
||||
<li>AWS-powered rendering</li>
|
||||
<li>Built-in CDN</li>
|
||||
<li>USD pricing</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="compare-card">
|
||||
<h3>CaptureKit</h3>
|
||||
<p>Modern API with generous free tier.</p>
|
||||
<ul>
|
||||
<li>Multiple output formats</li>
|
||||
<li>Custom viewport sizes</li>
|
||||
<li>USD pricing</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="compare-card">
|
||||
<h3>GetScreenshot</h3>
|
||||
<p>Simple screenshot API for quick integrations.</p>
|
||||
<ul>
|
||||
<li>Simple REST API</li>
|
||||
<li>PNG & JPEG output</li>
|
||||
<li>USD pricing</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Why SnapAPI?</h2>
|
||||
|
||||
<p>Every API on this list can take a screenshot. What sets SnapAPI apart is where and how it does it:</p>
|
||||
|
||||
<ul class="feature-list">
|
||||
<li><span class="icon">🇪🇺</span> <strong>EU-hosted & GDPR compliant</strong> — All rendering happens on servers in Germany. Your data never leaves the EU. No extra DPAs or compliance headaches.</li>
|
||||
<li><span class="icon">💶</span> <strong>Simple EUR pricing</strong> — No currency conversion, no hidden fees. Plans start at €9/month with clear per-screenshot pricing.</li>
|
||||
<li><span class="icon">🌙</span> <strong>Dark mode capture</strong> — Take screenshots in dark mode with a single parameter. Perfect for showcasing dark themes and modern designs.</li>
|
||||
<li><span class="icon">👁️🗨️</span> <strong>Element hiding</strong> — Hide cookie banners, popups, and ads before capture using CSS selectors for clean results.</li>
|
||||
<li><span class="icon">🔗</span> <strong>GET & POST endpoints</strong> — Use GET requests to embed screenshots directly in <code><img></code> tags. No server-side code needed for simple use cases.</li>
|
||||
<li><span class="icon">⚡</span> <strong>Built-in caching</strong> — Response caching out of the box. Repeated requests for the same URL return cached results instantly.</li>
|
||||
<li><span class="icon">🎮</span> <strong>Free playground</strong> — Try the API in your browser without creating an account or entering payment details.</li>
|
||||
<li><span class="icon">📦</span> <strong>Official SDKs</strong> — First-class Node.js and Python SDKs to get you started in minutes.</li>
|
||||
</ul>
|
||||
|
||||
<div class="cta-box">
|
||||
<h2>Try SnapAPI Free</h2>
|
||||
<p>No signup required. Test screenshots in the playground, then get an API key when you're ready.</p>
|
||||
<a href="/#pricing" class="btn btn-primary btn-lg">Get Your API Key →</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand"><h4>📸 SnapAPI</h4><p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p></div>
|
||||
<div class="footer-col"><h5>Product</h5><a href="/#features">Features</a><a href="/#pricing">Pricing</a><a href="/#playground">Playground</a><a href="/docs">API Docs</a></div>
|
||||
<div class="footer-col"><h5>Developers</h5><a href="/docs">Swagger / OpenAPI</a><a href="/health">Status</a><a href="/guides/quick-start">Quick-Start Guide</a></div>
|
||||
<div class="footer-col"><h5>Legal</h5><a href="/impressum.html">Impressum</a><a href="/privacy.html">Privacy Policy</a><a href="/terms.html">Terms of Service</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>Linzer Straße 192/1/2, 1140 Wien, Austria 🇦🇹</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
260
public/guides/quick-start.html
Normal file
260
public/guides/quick-start.html
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Quick-Start Guide — Take Your First Screenshot with SnapAPI</title>
|
||||
<meta name="description" content="Learn how to use SnapAPI in 5 minutes. Step-by-step guide: get an API key, take a screenshot with cURL, embed with GET, use caching, and try the SDKs.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="canonical" href="https://snapapi.eu/guides/quick-start">
|
||||
<meta property="og:title" content="Quick-Start Guide — Take Your First Screenshot with SnapAPI">
|
||||
<meta property="og:description" content="From zero to first screenshot in 5 minutes. cURL, GET embedding, caching, and SDK examples.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://snapapi.eu/guides/quick-start">
|
||||
<meta property="og:image" content="https://snapapi.eu/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Quick-Start Guide — Take Your First Screenshot with SnapAPI">
|
||||
<meta name="twitter:description" content="From zero to first screenshot in 5 minutes. cURL, GET embedding, caching, and SDK examples.">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"HowTo","name":"Take Your First Screenshot with SnapAPI","description":"Step-by-step guide from zero to first screenshot using the SnapAPI screenshot API.","step":[{"@type":"HowToStep","position":1,"name":"Get an API key","text":"Sign up for a SnapAPI plan to get your API key."},{"@type":"HowToStep","position":2,"name":"Take your first screenshot","text":"Use cURL to call the POST endpoint and capture a screenshot."},{"@type":"HowToStep","position":3,"name":"Use the GET endpoint for embedding","text":"Embed screenshots directly in img tags using GET requests."},{"@type":"HowToStep","position":4,"name":"Use caching headers","text":"Leverage built-in response caching for faster repeated requests."},{"@type":"HowToStep","position":5,"name":"Try the SDKs","text":"Use the official Node.js or Python SDK for easier integration."}],"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"}}
|
||||
</script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);
|
||||
--accent:#10b981;--accent-glow:rgba(16,185,129,0.15);
|
||||
--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;--radius-lg:16px;--radius-xl:24px;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
::selection{background:var(--primary);color:#fff}
|
||||
.container{max-width:800px;margin:0 auto;padding:0 24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4);color:#fff}
|
||||
.btn-lg{padding:16px 36px;font-size:1.05rem;border-radius:12px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.nav-cta{margin-left:8px}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
.article{padding:80px 0 60px}
|
||||
.article h1{font-size:2.5rem;font-weight:800;line-height:1.2;margin-bottom:24px}
|
||||
.article h2{font-size:1.6rem;font-weight:700;margin:48px 0 16px}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px}
|
||||
.article p{color:var(--text-secondary);font-size:1rem;line-height:1.8;margin-bottom:16px}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 16px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.step{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:28px;margin:24px 0}
|
||||
.step-number{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;background:var(--primary);color:#fff;font-weight:700;font-size:.9rem;margin-bottom:12px}
|
||||
.code-window{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin:24px 0;box-shadow:0 10px 40px rgba(0,0,0,0.2)}
|
||||
.code-titlebar{display:flex;align-items:center;gap:8px;padding:14px 20px;background:rgba(0,0,0,0.2);border-bottom:1px solid var(--border)}
|
||||
.code-dot{width:12px;height:12px;border-radius:50%}
|
||||
.code-dot:nth-child(1){background:#ff5f57}.code-dot:nth-child(2){background:#ffbd2e}.code-dot:nth-child(3){background:#28c840}
|
||||
.code-titlebar span{flex:1;text-align:center;font-size:.8rem;color:var(--muted);font-weight:500}
|
||||
.code-body{padding:24px;font-family:'JetBrains Mono',monospace;font-size:.85rem;line-height:1.9;overflow-x:auto;color:var(--text-secondary)}
|
||||
.code-body .kw{color:var(--purple)}.code-body .str{color:var(--accent)}.code-body .cmt{color:#475569;font-style:italic}.code-body .fn{color:var(--primary-light)}.code-body .prop{color:var(--orange)}
|
||||
.cta-box{background:linear-gradient(135deg,rgba(79,143,255,0.1) 0%,rgba(167,139,250,0.1) 100%);border:1px solid rgba(79,143,255,0.2);border-radius:var(--radius-xl);padding:48px;text-align:center;margin:48px 0}
|
||||
.cta-box h2{font-size:1.8rem;font-weight:800;margin-bottom:12px}
|
||||
.cta-box p{color:var(--text-secondary);margin-bottom:24px}
|
||||
.related{margin:48px 0;padding:32px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg)}
|
||||
.related h3{font-size:1.1rem;font-weight:700;margin-bottom:16px}
|
||||
.related a{display:block;padding:8px 0;font-size:.95rem}
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:640px){
|
||||
.article{padding:40px 0 30px}
|
||||
.article h1{font-size:1.8rem}
|
||||
.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}
|
||||
.nav-links.show{display:flex}
|
||||
.nav-mobile{display:block}
|
||||
.footer-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#playground">Try It Free</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<article class="article">
|
||||
<div class="container">
|
||||
<h1>Quick-Start Guide: Your First Screenshot in 5 Minutes</h1>
|
||||
|
||||
<p>This guide walks you through everything you need to go from zero to capturing screenshots with SnapAPI. No prior experience required.</p>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<h2>Get an API key</h2>
|
||||
<p>Head to the <a href="/#pricing">pricing page</a> and pick a plan. You'll receive your API key immediately after signing up. Plans start at €9/month.</p>
|
||||
<p>Want to try first? Use the <a href="/#playground">free playground</a> — no signup needed.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<h2>Take your first screenshot</h2>
|
||||
<p>Use <code>curl</code> to call the POST endpoint and capture a screenshot:</p>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>cURL</span></div>
|
||||
<div class="code-body"><pre>curl -X POST https://snapapi.eu/v1/screenshot \
|
||||
-H <span class="str">"Content-Type: application/json"</span> \
|
||||
-H <span class="str">"X-API-Key: YOUR_API_KEY"</span> \
|
||||
-d <span class="str">'{"url": "https://example.com", "format": "png"}'</span> \
|
||||
--output screenshot.png</pre></div>
|
||||
</div>
|
||||
|
||||
<p>That's it — you'll have a <code>screenshot.png</code> file on your machine.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">3</div>
|
||||
<h2>Use the GET endpoint for embedding</h2>
|
||||
<p>SnapAPI supports GET requests, which means you can embed screenshots directly in <code><img></code> tags — no server-side code needed:</p>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>HTML</span></div>
|
||||
<div class="code-body"><pre><img src=<span class="str">"https://snapapi.eu/v1/screenshot?url=https://example.com&format=png&apiKey=YOUR_API_KEY"</span>
|
||||
alt=<span class="str">"Screenshot of example.com"</span> /></pre></div>
|
||||
</div>
|
||||
|
||||
<p>This is perfect for dashboards, link previews, and documentation where you want live screenshots without any backend logic.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">4</div>
|
||||
<h2>Use caching headers</h2>
|
||||
<p>SnapAPI includes built-in response caching. When you request the same URL multiple times, subsequent requests return the cached result instantly — saving you both time and credits.</p>
|
||||
<p>Cache behavior is automatic. The <code>Cache-Control</code> headers in the response tell you the cache status.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">5</div>
|
||||
<h2>Try the Node.js and Python SDKs</h2>
|
||||
<p>For deeper integrations, use the official SDKs:</p>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>Node.js</span></div>
|
||||
<div class="code-body"><pre><span class="kw">import</span> { SnapAPI } <span class="kw">from</span> <span class="str">'snapapi'</span>;
|
||||
|
||||
<span class="kw">const</span> client = <span class="kw">new</span> <span class="fn">SnapAPI</span>(<span class="str">'YOUR_API_KEY'</span>);
|
||||
<span class="kw">const</span> screenshot = <span class="kw">await</span> client.<span class="fn">take</span>({
|
||||
url: <span class="str">'https://example.com'</span>,
|
||||
format: <span class="str">'png'</span>,
|
||||
width: <span class="prop">1280</span>,
|
||||
height: <span class="prop">720</span>
|
||||
});</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>Python</span></div>
|
||||
<div class="code-body"><pre><span class="kw">from</span> snapapi <span class="kw">import</span> SnapAPI
|
||||
|
||||
client = <span class="fn">SnapAPI</span>(<span class="str">"YOUR_API_KEY"</span>)
|
||||
screenshot = client.<span class="fn">take</span>(
|
||||
url=<span class="str">"https://example.com"</span>,
|
||||
format=<span class="str">"png"</span>,
|
||||
width=<span class="prop">1280</span>,
|
||||
height=<span class="prop">720</span>
|
||||
)</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">6</div>
|
||||
<h2>Dark Mode and Element Hiding</h2>
|
||||
<p>Take advantage of SnapAPI's newest features for cleaner, more professional screenshots:</p>
|
||||
|
||||
<h4>Dark Mode Screenshots</h4>
|
||||
<p>Capture websites in dark mode by setting <code>darkMode: true</code>:</p>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>cURL</span></div>
|
||||
<div class="code-body"><pre>curl -X POST https://snapapi.eu/v1/screenshot \
|
||||
-H <span class="str">"Content-Type: application/json"</span> \
|
||||
-H <span class="str">"X-API-Key: YOUR_API_KEY"</span> \
|
||||
-d <span class="str">'{"url": "https://example.com", "darkMode": true}'</span></pre></div>
|
||||
</div>
|
||||
|
||||
<h4>Hide Unwanted Elements</h4>
|
||||
<p>Remove cookie banners, popups, and ads using CSS selectors:</p>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>cURL</span></div>
|
||||
<div class="code-body"><pre>curl -X POST https://snapapi.eu/v1/screenshot \
|
||||
-H <span class="str">"Content-Type: application/json"</span> \
|
||||
-H <span class="str">"X-API-Key: YOUR_API_KEY"</span> \
|
||||
-d <span class="str">'{"url": "https://example.com", "hideSelectors": ["#cookie-banner", ".popup", ".ads"]}'</span></pre></div>
|
||||
</div>
|
||||
|
||||
<p>You can combine both features for clean dark mode screenshots without distractions.</p>
|
||||
</div>
|
||||
|
||||
<div class="cta-box">
|
||||
<h2>Ready to Build?</h2>
|
||||
<p>Get your API key and start capturing screenshots in production.</p>
|
||||
<a href="/#pricing" class="btn btn-primary btn-lg">Get Your API Key →</a>
|
||||
</div>
|
||||
|
||||
<div class="related">
|
||||
<h3>Next Steps</h3>
|
||||
<a href="/docs">Full API Documentation (Swagger) →</a>
|
||||
<a href="/use-cases/social-media-previews">Use Case: Generate OG Images →</a>
|
||||
<a href="/compare">Compare SnapAPI to Alternatives →</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand"><h4>📸 SnapAPI</h4><p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p></div>
|
||||
<div class="footer-col"><h5>Product</h5><a href="/#features">Features</a><a href="/#pricing">Pricing</a><a href="/#playground">Playground</a><a href="/docs">API Docs</a></div>
|
||||
<div class="footer-col"><h5>Developers</h5><a href="/docs">Swagger / OpenAPI</a><a href="/health">Status</a><a href="/guides/quick-start">Quick-Start Guide</a></div>
|
||||
<div class="footer-col"><h5>Legal</h5><a href="/impressum.html">Impressum</a><a href="/privacy.html">Privacy Policy</a><a href="/terms.html">Terms of Service</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>Linzer Straße 192/1/2, 1140 Wien, Austria 🇦🇹</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -69,10 +69,15 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
.footer-grid{grid-template-columns:1fr}
|
||||
.nav-links{display:none}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
|
|
@ -84,6 +89,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<section class="legal-content">
|
||||
<div class="legal-container">
|
||||
|
|
@ -141,6 +149,8 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
|
|
@ -162,9 +172,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
<a href="/impressum.html">Impressum</a>
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
|
|
|
|||
|
|
@ -73,12 +73,19 @@ nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-fi
|
|||
.code-body .flag{color:var(--orange)}
|
||||
.code-body .cmt{color:#475569;font-style:italic}
|
||||
.code-body .url{color:var(--primary-light)}
|
||||
.code-body .fn{color:var(--primary-light)}
|
||||
.code-body .prop{color:var(--orange)}
|
||||
.code-body .num{color:var(--accent)}
|
||||
.code-tabs{display:flex;gap:4px;flex:1;justify-content:center}
|
||||
.code-tab{background:none;border:none;color:var(--muted);font-size:.8rem;font-family:inherit;font-weight:500;padding:4px 12px;border-radius:4px;cursor:pointer;transition:all .15s}
|
||||
.code-tab:hover{color:var(--text-secondary)}
|
||||
.code-tab.active{color:var(--text);background:rgba(255,255,255,0.08)}
|
||||
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:0;max-width:700px;margin:0 auto;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
||||
.stat{padding:32px;text-align:center;border-right:1px solid var(--border)}
|
||||
.stat:last-child{border-right:none}
|
||||
.stat .number{font-size:2rem;font-weight:800;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.stat .label{font-size:.82rem;color:var(--muted);margin-top:4px;font-weight:500}
|
||||
.features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;margin-top:48px}
|
||||
.features-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;margin-top:48px}
|
||||
.feature-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:36px 28px;transition:all .3s}
|
||||
.feature-card:hover{border-color:var(--border-light);background:var(--card-hover);transform:translateY(-2px)}
|
||||
.feature-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.5rem;margin-bottom:20px}
|
||||
|
|
@ -192,6 +199,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
.footer-grid{grid-template-columns:1fr}
|
||||
.trust-badges{flex-direction:column;align-items:center;gap:12px}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
<link rel="canonical" href="https://snapapi.eu/">
|
||||
<meta property="og:title" content="SnapAPI — Screenshot API for Developers">
|
||||
|
|
@ -227,20 +237,29 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="#" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#playground">Try It Free</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="#docs">API Docs</a>
|
||||
<a href="/docs" target="_blank">Swagger</a>
|
||||
<a href="/usage">Usage</a>
|
||||
<a href="/compare">Compare</a>
|
||||
<a href="/guides/quick-start">Quick Start</a>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
|
|
@ -261,16 +280,77 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<div class="code-window">
|
||||
<div class="code-titlebar">
|
||||
<div class="code-dot"></div><div class="code-dot"></div><div class="code-dot"></div>
|
||||
<span>Terminal</span>
|
||||
<div class="code-tabs">
|
||||
<button class="code-tab active" onclick="switchCodeTab(this, 'code-curl')">cURL</button>
|
||||
<button class="code-tab" onclick="switchCodeTab(this, 'code-get')">GET/Embed</button>
|
||||
<button class="code-tab" onclick="switchCodeTab(this, 'code-node')">Node.js</button>
|
||||
<button class="code-tab" onclick="switchCodeTab(this, 'code-python')">Python</button>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="code-body">
|
||||
<div class="code-body" id="code-curl">
|
||||
<span class="cmt"># Take a screenshot of any URL</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","format":"png"}'</span> \
|
||||
<span class="flag">-d</span> <span class="str">'{"url":"https://example.com","format":"png","darkMode":true}'</span> \
|
||||
<span class="flag">-o</span> <span class="str">screenshot.png</span>
|
||||
|
||||
<span class="cmt"># Hide elements before capture</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","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>
|
||||
<span class="kw">curl</span> <span class="url">"https://snapapi.eu/v1/screenshot?url=https://example.com&key=YOUR_API_KEY&format=png&width=1920"</span>
|
||||
|
||||
<span class="cmt"># Direct image embedding in HTML</span>
|
||||
<<span class="kw">img</span> <span class="prop">src</span>=<span class="str">"https://snapapi.eu/v1/screenshot?url=https://example.com&key=YOUR_API_KEY"</span>
|
||||
<span class="prop">alt</span>=<span class="str">"Screenshot of example.com"</span>>
|
||||
|
||||
<span class="cmt"># Cached responses (5-min TTL)</span>
|
||||
<span class="kw">curl</span> <span class="url">"https://snapapi.eu/v1/screenshot?url=https://example.com&key=YOUR_API_KEY"</span>
|
||||
<span class="cmt"># First request: X-Cache: MISS</span>
|
||||
<span class="cmt"># Next 5 minutes: X-Cache: HIT</span>
|
||||
</div>
|
||||
<div class="code-body" id="code-node" style="display:none">
|
||||
<span class="cmt">// npm install snapapi</span>
|
||||
<span class="kw">import</span> { <span class="fn">SnapAPI</span> } <span class="kw">from</span> <span class="str">'snapapi'</span>;
|
||||
|
||||
<span class="kw">const</span> snap = <span class="kw">new</span> <span class="fn">SnapAPI</span>(<span class="str">'YOUR_API_KEY'</span>);
|
||||
|
||||
<span class="kw">const</span> screenshot = <span class="kw">await</span> snap.<span class="fn">capture</span>(<span class="str">'https://example.com'</span>, {
|
||||
<span class="prop">format</span>: <span class="str">'png'</span>,
|
||||
<span class="prop">width</span>: <span class="num">1920</span>,
|
||||
<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">
|
||||
<span class="cmt"># pip install snapapi</span>
|
||||
<span class="kw">from</span> <span class="fn">snapapi</span> <span class="kw">import</span> <span class="fn">SnapAPI</span>
|
||||
|
||||
snap = <span class="fn">SnapAPI</span>(<span class="str">"YOUR_API_KEY"</span>)
|
||||
|
||||
screenshot = snap.<span class="fn">capture</span>(
|
||||
<span class="str">"https://example.com"</span>,
|
||||
<span class="prop">format</span>=<span class="str">"png"</span>,
|
||||
<span class="prop">width</span>=<span class="num">1920</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>
|
||||
</div>
|
||||
|
|
@ -302,13 +382,19 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<label>URL to capture</label>
|
||||
<input type="url" id="pg-url" value="https://example.com" placeholder="https://example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label>Format</label>
|
||||
<select id="pg-format">
|
||||
<option value="png">PNG</option>
|
||||
<option value="jpeg">JPEG</option>
|
||||
<option value="webp">WebP</option>
|
||||
</select>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<label>Format</label>
|
||||
<select id="pg-format">
|
||||
<option value="png">PNG</option>
|
||||
<option value="jpeg">JPEG</option>
|
||||
<option value="webp">WebP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Quality</label>
|
||||
<input type="number" id="pg-quality" value="80" min="1" max="100" title="JPEG/WebP quality (1-100). Ignored for PNG.">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
|
|
@ -320,6 +406,34 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<input type="number" id="pg-height" value="800" min="200" max="1080">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<label>Device Scale</label>
|
||||
<select id="pg-scale">
|
||||
<option value="1">1x</option>
|
||||
<option value="2">2x (Retina)</option>
|
||||
<option value="3">3x</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Wait Until</label>
|
||||
<select id="pg-waituntil">
|
||||
<option value="domcontentloaded">DOM Ready</option>
|
||||
<option value="load">Page Load</option>
|
||||
<option value="networkidle0">Network Idle</option>
|
||||
<option value="networkidle2">Network Idle 2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;text-transform:none;letter-spacing:0;font-size:.9rem">
|
||||
<input type="checkbox" id="pg-fullpage" style="width:auto;padding:0;accent-color:var(--primary)"> Capture full page
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Wait for Selector <span style="font-weight:400;text-transform:none;letter-spacing:0">(optional)</span></label>
|
||||
<input type="text" id="pg-selector" placeholder="#content, .loaded, img" title="CSS selector to wait for before capturing">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="runPlayground()" id="pg-btn" style="margin-top:auto">
|
||||
Take Screenshot →
|
||||
</button>
|
||||
|
|
@ -378,6 +492,26 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<h3>Wait for Elements</h3>
|
||||
<p>Use CSS selectors to wait for specific elements before capturing. Ideal for SPAs and dynamic content.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon purple">🌙</div>
|
||||
<h3>Dark Mode Capture</h3>
|
||||
<p>Capture websites in dark mode with a single parameter. Perfect for design previews, marketing materials, and app stores.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon pink">👁️🗨️</div>
|
||||
<h3>Element Hiding</h3>
|
||||
<p>Hide cookie banners, popups, and ads before capture. Get clean screenshots every time with CSS selector-based hiding.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon purple">⚡</div>
|
||||
<h3>Response Caching</h3>
|
||||
<p>Automatic 5-minute caching for repeat requests. Faster responses and reduced server load. Bypass with cache=false.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon orange">🔗</div>
|
||||
<h3>GET Request Support</h3>
|
||||
<p>Direct image embedding with GET requests. Perfect for <img> tags and markdown. API key via query parameter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -451,6 +585,31 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="use-cases">
|
||||
<div class="container text-center">
|
||||
<div class="section-label">Use Cases</div>
|
||||
<h2 class="section-title">Built for real-world workflows</h2>
|
||||
<p class="section-subtitle">See how developers use SnapAPI to automate screenshots across their stack.</p>
|
||||
<div class="features-grid" style="grid-template-columns:repeat(3,1fr);margin-top:48px">
|
||||
<a href="/use-cases/social-media-previews" class="feature-card" style="text-decoration:none;color:inherit">
|
||||
<div class="feature-icon blue">🖼️</div>
|
||||
<h3>OG Images & Social Previews</h3>
|
||||
<p>Generate dynamic Open Graph images and Twitter cards from HTML templates on-the-fly.</p>
|
||||
</a>
|
||||
<a href="/use-cases/website-monitoring" class="feature-card" style="text-decoration:none;color:inherit">
|
||||
<div class="feature-icon green">👁️</div>
|
||||
<h3>Visual Website Monitoring</h3>
|
||||
<p>Schedule screenshots to detect layout regressions, broken pages, and visual bugs automatically.</p>
|
||||
</a>
|
||||
<a href="/use-cases/pdf-reports" class="feature-card" style="text-decoration:none;color:inherit">
|
||||
<div class="feature-icon purple">📄</div>
|
||||
<h3>Reports & Thumbnails</h3>
|
||||
<p>Create website thumbnails for link previews, directories, dashboards, and email digests.</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="pricing">
|
||||
<div class="container text-center">
|
||||
<div class="section-label">Pricing</div>
|
||||
|
|
@ -499,6 +658,8 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
<p style="margin-top:32px;color:var(--text-secondary);font-size:.95rem">
|
||||
Want to test first? <a href="#playground" style="font-weight:600">Try the playground</a> — free, instant, no signup.
|
||||
<br>
|
||||
Lost your API key? <a href="/recovery.html" style="font-weight:600;color:var(--primary)">Recover it here</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -594,6 +755,8 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
|
|
@ -606,12 +769,16 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<a href="#pricing">Pricing</a>
|
||||
<a href="#playground">Playground</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/usage">Check Usage</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Developers</h5>
|
||||
<a href="/docs">Swagger / OpenAPI</a>
|
||||
<a href="#docs">Quick Start</a>
|
||||
<a href="/health">Status</a>
|
||||
<a href="/usage">Usage Dashboard</a>
|
||||
<a href="/changelog">Changelog</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
|
|
@ -628,12 +795,24 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</footer>
|
||||
|
||||
<script>
|
||||
// Code tab switcher
|
||||
function switchCodeTab(btn, id){
|
||||
btn.parentElement.querySelectorAll('.code-tab').forEach(function(t){t.classList.remove('active')});
|
||||
btn.classList.add('active');
|
||||
btn.closest('.code-window').querySelectorAll('.code-body').forEach(function(b){b.style.display='none'});
|
||||
document.getElementById(id).style.display='';
|
||||
}
|
||||
// Playground - calls /v1/playground (no auth needed)
|
||||
async function runPlayground(){
|
||||
var url=document.getElementById('pg-url').value;
|
||||
var format=document.getElementById('pg-format').value;
|
||||
var quality=parseInt(document.getElementById('pg-quality').value)||80;
|
||||
var width=parseInt(document.getElementById('pg-width').value)||1280;
|
||||
var height=parseInt(document.getElementById('pg-height').value)||800;
|
||||
var fullPage=document.getElementById('pg-fullpage').checked;
|
||||
var deviceScale=parseInt(document.getElementById('pg-scale').value)||1;
|
||||
var waitUntil=document.getElementById('pg-waituntil').value;
|
||||
var waitForSelector=document.getElementById('pg-selector').value.trim()||undefined;
|
||||
if(!url){alert('Please enter a URL');return}
|
||||
|
||||
var btn=document.getElementById('pg-btn');
|
||||
|
|
@ -646,11 +825,14 @@ async function runPlayground(){
|
|||
placeholder.style.display='none';result.style.display='none';error.style.display='none';
|
||||
loading.style.display='flex';
|
||||
|
||||
var body={url:url,format:format,width:width,height:height,fullPage:fullPage,quality:quality,deviceScale:deviceScale,waitUntil:waitUntil};
|
||||
if(waitForSelector)body.waitForSelector=waitForSelector;
|
||||
|
||||
try{
|
||||
var r=await fetch('/v1/playground',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({url:url,format:format,width:width,height:height})
|
||||
body:JSON.stringify(body)
|
||||
});
|
||||
if(!r.ok){var d=await r.json().catch(function(){return{}});throw new Error(d.error||'HTTP '+r.status)}
|
||||
var blob=await r.blob();
|
||||
|
|
|
|||
287
public/pricing.html
Normal file
287
public/pricing.html
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Screenshot API Pricing — Affordable Plans from €9/mo | SnapAPI</title>
|
||||
<meta name="description" content="SnapAPI pricing plans for website screenshot API. Starter €9/mo for 1,000 screenshots, Pro €29/mo for 5,000, Business €79/mo for 25,000. EU-hosted, no hidden fees.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="canonical" href="https://snapapi.eu/pricing">
|
||||
<meta property="og:title" content="Screenshot API Pricing — Affordable Plans from €9/mo">
|
||||
<meta property="og:description" content="SnapAPI pricing: Starter €9/mo, Pro €29/mo, Business €79/mo. EU-hosted screenshot API with no hidden fees.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://snapapi.eu/pricing">
|
||||
<meta property="og:image" content="https://snapapi.eu/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Screenshot API Pricing — Affordable Plans from €9/mo">
|
||||
<meta name="twitter:description" content="SnapAPI pricing: Starter €9/mo, Pro €29/mo, Business €79/mo. EU-hosted screenshot API.">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Product","name":"SnapAPI Screenshot API","description":"EU-hosted website screenshot API with simple pricing plans.","url":"https://snapapi.eu/pricing","brand":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"offers":[{"@type":"Offer","name":"Starter","price":"9.00","priceCurrency":"EUR","description":"1,000 screenshots/month","url":"https://snapapi.eu/pricing"},{"@type":"Offer","name":"Pro","price":"29.00","priceCurrency":"EUR","description":"5,000 screenshots/month","url":"https://snapapi.eu/pricing"},{"@type":"Offer","name":"Business","price":"79.00","priceCurrency":"EUR","description":"25,000 screenshots/month","url":"https://snapapi.eu/pricing"}]}
|
||||
</script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);
|
||||
--accent:#10b981;--accent-glow:rgba(16,185,129,0.15);
|
||||
--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;--radius-lg:16px;--radius-xl:24px;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
::selection{background:var(--primary);color:#fff}
|
||||
.container{max-width:900px;margin:0 auto;padding:0 24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4);color:#fff}
|
||||
.btn-lg{padding:16px 36px;font-size:1.05rem;border-radius:12px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.nav-cta{margin-left:8px}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
.btn-sm{padding:8px 18px;font-size:.85rem;border-radius:8px}
|
||||
|
||||
.hero-section{padding:80px 0 40px;text-align:center}
|
||||
.hero-section h1{font-size:2.5rem;font-weight:800;line-height:1.2;margin-bottom:16px}
|
||||
.hero-section p{color:var(--text-secondary);font-size:1.1rem;max-width:600px;margin:0 auto}
|
||||
|
||||
.pricing-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;margin:48px 0}
|
||||
.price-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px 24px;text-align:center;transition:border-color .2s}
|
||||
.price-card:hover{border-color:var(--border-light)}
|
||||
.price-card.featured{border-color:var(--primary);background:linear-gradient(135deg,rgba(79,143,255,0.08),rgba(167,139,250,0.05));position:relative}
|
||||
.price-card.featured::before{content:"Most Popular";position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;font-size:.75rem;font-weight:700;padding:4px 16px;border-radius:20px}
|
||||
.price-tier{font-size:.85rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:12px}
|
||||
.price-amount{font-size:3rem;font-weight:800;margin-bottom:4px}
|
||||
.price-amount .currency{font-size:1.5rem;vertical-align:super;margin-right:2px}
|
||||
.price-amount .period{font-size:1rem;font-weight:500;color:var(--muted)}
|
||||
.price-limit{color:var(--text-secondary);font-size:.95rem;margin-bottom:24px}
|
||||
.price-features{list-style:none;text-align:left;margin-bottom:28px}
|
||||
.price-features li{padding:8px 0;font-size:.9rem;color:var(--text-secondary);border-bottom:1px solid var(--border)}
|
||||
.price-features li::before{content:"✓ ";color:var(--accent);font-weight:700}
|
||||
.price-features li:last-child{border-bottom:none}
|
||||
.price-card .btn{width:100%}
|
||||
|
||||
.feature-matrix{margin:64px 0}
|
||||
.feature-matrix h2{font-size:1.8rem;font-weight:800;text-align:center;margin-bottom:32px}
|
||||
.matrix-table{width:100%;border-collapse:collapse;background:var(--card);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--border)}
|
||||
.matrix-table th,.matrix-table td{padding:14px 20px;text-align:center;border-bottom:1px solid var(--border);font-size:.9rem}
|
||||
.matrix-table th{background:var(--bg2);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:1px;font-size:.75rem}
|
||||
.matrix-table th:first-child,.matrix-table td:first-child{text-align:left;font-weight:500;color:var(--text)}
|
||||
.matrix-table td{color:var(--text-secondary)}
|
||||
.check{color:var(--accent);font-weight:700}
|
||||
.cross{color:var(--muted)}
|
||||
|
||||
.faq-section{margin:64px 0}
|
||||
.faq-section h2{font-size:1.8rem;font-weight:800;text-align:center;margin-bottom:32px}
|
||||
.faq-item{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:12px}
|
||||
.faq-item h3{font-size:1rem;font-weight:600;margin-bottom:8px;color:var(--text)}
|
||||
.faq-item p{color:var(--text-secondary);font-size:.9rem;margin:0;line-height:1.7}
|
||||
|
||||
.cta-box{background:linear-gradient(135deg,rgba(79,143,255,0.1) 0%,rgba(167,139,250,0.1) 100%);border:1px solid rgba(79,143,255,0.2);border-radius:var(--radius-xl);padding:48px;text-align:center;margin:48px 0}
|
||||
.cta-box h2{font-size:1.8rem;font-weight:800;margin-bottom:12px}
|
||||
.cta-box p{color:var(--text-secondary);margin-bottom:24px}
|
||||
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;color:var(--muted);font-size:.8rem}
|
||||
|
||||
@media(max-width:768px){
|
||||
.pricing-grid{grid-template-columns:1fr}
|
||||
.hero-section h1{font-size:1.8rem}
|
||||
.matrix-table{font-size:.8rem}
|
||||
.matrix-table th,.matrix-table td{padding:10px 12px}
|
||||
.footer-grid{grid-template-columns:1fr}
|
||||
.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}
|
||||
.nav-links.show{display:flex}
|
||||
.nav-mobile{display:block}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#playground">Try It Free</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/compare">Compare</a>
|
||||
<a href="/guides/quick-start">Quick Start</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<section class="hero-section">
|
||||
<div class="container">
|
||||
<h1>Simple, transparent pricing</h1>
|
||||
<p>No hidden fees. No per-pixel charges. Just straightforward monthly plans with generous screenshot allowances. EU-hosted.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container">
|
||||
<div class="pricing-grid">
|
||||
<div class="price-card">
|
||||
<div class="price-tier">Starter</div>
|
||||
<div class="price-amount"><span class="currency">€</span>9<span class="period">/mo</span></div>
|
||||
<div class="price-limit">1,000 screenshots/month</div>
|
||||
<ul class="price-features">
|
||||
<li>All output formats (PNG, JPEG, WebP)</li>
|
||||
<li>Custom viewports & device scale</li>
|
||||
<li>Full-page capture</li>
|
||||
<li>No watermark</li>
|
||||
<li>GET & POST endpoints</li>
|
||||
<li>Response caching</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" onclick="checkout('starter')">Get Started</button>
|
||||
</div>
|
||||
<div class="price-card featured">
|
||||
<div class="price-tier">Pro</div>
|
||||
<div class="price-amount"><span class="currency">€</span>29<span class="period">/mo</span></div>
|
||||
<div class="price-limit">5,000 screenshots/month</div>
|
||||
<ul class="price-features">
|
||||
<li>Everything in Starter</li>
|
||||
<li>Priority rendering</li>
|
||||
<li>Webhook callbacks</li>
|
||||
<li>Batch API</li>
|
||||
<li>Custom viewport presets</li>
|
||||
<li>Response caching (extended)</li>
|
||||
<li>Priority support</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" onclick="checkout('pro')">Get Started</button>
|
||||
</div>
|
||||
<div class="price-card">
|
||||
<div class="price-tier">Business</div>
|
||||
<div class="price-amount"><span class="currency">€</span>79<span class="period">/mo</span></div>
|
||||
<div class="price-limit">25,000 screenshots/month</div>
|
||||
<ul class="price-features">
|
||||
<li>Everything in Pro</li>
|
||||
<li>SLA guarantee</li>
|
||||
<li>Dedicated support</li>
|
||||
<li>Custom integrations</li>
|
||||
<li>DPA included</li>
|
||||
<li>Custom user-agent</li>
|
||||
<li>Volume discounts available</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" onclick="checkout('business')">Get Started</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-matrix">
|
||||
<h2>Feature comparison</h2>
|
||||
<table class="matrix-table">
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>Starter</th><th>Pro</th><th>Business</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Screenshots/month</td><td>1,000</td><td>5,000</td><td>25,000</td></tr>
|
||||
<tr><td>PNG format</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>JPEG format</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>WebP format</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>Full-page capture</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>Custom viewport</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>GET endpoint (img embed)</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>Response caching</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>Priority rendering</td><td class="cross">—</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>Webhook callbacks</td><td class="cross">—</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>Batch API</td><td class="cross">—</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>SLA guarantee</td><td class="cross">—</td><td class="cross">—</td><td class="check">✓</td></tr>
|
||||
<tr><td>DPA included</td><td class="cross">—</td><td class="cross">—</td><td class="check">✓</td></tr>
|
||||
<tr><td>Priority support</td><td class="cross">—</td><td class="check">✓</td><td class="check">✓</td></tr>
|
||||
<tr><td>Dedicated support</td><td class="cross">—</td><td class="cross">—</td><td class="check">✓</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="faq-section">
|
||||
<h2>Pricing FAQ</h2>
|
||||
<div class="faq-item">
|
||||
<h3>What's the billing cycle?</h3>
|
||||
<p>All plans are billed monthly. Your billing cycle starts on the day you subscribe and renews automatically each month.</p>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<h3>Can I upgrade or downgrade my plan?</h3>
|
||||
<p>Yes, you can change your plan at any time from the customer portal. Upgrades take effect immediately with prorated billing. Downgrades take effect at the next billing cycle.</p>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<h3>What happens if I exceed my monthly limit?</h3>
|
||||
<p>API calls beyond your monthly quota will return a 429 status code. You won't be charged extra — simply upgrade your plan if you need more screenshots.</p>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<h3>What's your refund policy?</h3>
|
||||
<p>We offer a full refund within the first 7 days of your initial subscription. After that, you can cancel anytime and your plan remains active until the end of the current billing period.</p>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<h3>Do you offer annual pricing?</h3>
|
||||
<p>Not yet, but annual plans with a discount are coming soon. Subscribe to monthly for now and you'll be notified when annual plans are available.</p>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<h3>Is there a free tier?</h3>
|
||||
<p>We don't offer a free tier, but you can test the API anytime using the <a href="/#playground">free playground</a> — no signup required. Playground screenshots are watermarked.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta-box">
|
||||
<h2>Ready to get started?</h2>
|
||||
<p>Try the free playground first, or pick a plan and start capturing screenshots in minutes.</p>
|
||||
<a href="/#playground" class="btn btn-primary btn-lg">Try Playground Free →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand"><h4>📸 SnapAPI</h4><p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p></div>
|
||||
<div class="footer-col"><h5>Product</h5><a href="/#features">Features</a><a href="/pricing">Pricing</a><a href="/#playground">Playground</a><a href="/docs">API Docs</a></div>
|
||||
<div class="footer-col"><h5>Developers</h5><a href="/docs">Swagger / OpenAPI</a><a href="/health">Status</a><a href="/guides/quick-start">Quick-Start Guide</a><a href="/changelog">Changelog</a></div>
|
||||
<div class="footer-col"><h5>Legal</h5><a href="/impressum.html">Impressum</a><a href="/privacy.html">Privacy Policy</a><a href="/terms.html">Terms of Service</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>Linzer Straße 192/1/2, 1140 Wien, Austria 🇦🇹</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
async function checkout(plan) {
|
||||
try {
|
||||
const res = await fetch("/v1/billing/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) window.location.href = data.url;
|
||||
else alert(data.error || "Failed to start checkout");
|
||||
} catch (e) { alert("Network error"); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -69,10 +69,15 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
.footer-grid{grid-template-columns:1fr}
|
||||
.nav-links{display:none}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
|
|
@ -84,6 +89,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<section class="legal-content">
|
||||
<div class="legal-container">
|
||||
|
|
@ -277,6 +285,8 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
|
|
@ -298,9 +308,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
<a href="/impressum.html">Impressum</a>
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
|
|
|
|||
187
public/recovery.html
Normal file
187
public/recovery.html
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Recover API Key — SnapAPI</title>
|
||||
<meta name="description" content="Recover your lost SnapAPI key or access your billing portal to manage your subscription.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;
|
||||
--accent:#10b981;--orange:#f59e0b;
|
||||
--radius:12px;--radius-lg:16px;
|
||||
}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;flex-direction:column}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
.container{max-width:900px;margin:0 auto;padding:0 24px;flex:1;display:flex;align-items:center;justify-content:center}
|
||||
.back-link{position:fixed;top:24px;left:24px;display:inline-flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem;transition:color .2s}
|
||||
.back-link:hover{color:var(--text)}
|
||||
.recovery-box{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:48px;max-width:500px;width:100%;text-align:center}
|
||||
h1{font-size:1.8rem;font-weight:700;margin-bottom:8px;color:var(--text)}
|
||||
.subtitle{color:var(--text-secondary);margin-bottom:36px;line-height:1.6}
|
||||
.form-group{margin-bottom:24px;text-align:left}
|
||||
label{display:block;font-size:.85rem;font-weight:600;color:var(--muted);margin-bottom:8px;text-transform:uppercase;letter-spacing:1px}
|
||||
input[type="email"]{width:100%;padding:12px 16px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.95rem;font-family:inherit;transition:border-color .2s}
|
||||
input[type="email"]:focus{outline:none;border-color:var(--primary)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 28px;border-radius:8px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit;width:100%}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4)}
|
||||
.btn-primary:disabled{background:var(--muted);cursor:not-allowed;transform:none;box-shadow:none}
|
||||
.btn-secondary{background:rgba(255,255,255,0.06);color:var(--text);border:1px solid var(--border);margin-top:12px}
|
||||
.btn-secondary:hover{background:rgba(255,255,255,0.1);border-color:var(--border-light)}
|
||||
.divider{display:flex;align-items:center;gap:16px;margin:32px 0;color:var(--muted);font-size:.85rem;text-transform:uppercase;letter-spacing:1px}
|
||||
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
|
||||
.result{margin-top:24px;padding:16px;border-radius:8px;font-size:.9rem;line-height:1.6}
|
||||
.result.success{background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);color:var(--accent)}
|
||||
.result.error{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);color:#fca5a5}
|
||||
.masked-key{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:12px;margin:12px 0;font-family:monospace;font-size:.9rem;color:var(--primary-light);word-break:break-all}
|
||||
.spinner{width:20px;height:20px;border:2px solid transparent;border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
@media(max-width:640px){
|
||||
.recovery-box{padding:32px 24px}
|
||||
.back-link{position:static;margin-bottom:24px}
|
||||
.container{align-items:flex-start;padding-top:24px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="back-link">← Back to SnapAPI</a>
|
||||
|
||||
<div class="container">
|
||||
<div class="recovery-box">
|
||||
<h1>🔑 Recover API Key</h1>
|
||||
<p class="subtitle">Lost your API key or need to access your billing portal? Enter your email address below.</p>
|
||||
|
||||
<form id="recovery-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" placeholder="your@email.com" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="recover-btn">
|
||||
<span id="btn-text">Get My API Key</span>
|
||||
<div id="btn-spinner" class="spinner" style="display:none"></div>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-secondary" id="portal-btn">
|
||||
Open Billing Portal Instead
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="result" class="result" style="display:none"></div>
|
||||
|
||||
<div class="divider">or</div>
|
||||
|
||||
<p style="font-size:.85rem;color:var(--muted);line-height:1.6">
|
||||
<strong>Need help?</strong> Contact our support team if you can't access your account or need assistance with your subscription.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('recovery-form');
|
||||
const emailInput = document.getElementById('email');
|
||||
const recoverBtn = document.getElementById('recover-btn');
|
||||
const portalBtn = document.getElementById('portal-btn');
|
||||
const btnText = document.getElementById('btn-text');
|
||||
const btnSpinner = document.getElementById('btn-spinner');
|
||||
const result = document.getElementById('result');
|
||||
|
||||
// Recover API key
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const email = emailInput.value.trim();
|
||||
|
||||
if (!email) {
|
||||
showResult('Please enter a valid email address.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/v1/billing/recover?email=' + encodeURIComponent(email));
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
let message = data.message;
|
||||
if (data.maskedKey) {
|
||||
message += '<div class="masked-key">' + data.maskedKey + '</div>Your full API key has been logged for security. Check your email for the complete key.';
|
||||
}
|
||||
showResult(message, 'success');
|
||||
} else {
|
||||
showResult(data.error || 'Something went wrong. Please try again.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showResult('Network error. Please check your connection and try again.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Access billing portal
|
||||
portalBtn.addEventListener('click', async () => {
|
||||
const email = emailInput.value.trim();
|
||||
|
||||
if (!email) {
|
||||
showResult('Please enter your email address first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true, 'Opening portal...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/v1/billing/portal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
showResult(data.error || 'Could not open billing portal. Please try again.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showResult('Network error. Please check your connection and try again.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
function setLoading(loading, customText = 'Processing...') {
|
||||
recoverBtn.disabled = loading;
|
||||
portalBtn.disabled = loading;
|
||||
|
||||
if (loading) {
|
||||
btnText.textContent = customText;
|
||||
btnSpinner.style.display = 'block';
|
||||
} else {
|
||||
btnText.textContent = 'Get My API Key';
|
||||
btnSpinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showResult(message, type) {
|
||||
result.innerHTML = message;
|
||||
result.className = `result ${type}`;
|
||||
result.style.display = 'block';
|
||||
|
||||
// Auto-hide after 10 seconds for success messages
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
result.style.display = 'none';
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -2,7 +2,19 @@
|
|||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://snapapi.eu/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://snapapi.eu/docs</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://snapapi.eu/use-cases/social-media-previews</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/use-cases/website-monitoring</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/use-cases/pdf-reports</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/compare</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/guides/quick-start</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/pricing</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://snapapi.eu/changelog</loc><changefreq>monthly</changefreq><priority>0.6</priority></url>
|
||||
<url><loc>https://snapapi.eu/status</loc><changefreq>always</changefreq><priority>0.3</priority></url>
|
||||
<url><loc>https://snapapi.eu/blog</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://snapapi.eu/blog/why-screenshot-api</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/blog/screenshot-api-performance</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/blog/dark-mode-screenshots</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/blog/automating-og-images</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://snapapi.eu/impressum.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url>
|
||||
<url><loc>https://snapapi.eu/privacy.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url>
|
||||
<url><loc>https://snapapi.eu/terms.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url>
|
||||
|
|
|
|||
|
|
@ -69,10 +69,15 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
.footer-grid{grid-template-columns:1fr}
|
||||
.nav-links{display:none}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
|
|
@ -84,6 +89,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<section class="legal-content">
|
||||
<div class="legal-container">
|
||||
|
|
@ -236,7 +244,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
<h2>6. Data & Privacy</h2>
|
||||
|
||||
<ul>
|
||||
<li>Your privacy is governed by our <a href="/privacy">Privacy Policy</a></li>
|
||||
<li>Your privacy is governed by our <a href="/privacy.html">Privacy Policy</a></li>
|
||||
<li>All data processing occurs within the European Union</li>
|
||||
<li>Screenshots are generated and returned immediately (not stored)</li>
|
||||
<li>API usage logs retained for billing and security purposes</li>
|
||||
|
|
@ -358,6 +366,8 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
|
|
@ -379,9 +389,9 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
|||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Legal</h5>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
<a href="/impressum.html">Impressum</a>
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
|
|
|
|||
152
public/usage.html
Normal file
152
public/usage.html
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Usage Dashboard — SnapAPI</title>
|
||||
<meta name="description" content="Check your SnapAPI usage statistics, remaining quota, and plan details.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;
|
||||
--accent:#10b981;--orange:#f59e0b;--red:#ef4444;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;
|
||||
}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;flex-direction:column}
|
||||
a{color:var(--primary-light);text-decoration:none}a:hover{color:var(--primary)}
|
||||
nav{background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:24px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
main{flex:1;max-width:560px;margin:80px auto;padding:0 24px;width:100%}
|
||||
h1{font-size:2rem;font-weight:800;margin-bottom:8px}
|
||||
.subtitle{color:var(--text-secondary);margin-bottom:40px}
|
||||
label{display:block;font-size:.85rem;font-weight:600;color:var(--text-secondary);margin-bottom:8px}
|
||||
.input-row{display:flex;gap:12px;margin-bottom:32px}
|
||||
input[type="text"]{flex:1;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px 16px;color:var(--text);font-size:.95rem;font-family:inherit;outline:none;transition:border-color .2s}
|
||||
input[type="text"]:focus{border-color:var(--primary)}
|
||||
button{background:var(--primary);color:#fff;border:none;border-radius:10px;padding:12px 24px;font-weight:600;font-size:.95rem;cursor:pointer;font-family:inherit;transition:background .2s}
|
||||
button:hover{background:var(--primary-dark)}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.result{display:none}
|
||||
.stats{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px}
|
||||
.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px}
|
||||
.stat-label{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:4px}
|
||||
.stat-value{font-size:1.8rem;font-weight:800;font-family:'JetBrains Mono',monospace}
|
||||
.stat-value.plan{text-transform:capitalize;font-family:'Inter',sans-serif;font-size:1.4rem}
|
||||
.progress-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:24px}
|
||||
.progress-header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px}
|
||||
.progress-title{font-weight:600}
|
||||
.progress-pct{font-family:'JetBrains Mono',monospace;font-size:1.1rem;font-weight:700}
|
||||
.progress-bar{height:12px;background:rgba(255,255,255,0.06);border-radius:6px;overflow:hidden}
|
||||
.progress-fill{height:100%;border-radius:6px;background:var(--primary);transition:width .6s ease}
|
||||
.progress-fill.warning{background:var(--orange)}
|
||||
.progress-fill.danger{background:var(--red)}
|
||||
.progress-msg{margin-top:10px;font-size:.85rem;color:var(--muted)}
|
||||
.progress-msg.warning{color:var(--orange)}
|
||||
.progress-msg.danger{color:var(--red)}
|
||||
.links{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px}
|
||||
.links a{font-size:.9rem;font-weight:500}
|
||||
.error-msg{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:10px;padding:14px 18px;color:var(--red);font-size:.9rem;margin-bottom:16px;display:none}
|
||||
footer{border-top:1px solid var(--border);padding:24px;text-align:center;font-size:.8rem;color:var(--muted);margin-top:auto}
|
||||
@media(max-width:480px){.stats{grid-template-columns:1fr}.input-row{flex-direction:column}}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav><div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
</div>
|
||||
</div></nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<h1>Usage Dashboard</h1>
|
||||
<p class="subtitle">Check your API usage for the current billing month.</p>
|
||||
|
||||
<label for="apikey">API Key</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="apikey" placeholder="snap_..." autocomplete="off" spellcheck="false" aria-label="API Key">
|
||||
<button id="checkBtn" type="button">Check Usage</button>
|
||||
</div>
|
||||
|
||||
<div class="error-msg" id="error" role="alert"></div>
|
||||
|
||||
<div class="result" id="result">
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Used / Limit</div>
|
||||
<div class="stat-value" id="usedLimit">—</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Plan</div>
|
||||
<div class="stat-value plan" id="plan">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-header">
|
||||
<span class="progress-title">Monthly Usage</span>
|
||||
<span class="progress-pct" id="pct">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="bar" style="width:0%"></div></div>
|
||||
<div class="progress-msg" id="msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="/">← Home</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>© 2026 Cloonar Technologies GmbH · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></footer>
|
||||
|
||||
<script>
|
||||
const btn=document.getElementById('checkBtn'),input=document.getElementById('apikey'),
|
||||
result=document.getElementById('result'),error=document.getElementById('error');
|
||||
|
||||
async function check(){
|
||||
const key=input.value.trim();
|
||||
if(!key){input.focus();return}
|
||||
btn.disabled=true;btn.textContent='Checking...';
|
||||
error.style.display='none';result.style.display='none';
|
||||
try{
|
||||
const r=await fetch('/v1/usage',{headers:{'Authorization':'Bearer '+key}});
|
||||
if(!r.ok){const e=await r.json().catch(()=>({error:'Request failed'}));throw new Error(e.error||'Error '+r.status)}
|
||||
const d=await r.json();
|
||||
document.getElementById('usedLimit').textContent=d.used.toLocaleString()+' / '+d.limit.toLocaleString();
|
||||
document.getElementById('plan').textContent=d.plan;
|
||||
document.getElementById('pct').textContent=d.percentUsed+'%';
|
||||
const bar=document.getElementById('bar'),msg=document.getElementById('msg');
|
||||
bar.style.width=Math.min(d.percentUsed,100)+'%';
|
||||
bar.className='progress-fill'+(d.percentUsed>95?' danger':d.percentUsed>80?' warning':'');
|
||||
msg.className='progress-msg';
|
||||
if(d.percentUsed>95){msg.textContent='⚠️ Critical — almost at your limit!';msg.classList.add('danger')}
|
||||
else if(d.percentUsed>80){msg.textContent='⚠️ Approaching your monthly limit.';msg.classList.add('warning')}
|
||||
else{msg.textContent=d.remaining.toLocaleString()+' screenshots remaining for '+d.month+'.'}
|
||||
result.style.display='block';
|
||||
}catch(e){error.textContent=e.message;error.style.display='block'}
|
||||
finally{btn.disabled=false;btn.textContent='Check Usage'}
|
||||
}
|
||||
btn.addEventListener('click',check);
|
||||
input.addEventListener('keydown',e=>{if(e.key==='Enter')check()});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
215
public/use-cases/pdf-reports.html
Normal file
215
public/use-cases/pdf-reports.html
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Generate Visual Reports & Thumbnails from Web Content | SnapAPI</title>
|
||||
<meta name="description" content="Use SnapAPI to generate website thumbnails, link preview images, and visual reports from any URL. Create web page previews for dashboards, directories, and content platforms.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="canonical" href="https://snapapi.eu/use-cases/pdf-reports">
|
||||
<meta property="og:title" content="Generate Visual Reports & Thumbnails from Web Content">
|
||||
<meta property="og:description" content="Create website thumbnails and visual reports from any URL with SnapAPI's screenshot API.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://snapapi.eu/use-cases/pdf-reports">
|
||||
<meta property="og:image" content="https://snapapi.eu/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Generate Visual Reports & Thumbnails from Web Content">
|
||||
<meta name="twitter:description" content="Create website thumbnails and visual reports from any URL with SnapAPI's screenshot API.">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Article","headline":"Generate Visual Reports & Thumbnails from Web Content","description":"Use SnapAPI to generate website thumbnails, link preview images, and visual reports from any URL.","author":{"@type":"Organization","name":"Cloonar Technologies GmbH"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"datePublished":"2026-03-02","url":"https://snapapi.eu/use-cases/pdf-reports"}
|
||||
</script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);
|
||||
--accent:#10b981;--accent-glow:rgba(16,185,129,0.15);
|
||||
--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;--radius-lg:16px;--radius-xl:24px;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
::selection{background:var(--primary);color:#fff}
|
||||
.container{max-width:800px;margin:0 auto;padding:0 24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4);color:#fff}
|
||||
.btn-lg{padding:16px 36px;font-size:1.05rem;border-radius:12px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.nav-cta{margin-left:8px}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
.article{padding:80px 0 60px}
|
||||
.article h1{font-size:2.5rem;font-weight:800;line-height:1.2;margin-bottom:24px}
|
||||
.article h2{font-size:1.6rem;font-weight:700;margin:48px 0 16px}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px}
|
||||
.article p{color:var(--text-secondary);font-size:1rem;line-height:1.8;margin-bottom:16px}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 16px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.code-window{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin:24px 0;box-shadow:0 10px 40px rgba(0,0,0,0.2)}
|
||||
.code-titlebar{display:flex;align-items:center;gap:8px;padding:14px 20px;background:rgba(0,0,0,0.2);border-bottom:1px solid var(--border)}
|
||||
.code-dot{width:12px;height:12px;border-radius:50%}
|
||||
.code-dot:nth-child(1){background:#ff5f57}.code-dot:nth-child(2){background:#ffbd2e}.code-dot:nth-child(3){background:#28c840}
|
||||
.code-titlebar span{flex:1;text-align:center;font-size:.8rem;color:var(--muted);font-weight:500}
|
||||
.code-body{padding:24px;font-family:'JetBrains Mono',monospace;font-size:.85rem;line-height:1.9;overflow-x:auto;color:var(--text-secondary)}
|
||||
.code-body .kw{color:var(--purple)}.code-body .str{color:var(--accent)}.code-body .cmt{color:#475569;font-style:italic}.code-body .fn{color:var(--primary-light)}.code-body .prop{color:var(--orange)}
|
||||
.cta-box{background:linear-gradient(135deg,rgba(79,143,255,0.1) 0%,rgba(167,139,250,0.1) 100%);border:1px solid rgba(79,143,255,0.2);border-radius:var(--radius-xl);padding:48px;text-align:center;margin:48px 0}
|
||||
.cta-box h2{font-size:1.8rem;font-weight:800;margin-bottom:12px}
|
||||
.cta-box p{color:var(--text-secondary);margin-bottom:24px}
|
||||
.related{margin:48px 0;padding:32px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg)}
|
||||
.related h3{font-size:1.1rem;font-weight:700;margin-bottom:16px}
|
||||
.related a{display:block;padding:8px 0;font-size:.95rem}
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:640px){
|
||||
.article{padding:40px 0 30px}
|
||||
.article h1{font-size:1.8rem}
|
||||
.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}
|
||||
.nav-links.show{display:flex}
|
||||
.nav-mobile{display:block}
|
||||
.footer-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#playground">Try It Free</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<article class="article">
|
||||
<div class="container">
|
||||
<h1>Generate Visual Reports & Thumbnails from Web Content</h1>
|
||||
|
||||
<p>Building a link directory, content aggregator, or dashboard? You need thumbnail previews of web pages. Rather than relying on unreliable meta images or building your own headless browser infrastructure, use SnapAPI to generate accurate website thumbnails on demand.</p>
|
||||
|
||||
<h2>Common Use Cases</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>Link preview thumbnails</strong> — Show visual previews of URLs in chat apps, bookmarking tools, or CMS platforms.</li>
|
||||
<li><strong>Report generation</strong> — Capture web dashboards (Grafana, analytics) as images for PDF reports or email digests.</li>
|
||||
<li><strong>Content directories</strong> — Generate thumbnails for website listings, app stores, or portfolio showcases.</li>
|
||||
<li><strong>Email newsletters</strong> — Embed live website previews in newsletters instead of stale static images.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Code Example</h2>
|
||||
|
||||
<h3>Generate a Website Thumbnail</h3>
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>Node.js</span></div>
|
||||
<div class="code-body"><pre><span class="kw">async function</span> <span class="fn">getThumbnail</span>(url) {
|
||||
<span class="kw">const</span> res = <span class="kw">await</span> <span class="fn">fetch</span>(<span class="str">'https://snapapi.eu/v1/screenshot'</span>, {
|
||||
method: <span class="str">'POST'</span>,
|
||||
headers: {
|
||||
<span class="str">'Content-Type'</span>: <span class="str">'application/json'</span>,
|
||||
<span class="str">'X-API-Key'</span>: process.env.<span class="prop">SNAPAPI_KEY</span>
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
width: <span class="prop">1280</span>,
|
||||
height: <span class="prop">800</span>,
|
||||
format: <span class="str">'webp'</span>, <span class="cmt">// Smaller file size</span>
|
||||
quality: <span class="prop">80</span>
|
||||
})
|
||||
});
|
||||
|
||||
<span class="kw">return</span> Buffer.from(<span class="kw">await</span> res.<span class="fn">arrayBuffer</span>());
|
||||
}
|
||||
|
||||
<span class="cmt">// Generate thumbnails for a list of URLs</span>
|
||||
<span class="kw">const</span> urls = [<span class="str">'https://github.com'</span>, <span class="str">'https://news.ycombinator.com'</span>];
|
||||
<span class="kw">for</span> (<span class="kw">const</span> url <span class="kw">of</span> urls) {
|
||||
<span class="kw">const</span> img = <span class="kw">await</span> <span class="fn">getThumbnail</span>(url);
|
||||
<span class="cmt">// Store in database, upload to CDN, etc.</span>
|
||||
}</pre></div>
|
||||
</div>
|
||||
|
||||
<h3>Batch Processing with Caching</h3>
|
||||
<p>For high-volume use, cache thumbnails and refresh them periodically:</p>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>Bash</span></div>
|
||||
<div class="code-body"><pre><span class="cmt"># Quick thumbnail via cURL</span>
|
||||
curl -X POST https://snapapi.eu/v1/screenshot \
|
||||
-H <span class="str">"Content-Type: application/json"</span> \
|
||||
-H <span class="str">"X-API-Key: $SNAPAPI_KEY"</span> \
|
||||
-d <span class="str">'{"url":"https://example.com","width":1280,"height":800,"format":"webp"}'</span> \
|
||||
--output thumbnail.webp
|
||||
|
||||
<span class="cmt"># Resize locally for smaller thumbnails</span>
|
||||
convert thumbnail.webp -resize 400x250 thumbnail-sm.webp</pre></div>
|
||||
</div>
|
||||
|
||||
<h2>Why SnapAPI for Thumbnails?</h2>
|
||||
<ul>
|
||||
<li><strong>Accurate rendering</strong> — Real Chromium browser captures the page exactly as users see it, including JavaScript-rendered content.</li>
|
||||
<li><strong>Multiple formats</strong> — PNG for lossless quality, WebP for smaller file sizes, JPEG for maximum compatibility.</li>
|
||||
<li><strong>Custom viewport</strong> — Set any width/height to capture desktop, tablet, or mobile views.</li>
|
||||
<li><strong>EU-hosted</strong> — All screenshots rendered in Germany. GDPR compliant by default.</li>
|
||||
</ul>
|
||||
|
||||
<div class="cta-box">
|
||||
<h2>Start Generating Thumbnails</h2>
|
||||
<p>Try it free in the playground — no signup needed.</p>
|
||||
<a href="/#pricing" class="btn btn-primary btn-lg">Get Your API Key →</a>
|
||||
</div>
|
||||
|
||||
<div class="related">
|
||||
<h3>Related Use Cases</h3>
|
||||
<a href="/use-cases/social-media-previews">Generate OG Images & Social Media Previews →</a>
|
||||
<a href="/use-cases/website-monitoring">Visual Website Monitoring & Regression Testing →</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand"><h4>📸 SnapAPI</h4><p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p></div>
|
||||
<div class="footer-col"><h5>Product</h5><a href="/#features">Features</a><a href="/#pricing">Pricing</a><a href="/#playground">Playground</a><a href="/docs">API Docs</a></div>
|
||||
<div class="footer-col"><h5>Developers</h5><a href="/docs">Swagger / OpenAPI</a><a href="/health">Status</a></div>
|
||||
<div class="footer-col"><h5>Legal</h5><a href="/impressum.html">Impressum</a><a href="/privacy.html">Privacy Policy</a><a href="/terms.html">Terms of Service</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>Linzer Straße 192/1/2, 1140 Wien, Austria 🇦🇹</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
204
public/use-cases/social-media-previews.html
Normal file
204
public/use-cases/social-media-previews.html
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Generate OG Images & Social Media Previews with a Screenshot API | SnapAPI</title>
|
||||
<meta name="description" content="Use SnapAPI to generate dynamic OG images and social media preview cards from HTML templates. Render custom Open Graph images on-the-fly with a simple API call.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="canonical" href="https://snapapi.eu/use-cases/social-media-previews">
|
||||
<meta property="og:title" content="Generate OG Images & Social Media Previews with a Screenshot API">
|
||||
<meta property="og:description" content="Render dynamic Open Graph images and Twitter cards from HTML templates using SnapAPI.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://snapapi.eu/use-cases/social-media-previews">
|
||||
<meta property="og:image" content="https://snapapi.eu/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Generate OG Images & Social Media Previews with a Screenshot API">
|
||||
<meta name="twitter:description" content="Render dynamic Open Graph images and Twitter cards from HTML templates using SnapAPI.">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Article","headline":"Generate OG Images & Social Media Previews with a Screenshot API","description":"Use SnapAPI to generate dynamic OG images and social media preview cards from HTML templates.","author":{"@type":"Organization","name":"Cloonar Technologies GmbH"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"datePublished":"2026-03-02","url":"https://snapapi.eu/use-cases/social-media-previews"}
|
||||
</script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);
|
||||
--accent:#10b981;--accent-glow:rgba(16,185,129,0.15);
|
||||
--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;--radius-lg:16px;--radius-xl:24px;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
::selection{background:var(--primary);color:#fff}
|
||||
.container{max-width:800px;margin:0 auto;padding:0 24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4);color:#fff}
|
||||
.btn-lg{padding:16px 36px;font-size:1.05rem;border-radius:12px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.nav-cta{margin-left:8px}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
.article{padding:80px 0 60px}
|
||||
.article h1{font-size:2.5rem;font-weight:800;line-height:1.2;margin-bottom:24px}
|
||||
.article h2{font-size:1.6rem;font-weight:700;margin:48px 0 16px}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px}
|
||||
.article p{color:var(--text-secondary);font-size:1rem;line-height:1.8;margin-bottom:16px}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 16px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.code-window{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin:24px 0;box-shadow:0 10px 40px rgba(0,0,0,0.2)}
|
||||
.code-titlebar{display:flex;align-items:center;gap:8px;padding:14px 20px;background:rgba(0,0,0,0.2);border-bottom:1px solid var(--border)}
|
||||
.code-dot{width:12px;height:12px;border-radius:50%}
|
||||
.code-dot:nth-child(1){background:#ff5f57}.code-dot:nth-child(2){background:#ffbd2e}.code-dot:nth-child(3){background:#28c840}
|
||||
.code-titlebar span{flex:1;text-align:center;font-size:.8rem;color:var(--muted);font-weight:500}
|
||||
.code-body{padding:24px;font-family:'JetBrains Mono',monospace;font-size:.85rem;line-height:1.9;overflow-x:auto;color:var(--text-secondary)}
|
||||
.code-body .kw{color:var(--purple)}.code-body .str{color:var(--accent)}.code-body .cmt{color:#475569;font-style:italic}.code-body .fn{color:var(--primary-light)}.code-body .prop{color:var(--orange)}
|
||||
.cta-box{background:linear-gradient(135deg,rgba(79,143,255,0.1) 0%,rgba(167,139,250,0.1) 100%);border:1px solid rgba(79,143,255,0.2);border-radius:var(--radius-xl);padding:48px;text-align:center;margin:48px 0}
|
||||
.cta-box h2{font-size:1.8rem;font-weight:800;margin-bottom:12px}
|
||||
.cta-box p{color:var(--text-secondary);margin-bottom:24px}
|
||||
.related{margin:48px 0;padding:32px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg)}
|
||||
.related h3{font-size:1.1rem;font-weight:700;margin-bottom:16px}
|
||||
.related a{display:block;padding:8px 0;font-size:.95rem}
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:640px){
|
||||
.article{padding:40px 0 30px}
|
||||
.article h1{font-size:1.8rem}
|
||||
.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}
|
||||
.nav-links.show{display:flex}
|
||||
.nav-mobile{display:block}
|
||||
.footer-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#playground">Try It Free</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<article class="article">
|
||||
<div class="container">
|
||||
<h1>Generate OG Images & Social Media Previews with a Screenshot API</h1>
|
||||
|
||||
<p>When you share a link on Twitter, LinkedIn, or Slack, the platform fetches an Open Graph image to display as a preview card. Static OG images are fine for homepages — but what about blog posts, user profiles, or product pages that need <strong>unique, dynamic preview images</strong>?</p>
|
||||
|
||||
<p>SnapAPI lets you render any HTML page as a PNG image via a simple API call. Build an HTML template with your title, author, and branding, host it on a URL, and let SnapAPI screenshot it into a pixel-perfect OG image.</p>
|
||||
|
||||
<h2>How It Works</h2>
|
||||
|
||||
<p>The workflow is straightforward:</p>
|
||||
<ol>
|
||||
<li><strong>Create an HTML template</strong> — Design your OG card layout (1200×630px) with CSS. Use query parameters for dynamic content.</li>
|
||||
<li><strong>Call SnapAPI</strong> — Pass the template URL to the screenshot endpoint. SnapAPI renders it in a real Chromium browser.</li>
|
||||
<li><strong>Serve the image</strong> — Use the returned PNG as your <code>og:image</code> meta tag, or cache it on your CDN.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Code Example</h2>
|
||||
|
||||
<h3>Screenshot an OG Template</h3>
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>Node.js</span></div>
|
||||
<div class="code-body"><pre><span class="kw">const</span> response = <span class="kw">await</span> <span class="fn">fetch</span>(<span class="str">'https://snapapi.eu/v1/screenshot'</span>, {
|
||||
method: <span class="str">'POST'</span>,
|
||||
headers: {
|
||||
<span class="str">'Content-Type'</span>: <span class="str">'application/json'</span>,
|
||||
<span class="str">'X-API-Key'</span>: process.env.<span class="prop">SNAPAPI_KEY</span>
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: <span class="str">'https://yoursite.com/og-template?title=My+Post&author=Jane'</span>,
|
||||
width: <span class="prop">1200</span>,
|
||||
height: <span class="prop">630</span>,
|
||||
format: <span class="str">'png'</span>
|
||||
})
|
||||
});
|
||||
|
||||
<span class="kw">const</span> imageBuffer = <span class="kw">await</span> response.<span class="fn">arrayBuffer</span>();
|
||||
<span class="cmt">// Upload to S3, serve from CDN, or return directly</span></pre></div>
|
||||
</div>
|
||||
|
||||
<h3>Dynamic Meta Tags</h3>
|
||||
<p>Point your <code>og:image</code> to a serverless function that calls SnapAPI on-the-fly:</p>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>HTML</span></div>
|
||||
<div class="code-body"><pre><span class="cmt"><!-- In your page's <head> --></span>
|
||||
<meta property=<span class="str">"og:image"</span>
|
||||
content=<span class="str">"https://yoursite.com/api/og?title=My+Blog+Post"</span> />
|
||||
<meta property=<span class="str">"og:image:width"</span> content=<span class="str">"1200"</span> />
|
||||
<meta property=<span class="str">"og:image:height"</span> content=<span class="str">"630"</span> /></pre></div>
|
||||
</div>
|
||||
|
||||
<h2>Why Use SnapAPI for OG Images?</h2>
|
||||
<ul>
|
||||
<li><strong>Real browser rendering</strong> — Full CSS support, custom fonts, gradients, SVGs. No template language limitations.</li>
|
||||
<li><strong>EU-hosted, GDPR compliant</strong> — All rendering happens on servers in Germany. No data leaves the EU.</li>
|
||||
<li><strong>Fast</strong> — Typical render times under 2 seconds. Cache the result and serve instantly.</li>
|
||||
<li><strong>Simple API</strong> — One POST request, one image back. No SDKs required.</li>
|
||||
</ul>
|
||||
|
||||
<div class="cta-box">
|
||||
<h2>Start Generating OG Images</h2>
|
||||
<p>Try SnapAPI free in the playground — no signup needed.</p>
|
||||
<a href="/#pricing" class="btn btn-primary btn-lg">Get Your API Key →</a>
|
||||
</div>
|
||||
|
||||
<div class="related">
|
||||
<h3>Related Use Cases</h3>
|
||||
<a href="/use-cases/website-monitoring">Visual Website Monitoring & Regression Testing →</a>
|
||||
<a href="/use-cases/pdf-reports">Generate Visual Reports & Thumbnails from Web Content →</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand"><h4>📸 SnapAPI</h4><p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p></div>
|
||||
<div class="footer-col"><h5>Product</h5><a href="/#features">Features</a><a href="/#pricing">Pricing</a><a href="/#playground">Playground</a><a href="/docs">API Docs</a></div>
|
||||
<div class="footer-col"><h5>Developers</h5><a href="/docs">Swagger / OpenAPI</a><a href="/health">Status</a></div>
|
||||
<div class="footer-col"><h5>Legal</h5><a href="/impressum.html">Impressum</a><a href="/privacy.html">Privacy Policy</a><a href="/terms.html">Terms of Service</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>Linzer Straße 192/1/2, 1140 Wien, Austria 🇦🇹</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
221
public/use-cases/website-monitoring.html
Normal file
221
public/use-cases/website-monitoring.html
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Visual Website Monitoring & Regression Testing with a Screenshot API | SnapAPI</title>
|
||||
<meta name="description" content="Use SnapAPI for automated visual website monitoring and regression testing. Take scheduled screenshots to detect layout changes, broken pages, and visual bugs.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="canonical" href="https://snapapi.eu/use-cases/website-monitoring">
|
||||
<meta property="og:title" content="Visual Website Monitoring & Regression Testing with a Screenshot API">
|
||||
<meta property="og:description" content="Automate visual regression testing with scheduled screenshots via SnapAPI.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://snapapi.eu/use-cases/website-monitoring">
|
||||
<meta property="og:image" content="https://snapapi.eu/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Visual Website Monitoring & Regression Testing with a Screenshot API">
|
||||
<meta name="twitter:description" content="Automate visual regression testing with scheduled screenshots via SnapAPI.">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Article","headline":"Visual Website Monitoring & Regression Testing with a Screenshot API","description":"Use SnapAPI for automated visual website monitoring and regression testing.","author":{"@type":"Organization","name":"Cloonar Technologies GmbH"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"datePublished":"2026-03-02","url":"https://snapapi.eu/use-cases/website-monitoring"}
|
||||
</script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;
|
||||
--border:#1e2a3f;--border-light:#2a3752;
|
||||
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
|
||||
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);
|
||||
--accent:#10b981;--accent-glow:rgba(16,185,129,0.15);
|
||||
--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;
|
||||
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
|
||||
--radius:12px;--radius-lg:16px;--radius-xl:24px;
|
||||
}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
|
||||
a:hover{color:var(--primary)}
|
||||
::selection{background:var(--primary);color:#fff}
|
||||
.container{max-width:800px;margin:0 auto;padding:0 24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
|
||||
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);box-shadow:0 6px 28px rgba(79,143,255,0.4);color:#fff}
|
||||
.btn-lg{padding:16px 36px;font-size:1.05rem;border-radius:12px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
|
||||
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
|
||||
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.nav-links{display:flex;gap:32px;align-items:center}
|
||||
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
|
||||
.nav-links a:hover{color:var(--text)}
|
||||
.nav-cta{margin-left:8px}
|
||||
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
|
||||
.article{padding:80px 0 60px}
|
||||
.article h1{font-size:2.5rem;font-weight:800;line-height:1.2;margin-bottom:24px}
|
||||
.article h2{font-size:1.6rem;font-weight:700;margin:48px 0 16px}
|
||||
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px}
|
||||
.article p{color:var(--text-secondary);font-size:1rem;line-height:1.8;margin-bottom:16px}
|
||||
.article ul,.article ol{color:var(--text-secondary);margin:0 0 16px 24px;line-height:1.8}
|
||||
.article li{margin-bottom:8px}
|
||||
.code-window{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin:24px 0;box-shadow:0 10px 40px rgba(0,0,0,0.2)}
|
||||
.code-titlebar{display:flex;align-items:center;gap:8px;padding:14px 20px;background:rgba(0,0,0,0.2);border-bottom:1px solid var(--border)}
|
||||
.code-dot{width:12px;height:12px;border-radius:50%}
|
||||
.code-dot:nth-child(1){background:#ff5f57}.code-dot:nth-child(2){background:#ffbd2e}.code-dot:nth-child(3){background:#28c840}
|
||||
.code-titlebar span{flex:1;text-align:center;font-size:.8rem;color:var(--muted);font-weight:500}
|
||||
.code-body{padding:24px;font-family:'JetBrains Mono',monospace;font-size:.85rem;line-height:1.9;overflow-x:auto;color:var(--text-secondary)}
|
||||
.code-body .kw{color:var(--purple)}.code-body .str{color:var(--accent)}.code-body .cmt{color:#475569;font-style:italic}.code-body .fn{color:var(--primary-light)}.code-body .prop{color:var(--orange)}
|
||||
.cta-box{background:linear-gradient(135deg,rgba(79,143,255,0.1) 0%,rgba(167,139,250,0.1) 100%);border:1px solid rgba(79,143,255,0.2);border-radius:var(--radius-xl);padding:48px;text-align:center;margin:48px 0}
|
||||
.cta-box h2{font-size:1.8rem;font-weight:800;margin-bottom:12px}
|
||||
.cta-box p{color:var(--text-secondary);margin-bottom:24px}
|
||||
.related{margin:48px 0;padding:32px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg)}
|
||||
.related h3{font-size:1.1rem;font-weight:700;margin-bottom:16px}
|
||||
.related a{display:block;padding:8px 0;font-size:.95rem}
|
||||
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
|
||||
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
|
||||
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
|
||||
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
|
||||
.footer-col a:hover{color:var(--text)}
|
||||
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
|
||||
@media(max-width:640px){
|
||||
.article{padding:40px 0 30px}
|
||||
.article h1{font-size:1.8rem}
|
||||
.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}
|
||||
.nav-links.show{display:flex}
|
||||
.nav-mobile{display:block}
|
||||
.footer-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
.skip-link{position:absolute;top:-100%;left:50%;transform:translateX(-50%);background:var(--primary);color:#fff;padding:12px 24px;border-radius:0 0 8px 8px;font-weight:600;font-size:.9rem;z-index:1000;transition:top .2s}
|
||||
.skip-link:focus{top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#playground">Try It Free</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<a href="/docs">API Docs</a>
|
||||
<a href="/#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
|
||||
</div>
|
||||
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu">☰</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
|
||||
<article class="article">
|
||||
<div class="container">
|
||||
<h1>Visual Website Monitoring & Regression Testing</h1>
|
||||
|
||||
<p>CSS changes, dependency updates, or CMS edits can silently break your site's layout. Traditional uptime monitoring checks if a page returns 200 — but it won't tell you if your hero section is now overlapping your navigation bar.</p>
|
||||
|
||||
<p><strong>Visual monitoring</strong> solves this by taking periodic screenshots and comparing them against a known-good baseline. SnapAPI provides the screenshot capture part — you bring the comparison logic or just review the images manually.</p>
|
||||
|
||||
<h2>How It Works</h2>
|
||||
|
||||
<ol>
|
||||
<li><strong>Schedule screenshots</strong> — Use a cron job or CI pipeline to call SnapAPI at regular intervals (daily, hourly, per-deploy).</li>
|
||||
<li><strong>Store the results</strong> — Save screenshots to S3, a database, or your local filesystem with timestamps.</li>
|
||||
<li><strong>Compare</strong> — Use pixel-diff tools like <code>pixelmatch</code> or <code>resemble.js</code> to detect visual changes. Alert when the diff exceeds a threshold.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Code Example</h2>
|
||||
|
||||
<h3>Daily Screenshot Cron Job</h3>
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>Node.js</span></div>
|
||||
<div class="code-body"><pre><span class="kw">import</span> fs <span class="kw">from</span> <span class="str">'fs'</span>;
|
||||
|
||||
<span class="kw">const</span> PAGES = [
|
||||
<span class="str">'https://yoursite.com'</span>,
|
||||
<span class="str">'https://yoursite.com/pricing'</span>,
|
||||
<span class="str">'https://yoursite.com/docs'</span>,
|
||||
];
|
||||
|
||||
<span class="kw">for</span> (<span class="kw">const</span> url <span class="kw">of</span> PAGES) {
|
||||
<span class="kw">const</span> res = <span class="kw">await</span> <span class="fn">fetch</span>(<span class="str">'https://snapapi.eu/v1/screenshot'</span>, {
|
||||
method: <span class="str">'POST'</span>,
|
||||
headers: {
|
||||
<span class="str">'Content-Type'</span>: <span class="str">'application/json'</span>,
|
||||
<span class="str">'X-API-Key'</span>: process.env.<span class="prop">SNAPAPI_KEY</span>
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
width: <span class="prop">1440</span>,
|
||||
height: <span class="prop">900</span>,
|
||||
format: <span class="str">'png'</span>,
|
||||
fullPage: <span class="prop">true</span>
|
||||
})
|
||||
});
|
||||
|
||||
<span class="kw">const</span> slug = <span class="kw">new</span> URL(url).pathname.replace(<span class="str">/\//g</span>, <span class="str">'_'</span>) || <span class="str">'home'</span>;
|
||||
<span class="kw">const</span> date = <span class="kw">new</span> Date().toISOString().slice(<span class="prop">0</span>, <span class="prop">10</span>);
|
||||
fs.<span class="fn">writeFileSync</span>(<span class="str">`screenshots/${slug}_${date}.png`</span>,
|
||||
Buffer.from(<span class="kw">await</span> res.<span class="fn">arrayBuffer</span>()));
|
||||
}
|
||||
|
||||
<span class="cmt">// Run via: node monitor.mjs (cron: 0 6 * * *)</span></pre></div>
|
||||
</div>
|
||||
|
||||
<h3>CI Pipeline Integration</h3>
|
||||
<p>Add visual regression checks to your deployment pipeline. Take a screenshot after each deploy and compare it to the previous version:</p>
|
||||
|
||||
<div class="code-window">
|
||||
<div class="code-titlebar"><span class="code-dot"></span><span class="code-dot"></span><span class="code-dot"></span><span>Bash</span></div>
|
||||
<div class="code-body"><pre><span class="cmt"># In your CI pipeline (GitHub Actions, GitLab CI, etc.)</span>
|
||||
curl -X POST https://snapapi.eu/v1/screenshot \
|
||||
-H <span class="str">"Content-Type: application/json"</span> \
|
||||
-H <span class="str">"X-API-Key: $SNAPAPI_KEY"</span> \
|
||||
-d <span class="str">'{"url":"https://staging.yoursite.com","width":1440,"height":900,"format":"png"}'</span> \
|
||||
--output screenshot-after-deploy.png
|
||||
|
||||
<span class="cmt"># Compare with baseline using pixelmatch, ImageMagick, etc.</span></pre></div>
|
||||
</div>
|
||||
|
||||
<h2>Use Cases for Visual Monitoring</h2>
|
||||
<ul>
|
||||
<li><strong>Pre/post deploy checks</strong> — Catch CSS regressions before users see them.</li>
|
||||
<li><strong>Third-party widget monitoring</strong> — Detect when embedded widgets (chat, analytics banners) break your layout.</li>
|
||||
<li><strong>Competitor tracking</strong> — Screenshot competitor pages periodically to track pricing or feature changes.</li>
|
||||
<li><strong>Compliance archiving</strong> — Keep dated visual records of your pages for regulatory requirements.</li>
|
||||
</ul>
|
||||
|
||||
<div class="cta-box">
|
||||
<h2>Start Monitoring Visually</h2>
|
||||
<p>Try SnapAPI free in the playground. Set up your first visual monitor in minutes.</p>
|
||||
<a href="/#pricing" class="btn btn-primary btn-lg">Get Your API Key →</a>
|
||||
</div>
|
||||
|
||||
<div class="related">
|
||||
<h3>Related Use Cases</h3>
|
||||
<a href="/use-cases/social-media-previews">Generate OG Images & Social Media Previews →</a>
|
||||
<a href="/use-cases/pdf-reports">Generate Visual Reports & Thumbnails from Web Content →</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand"><h4>📸 SnapAPI</h4><p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p></div>
|
||||
<div class="footer-col"><h5>Product</h5><a href="/#features">Features</a><a href="/#pricing">Pricing</a><a href="/#playground">Playground</a><a href="/docs">API Docs</a></div>
|
||||
<div class="footer-col"><h5>Developers</h5><a href="/docs">Swagger / OpenAPI</a><a href="/health">Status</a></div>
|
||||
<div class="footer-col"><h5>Legal</h5><a href="/impressum.html">Impressum</a><a href="/privacy.html">Privacy Policy</a><a href="/terms.html">Terms of Service</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
|
||||
<span>Linzer Straße 192/1/2, 1140 Wien, Austria 🇦🇹</span>
|
||||
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
267
sdk/node/README.md
Normal file
267
sdk/node/README.md
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# SnapAPI Node.js SDK
|
||||
|
||||
Official Node.js client for [SnapAPI](https://snapapi.eu) — the EU-hosted screenshot API.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install snapapi
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { SnapAPI } from 'snapapi';
|
||||
import fs from 'fs';
|
||||
|
||||
const snap = new SnapAPI('your-api-key');
|
||||
|
||||
// Simple screenshot
|
||||
const png = await snap.capture('https://example.com');
|
||||
fs.writeFileSync('screenshot.png', png);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Screenshot
|
||||
|
||||
```typescript
|
||||
const screenshot = await snap.capture('https://example.com');
|
||||
```
|
||||
|
||||
### With Options
|
||||
|
||||
```typescript
|
||||
const screenshot = await snap.capture('https://example.com', {
|
||||
format: 'jpeg',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
quality: 90,
|
||||
});
|
||||
```
|
||||
|
||||
### Full-Page Capture
|
||||
|
||||
```typescript
|
||||
const screenshot = await snap.capture({
|
||||
url: 'https://example.com/blog',
|
||||
fullPage: true,
|
||||
format: 'png',
|
||||
deviceScale: 2, // Retina
|
||||
});
|
||||
```
|
||||
|
||||
### Mobile Viewport
|
||||
|
||||
```typescript
|
||||
const screenshot = await snap.capture({
|
||||
url: 'https://example.com',
|
||||
width: 375,
|
||||
height: 812,
|
||||
deviceScale: 2,
|
||||
});
|
||||
```
|
||||
|
||||
### Wait for Dynamic Content
|
||||
|
||||
```typescript
|
||||
const screenshot = await snap.capture({
|
||||
url: 'https://example.com/dashboard',
|
||||
waitForSelector: '#chart-loaded',
|
||||
waitUntil: 'networkidle2',
|
||||
});
|
||||
```
|
||||
|
||||
### Dark Mode Capture
|
||||
|
||||
```typescript
|
||||
// Capture in dark mode (prefers-color-scheme: dark)
|
||||
const darkScreenshot = await snap.capture({
|
||||
url: 'https://example.com',
|
||||
darkMode: true,
|
||||
format: 'png',
|
||||
});
|
||||
```
|
||||
|
||||
### Custom User Agent
|
||||
|
||||
```typescript
|
||||
// Set a custom User-Agent string for the request
|
||||
const screenshot = await snap.capture({
|
||||
url: 'https://example.com',
|
||||
userAgent: 'Mozilla/5.0 (compatible; SnapAPI/1.0)',
|
||||
format: 'png',
|
||||
});
|
||||
```
|
||||
|
||||
### Hide Elements Before Capture
|
||||
|
||||
```typescript
|
||||
// Hide cookie banners, popups, ads
|
||||
const cleanScreenshot = await snap.capture({
|
||||
url: 'https://example.com',
|
||||
hideSelectors: [
|
||||
'.cookie-banner',
|
||||
'.popup-overlay',
|
||||
'#advertisement',
|
||||
'.tracking-notice'
|
||||
],
|
||||
});
|
||||
|
||||
// Hide single element
|
||||
const singleHide = await snap.capture('https://example.com', {
|
||||
hideSelectors: '.newsletter-popup',
|
||||
});
|
||||
```
|
||||
|
||||
### Custom CSS Injection
|
||||
|
||||
```typescript
|
||||
// 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: ['.cookie-banner'],
|
||||
});
|
||||
```
|
||||
|
||||
### JavaScript Injection
|
||||
|
||||
```typescript
|
||||
// Execute custom JavaScript before capture
|
||||
const interactiveScreenshot = await snap.capture({
|
||||
url: 'https://example.com',
|
||||
js: `
|
||||
// Dismiss modal popup
|
||||
document.querySelector('.modal-overlay')?.remove();
|
||||
|
||||
// Scroll to specific content
|
||||
window.scrollTo(0, 500);
|
||||
|
||||
// Click button to reveal content
|
||||
document.querySelector('#show-more-btn')?.click();
|
||||
|
||||
// Wait for animation to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
`,
|
||||
});
|
||||
|
||||
// Combine with other options for complex scenarios
|
||||
const complexCapture = await snap.capture({
|
||||
url: 'https://example.com/app',
|
||||
js: 'document.querySelector(".sidebar").style.display = "none";',
|
||||
css: 'body { zoom: 0.8 }',
|
||||
waitForSelector: '#content-loaded',
|
||||
hideSelectors: ['.ad-banner', '.cookie-notice'],
|
||||
});
|
||||
```
|
||||
|
||||
### Crop Specific Areas (Clip)
|
||||
|
||||
```typescript
|
||||
// Crop a specific rectangular area from the screenshot
|
||||
const croppedScreenshot = await snap.capture({
|
||||
url: 'https://example.com',
|
||||
clip: {
|
||||
x: 100, // X coordinate (pixels from left)
|
||||
y: 50, // Y coordinate (pixels from top)
|
||||
width: 800, // Width of the crop area
|
||||
height: 600, // Height of the crop area
|
||||
},
|
||||
});
|
||||
|
||||
// Useful for capturing specific UI elements or sections
|
||||
const headerScreenshot = await snap.capture({
|
||||
url: 'https://example.com',
|
||||
clip: { x: 0, y: 0, width: 1280, height: 120 }, // Top banner only
|
||||
format: 'png',
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new SnapAPI(apiKey, config?)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `apiKey` | `string` | Your SnapAPI API key (required) |
|
||||
| `config.baseUrl` | `string` | API base URL (default: `https://snapapi.eu`) |
|
||||
| `config.timeout` | `number` | Request timeout in ms (default: `30000`) |
|
||||
|
||||
### `snap.capture(url, options?)` / `snap.capture(options)`
|
||||
|
||||
Returns a `Promise<Buffer>` containing the screenshot image.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `url` | `string` | — | URL to capture (required) |
|
||||
| `format` | `'png' \| 'jpeg' \| 'webp'` | `'png'` | Output format |
|
||||
| `width` | `number` | `1280` | Viewport width (320–3840) |
|
||||
| `height` | `number` | `800` | Viewport height (200–2160) |
|
||||
| `fullPage` | `boolean` | `false` | Capture full scrollable page |
|
||||
| `quality` | `number` | `80` | JPEG/WebP quality (1–100) |
|
||||
| `waitForSelector` | `string` | — | CSS selector to wait for |
|
||||
| `deviceScale` | `number` | `1` | Device pixel ratio (1–3) |
|
||||
| `delay` | `number` | `0` | Extra delay in ms (0–5000) |
|
||||
| `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) |
|
||||
| `clip` | `object` | — | Crop rectangle: `{x, y, width, height}` (mutually exclusive with fullPage/selector) |
|
||||
|
||||
### `snap.batch(urls, options?)`
|
||||
|
||||
Take multiple screenshots in a single request. Each URL counts as one screenshot toward usage limits.
|
||||
|
||||
```typescript
|
||||
const results = await snap.batch(
|
||||
['https://example.com', 'https://example.org'],
|
||||
{ format: 'jpeg', width: 1920, height: 1080 }
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'success') {
|
||||
fs.writeFileSync(`${result.url}.jpg`, Buffer.from(result.image, 'base64'));
|
||||
} else {
|
||||
console.error(`Failed: ${result.url} — ${result.error}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Max 10 URLs per batch**
|
||||
- All options (format, width, height, etc.) are shared across all URLs
|
||||
- Returns partial results — some may succeed while others fail
|
||||
- Response is always JSON with `{ results: [...] }`
|
||||
|
||||
### `snap.health()`
|
||||
|
||||
Returns API health status.
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { SnapAPI, SnapAPIError } from 'snapapi';
|
||||
|
||||
try {
|
||||
const screenshot = await snap.capture('https://example.com');
|
||||
} catch (err) {
|
||||
if (err instanceof SnapAPIError) {
|
||||
console.error(`API error ${err.status}: ${err.detail}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## EU-Hosted & GDPR Compliant
|
||||
|
||||
SnapAPI runs entirely on EU infrastructure (Germany). Your data never leaves the EU. [Learn more](https://snapapi.eu).
|
||||
|
||||
## License
|
||||
|
||||
MIT — [Cloonar Technologies GmbH](https://snapapi.eu)
|
||||
1484
sdk/node/package-lock.json
generated
Normal file
1484
sdk/node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
48
sdk/node/package.json
Normal file
48
sdk/node/package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "snapapi",
|
||||
"version": "1.0.0",
|
||||
"description": "Official Node.js SDK for SnapAPI — EU-hosted screenshot API",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && tsc --module commonjs --outDir dist/cjs && mv dist/cjs/index.js dist/index.cjs && rm -rf dist/cjs",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"screenshot",
|
||||
"api",
|
||||
"webpage",
|
||||
"capture",
|
||||
"puppeteer",
|
||||
"headless",
|
||||
"eu",
|
||||
"gdpr"
|
||||
],
|
||||
"author": "Cloonar Technologies GmbH",
|
||||
"license": "MIT",
|
||||
"homepage": "https://snapapi.eu",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.cloonar.com/openclawd/SnapAPI"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
200
sdk/node/src/index.ts
Normal file
200
sdk/node/src/index.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* SnapAPI Node.js SDK
|
||||
* Official client for https://snapapi.eu — EU-hosted screenshot API
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { SnapAPI } from 'snapapi';
|
||||
*
|
||||
* const snap = new SnapAPI('your-api-key');
|
||||
* const screenshot = await snap.capture('https://example.com');
|
||||
* fs.writeFileSync('screenshot.png', screenshot);
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface ScreenshotOptions {
|
||||
/** URL to capture (required) */
|
||||
url: string;
|
||||
/** Output format: png, jpeg, or webp (default: png) */
|
||||
format?: "png" | "jpeg" | "webp";
|
||||
/** Viewport width in pixels, 320-3840 (default: 1280) */
|
||||
width?: number;
|
||||
/** Viewport height in pixels, 200-2160 (default: 800) */
|
||||
height?: number;
|
||||
/** Capture full scrollable page (default: false) */
|
||||
fullPage?: boolean;
|
||||
/** JPEG/WebP quality 1-100 (default: 80, ignored for PNG) */
|
||||
quality?: number;
|
||||
/** CSS selector to wait for before capturing */
|
||||
waitForSelector?: string;
|
||||
/** Device scale factor 1-3 (default: 1, use 2 for Retina) */
|
||||
deviceScale?: number;
|
||||
/** Extra delay in ms after page load, 0-5000 (default: 0) */
|
||||
delay?: number;
|
||||
/** Page load event to wait for (default: domcontentloaded) */
|
||||
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
|
||||
/** Emulate dark mode (prefers-color-scheme: dark) */
|
||||
darkMode?: boolean;
|
||||
/** CSS selectors to hide before capture (max 10, each max 200 chars) */
|
||||
hideSelectors?: string | string[];
|
||||
}
|
||||
|
||||
export interface SnapAPIConfig {
|
||||
/** API base URL (default: https://snapapi.eu) */
|
||||
baseUrl?: string;
|
||||
/** Request timeout in ms (default: 30000) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class SnapAPIError extends Error {
|
||||
/** HTTP status code */
|
||||
status: number;
|
||||
/** Error message from the API */
|
||||
detail: string;
|
||||
|
||||
constructor(status: number, detail: string) {
|
||||
super(`SnapAPI error ${status}: ${detail}`);
|
||||
this.name = "SnapAPIError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
export class SnapAPI {
|
||||
private apiKey: string;
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
|
||||
/**
|
||||
* Create a new SnapAPI client.
|
||||
*
|
||||
* @param apiKey - Your SnapAPI API key
|
||||
* @param config - Optional configuration
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const snap = new SnapAPI('sk_live_...');
|
||||
* ```
|
||||
*/
|
||||
constructor(apiKey: string, config?: SnapAPIConfig) {
|
||||
if (!apiKey) throw new Error("SnapAPI: apiKey is required");
|
||||
this.apiKey = apiKey;
|
||||
this.baseUrl = (config?.baseUrl ?? "https://snapapi.eu").replace(/\/$/, "");
|
||||
this.timeout = config?.timeout ?? 30000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a screenshot of a URL.
|
||||
*
|
||||
* Returns the screenshot as a Buffer (Node.js) or Uint8Array.
|
||||
*
|
||||
* @param urlOrOptions - URL string or full ScreenshotOptions object
|
||||
* @param options - Additional options when first arg is a URL string
|
||||
* @returns Screenshot image as Buffer
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple usage
|
||||
* const png = await snap.capture('https://example.com');
|
||||
*
|
||||
* // With options
|
||||
* const jpg = await snap.capture('https://example.com', {
|
||||
* format: 'jpeg',
|
||||
* width: 1920,
|
||||
* height: 1080,
|
||||
* quality: 90
|
||||
* });
|
||||
*
|
||||
* // Full-page capture
|
||||
* const full = await snap.capture({
|
||||
* url: 'https://example.com',
|
||||
* fullPage: true,
|
||||
* format: 'png',
|
||||
* deviceScale: 2
|
||||
* });
|
||||
*
|
||||
* // Dark mode with hidden elements
|
||||
* const darkScreenshot = await snap.capture({
|
||||
* url: 'https://example.com',
|
||||
* darkMode: true,
|
||||
* hideSelectors: ['.ads', '.popup', '.cookie-banner']
|
||||
* });
|
||||
*
|
||||
* // Hide single selector
|
||||
* const clean = await snap.capture('https://example.com', {
|
||||
* hideSelectors: '.advertisement'
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @throws {SnapAPIError} When the API returns an error response
|
||||
*/
|
||||
async capture(
|
||||
urlOrOptions: string | ScreenshotOptions,
|
||||
options?: Omit<ScreenshotOptions, "url">
|
||||
): Promise<Buffer> {
|
||||
const body: ScreenshotOptions =
|
||||
typeof urlOrOptions === "string"
|
||||
? { url: urlOrOptions, ...options }
|
||||
: urlOrOptions;
|
||||
|
||||
if (!body.url) throw new Error("SnapAPI: url is required");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/v1/screenshot`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const json = (await res.json()) as { error?: string };
|
||||
detail = json.error ?? detail;
|
||||
} catch {}
|
||||
throw new SnapAPIError(res.status, detail);
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API health status.
|
||||
*
|
||||
* @returns Health check response
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const health = await snap.health();
|
||||
* console.log(health.status); // "ok"
|
||||
* ```
|
||||
*/
|
||||
async health(): Promise<{
|
||||
status: string;
|
||||
version: string;
|
||||
uptime: number;
|
||||
browser: {
|
||||
browsers: number;
|
||||
totalPages: number;
|
||||
availablePages: number;
|
||||
queueDepth: number;
|
||||
};
|
||||
}> {
|
||||
const res = await fetch(`${this.baseUrl}/health`);
|
||||
if (!res.ok) throw new SnapAPIError(res.status, "Health check failed");
|
||||
return res.json() as any;
|
||||
}
|
||||
}
|
||||
|
||||
export default SnapAPI;
|
||||
415
sdk/node/test/snapapi.test.ts
Normal file
415
sdk/node/test/snapapi.test.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SnapAPI, SnapAPIError, ScreenshotOptions } from '../src/index.js';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// Mock AbortController and AbortSignal
|
||||
const mockAbort = vi.fn();
|
||||
const mockAbortController = {
|
||||
abort: mockAbort,
|
||||
signal: {
|
||||
aborted: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
} as AbortSignal,
|
||||
};
|
||||
|
||||
// Properly mock AbortController as a constructor
|
||||
class MockAbortController {
|
||||
abort = mockAbort;
|
||||
signal = mockAbortController.signal;
|
||||
}
|
||||
|
||||
vi.stubGlobal('AbortController', MockAbortController);
|
||||
vi.stubGlobal('setTimeout', vi.fn((fn: () => void, delay: number) => {
|
||||
// Return a timer ID that can be cleared
|
||||
return 123;
|
||||
}));
|
||||
vi.stubGlobal('clearTimeout', vi.fn());
|
||||
|
||||
describe('SnapAPI', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAbort.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('throws if no apiKey', () => {
|
||||
expect(() => new SnapAPI('')).toThrow('SnapAPI: apiKey is required');
|
||||
expect(() => new SnapAPI(null as any)).toThrow('SnapAPI: apiKey is required');
|
||||
expect(() => new SnapAPI(undefined as any)).toThrow('SnapAPI: apiKey is required');
|
||||
});
|
||||
|
||||
it('sets defaults (baseUrl, timeout)', () => {
|
||||
const snap = new SnapAPI('test-key');
|
||||
expect(snap['baseUrl']).toBe('https://snapapi.eu');
|
||||
expect(snap['timeout']).toBe(30000);
|
||||
});
|
||||
|
||||
it('accepts custom config', () => {
|
||||
const snap = new SnapAPI('test-key', {
|
||||
baseUrl: 'https://custom.snapapi.com',
|
||||
timeout: 60000,
|
||||
});
|
||||
expect(snap['baseUrl']).toBe('https://custom.snapapi.com');
|
||||
expect(snap['timeout']).toBe(60000);
|
||||
});
|
||||
|
||||
it('strips trailing slash from baseUrl', () => {
|
||||
const snap = new SnapAPI('test-key', {
|
||||
baseUrl: 'https://custom.snapapi.com/',
|
||||
});
|
||||
expect(snap['baseUrl']).toBe('https://custom.snapapi.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('capture()', () => {
|
||||
let snap: SnapAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
snap = new SnapAPI('test-api-key');
|
||||
});
|
||||
|
||||
it('sends correct POST with Bearer auth', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
await snap.capture('https://example.com');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://snapapi.eu/v1/screenshot',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-api-key',
|
||||
},
|
||||
body: JSON.stringify({ url: 'https://example.com' }),
|
||||
signal: mockAbortController.signal,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('sends all options in body', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
await snap.capture('https://example.com', {
|
||||
format: 'jpeg',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
quality: 90,
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://snapapi.eu/v1/screenshot',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
format: 'jpeg',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
quality: 90,
|
||||
fullPage: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends darkMode parameter correctly', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
await snap.capture('https://example.com', {
|
||||
darkMode: true,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://snapapi.eu/v1/screenshot',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
darkMode: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends hideSelectors as string correctly', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
await snap.capture('https://example.com', {
|
||||
hideSelectors: '.ads',
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://snapapi.eu/v1/screenshot',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
hideSelectors: '.ads',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends hideSelectors as array correctly', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
await snap.capture('https://example.com', {
|
||||
hideSelectors: ['.ads', '.popup', '.banner'],
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://snapapi.eu/v1/screenshot',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
hideSelectors: ['.ads', '.popup', '.banner'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends both darkMode and hideSelectors together', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
await snap.capture('https://example.com', {
|
||||
darkMode: true,
|
||||
hideSelectors: ['.ads', '.tracking'],
|
||||
format: 'png',
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://snapapi.eu/v1/screenshot',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
darkMode: true,
|
||||
hideSelectors: ['.ads', '.tracking'],
|
||||
format: 'png',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('works with ScreenshotOptions object form', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
const options: ScreenshotOptions = {
|
||||
url: 'https://example.com',
|
||||
format: 'png',
|
||||
width: 1280,
|
||||
height: 800,
|
||||
deviceScale: 2,
|
||||
waitForSelector: '.content',
|
||||
};
|
||||
|
||||
await snap.capture(options);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://snapapi.eu/v1/screenshot',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify(options),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if no url', async () => {
|
||||
await expect(snap.capture('')).rejects.toThrow('SnapAPI: url is required');
|
||||
|
||||
const optionsWithoutUrl = {} as ScreenshotOptions;
|
||||
await expect(snap.capture(optionsWithoutUrl)).rejects.toThrow('SnapAPI: url is required');
|
||||
});
|
||||
|
||||
it('throws SnapAPIError on 401/403/429 with error detail', async () => {
|
||||
// Test 401
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ error: 'Invalid API key' }),
|
||||
});
|
||||
|
||||
await expect(snap.capture('https://example.com')).rejects.toMatchObject({
|
||||
name: 'SnapAPIError',
|
||||
status: 401,
|
||||
detail: 'Invalid API key',
|
||||
message: 'SnapAPI error 401: Invalid API key',
|
||||
});
|
||||
|
||||
// Test 403
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ error: 'Forbidden' }),
|
||||
});
|
||||
|
||||
await expect(snap.capture('https://example.com')).rejects.toMatchObject({
|
||||
name: 'SnapAPIError',
|
||||
status: 403,
|
||||
detail: 'Forbidden',
|
||||
});
|
||||
|
||||
// Test 429
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
json: () => Promise.resolve({ error: 'Rate limit exceeded' }),
|
||||
});
|
||||
|
||||
await expect(snap.capture('https://example.com')).rejects.toMatchObject({
|
||||
name: 'SnapAPIError',
|
||||
status: 429,
|
||||
detail: 'Rate limit exceeded',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws SnapAPIError with fallback message when error JSON fails', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||
});
|
||||
|
||||
await expect(snap.capture('https://example.com')).rejects.toMatchObject({
|
||||
name: 'SnapAPIError',
|
||||
status: 500,
|
||||
detail: 'HTTP 500',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Buffer on success', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
// Fill with some test data
|
||||
const view = new Uint8Array(mockArrayBuffer);
|
||||
view.fill(0xFF);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
const result = await snap.capture('https://example.com');
|
||||
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(result.length).toBe(100);
|
||||
expect(result[0]).toBe(0xFF);
|
||||
});
|
||||
|
||||
it('sets up AbortController correctly for timeout', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(100);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
});
|
||||
|
||||
const customSnap = new SnapAPI('test-key', { timeout: 15000 });
|
||||
await customSnap.capture('https://example.com');
|
||||
|
||||
// Verify setTimeout was called with correct timeout
|
||||
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 15000);
|
||||
|
||||
// Verify clearTimeout was called (cleanup)
|
||||
expect(clearTimeout).toHaveBeenCalledWith(123);
|
||||
|
||||
// Verify fetch was called with the abort signal
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
signal: mockAbortController.signal,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('health()', () => {
|
||||
let snap: SnapAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
snap = new SnapAPI('test-api-key');
|
||||
});
|
||||
|
||||
it('calls GET /health and returns parsed JSON', async () => {
|
||||
const mockHealthResponse = {
|
||||
status: 'ok',
|
||||
version: '1.0.0',
|
||||
uptime: 12345,
|
||||
browser: {
|
||||
browsers: 2,
|
||||
totalPages: 10,
|
||||
availablePages: 8,
|
||||
queueDepth: 0,
|
||||
},
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockHealthResponse),
|
||||
});
|
||||
|
||||
const result = await snap.health();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://snapapi.eu/health');
|
||||
expect(result).toEqual(mockHealthResponse);
|
||||
});
|
||||
|
||||
it('throws SnapAPIError on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
});
|
||||
|
||||
await expect(snap.health()).rejects.toMatchObject({
|
||||
name: 'SnapAPIError',
|
||||
status: 503,
|
||||
detail: 'Health check failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnapAPIError', () => {
|
||||
it('creates proper error with status and detail', () => {
|
||||
const error = new SnapAPIError(400, 'Bad Request');
|
||||
|
||||
expect(error.name).toBe('SnapAPIError');
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.detail).toBe('Bad Request');
|
||||
expect(error.message).toBe('SnapAPI error 400: Bad Request');
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(SnapAPIError);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
sdk/node/tsconfig.json
Normal file
14
sdk/node/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
8
sdk/node/vitest.config.ts
Normal file
8
sdk/node/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
261
sdk/python/README.md
Normal file
261
sdk/python/README.md
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
# SnapAPI Python SDK
|
||||
|
||||
Official Python client for [SnapAPI](https://snapapi.eu) — the EU-hosted screenshot API.
|
||||
|
||||
**Zero dependencies.** Uses only Python standard library (`urllib`).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install snapapi
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from snapapi import SnapAPI
|
||||
|
||||
snap = SnapAPI("your-api-key")
|
||||
|
||||
# Capture a screenshot
|
||||
screenshot = snap.capture("https://example.com")
|
||||
|
||||
with open("screenshot.png", "wb") as f:
|
||||
f.write(screenshot)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Screenshot
|
||||
|
||||
```python
|
||||
png = snap.capture("https://example.com")
|
||||
```
|
||||
|
||||
### With Options
|
||||
|
||||
```python
|
||||
jpg = snap.capture(
|
||||
"https://example.com",
|
||||
format="jpeg",
|
||||
width=1920,
|
||||
height=1080,
|
||||
quality=90,
|
||||
)
|
||||
```
|
||||
|
||||
### Full-Page Capture
|
||||
|
||||
```python
|
||||
full = snap.capture(
|
||||
"https://example.com/blog",
|
||||
full_page=True,
|
||||
device_scale=2, # Retina
|
||||
)
|
||||
```
|
||||
|
||||
### Mobile Viewport
|
||||
|
||||
```python
|
||||
mobile = snap.capture(
|
||||
"https://example.com",
|
||||
width=375,
|
||||
height=812,
|
||||
device_scale=2,
|
||||
)
|
||||
```
|
||||
|
||||
### Wait for Dynamic Content
|
||||
|
||||
```python
|
||||
screenshot = snap.capture(
|
||||
"https://example.com/dashboard",
|
||||
wait_for_selector="#chart-loaded",
|
||||
wait_until="networkidle2",
|
||||
)
|
||||
```
|
||||
|
||||
### Dark Mode Capture
|
||||
|
||||
```python
|
||||
# Capture in dark mode (prefers-color-scheme: dark)
|
||||
dark_screenshot = snap.capture(
|
||||
"https://example.com",
|
||||
dark_mode=True,
|
||||
format="png",
|
||||
)
|
||||
```
|
||||
|
||||
### Custom User Agent
|
||||
|
||||
```python
|
||||
# Set a custom User-Agent string for the request
|
||||
screenshot = snap.capture(
|
||||
"https://example.com",
|
||||
user_agent="Mozilla/5.0 (compatible; SnapAPI/1.0)",
|
||||
format="png",
|
||||
)
|
||||
```
|
||||
|
||||
### Hide Elements Before Capture
|
||||
|
||||
```python
|
||||
# Hide cookie banners, popups, ads
|
||||
clean_screenshot = snap.capture(
|
||||
"https://example.com",
|
||||
hide_selectors=[
|
||||
".cookie-banner",
|
||||
".popup-overlay",
|
||||
"#advertisement",
|
||||
".tracking-notice"
|
||||
],
|
||||
)
|
||||
|
||||
# Hide single element
|
||||
single_hide = snap.capture(
|
||||
"https://example.com",
|
||||
hide_selectors=".newsletter-popup",
|
||||
)
|
||||
```
|
||||
|
||||
### Custom CSS Injection
|
||||
|
||||
```python
|
||||
# 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=[".cookie-banner"],
|
||||
)
|
||||
```
|
||||
|
||||
### JavaScript Injection
|
||||
|
||||
```python
|
||||
# Execute custom JavaScript before capture
|
||||
interactive_screenshot = snap.capture(
|
||||
"https://example.com",
|
||||
js="""
|
||||
// Dismiss modal popup
|
||||
document.querySelector('.modal-overlay')?.remove();
|
||||
|
||||
// Scroll to specific content
|
||||
window.scrollTo(0, 500);
|
||||
|
||||
// Click button to reveal content
|
||||
document.querySelector('#show-more-btn')?.click();
|
||||
|
||||
// Wait for animation to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
""",
|
||||
)
|
||||
|
||||
# Combine with other options for complex scenarios
|
||||
complex_capture = snap.capture(
|
||||
"https://example.com/app",
|
||||
js='document.querySelector(".sidebar").style.display = "none";',
|
||||
css="body { zoom: 0.8 }",
|
||||
wait_for_selector="#content-loaded",
|
||||
hide_selectors=[".ad-banner", ".cookie-notice"],
|
||||
)
|
||||
```
|
||||
|
||||
### Crop Specific Areas (Clip)
|
||||
|
||||
```python
|
||||
# Crop a specific rectangular area from the screenshot
|
||||
cropped_screenshot = snap.capture(
|
||||
"https://example.com",
|
||||
clip={
|
||||
"x": 100, # X coordinate (pixels from left)
|
||||
"y": 50, # Y coordinate (pixels from top)
|
||||
"width": 800, # Width of the crop area
|
||||
"height": 600, # Height of the crop area
|
||||
},
|
||||
)
|
||||
|
||||
# Useful for capturing specific UI elements or sections
|
||||
header_screenshot = snap.capture(
|
||||
"https://example.com",
|
||||
clip={"x": 0, "y": 0, "width": 1280, "height": 120}, # Top banner only
|
||||
format="png",
|
||||
)
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
from snapapi import SnapAPI, SnapAPIError
|
||||
|
||||
snap = SnapAPI("your-api-key")
|
||||
|
||||
try:
|
||||
screenshot = snap.capture("https://example.com")
|
||||
except SnapAPIError as e:
|
||||
print(f"API error {e.status}: {e.detail}")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SnapAPI(api_key, base_url="https://snapapi.eu", timeout=30)`
|
||||
|
||||
### `snap.capture(url, **options) -> bytes`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `url` | `str` | — | URL to capture (required) |
|
||||
| `format` | `str` | `"png"` | Output: `png`, `jpeg`, `webp` |
|
||||
| `width` | `int` | `1280` | Viewport width (320–3840) |
|
||||
| `height` | `int` | `800` | Viewport height (200–2160) |
|
||||
| `full_page` | `bool` | `False` | Capture full page |
|
||||
| `quality` | `int` | `80` | JPEG/WebP quality (1–100) |
|
||||
| `wait_for_selector` | `str` | — | CSS selector to wait for |
|
||||
| `device_scale` | `float` | `1` | Device pixel ratio (1–3) |
|
||||
| `delay` | `int` | `0` | Extra delay in ms (0–5000) |
|
||||
| `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) |
|
||||
| `clip` | `dict` | — | Crop rectangle: `{"x": int, "y": int, "width": int, "height": int}` (mutually exclusive with full_page/selector) |
|
||||
|
||||
### `snap.batch(urls, **options) -> list[dict]`
|
||||
|
||||
Take multiple screenshots in a single request. Each URL counts as one screenshot toward usage limits.
|
||||
|
||||
```python
|
||||
results = snap.batch(
|
||||
["https://example.com", "https://example.org"],
|
||||
format="jpeg", width=1920, height=1080
|
||||
)
|
||||
|
||||
for result in results:
|
||||
if result["status"] == "success":
|
||||
with open(f"{result['url']}.jpg", "wb") as f:
|
||||
f.write(base64.b64decode(result["image"]))
|
||||
else:
|
||||
print(f"Failed: {result['url']} — {result['error']}")
|
||||
```
|
||||
|
||||
- **Max 10 URLs per batch**
|
||||
- All options (format, width, height, etc.) are shared across all URLs
|
||||
- Returns partial results — some may succeed while others fail
|
||||
- Response is always JSON with `{ "results": [...] }`
|
||||
|
||||
### `snap.health() -> dict`
|
||||
|
||||
Returns API health status.
|
||||
|
||||
## EU-Hosted & GDPR Compliant
|
||||
|
||||
SnapAPI runs entirely on EU infrastructure (Germany). Your data never leaves the EU.
|
||||
|
||||
## License
|
||||
|
||||
MIT — [Cloonar Technologies GmbH](https://snapapi.eu)
|
||||
25
sdk/python/pyproject.toml
Normal file
25
sdk/python/pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "snapapi"
|
||||
version = "1.0.0"
|
||||
description = "Official Python SDK for SnapAPI — EU-hosted screenshot API"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.8"
|
||||
authors = [{name = "Cloonar Technologies GmbH"}]
|
||||
keywords = ["screenshot", "api", "webpage", "capture", "eu", "gdpr"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://snapapi.eu"
|
||||
Repository = "https://git.cloonar.com/openclawd/SnapAPI"
|
||||
Documentation = "https://snapapi.eu/docs"
|
||||
6
sdk/python/src/snapapi/__init__.py
Normal file
6
sdk/python/src/snapapi/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""SnapAPI Python SDK — EU-hosted screenshot API client."""
|
||||
|
||||
from .client import SnapAPI, SnapAPIError, ScreenshotOptions
|
||||
|
||||
__all__ = ["SnapAPI", "SnapAPIError", "ScreenshotOptions"]
|
||||
__version__ = "1.0.0"
|
||||
BIN
sdk/python/src/snapapi/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
sdk/python/src/snapapi/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
sdk/python/src/snapapi/__pycache__/client.cpython-312.pyc
Normal file
BIN
sdk/python/src/snapapi/__pycache__/client.cpython-312.pyc
Normal file
Binary file not shown.
219
sdk/python/src/snapapi/client.py
Normal file
219
sdk/python/src/snapapi/client.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""SnapAPI client implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class SnapAPIError(Exception):
|
||||
"""Raised when the SnapAPI returns an error response."""
|
||||
|
||||
def __init__(self, status: int, detail: str):
|
||||
self.status = status
|
||||
self.detail = detail
|
||||
super().__init__(f"SnapAPI error {status}: {detail}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenshotOptions:
|
||||
"""Screenshot capture options."""
|
||||
|
||||
url: str
|
||||
format: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
full_page: Optional[bool] = None
|
||||
quality: Optional[int] = None
|
||||
wait_for_selector: Optional[str] = None
|
||||
device_scale: Optional[float] = None
|
||||
delay: Optional[int] = None
|
||||
wait_until: Optional[str] = None
|
||||
dark_mode: Optional[bool] = None
|
||||
hide_selectors: Optional[list] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to API request body (camelCase keys)."""
|
||||
mapping = {
|
||||
"url": "url",
|
||||
"format": "format",
|
||||
"width": "width",
|
||||
"height": "height",
|
||||
"full_page": "fullPage",
|
||||
"quality": "quality",
|
||||
"wait_for_selector": "waitForSelector",
|
||||
"device_scale": "deviceScale",
|
||||
"delay": "delay",
|
||||
"wait_until": "waitUntil",
|
||||
"dark_mode": "darkMode",
|
||||
"hide_selectors": "hideSelectors",
|
||||
}
|
||||
return {
|
||||
mapping[k]: v
|
||||
for k, v in asdict(self).items()
|
||||
if v is not None
|
||||
}
|
||||
|
||||
|
||||
class SnapAPI:
|
||||
"""SnapAPI client.
|
||||
|
||||
Args:
|
||||
api_key: Your SnapAPI API key.
|
||||
base_url: API base URL (default: https://snapapi.eu).
|
||||
timeout: Request timeout in seconds (default: 30).
|
||||
|
||||
Example::
|
||||
|
||||
from snapapi import SnapAPI
|
||||
|
||||
snap = SnapAPI("your-api-key")
|
||||
screenshot = snap.capture("https://example.com")
|
||||
|
||||
with open("screenshot.png", "wb") as f:
|
||||
f.write(screenshot)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://snapapi.eu",
|
||||
timeout: int = 30,
|
||||
):
|
||||
if not api_key:
|
||||
raise ValueError("api_key is required")
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
def capture(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
*,
|
||||
format: Optional[str] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
full_page: Optional[bool] = None,
|
||||
quality: Optional[int] = None,
|
||||
wait_for_selector: Optional[str] = None,
|
||||
device_scale: Optional[float] = None,
|
||||
delay: Optional[int] = None,
|
||||
wait_until: Optional[str] = None,
|
||||
dark_mode: Optional[bool] = None,
|
||||
hide_selectors: Optional[list] = None,
|
||||
options: Optional[ScreenshotOptions] = None,
|
||||
) -> bytes:
|
||||
"""Capture a screenshot.
|
||||
|
||||
Args:
|
||||
url: URL to capture.
|
||||
format: Output format (png, jpeg, webp).
|
||||
width: Viewport width (320-3840).
|
||||
height: Viewport height (200-2160).
|
||||
full_page: Capture full scrollable page.
|
||||
quality: JPEG/WebP quality (1-100).
|
||||
wait_for_selector: CSS selector to wait for.
|
||||
device_scale: Device pixel ratio (1-3).
|
||||
delay: Extra delay in ms (0-5000).
|
||||
wait_until: Load event (domcontentloaded, load, networkidle0, networkidle2).
|
||||
dark_mode: Emulate dark mode (prefers-color-scheme: dark).
|
||||
hide_selectors: CSS selectors to hide before capture (max 10, each max 200 chars).
|
||||
options: ScreenshotOptions object (alternative to keyword args).
|
||||
|
||||
Returns:
|
||||
Screenshot image as bytes.
|
||||
|
||||
Raises:
|
||||
SnapAPIError: When the API returns an error.
|
||||
ValueError: When url is missing.
|
||||
|
||||
Example::
|
||||
|
||||
# Simple
|
||||
png = snap.capture("https://example.com")
|
||||
|
||||
# With options
|
||||
jpg = snap.capture(
|
||||
"https://example.com",
|
||||
format="jpeg",
|
||||
width=1920,
|
||||
quality=90,
|
||||
)
|
||||
|
||||
# Full-page Retina
|
||||
full = snap.capture(
|
||||
"https://example.com",
|
||||
full_page=True,
|
||||
device_scale=2,
|
||||
)
|
||||
|
||||
# Dark mode with hidden elements
|
||||
dark_screenshot = snap.capture(
|
||||
"https://example.com",
|
||||
dark_mode=True,
|
||||
hide_selectors=['.ads', '.popup', '.cookie-banner']
|
||||
)
|
||||
"""
|
||||
if options:
|
||||
body = options.to_dict()
|
||||
if not body.get('url'):
|
||||
raise ValueError("url is required")
|
||||
else:
|
||||
if not url:
|
||||
raise ValueError("url is required")
|
||||
opts = ScreenshotOptions(
|
||||
url=url,
|
||||
format=format,
|
||||
width=width,
|
||||
height=height,
|
||||
full_page=full_page,
|
||||
quality=quality,
|
||||
wait_for_selector=wait_for_selector,
|
||||
device_scale=device_scale,
|
||||
delay=delay,
|
||||
wait_until=wait_until,
|
||||
dark_mode=dark_mode,
|
||||
hide_selectors=hide_selectors,
|
||||
)
|
||||
body = opts.to_dict()
|
||||
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/v1/screenshot",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
return resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = f"HTTP {e.code}"
|
||||
try:
|
||||
err_body = json.loads(e.read())
|
||||
detail = err_body.get("error", detail)
|
||||
except Exception:
|
||||
pass
|
||||
raise SnapAPIError(e.code, detail) from e
|
||||
|
||||
def health(self) -> dict:
|
||||
"""Check API health status.
|
||||
|
||||
Returns:
|
||||
Health check response dict.
|
||||
|
||||
Example::
|
||||
|
||||
health = snap.health()
|
||||
print(health["status"]) # "ok"
|
||||
"""
|
||||
req = urllib.request.Request(f"{self.base_url}/health")
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
BIN
sdk/python/tests/__pycache__/test_snapapi.cpython-312.pyc
Normal file
BIN
sdk/python/tests/__pycache__/test_snapapi.cpython-312.pyc
Normal file
Binary file not shown.
502
sdk/python/tests/test_snapapi.py
Normal file
502
sdk/python/tests/test_snapapi.py
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
"""Comprehensive unit tests for SnapAPI Python SDK."""
|
||||
|
||||
import unittest
|
||||
import json
|
||||
from unittest.mock import patch, Mock, MagicMock
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from snapapi import SnapAPI, SnapAPIError, ScreenshotOptions
|
||||
|
||||
|
||||
class TestSnapAPI(unittest.TestCase):
|
||||
"""Test cases for SnapAPI client."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.api_key = "test-api-key"
|
||||
self.snap = SnapAPI(self.api_key)
|
||||
|
||||
def test_constructor_raises_value_error_if_no_api_key(self):
|
||||
"""Constructor should raise ValueError if no api_key provided."""
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
SnapAPI("")
|
||||
self.assertEqual(str(cm.exception), "api_key is required")
|
||||
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
SnapAPI(None)
|
||||
self.assertEqual(str(cm.exception), "api_key is required")
|
||||
|
||||
def test_constructor_defaults(self):
|
||||
"""Constructor should set default values correctly."""
|
||||
snap = SnapAPI("test-key")
|
||||
self.assertEqual(snap.api_key, "test-key")
|
||||
self.assertEqual(snap.base_url, "https://snapapi.eu")
|
||||
self.assertEqual(snap.timeout, 30)
|
||||
|
||||
def test_constructor_custom_config(self):
|
||||
"""Constructor should accept custom configuration."""
|
||||
snap = SnapAPI(
|
||||
"test-key",
|
||||
base_url="https://custom.snapapi.com",
|
||||
timeout=60
|
||||
)
|
||||
self.assertEqual(snap.api_key, "test-key")
|
||||
self.assertEqual(snap.base_url, "https://custom.snapapi.com")
|
||||
self.assertEqual(snap.timeout, 60)
|
||||
|
||||
def test_constructor_strips_trailing_slash(self):
|
||||
"""Constructor should strip trailing slash from base_url."""
|
||||
snap = SnapAPI("test-key", base_url="https://custom.snapapi.com/")
|
||||
self.assertEqual(snap.base_url, "https://custom.snapapi.com")
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_url_correct_request_with_auth_header(self, mock_urlopen):
|
||||
"""capture(url) should send correct request with authorization header."""
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'fake-image-data'
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=None)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = self.snap.capture("https://example.com")
|
||||
|
||||
# Verify the request
|
||||
self.assertEqual(mock_urlopen.call_count, 1)
|
||||
request_arg = mock_urlopen.call_args[0][0]
|
||||
|
||||
self.assertEqual(request_arg.full_url, "https://snapapi.eu/v1/screenshot")
|
||||
self.assertEqual(request_arg.method, "POST")
|
||||
self.assertEqual(request_arg.headers["Content-type"], "application/json")
|
||||
self.assertEqual(request_arg.headers["Authorization"], "Bearer test-api-key")
|
||||
|
||||
# Verify body
|
||||
request_body = json.loads(request_arg.data.decode())
|
||||
self.assertEqual(request_body, {"url": "https://example.com"})
|
||||
|
||||
# Verify return value
|
||||
self.assertEqual(result, b'fake-image-data')
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_with_keyword_args_converts_to_camel_case(self, mock_urlopen):
|
||||
"""capture with keyword args should convert all params to camelCase."""
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'fake-image-data'
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=None)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = self.snap.capture(
|
||||
url="https://example.com",
|
||||
format="jpeg",
|
||||
width=1920,
|
||||
height=1080,
|
||||
full_page=True,
|
||||
quality=90,
|
||||
wait_for_selector=".content",
|
||||
device_scale=2.0,
|
||||
delay=1000,
|
||||
wait_until="networkidle0"
|
||||
)
|
||||
|
||||
# Verify request body conversion to camelCase
|
||||
request_arg = mock_urlopen.call_args[0][0]
|
||||
request_body = json.loads(request_arg.data.decode())
|
||||
|
||||
expected_body = {
|
||||
"url": "https://example.com",
|
||||
"format": "jpeg",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fullPage": True, # snake_case -> camelCase
|
||||
"quality": 90,
|
||||
"waitForSelector": ".content", # snake_case -> camelCase
|
||||
"deviceScale": 2.0, # snake_case -> camelCase
|
||||
"delay": 1000,
|
||||
"waitUntil": "networkidle0" # snake_case -> camelCase
|
||||
}
|
||||
|
||||
self.assertEqual(request_body, expected_body)
|
||||
self.assertEqual(result, b'fake-image-data')
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_with_screenshot_options_object(self, mock_urlopen):
|
||||
"""capture with ScreenshotOptions object should work correctly."""
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'fake-image-data'
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=None)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
options = ScreenshotOptions(
|
||||
url="https://example.com",
|
||||
format="png",
|
||||
width=1280,
|
||||
device_scale=2.0,
|
||||
wait_for_selector=".content"
|
||||
)
|
||||
|
||||
result = self.snap.capture(options=options)
|
||||
|
||||
# Verify request body
|
||||
request_arg = mock_urlopen.call_args[0][0]
|
||||
request_body = json.loads(request_arg.data.decode())
|
||||
|
||||
expected_body = {
|
||||
"url": "https://example.com",
|
||||
"format": "png",
|
||||
"width": 1280,
|
||||
"deviceScale": 2.0, # converted to camelCase
|
||||
"waitForSelector": ".content" # converted to camelCase
|
||||
}
|
||||
|
||||
self.assertEqual(request_body, expected_body)
|
||||
self.assertEqual(result, b'fake-image-data')
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_with_dark_mode_parameter(self, mock_urlopen):
|
||||
"""capture with dark_mode parameter should work correctly."""
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'fake-dark-image-data'
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=None)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = self.snap.capture(
|
||||
url="https://example.com",
|
||||
dark_mode=True
|
||||
)
|
||||
|
||||
# Verify request body contains darkMode
|
||||
request_arg = mock_urlopen.call_args[0][0]
|
||||
request_body = json.loads(request_arg.data.decode())
|
||||
|
||||
expected_body = {
|
||||
"url": "https://example.com",
|
||||
"darkMode": True
|
||||
}
|
||||
|
||||
self.assertEqual(request_body, expected_body)
|
||||
self.assertEqual(result, b'fake-dark-image-data')
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_with_hide_selectors_list_parameter(self, mock_urlopen):
|
||||
"""capture with hide_selectors as list should work correctly."""
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'fake-clean-image-data'
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=None)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = self.snap.capture(
|
||||
url="https://example.com",
|
||||
hide_selectors=['.ads', '.popup', '#cookie-banner']
|
||||
)
|
||||
|
||||
# Verify request body contains hideSelectors
|
||||
request_arg = mock_urlopen.call_args[0][0]
|
||||
request_body = json.loads(request_arg.data.decode())
|
||||
|
||||
expected_body = {
|
||||
"url": "https://example.com",
|
||||
"hideSelectors": ['.ads', '.popup', '#cookie-banner']
|
||||
}
|
||||
|
||||
self.assertEqual(request_body, expected_body)
|
||||
self.assertEqual(result, b'fake-clean-image-data')
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_with_both_dark_mode_and_hide_selectors(self, mock_urlopen):
|
||||
"""capture with both dark_mode and hide_selectors should work correctly."""
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'fake-dark-clean-image-data'
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=None)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = self.snap.capture(
|
||||
url="https://example.com",
|
||||
dark_mode=True,
|
||||
hide_selectors=['.tracking', '.ads'],
|
||||
format="png"
|
||||
)
|
||||
|
||||
# Verify request body contains both parameters
|
||||
request_arg = mock_urlopen.call_args[0][0]
|
||||
request_body = json.loads(request_arg.data.decode())
|
||||
|
||||
expected_body = {
|
||||
"url": "https://example.com",
|
||||
"darkMode": True,
|
||||
"hideSelectors": ['.tracking', '.ads'],
|
||||
"format": "png"
|
||||
}
|
||||
|
||||
self.assertEqual(request_body, expected_body)
|
||||
self.assertEqual(result, b'fake-dark-clean-image-data')
|
||||
|
||||
def test_capture_raises_value_error_if_no_url(self):
|
||||
"""capture() should raise ValueError if no url provided."""
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
self.snap.capture("")
|
||||
self.assertEqual(str(cm.exception), "url is required")
|
||||
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
self.snap.capture(None)
|
||||
self.assertEqual(str(cm.exception), "url is required")
|
||||
|
||||
def test_capture_raises_value_error_if_options_has_empty_url(self):
|
||||
"""capture(options=ScreenshotOptions(url='')) should raise ValueError."""
|
||||
options = ScreenshotOptions(url="")
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
self.snap.capture(options=options)
|
||||
self.assertEqual(str(cm.exception), "url is required")
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_raises_snap_api_error_on_http_error(self, mock_urlopen):
|
||||
"""capture() should raise SnapAPIError on HTTPError."""
|
||||
# Test 401 Unauthorized
|
||||
mock_fp = Mock()
|
||||
mock_fp.read.return_value = b'{"error": "Invalid API key"}'
|
||||
|
||||
mock_error = HTTPError(
|
||||
url="https://snapapi.eu/v1/screenshot",
|
||||
code=401,
|
||||
msg="Unauthorized",
|
||||
hdrs=None,
|
||||
fp=mock_fp
|
||||
)
|
||||
# HTTPError.read() should delegate to fp.read()
|
||||
mock_error.read = mock_fp.read
|
||||
mock_urlopen.side_effect = mock_error
|
||||
|
||||
with self.assertRaises(SnapAPIError) as cm:
|
||||
self.snap.capture("https://example.com")
|
||||
|
||||
error = cm.exception
|
||||
self.assertEqual(error.status, 401)
|
||||
self.assertEqual(error.detail, "Invalid API key")
|
||||
self.assertEqual(str(error), "SnapAPI error 401: Invalid API key")
|
||||
|
||||
# Test 429 Rate Limit
|
||||
mock_fp2 = Mock()
|
||||
mock_fp2.read.return_value = b'{"error": "Rate limit exceeded"}'
|
||||
|
||||
mock_error2 = HTTPError(
|
||||
url="https://snapapi.eu/v1/screenshot",
|
||||
code=429,
|
||||
msg="Too Many Requests",
|
||||
hdrs=None,
|
||||
fp=mock_fp2
|
||||
)
|
||||
mock_error2.read = mock_fp2.read
|
||||
mock_urlopen.side_effect = mock_error2
|
||||
|
||||
with self.assertRaises(SnapAPIError) as cm:
|
||||
self.snap.capture("https://example.com")
|
||||
|
||||
error = cm.exception
|
||||
self.assertEqual(error.status, 429)
|
||||
self.assertEqual(error.detail, "Rate limit exceeded")
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_raises_snap_api_error_with_fallback_on_json_parse_error(self, mock_urlopen):
|
||||
"""capture() should use fallback message when error JSON parsing fails."""
|
||||
mock_error = HTTPError(
|
||||
url="https://snapapi.eu/v1/screenshot",
|
||||
code=500,
|
||||
msg="Internal Server Error",
|
||||
hdrs=None,
|
||||
fp=Mock()
|
||||
)
|
||||
mock_error.read.return_value = b'invalid json'
|
||||
mock_urlopen.side_effect = mock_error
|
||||
|
||||
with self.assertRaises(SnapAPIError) as cm:
|
||||
self.snap.capture("https://example.com")
|
||||
|
||||
error = cm.exception
|
||||
self.assertEqual(error.status, 500)
|
||||
self.assertEqual(error.detail, "HTTP 500")
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_capture_returns_bytes_on_success(self, mock_urlopen):
|
||||
"""capture() should return bytes on successful response."""
|
||||
# Mock response
|
||||
test_image_data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00' * 10 # Fake PNG data
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = test_image_data
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=None)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = self.snap.capture("https://example.com")
|
||||
|
||||
self.assertIsInstance(result, bytes)
|
||||
self.assertEqual(result, test_image_data)
|
||||
self.assertEqual(len(result), len(test_image_data))
|
||||
|
||||
@patch('snapapi.client.urllib.request.urlopen')
|
||||
def test_health_correct_request_returns_dict(self, mock_urlopen):
|
||||
"""health() should make correct request and return dict."""
|
||||
# Mock response
|
||||
health_data = {
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"uptime": 12345,
|
||||
"browser": {
|
||||
"browsers": 2,
|
||||
"totalPages": 10,
|
||||
"availablePages": 8,
|
||||
"queueDepth": 0
|
||||
}
|
||||
}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = json.dumps(health_data).encode()
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=None)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = self.snap.health()
|
||||
|
||||
# Verify the request
|
||||
self.assertEqual(mock_urlopen.call_count, 1)
|
||||
request_arg = mock_urlopen.call_args[0][0]
|
||||
|
||||
self.assertEqual(request_arg.full_url, "https://snapapi.eu/health")
|
||||
self.assertIsNone(request_arg.data) # GET request, no body
|
||||
|
||||
# Verify return value
|
||||
self.assertEqual(result, health_data)
|
||||
self.assertIsInstance(result, dict)
|
||||
|
||||
|
||||
class TestScreenshotOptions(unittest.TestCase):
|
||||
"""Test cases for ScreenshotOptions dataclass."""
|
||||
|
||||
def test_to_dict_correct_camel_case_mapping_none_excluded(self):
|
||||
"""to_dict() should correctly map to camelCase and exclude None values."""
|
||||
# Test with all fields
|
||||
options = ScreenshotOptions(
|
||||
url="https://example.com",
|
||||
format="png",
|
||||
width=1920,
|
||||
height=1080,
|
||||
full_page=True,
|
||||
quality=90,
|
||||
wait_for_selector=".content",
|
||||
device_scale=2.0,
|
||||
delay=1000,
|
||||
wait_until="networkidle0"
|
||||
)
|
||||
|
||||
result = options.to_dict()
|
||||
|
||||
expected = {
|
||||
"url": "https://example.com",
|
||||
"format": "png",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fullPage": True,
|
||||
"quality": 90,
|
||||
"waitForSelector": ".content",
|
||||
"deviceScale": 2.0,
|
||||
"delay": 1000,
|
||||
"waitUntil": "networkidle0"
|
||||
}
|
||||
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_to_dict_excludes_none_values(self):
|
||||
"""to_dict() should exclude None values."""
|
||||
options = ScreenshotOptions(
|
||||
url="https://example.com",
|
||||
format="png",
|
||||
width=1920,
|
||||
# All other fields are None and should be excluded
|
||||
)
|
||||
|
||||
result = options.to_dict()
|
||||
|
||||
expected = {
|
||||
"url": "https://example.com",
|
||||
"format": "png",
|
||||
"width": 1920
|
||||
}
|
||||
|
||||
self.assertEqual(result, expected)
|
||||
# Verify None fields are not present
|
||||
self.assertNotIn("height", result)
|
||||
self.assertNotIn("fullPage", result)
|
||||
self.assertNotIn("quality", result)
|
||||
self.assertNotIn("waitForSelector", result)
|
||||
self.assertNotIn("deviceScale", result)
|
||||
self.assertNotIn("delay", result)
|
||||
self.assertNotIn("waitUntil", result)
|
||||
|
||||
def test_to_dict_with_only_url(self):
|
||||
"""to_dict() should work with only required url field."""
|
||||
options = ScreenshotOptions(url="https://example.com")
|
||||
|
||||
result = options.to_dict()
|
||||
|
||||
expected = {"url": "https://example.com"}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_to_dict_with_dark_mode_and_hide_selectors(self):
|
||||
"""to_dict() should correctly handle darkMode and hideSelectors."""
|
||||
options = ScreenshotOptions(
|
||||
url="https://example.com",
|
||||
dark_mode=True,
|
||||
hide_selectors=['.ads', '.popup', '#banner']
|
||||
)
|
||||
|
||||
result = options.to_dict()
|
||||
|
||||
expected = {
|
||||
"url": "https://example.com",
|
||||
"darkMode": True, # snake_case -> camelCase
|
||||
"hideSelectors": ['.ads', '.popup', '#banner'] # snake_case -> camelCase
|
||||
}
|
||||
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_to_dict_with_dark_mode_false(self):
|
||||
"""to_dict() should include darkMode when explicitly set to False."""
|
||||
options = ScreenshotOptions(
|
||||
url="https://example.com",
|
||||
dark_mode=False
|
||||
)
|
||||
|
||||
result = options.to_dict()
|
||||
|
||||
expected = {
|
||||
"url": "https://example.com",
|
||||
"darkMode": False
|
||||
}
|
||||
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
|
||||
class TestSnapAPIError(unittest.TestCase):
|
||||
"""Test cases for SnapAPIError exception."""
|
||||
|
||||
def test_snap_api_error_creation(self):
|
||||
"""SnapAPIError should be created correctly with status and detail."""
|
||||
error = SnapAPIError(400, "Bad Request")
|
||||
|
||||
self.assertEqual(error.status, 400)
|
||||
self.assertEqual(error.detail, "Bad Request")
|
||||
self.assertEqual(str(error), "SnapAPI error 400: Bad Request")
|
||||
self.assertIsInstance(error, Exception)
|
||||
self.assertIsInstance(error, SnapAPIError)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
54
src/docs/__tests__/openapi.test.ts
Normal file
54
src/docs/__tests__/openapi.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { openapiSpec } from '../openapi.js'
|
||||
import packageJson from '../../../package.json'
|
||||
|
||||
describe('OpenAPI Spec', () => {
|
||||
it('should include GET /v1/screenshot endpoint', () => {
|
||||
expect(openapiSpec.paths['/v1/screenshot']).toBeDefined()
|
||||
expect(openapiSpec.paths['/v1/screenshot'].get).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include GET /v1/usage endpoint', () => {
|
||||
expect(openapiSpec.paths['/v1/usage']).toBeDefined()
|
||||
expect(openapiSpec.paths['/v1/usage'].get).toBeDefined()
|
||||
})
|
||||
|
||||
it('should NOT include /v1/signup/free endpoint', () => {
|
||||
const signupPath = openapiSpec.paths['/v1/signup/free']
|
||||
expect(signupPath).toBeUndefined()
|
||||
})
|
||||
|
||||
// New TDD tests for the issues we need to fix
|
||||
it('should have version matching package.json', () => {
|
||||
expect(openapiSpec.info.version).toBe(packageJson.version)
|
||||
})
|
||||
|
||||
it('should NOT contain "Signup" tag', () => {
|
||||
const signupTag = openapiSpec.tags?.find(tag => tag.name === 'Signup')
|
||||
expect(signupTag).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should include cache parameter in POST /v1/screenshot body schema', () => {
|
||||
const postScreenshot = openapiSpec.paths['/v1/screenshot']?.post
|
||||
expect(postScreenshot).toBeDefined()
|
||||
|
||||
const requestBody = postScreenshot.requestBody
|
||||
expect(requestBody).toBeDefined()
|
||||
expect(requestBody.required).toBe(true)
|
||||
|
||||
const jsonSchema = requestBody.content['application/json']?.schema
|
||||
expect(jsonSchema).toBeDefined()
|
||||
expect(jsonSchema.properties).toBeDefined()
|
||||
|
||||
const cacheProperty = jsonSchema.properties.cache
|
||||
expect(cacheProperty).toBeDefined()
|
||||
expect(cacheProperty.type).toBe('boolean')
|
||||
expect(cacheProperty.default).toBe(true)
|
||||
expect(cacheProperty.description).toContain('bypass')
|
||||
expect(cacheProperty.description).toContain('cache')
|
||||
})
|
||||
|
||||
it('should use info@cloonar.com as contact email (not non-existent support@snapapi.eu)', () => {
|
||||
expect(openapiSpec.info.contact.email).toBe('info@cloonar.com')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
import swaggerJsdoc from "swagger-jsdoc";
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
// Read version from package.json dynamically
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const packageJson = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-8"));
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
|
|
@ -10,18 +18,21 @@ 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" +
|
||||
"- 120 requests per minute per IP (global)\n" +
|
||||
"- 5 requests per hour per IP (playground)\n" +
|
||||
"- Monthly screenshot limits based on your plan tier (API)",
|
||||
version: "0.3.0",
|
||||
version: packageJson.version,
|
||||
contact: {
|
||||
name: "SnapAPI Support",
|
||||
url: "https://snapapi.eu",
|
||||
email: "support@snapapi.eu",
|
||||
email: "info@cloonar.com",
|
||||
},
|
||||
license: { name: "Proprietary" },
|
||||
},
|
||||
|
|
@ -29,14 +40,15 @@ const options: swaggerJsdoc.Options = {
|
|||
tags: [
|
||||
{ name: "Screenshots", description: "Screenshot capture endpoints" },
|
||||
{ name: "Playground", description: "Free demo (no auth, watermarked)" },
|
||||
{ name: "Signup", description: "Account creation" },
|
||||
{ name: "Billing", description: "Subscription and payment management" },
|
||||
{ name: "Usage", description: "API usage tracking" },
|
||||
{ name: "System", description: "Health and status endpoints" },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
BearerAuth: { type: "http", scheme: "bearer" },
|
||||
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
|
||||
QueryKeyAuth: { type: "apiKey", in: "query", name: "key" },
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
|
|
@ -47,7 +59,7 @@ const options: swaggerJsdoc.Options = {
|
|||
},
|
||||
},
|
||||
},
|
||||
apis: ["./src/routes/*.ts"],
|
||||
apis: ["./src/routes/*.ts", "./dist/routes/*.js"],
|
||||
};
|
||||
|
||||
export const openapiSpec = swaggerJsdoc(options);
|
||||
|
|
|
|||
33
src/index.ts
33
src/index.ts
|
|
@ -7,6 +7,7 @@ import path from "path";
|
|||
import { fileURLToPath } from "url";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { screenshotRouter } from "./routes/screenshot.js";
|
||||
import { batchRouter } from "./routes/batch.js";
|
||||
import { healthRouter } from "./routes/health.js";
|
||||
import { playgroundRouter } from "./routes/playground.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
|
|
@ -15,8 +16,9 @@ import { initBrowser, closeBrowser } from "./services/browser.js";
|
|||
import { loadKeys, getAllKeys } from "./services/keys.js";
|
||||
import { initDatabase, pool } from "./services/db.js";
|
||||
import { billingRouter } from "./routes/billing.js";
|
||||
import { statusRouter } from "./routes/status.js";
|
||||
import { signupRouter } from "./routes/signup.js";
|
||||
|
||||
|
||||
import { usageRouter } from "./routes/usage.js";
|
||||
import { openapiSpec } from "./docs/openapi.js";
|
||||
|
||||
const app = express();
|
||||
|
|
@ -93,12 +95,13 @@ app.use(rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHea
|
|||
// Public routes
|
||||
app.use("/health", healthRouter);
|
||||
app.use("/v1/billing", billingRouter);
|
||||
app.use("/status", statusRouter);
|
||||
app.use("/v1/playground", playgroundRouter);
|
||||
app.use("/v1/signup", signupRouter);
|
||||
|
||||
|
||||
// Authenticated routes
|
||||
app.use("/v1/usage", usageRouter);
|
||||
app.use("/v1/screenshot", authMiddleware, usageMiddleware, screenshotRouter);
|
||||
app.use("/v1/screenshots/batch", authMiddleware, batchRouter);
|
||||
|
||||
// API info
|
||||
app.get("/api", (_req, res) => {
|
||||
|
|
@ -108,6 +111,8 @@ app.get("/api", (_req, res) => {
|
|||
endpoints: [
|
||||
"POST /v1/playground — Try the API (no auth, watermarked, 5 req/hr)",
|
||||
"POST /v1/screenshot — Take a screenshot (requires API key)",
|
||||
"POST /v1/screenshots/batch — Take multiple screenshots (requires API key)",
|
||||
"GET /v1/usage — Usage statistics (requires API key)",
|
||||
"GET /health — Health check",
|
||||
],
|
||||
});
|
||||
|
|
@ -122,6 +127,26 @@ app.get("/openapi.json", (_req, res) => {
|
|||
app.get("/docs", (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../public/docs.html"));
|
||||
});
|
||||
// Clean URLs for legal pages (redirect /privacy → /privacy.html, etc.)
|
||||
for (const page of ["privacy", "terms", "impressum", "status", "usage"]) {
|
||||
app.get(`/${page}`, (_req, res) => res.redirect(301, `/${page}.html`));
|
||||
}
|
||||
|
||||
// Clean URLs for use case pages
|
||||
for (const page of ["social-media-previews", "website-monitoring", "pdf-reports"]) {
|
||||
app.get(`/use-cases/${page}`, (_req, res) => res.redirect(301, `/use-cases/${page}.html`));
|
||||
}
|
||||
|
||||
// Clean URLs for SEO pages
|
||||
app.get("/compare", (_req, res) => res.redirect(301, "/compare.html"));
|
||||
app.get("/guides/quick-start", (_req, res) => res.redirect(301, "/guides/quick-start.html"));
|
||||
app.get("/pricing", (_req, res) => res.redirect(301, "/pricing.html"));
|
||||
app.get("/changelog", (_req, res) => res.redirect(301, "/changelog.html"));
|
||||
|
||||
// Blog routes
|
||||
app.get("/blog", (_req, res) => res.redirect(301, "/blog.html"));
|
||||
app.get("/blog/:slug([^.]+)", (req, res) => res.redirect(301, `/blog/${(req.params as any).slug}.html`));
|
||||
|
||||
// Static files (landing page)
|
||||
app.use(express.static(path.join(__dirname, "../public"), { etag: true }));
|
||||
|
||||
|
|
|
|||
97
src/middleware/__tests__/auth.test.ts
Normal file
97
src/middleware/__tests__/auth.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../../services/keys.js', () => ({
|
||||
isValidKey: vi.fn(),
|
||||
getKeyInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
import { authMiddleware } from '../auth.js'
|
||||
import { isValidKey, getKeyInfo } from '../../services/keys.js'
|
||||
|
||||
function mockReqResNext(overrides: Partial<{ headers: any; query: any }> = {}) {
|
||||
const req = { headers: {}, query: {}, ...overrides } as any
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as any
|
||||
const next = vi.fn()
|
||||
return { req, res, next }
|
||||
}
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 when no API key is provided', async () => {
|
||||
const { req, res, next } = mockReqResNext()
|
||||
await authMiddleware(req, res, next)
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('Missing API key') }))
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extracts key from Authorization Bearer header', async () => {
|
||||
vi.mocked(isValidKey).mockResolvedValue(true)
|
||||
vi.mocked(getKeyInfo).mockResolvedValue({ key: 'test-key', tier: 'free', email: 'a@b.com', createdAt: '' })
|
||||
const { req, res, next } = mockReqResNext({ headers: { authorization: 'Bearer test-key' } })
|
||||
await authMiddleware(req, res, next)
|
||||
expect(isValidKey).toHaveBeenCalledWith('test-key')
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(req.apiKeyInfo).toBeDefined()
|
||||
})
|
||||
|
||||
it('extracts key from X-API-Key header', async () => {
|
||||
vi.mocked(isValidKey).mockResolvedValue(true)
|
||||
vi.mocked(getKeyInfo).mockResolvedValue({ key: 'xkey', tier: 'pro', email: 'a@b.com', createdAt: '' })
|
||||
const { req, res, next } = mockReqResNext({ headers: { 'x-api-key': 'xkey' } })
|
||||
await authMiddleware(req, res, next)
|
||||
expect(isValidKey).toHaveBeenCalledWith('xkey')
|
||||
expect(next).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extracts key from query parameter', async () => {
|
||||
vi.mocked(isValidKey).mockResolvedValue(true)
|
||||
vi.mocked(getKeyInfo).mockResolvedValue({ key: 'qkey', tier: 'starter', email: 'a@b.com', createdAt: '' })
|
||||
const { req, res, next } = mockReqResNext({ query: { key: 'qkey' } })
|
||||
await authMiddleware(req, res, next)
|
||||
expect(isValidKey).toHaveBeenCalledWith('qkey')
|
||||
expect(next).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers Bearer header over X-API-Key and query', async () => {
|
||||
vi.mocked(isValidKey).mockResolvedValue(true)
|
||||
vi.mocked(getKeyInfo).mockResolvedValue({ key: 'bearer-key', tier: 'free', email: 'a@b.com', createdAt: '' })
|
||||
const { req, res, next } = mockReqResNext({
|
||||
headers: { authorization: 'Bearer bearer-key', 'x-api-key': 'other' },
|
||||
query: { key: 'another' },
|
||||
})
|
||||
await authMiddleware(req, res, next)
|
||||
expect(isValidKey).toHaveBeenCalledWith('bearer-key')
|
||||
})
|
||||
|
||||
it('returns 403 for invalid API key', async () => {
|
||||
vi.mocked(isValidKey).mockResolvedValue(false)
|
||||
const { req, res, next } = mockReqResNext({ headers: { authorization: 'Bearer bad-key' } })
|
||||
await authMiddleware(req, res, next)
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API key' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches apiKeyInfo to request on success', async () => {
|
||||
const info = { key: 'k', tier: 'business' as const, email: 'x@y.com', createdAt: '2025-01-01' }
|
||||
vi.mocked(isValidKey).mockResolvedValue(true)
|
||||
vi.mocked(getKeyInfo).mockResolvedValue(info)
|
||||
const { req, res, next } = mockReqResNext({ headers: { authorization: 'Bearer k' } })
|
||||
await authMiddleware(req, res, next)
|
||||
expect(req.apiKeyInfo).toEqual(info)
|
||||
})
|
||||
|
||||
it('returns 401 when Authorization header is not Bearer', async () => {
|
||||
const { req, res, next } = mockReqResNext({ headers: { authorization: 'Basic abc123' } })
|
||||
await authMiddleware(req, res, next)
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
65
src/middleware/__tests__/compression.test.ts
Normal file
65
src/middleware/__tests__/compression.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import express from 'express'
|
||||
import request from 'supertest'
|
||||
import { compressionMiddleware } from '../compression.js'
|
||||
|
||||
function createApp() {
|
||||
const app = express()
|
||||
app.use(compressionMiddleware)
|
||||
|
||||
app.get('/text', (_req, res) => {
|
||||
// Send enough data to exceed 1024 byte threshold
|
||||
res.type('text/html').send('x'.repeat(2000))
|
||||
})
|
||||
|
||||
app.get('/small', (_req, res) => {
|
||||
res.type('text/html').send('small')
|
||||
})
|
||||
|
||||
app.get('/image', (_req, res) => {
|
||||
res.type('image/png').send(Buffer.alloc(2000))
|
||||
})
|
||||
|
||||
app.get('/json', (_req, res) => {
|
||||
res.type('application/json').send(JSON.stringify({ data: 'y'.repeat(2000) }))
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
describe('compressionMiddleware', () => {
|
||||
it('compresses text responses above threshold', async () => {
|
||||
const res = await request(createApp())
|
||||
.get('/text')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
expect(res.headers['content-encoding']).toBe('gzip')
|
||||
})
|
||||
|
||||
it('does not compress responses below threshold', async () => {
|
||||
const res = await request(createApp())
|
||||
.get('/small')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
expect(res.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not compress image responses', async () => {
|
||||
const res = await request(createApp())
|
||||
.get('/image')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
expect(res.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('compresses JSON responses above threshold', async () => {
|
||||
const res = await request(createApp())
|
||||
.get('/json')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
expect(res.headers['content-encoding']).toBe('gzip')
|
||||
})
|
||||
|
||||
it('does not compress when client does not accept gzip', async () => {
|
||||
const res = await request(createApp())
|
||||
.get('/text')
|
||||
.set('Accept-Encoding', 'identity')
|
||||
expect(res.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
127
src/middleware/__tests__/usage.test.ts
Normal file
127
src/middleware/__tests__/usage.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../../services/db.js', () => ({
|
||||
queryWithRetry: vi.fn(),
|
||||
connectWithRetry: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/keys.js', () => ({
|
||||
getTierLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/logger.js', () => ({
|
||||
default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}))
|
||||
|
||||
// Must import after mocks
|
||||
import { usageMiddleware, loadUsageData, getUsageForKey } from '../usage.js'
|
||||
import { queryWithRetry } from '../../services/db.js'
|
||||
import { getTierLimit } from '../../services/keys.js'
|
||||
|
||||
function mockReqResNext(apiKeyInfo?: any) {
|
||||
const req = { apiKeyInfo } as any
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
setHeader: vi.fn(),
|
||||
} as any
|
||||
const next = vi.fn()
|
||||
return { req, res, next }
|
||||
}
|
||||
|
||||
describe('usageMiddleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('calls next when no apiKeyInfo on request', () => {
|
||||
const { req, res, next } = mockReqResNext()
|
||||
usageMiddleware(req, res, next)
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks usage and sets headers', () => {
|
||||
vi.mocked(getTierLimit).mockReturnValue(100)
|
||||
const { req, res, next } = mockReqResNext({ key: 'new-key', tier: 'free' })
|
||||
usageMiddleware(req, res, next)
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(res.setHeader).toHaveBeenCalledWith('X-Usage-Count', '1')
|
||||
expect(res.setHeader).toHaveBeenCalledWith('X-Usage-Limit', '100')
|
||||
})
|
||||
|
||||
it('increments count on repeated calls', () => {
|
||||
vi.mocked(getTierLimit).mockReturnValue(100)
|
||||
const { req: req1, res: res1, next: next1 } = mockReqResNext({ key: 'inc-key', tier: 'free' })
|
||||
usageMiddleware(req1, res1, next1)
|
||||
|
||||
const { req: req2, res: res2, next: next2 } = mockReqResNext({ key: 'inc-key', tier: 'free' })
|
||||
usageMiddleware(req2, res2, next2)
|
||||
expect(res2.setHeader).toHaveBeenCalledWith('X-Usage-Count', '2')
|
||||
})
|
||||
|
||||
it('returns 429 when usage limit is reached', () => {
|
||||
vi.mocked(getTierLimit).mockReturnValue(1)
|
||||
// First call uses up the limit
|
||||
const { req: req1, res: res1, next: next1 } = mockReqResNext({ key: 'limit-key', tier: 'free' })
|
||||
usageMiddleware(req1, res1, next1)
|
||||
expect(next1).toHaveBeenCalled()
|
||||
|
||||
// Second call should be rate limited
|
||||
const { req: req2, res: res2, next: next2 } = mockReqResNext({ key: 'limit-key', tier: 'free' })
|
||||
usageMiddleware(req2, res2, next2)
|
||||
expect(res2.status).toHaveBeenCalledWith(429)
|
||||
expect(res2.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: expect.stringContaining('Monthly limit reached'),
|
||||
usage: 1,
|
||||
limit: 1,
|
||||
}))
|
||||
expect(next2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets count for a new month', () => {
|
||||
vi.mocked(getTierLimit).mockReturnValue(1)
|
||||
// Use a unique key to avoid state from other tests
|
||||
const { req: req1, res: res1, next: next1 } = mockReqResNext({ key: 'month-key', tier: 'free' })
|
||||
usageMiddleware(req1, res1, next1)
|
||||
|
||||
// Simulate month change using fake timers
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2099-02-15T12:00:00Z'))
|
||||
|
||||
const { req: req2, res: res2, next: next2 } = mockReqResNext({ key: 'month-key', tier: 'free' })
|
||||
usageMiddleware(req2, res2, next2)
|
||||
expect(next2).toHaveBeenCalled()
|
||||
expect(res2.setHeader).toHaveBeenCalledWith('X-Usage-Count', '1')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadUsageData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads usage data from database', async () => {
|
||||
vi.mocked(queryWithRetry).mockResolvedValue({
|
||||
rows: [{ key: 'db-key', count: 42, month_key: '2026-03' }],
|
||||
} as any)
|
||||
await loadUsageData()
|
||||
const record = getUsageForKey('db-key')
|
||||
expect(record).toEqual({ count: 42, monthKey: '2026-03' })
|
||||
})
|
||||
|
||||
it('handles database errors gracefully', async () => {
|
||||
vi.mocked(queryWithRetry).mockRejectedValue(new Error('DB down'))
|
||||
await loadUsageData()
|
||||
// Should not throw, usage map should be empty
|
||||
expect(getUsageForKey('any-key')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsageForKey', () => {
|
||||
it('returns undefined for unknown key', () => {
|
||||
expect(getUsageForKey('nonexistent-key-xyz')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -89,3 +89,14 @@ export function usageMiddleware(req: any, res: any, next: any): void {
|
|||
export function getUsageForKey(key: string): { count: number; monthKey: string } | undefined {
|
||||
return usage.get(key);
|
||||
}
|
||||
|
||||
export function incrementUsage(key: string): void {
|
||||
const monthKey = getMonthKey();
|
||||
const record = usage.get(key);
|
||||
if (!record || record.monthKey !== monthKey) {
|
||||
usage.set(key, { count: 1, monthKey });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
dirtyKeys.add(key);
|
||||
}
|
||||
|
|
|
|||
46
src/routes/__tests__/accessibility.test.ts
Normal file
46
src/routes/__tests__/accessibility.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = path.join(__dirname, '../../../public')
|
||||
|
||||
// All HTML pages that have a nav (full-layout pages)
|
||||
const fullLayoutPages = fs.readdirSync(publicDir, { recursive: true })
|
||||
.filter((f): f is string => typeof f === 'string' && f.endsWith('.html'))
|
||||
.map(f => path.join(publicDir, f))
|
||||
.filter(f => {
|
||||
const html = fs.readFileSync(f, 'utf-8')
|
||||
return html.includes('<nav')
|
||||
})
|
||||
|
||||
describe('Accessibility landmarks', () => {
|
||||
it('found full-layout pages to test', () => {
|
||||
expect(fullLayoutPages.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
for (const filePath of fullLayoutPages) {
|
||||
const rel = path.relative(publicDir, filePath)
|
||||
|
||||
describe(rel, () => {
|
||||
const html = fs.readFileSync(filePath, 'utf-8')
|
||||
|
||||
it('has <header> landmark wrapping nav', () => {
|
||||
expect(html).toMatch(/<header[\s>]/)
|
||||
})
|
||||
|
||||
it('has <main id="main-content"> landmark', () => {
|
||||
expect(html).toMatch(/<main[\s][^>]*id=["']main-content["']/)
|
||||
})
|
||||
|
||||
it('has skip-to-content link', () => {
|
||||
expect(html).toMatch(/<a[^>]*href=["']#main-content["'][^>]*class=["'][^"']*skip-link/)
|
||||
})
|
||||
|
||||
it('has <footer> landmark', () => {
|
||||
expect(html).toMatch(/<footer[\s>]/)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
10
src/routes/__tests__/api.test.ts
Normal file
10
src/routes/__tests__/api.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Integration tests are skipped because importing the app requires
|
||||
// Stripe API keys, database, and browser infrastructure.
|
||||
// Enable these when running with full infrastructure in CI/CD.
|
||||
describe.skip('API Integration Tests', () => {
|
||||
it('placeholder - enable with infrastructure', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
210
src/routes/__tests__/batch.test.ts
Normal file
210
src/routes/__tests__/batch.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock dependencies before imports
|
||||
vi.mock('../../services/screenshot.js', () => ({
|
||||
takeScreenshot: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../services/logger.js', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../services/keys.js', () => ({
|
||||
isValidKey: vi.fn().mockResolvedValue(true),
|
||||
getKeyInfo: vi.fn().mockResolvedValue({ key: 'test_key', tier: 'pro', email: 'test@test.com' }),
|
||||
getTierLimit: vi.fn().mockReturnValue(5000),
|
||||
loadKeys: vi.fn(),
|
||||
getAllKeys: vi.fn().mockReturnValue([])
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/usage.js', () => ({
|
||||
usageMiddleware: vi.fn((req: any, res: any, next: any) => next()),
|
||||
getUsageForKey: vi.fn().mockReturnValue(undefined),
|
||||
loadUsageData: vi.fn(),
|
||||
incrementUsage: vi.fn()
|
||||
}))
|
||||
|
||||
const { takeScreenshot } = await import('../../services/screenshot.js')
|
||||
const { getTierLimit } = await import('../../services/keys.js')
|
||||
const { getUsageForKey, incrementUsage } = await import('../../middleware/usage.js')
|
||||
const mockTakeScreenshot = vi.mocked(takeScreenshot)
|
||||
|
||||
import express from 'express'
|
||||
import request from 'supertest'
|
||||
import { batchRouter } from '../batch.js'
|
||||
import { authMiddleware } from '../../middleware/auth.js'
|
||||
|
||||
// Create test app
|
||||
function createApp() {
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
app.use('/v1/screenshots/batch', authMiddleware, batchRouter)
|
||||
return app
|
||||
}
|
||||
|
||||
describe('POST /v1/screenshots/batch', () => {
|
||||
let app: express.Express
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
app = createApp()
|
||||
mockTakeScreenshot.mockResolvedValue({
|
||||
buffer: Buffer.from('fake-png'),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
vi.mocked(getTierLimit).mockReturnValue(5000)
|
||||
vi.mocked(getUsageForKey).mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
it('should return 401 without API key', async () => {
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.send({ urls: ['https://example.com'] })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('should return 400 when urls field is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({ format: 'png' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toMatch(/urls/)
|
||||
})
|
||||
|
||||
it('should return 400 when urls is empty array', async () => {
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({ urls: [] })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toMatch(/urls/)
|
||||
})
|
||||
|
||||
it('should return 400 when urls has more than 10 items', async () => {
|
||||
const urls = Array.from({ length: 11 }, (_, i) => `https://example${i}.com`)
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({ urls })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toMatch(/10/)
|
||||
})
|
||||
|
||||
it('should return results for 2 valid URLs', async () => {
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({ urls: ['https://example.com', 'https://example.org'] })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.results).toHaveLength(2)
|
||||
expect(res.body.results[0]).toMatchObject({
|
||||
url: 'https://example.com',
|
||||
status: 'success'
|
||||
})
|
||||
expect(res.body.results[0].image).toBeDefined()
|
||||
expect(res.body.results[1]).toMatchObject({
|
||||
url: 'https://example.org',
|
||||
status: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return partial success when 1 URL fails', async () => {
|
||||
mockTakeScreenshot
|
||||
.mockResolvedValueOnce({ buffer: Buffer.from('fake-png'), contentType: 'image/png' })
|
||||
.mockRejectedValueOnce(new Error('Navigation timeout'))
|
||||
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({ urls: ['https://example.com', 'https://fail.example.com'] })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.results).toHaveLength(2)
|
||||
expect(res.body.results[0].status).toBe('success')
|
||||
expect(res.body.results[1].status).toBe('error')
|
||||
expect(res.body.results[1].error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reject SSRF URLs per-URL, not whole batch', async () => {
|
||||
mockTakeScreenshot
|
||||
.mockRejectedValueOnce(new Error('URL not allowed: private IP'))
|
||||
.mockResolvedValueOnce({ buffer: Buffer.from('fake-png'), contentType: 'image/png' })
|
||||
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({ urls: ['http://169.254.169.254/metadata', 'https://example.com'] })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.results[0].status).toBe('error')
|
||||
expect(res.body.results[1].status).toBe('success')
|
||||
})
|
||||
|
||||
it('should apply shared params (format, width) to all URLs', async () => {
|
||||
await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({
|
||||
urls: ['https://example.com', 'https://example.org'],
|
||||
format: 'jpeg',
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledTimes(2)
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com',
|
||||
format: 'jpeg',
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
)
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.org',
|
||||
format: 'jpeg',
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return 429 when not enough quota for batch size', async () => {
|
||||
vi.mocked(getTierLimit).mockReturnValue(100)
|
||||
vi.mocked(getUsageForKey).mockReturnValue({ count: 99, monthKey: '2026-03' })
|
||||
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({ urls: ['https://example.com', 'https://example.org'] })
|
||||
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.body.error).toMatch(/limit/)
|
||||
})
|
||||
|
||||
it('should increment usage for each successful screenshot', async () => {
|
||||
mockTakeScreenshot
|
||||
.mockResolvedValueOnce({ buffer: Buffer.from('ok'), contentType: 'image/png' })
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
|
||||
const res = await request(app)
|
||||
.post('/v1/screenshots/batch')
|
||||
.set('Authorization', 'Bearer test_key')
|
||||
.send({ urls: ['https://example.com', 'https://fail.com'] })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// incrementUsage should be called only once (for the successful one)
|
||||
expect(incrementUsage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
378
src/routes/__tests__/billing.test.ts
Normal file
378
src/routes/__tests__/billing.test.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import request from 'supertest'
|
||||
import express from 'express'
|
||||
|
||||
// Hoist mock functions so they're available in vi.mock factories
|
||||
const {
|
||||
mockBillingPortalCreate,
|
||||
mockCheckoutSessionsCreate,
|
||||
mockCheckoutSessionsRetrieve,
|
||||
mockSubscriptionsRetrieve,
|
||||
mockWebhooksConstructEvent,
|
||||
mockProductsSearch,
|
||||
mockPricesList,
|
||||
mockPricesCreate,
|
||||
mockPricesRetrieve,
|
||||
mockProductsCreate,
|
||||
mockStripeInstance,
|
||||
} = vi.hoisted(() => {
|
||||
const mockBillingPortalCreate = vi.fn()
|
||||
const mockCheckoutSessionsCreate = vi.fn()
|
||||
const mockCheckoutSessionsRetrieve = vi.fn()
|
||||
const mockSubscriptionsRetrieve = vi.fn()
|
||||
const mockWebhooksConstructEvent = vi.fn()
|
||||
const mockProductsSearch = vi.fn()
|
||||
const mockPricesList = vi.fn()
|
||||
const mockPricesCreate = vi.fn()
|
||||
const mockPricesRetrieve = vi.fn()
|
||||
const mockProductsCreate = vi.fn()
|
||||
|
||||
const mockStripeInstance = {
|
||||
billingPortal: { sessions: { create: mockBillingPortalCreate } },
|
||||
checkout: { sessions: { create: mockCheckoutSessionsCreate, retrieve: mockCheckoutSessionsRetrieve } },
|
||||
subscriptions: { retrieve: mockSubscriptionsRetrieve },
|
||||
webhooks: { constructEvent: mockWebhooksConstructEvent },
|
||||
products: { search: mockProductsSearch, create: mockProductsCreate },
|
||||
prices: { list: mockPricesList, create: mockPricesCreate, retrieve: mockPricesRetrieve },
|
||||
}
|
||||
|
||||
return {
|
||||
mockBillingPortalCreate, mockCheckoutSessionsCreate, mockCheckoutSessionsRetrieve,
|
||||
mockSubscriptionsRetrieve, mockWebhooksConstructEvent, mockProductsSearch,
|
||||
mockPricesList, mockPricesCreate, mockPricesRetrieve, mockProductsCreate, mockStripeInstance,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../services/logger.js', () => ({
|
||||
default: { info: vi.fn(), error: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../../services/keys.js', () => ({
|
||||
getCustomerIdByEmail: vi.fn(),
|
||||
getKeyByEmail: vi.fn(),
|
||||
createPaidKey: vi.fn(),
|
||||
downgradeByCustomer: vi.fn(),
|
||||
updateEmailByCustomer: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('stripe', () => ({
|
||||
default: function Stripe() { return mockStripeInstance },
|
||||
}))
|
||||
|
||||
// Set env vars BEFORE import (vi.stubEnv may not work for module-level reads)
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_123456789'
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_secret'
|
||||
process.env.BASE_URL = 'https://test.snapapi.eu'
|
||||
|
||||
// Setup default mocks for initPrices (runs on import) — prices not found, so create them
|
||||
mockProductsSearch.mockResolvedValue({ data: [{ id: 'prod_snap_test' }] })
|
||||
mockPricesList.mockImplementation(async () => ({ data: [] }))
|
||||
mockPricesCreate.mockImplementation(async ({ unit_amount }: any) => ({ id: `price_${unit_amount}` }))
|
||||
|
||||
import { billingRouter } from '../billing.js'
|
||||
import { getCustomerIdByEmail, getKeyByEmail, createPaidKey, downgradeByCustomer, updateEmailByCustomer } from '../../services/keys.js'
|
||||
|
||||
const app = express()
|
||||
app.use('/v1/billing/webhook', express.raw({ type: '*/*' }))
|
||||
app.use(express.json())
|
||||
app.use('/v1/billing', billingRouter)
|
||||
|
||||
describe('POST /v1/billing/portal', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('should return portal URL when email has stripe customer ID', async () => {
|
||||
vi.mocked(getCustomerIdByEmail).mockResolvedValue('cus_123456')
|
||||
mockBillingPortalCreate.mockResolvedValue({ url: 'https://billing.stripe.com/p/session_123456' })
|
||||
|
||||
const response = await request(app).post('/v1/billing/portal').send({ email: 'user@example.com' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body).toEqual({ url: 'https://billing.stripe.com/p/session_123456' })
|
||||
expect(getCustomerIdByEmail).toHaveBeenCalledWith('user@example.com')
|
||||
expect(mockBillingPortalCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ customer: 'cus_123456' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should return 404 when email has no stripe customer ID', async () => {
|
||||
vi.mocked(getCustomerIdByEmail).mockResolvedValue(undefined)
|
||||
const response = await request(app).post('/v1/billing/portal').send({ email: 'nonexistent@example.com' })
|
||||
expect(response.status).toBe(404)
|
||||
expect(response.body.error).toContain('No subscription found')
|
||||
})
|
||||
|
||||
it('should return 400 when email is missing', async () => {
|
||||
const response = await request(app).post('/v1/billing/portal').send({})
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.body).toEqual({ error: 'Email address is required' })
|
||||
})
|
||||
|
||||
it('should return 400 when email is empty string', async () => {
|
||||
const response = await request(app).post('/v1/billing/portal').send({ email: '' })
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.body).toEqual({ error: 'Email address is required' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /v1/billing/recover', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('should return success message and masked key when email exists', async () => {
|
||||
vi.mocked(getKeyByEmail).mockResolvedValue({
|
||||
key: 'snap_abcd1234efgh5678ijkl9012', tier: 'pro', email: 'user@example.com',
|
||||
createdAt: '2024-01-01T00:00:00Z', stripeCustomerId: 'cus_123456'
|
||||
})
|
||||
const response = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.maskedKey).toBe('snap_abcd...9012')
|
||||
})
|
||||
|
||||
it('should return success message when email does not exist (no info leak)', async () => {
|
||||
vi.mocked(getKeyByEmail).mockResolvedValue(undefined)
|
||||
const response = await request(app).get('/v1/billing/recover').query({ email: 'nonexistent@example.com' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.message).toContain('If an account exists')
|
||||
expect(response.body.maskedKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return 400 when email is missing', async () => {
|
||||
const response = await request(app).get('/v1/billing/recover')
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('should return 400 when email is empty string', async () => {
|
||||
const response = await request(app).get('/v1/billing/recover').query({ email: '' })
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('should properly mask API keys with correct format', async () => {
|
||||
vi.mocked(getKeyByEmail).mockResolvedValue({
|
||||
key: 'snap_1234567890abcdef', tier: 'starter', email: 'user@example.com', createdAt: '2024-01-01T00:00:00Z'
|
||||
})
|
||||
const response = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' })
|
||||
expect(response.body.maskedKey).toBe('snap_1234...cdef')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /v1/billing/checkout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProductsSearch.mockResolvedValue({ data: [{ id: 'prod_snap_test' }] })
|
||||
mockPricesList.mockResolvedValue({ data: [{ id: 'price_test', unit_amount: 900 }] })
|
||||
})
|
||||
|
||||
it('should return 400 for missing plan', async () => {
|
||||
const response = await request(app).post('/v1/billing/checkout').send({})
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.body.error).toContain('Invalid plan')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid plan name', async () => {
|
||||
const response = await request(app).post('/v1/billing/checkout').send({ plan: 'enterprise' })
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.body.error).toContain('Invalid plan')
|
||||
})
|
||||
|
||||
it('should return checkout URL for valid plan (starter)', async () => {
|
||||
mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_abc' })
|
||||
const response = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body).toEqual({ url: 'https://checkout.stripe.com/session_abc' })
|
||||
expect(mockCheckoutSessionsCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mode: 'subscription',
|
||||
metadata: { plan: 'starter', service: 'snapapi' },
|
||||
}))
|
||||
})
|
||||
|
||||
it('should return checkout URL for pro plan', async () => {
|
||||
mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/pro' })
|
||||
const response = await request(app).post('/v1/billing/checkout').send({ plan: 'pro' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.url).toBe('https://checkout.stripe.com/pro')
|
||||
})
|
||||
|
||||
it('should return checkout URL for business plan', async () => {
|
||||
mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/biz' })
|
||||
const response = await request(app).post('/v1/billing/checkout').send({ plan: 'business' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.url).toBe('https://checkout.stripe.com/biz')
|
||||
})
|
||||
|
||||
it('should return 500 on Stripe API error', async () => {
|
||||
mockCheckoutSessionsCreate.mockRejectedValue(new Error('Stripe is down'))
|
||||
const response = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' })
|
||||
expect(response.status).toBe(500)
|
||||
expect(response.body.error).toContain('Failed to create checkout session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /v1/billing/success', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('should return 400 for missing session_id', async () => {
|
||||
const response = await request(app).get('/v1/billing/success')
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.text).toBe('Missing session_id')
|
||||
})
|
||||
|
||||
it('should return HTML with API key for valid session', async () => {
|
||||
mockCheckoutSessionsRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_unique_success_1',
|
||||
customer_details: { email: 'buyer@example.com' },
|
||||
metadata: { plan: 'pro' },
|
||||
customer: 'cus_buyer_1',
|
||||
})
|
||||
vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_newkey123456' } as any)
|
||||
|
||||
const response = await request(app).get('/v1/billing/success').query({ session_id: 'cs_test_unique_success_1' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers['content-type']).toMatch(/html/)
|
||||
expect(response.text).toContain('snap_newkey123456')
|
||||
expect(response.text).toContain('Welcome to SnapAPI')
|
||||
expect(response.text).toContain('Pro Plan')
|
||||
expect(createPaidKey).toHaveBeenCalledWith('buyer@example.com', 'pro', 'cus_buyer_1')
|
||||
})
|
||||
|
||||
it('should handle duplicate session_id (dedup via provisionedSessions)', async () => {
|
||||
const sessionId = 'cs_test_dedup_unique_2'
|
||||
mockCheckoutSessionsRetrieve.mockResolvedValue({
|
||||
id: sessionId, customer_details: { email: 'dedup@example.com' },
|
||||
metadata: { plan: 'starter' }, customer: 'cus_dedup',
|
||||
})
|
||||
vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_first_key' } as any)
|
||||
|
||||
await request(app).get('/v1/billing/success').query({ session_id: sessionId })
|
||||
vi.mocked(createPaidKey).mockClear()
|
||||
const response = await request(app).get('/v1/billing/success').query({ session_id: sessionId })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.text).toContain('already provisioned')
|
||||
expect(createPaidKey).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 500 on Stripe API error', async () => {
|
||||
mockCheckoutSessionsRetrieve.mockRejectedValue(new Error('Stripe error'))
|
||||
const response = await request(app).get('/v1/billing/success').query({ session_id: 'cs_test_error_3' })
|
||||
expect(response.status).toBe(500)
|
||||
expect(response.text).toContain('Something went wrong')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /v1/billing/recover - security', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('should return masked key, not the full key', async () => {
|
||||
vi.mocked(getKeyByEmail).mockResolvedValue({
|
||||
key: 'snap_abcdef1234567890abcdef1234567890abcdef12345678',
|
||||
tier: 'pro',
|
||||
email: 'user@example.com',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
const response = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.maskedKey).toBeDefined()
|
||||
expect(response.body.maskedKey).toContain('...')
|
||||
// Must NOT contain the full key
|
||||
expect(response.body.key).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Billing rate limiting', () => {
|
||||
it('should return rate limit headers on billing endpoints', async () => {
|
||||
vi.mocked(getKeyByEmail).mockResolvedValue(undefined)
|
||||
const response = await request(app).get('/v1/billing/recover').query({ email: 'test@example.com' })
|
||||
expect(response.headers['ratelimit-limit']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /v1/billing/webhook', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('should return 400 for missing stripe-signature header', async () => {
|
||||
const response = await request(app).post('/v1/billing/webhook').send(Buffer.from('{}'))
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.text).toBe('Missing signature')
|
||||
})
|
||||
|
||||
it('should handle checkout.session.completed — provisions key', async () => {
|
||||
const sessionId = 'cs_webhook_unique_4'
|
||||
mockWebhooksConstructEvent.mockReturnValue({
|
||||
id: 'evt_1', type: 'checkout.session.completed',
|
||||
data: { object: {
|
||||
id: sessionId, customer_details: { email: 'webhook@example.com' },
|
||||
metadata: { plan: 'pro' }, customer: 'cus_wh_1', subscription: 'sub_123',
|
||||
}}
|
||||
})
|
||||
mockSubscriptionsRetrieve.mockResolvedValue({
|
||||
items: { data: [{ price: { product: 'prod_snap_test' } }] }
|
||||
})
|
||||
vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_wh_key' } as any)
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}'))
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.received).toBe(true)
|
||||
expect(createPaidKey).toHaveBeenCalledWith('webhook@example.com', 'pro', 'cus_wh_1')
|
||||
})
|
||||
|
||||
it('should handle customer.subscription.updated with canceled status — downgrades', async () => {
|
||||
mockWebhooksConstructEvent.mockReturnValue({
|
||||
id: 'evt_2', type: 'customer.subscription.updated',
|
||||
data: { object: {
|
||||
customer: 'cus_cancel_1', status: 'canceled',
|
||||
items: { data: [{ price: { product: 'prod_snap_test' } }] },
|
||||
}}
|
||||
})
|
||||
const response = await request(app)
|
||||
.post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}'))
|
||||
expect(response.status).toBe(200)
|
||||
expect(downgradeByCustomer).toHaveBeenCalledWith('cus_cancel_1')
|
||||
})
|
||||
|
||||
it('should handle customer.subscription.deleted — downgrades', async () => {
|
||||
mockWebhooksConstructEvent.mockReturnValue({
|
||||
id: 'evt_3', type: 'customer.subscription.deleted',
|
||||
data: { object: {
|
||||
customer: 'cus_delete_1',
|
||||
items: { data: [{ price: { product: 'prod_snap_test' } }] },
|
||||
}}
|
||||
})
|
||||
const response = await request(app)
|
||||
.post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}'))
|
||||
expect(response.status).toBe(200)
|
||||
expect(downgradeByCustomer).toHaveBeenCalledWith('cus_delete_1')
|
||||
})
|
||||
|
||||
it('should handle customer.updated — updates email', async () => {
|
||||
mockWebhooksConstructEvent.mockReturnValue({
|
||||
id: 'evt_4', type: 'customer.updated',
|
||||
data: { object: { id: 'cus_email_update', email: 'newemail@example.com' } }
|
||||
})
|
||||
const response = await request(app)
|
||||
.post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}'))
|
||||
expect(response.status).toBe(200)
|
||||
expect(updateEmailByCustomer).toHaveBeenCalledWith('cus_email_update', 'newemail@example.com')
|
||||
})
|
||||
|
||||
it('should ignore non-SnapAPI events (different product ID)', async () => {
|
||||
mockWebhooksConstructEvent.mockReturnValue({
|
||||
id: 'evt_5', type: 'customer.subscription.updated',
|
||||
data: { object: {
|
||||
customer: 'cus_other', status: 'canceled',
|
||||
items: { data: [{ price: { product: 'prod_OTHER_not_snapapi' } }] },
|
||||
}}
|
||||
})
|
||||
const response = await request(app)
|
||||
.post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}'))
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body).toEqual({ received: true, ignored: true })
|
||||
expect(downgradeByCustomer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 400 for invalid signature', async () => {
|
||||
mockWebhooksConstructEvent.mockImplementation(() => { throw new Error('Invalid signature') })
|
||||
const response = await request(app)
|
||||
.post('/v1/billing/webhook').set('stripe-signature', 'sig_invalid').send(Buffer.from('{}'))
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.text).toContain('Webhook error')
|
||||
})
|
||||
})
|
||||
284
src/routes/__tests__/blog.test.ts
Normal file
284
src/routes/__tests__/blog.test.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import request from 'supertest'
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import fs from 'fs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = path.join(__dirname, '../../../public')
|
||||
|
||||
function createApp() {
|
||||
const app = express()
|
||||
|
||||
// Blog routes matching index.ts
|
||||
app.get('/blog', (_req, res) => res.redirect(301, '/blog.html'))
|
||||
app.get('/blog/:slug([^.]+)', (_req, res) => res.redirect(301, `/blog/${_req.params.slug}.html`))
|
||||
|
||||
app.use(express.static(publicDir, { etag: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
describe('Blog Index Page', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /blog.html returns 200', async () => {
|
||||
const res = await request(app).get('/blog.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /blog redirects 301 to /blog.html', async () => {
|
||||
const res = await request(app).get('/blog')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/blog.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/blog.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/blog"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
})
|
||||
|
||||
it('contains JSON-LD Blog schema', async () => {
|
||||
const res = await request(app).get('/blog.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('"@type":"Blog"')
|
||||
})
|
||||
|
||||
it('links to all blog posts', async () => {
|
||||
const res = await request(app).get('/blog.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('/blog/why-screenshot-api')
|
||||
expect(html).toContain('/blog/screenshot-api-performance')
|
||||
expect(html).toContain('/blog/automating-og-images')
|
||||
expect(html).toContain('/blog/dark-mode-screenshots')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Blog Post: Why Screenshot API', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /blog/why-screenshot-api.html returns 200', async () => {
|
||||
const res = await request(app).get('/blog/why-screenshot-api.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /blog/why-screenshot-api redirects 301 to .html', async () => {
|
||||
const res = await request(app).get('/blog/why-screenshot-api')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/blog/why-screenshot-api.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/blog/why-screenshot-api.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/blog\/why-screenshot-api"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
})
|
||||
|
||||
it('contains JSON-LD BlogPosting schema', async () => {
|
||||
const res = await request(app).get('/blog/why-screenshot-api.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('"@type":"BlogPosting"')
|
||||
})
|
||||
|
||||
it('has substantial content (~800 words)', async () => {
|
||||
const res = await request(app).get('/blog/why-screenshot-api.html')
|
||||
const text = res.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
|
||||
const wordCount = text.split(' ').filter(w => w.length > 0).length
|
||||
expect(wordCount).toBeGreaterThan(600)
|
||||
})
|
||||
|
||||
it('contains relevant keywords', async () => {
|
||||
const res = await request(app).get('/blog/why-screenshot-api.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('screenshot API')
|
||||
expect(html).toContain('Puppeteer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Blog Post: Screenshot API Performance', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /blog/screenshot-api-performance.html returns 200', async () => {
|
||||
const res = await request(app).get('/blog/screenshot-api-performance.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /blog/screenshot-api-performance redirects 301 to .html', async () => {
|
||||
const res = await request(app).get('/blog/screenshot-api-performance')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/blog/screenshot-api-performance.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/blog/screenshot-api-performance.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/blog\/screenshot-api-performance"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
})
|
||||
|
||||
it('contains JSON-LD BlogPosting schema', async () => {
|
||||
const res = await request(app).get('/blog/screenshot-api-performance.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('"@type":"BlogPosting"')
|
||||
})
|
||||
|
||||
it('has substantial content (~600 words)', async () => {
|
||||
const res = await request(app).get('/blog/screenshot-api-performance.html')
|
||||
const text = res.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
|
||||
const wordCount = text.split(' ').filter(w => w.length > 0).length
|
||||
expect(wordCount).toBeGreaterThan(400)
|
||||
})
|
||||
|
||||
it('contains relevant keywords', async () => {
|
||||
const res = await request(app).get('/blog/screenshot-api-performance.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('caching')
|
||||
expect(html).toContain('performance')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Blog Post: Automating OG Images', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /blog/automating-og-images.html returns 200', async () => {
|
||||
const res = await request(app).get('/blog/automating-og-images.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /blog/automating-og-images redirects 301 to .html', async () => {
|
||||
const res = await request(app).get('/blog/automating-og-images')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/blog/automating-og-images.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/blog/automating-og-images.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/blog\/automating-og-images"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
})
|
||||
|
||||
it('contains JSON-LD BlogPosting schema', async () => {
|
||||
const res = await request(app).get('/blog/automating-og-images.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('"@type":"BlogPosting"')
|
||||
})
|
||||
|
||||
it('has substantial content (~800 words)', async () => {
|
||||
const res = await request(app).get('/blog/automating-og-images.html')
|
||||
const text = res.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
|
||||
const wordCount = text.split(' ').filter(w => w.length > 0).length
|
||||
expect(wordCount).toBeGreaterThan(600)
|
||||
})
|
||||
|
||||
it('contains relevant keywords', async () => {
|
||||
const res = await request(app).get('/blog/automating-og-images.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('OG image')
|
||||
expect(html).toContain('screenshot API')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Blog Post: Dark Mode Screenshots', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /blog/dark-mode-screenshots.html returns 200', async () => {
|
||||
const res = await request(app).get('/blog/dark-mode-screenshots.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /blog/dark-mode-screenshots redirects 301 to .html', async () => {
|
||||
const res = await request(app).get('/blog/dark-mode-screenshots')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/blog/dark-mode-screenshots.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/blog/dark-mode-screenshots.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/blog\/dark-mode-screenshots"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
})
|
||||
|
||||
it('contains JSON-LD BlogPosting schema', async () => {
|
||||
const res = await request(app).get('/blog/dark-mode-screenshots.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('"@type":"BlogPosting"')
|
||||
})
|
||||
|
||||
it('has substantial content (~1000 words)', async () => {
|
||||
const res = await request(app).get('/blog/dark-mode-screenshots.html')
|
||||
const text = res.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
|
||||
const wordCount = text.split(' ').filter(w => w.length > 0).length
|
||||
expect(wordCount).toBeGreaterThan(800)
|
||||
})
|
||||
|
||||
it('contains relevant keywords', async () => {
|
||||
const res = await request(app).get('/blog/dark-mode-screenshots.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('dark mode')
|
||||
expect(html).toContain('darkMode')
|
||||
expect(html).toContain('screenshot')
|
||||
})
|
||||
|
||||
it('contains code examples', async () => {
|
||||
const res = await request(app).get('/blog/dark-mode-screenshots.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('<pre>')
|
||||
expect(html).toContain('<code>')
|
||||
expect(html).toContain('curl')
|
||||
expect(html).toContain('Node.js')
|
||||
expect(html).toContain('Python')
|
||||
})
|
||||
|
||||
it('contains internal links to pricing and docs', async () => {
|
||||
const res = await request(app).get('/blog/dark-mode-screenshots.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('/pricing')
|
||||
expect(html).toContain('/docs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Blog Sitemap & Navigation', () => {
|
||||
it('sitemap contains blog URLs', () => {
|
||||
const sitemap = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf-8')
|
||||
expect(sitemap).toContain('https://snapapi.eu/blog')
|
||||
expect(sitemap).toContain('https://snapapi.eu/blog/why-screenshot-api')
|
||||
expect(sitemap).toContain('https://snapapi.eu/blog/screenshot-api-performance')
|
||||
expect(sitemap).toContain('https://snapapi.eu/blog/automating-og-images')
|
||||
expect(sitemap).toContain('https://snapapi.eu/blog/dark-mode-screenshots')
|
||||
})
|
||||
|
||||
it('index.html has Blog link in nav or footer', () => {
|
||||
const index = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf-8')
|
||||
expect(index).toContain('/blog')
|
||||
})
|
||||
})
|
||||
225
src/routes/__tests__/health.test.ts
Normal file
225
src/routes/__tests__/health.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { Request, Response } from 'express'
|
||||
import { createRequire } from 'module'
|
||||
import { healthRouter } from '../health.js'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require('../../../package.json')
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/browser.js', () => ({
|
||||
getPoolStats: vi.fn()
|
||||
}))
|
||||
|
||||
const { getPoolStats } = await import('../../services/browser.js')
|
||||
const mockGetPoolStats = vi.mocked(getPoolStats)
|
||||
|
||||
function createMockRequest(overrides: any = {}): Partial<Request> {
|
||||
return {
|
||||
method: 'GET',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockResponse(): Partial<Response> {
|
||||
const res: any = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
describe('Health Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('should return health status with browser pool stats', async () => {
|
||||
const mockPoolStats = {
|
||||
size: 3,
|
||||
available: 2,
|
||||
pending: 0,
|
||||
borrowed: 1,
|
||||
min: 1,
|
||||
max: 5
|
||||
}
|
||||
|
||||
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
|
||||
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
|
||||
// Get the GET handler from the router
|
||||
const handler = healthRouter.stack.find(layer =>
|
||||
layer.route?.methods.get && layer.route.path === '/'
|
||||
)?.route.stack[0].handle
|
||||
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockGetPoolStats).toHaveBeenCalledOnce()
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
status: "ok",
|
||||
version: pkg.version,
|
||||
uptime: expect.any(Number),
|
||||
browser: mockPoolStats
|
||||
})
|
||||
})
|
||||
|
||||
it('should include process uptime in response', async () => {
|
||||
const mockPoolStats = {
|
||||
size: 2,
|
||||
available: 1,
|
||||
pending: 1,
|
||||
borrowed: 1,
|
||||
min: 1,
|
||||
max: 3
|
||||
}
|
||||
|
||||
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
|
||||
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = healthRouter.stack.find(layer =>
|
||||
layer.route?.methods.get
|
||||
)?.route.stack[0].handle
|
||||
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
const responseCall = res.json.mock.calls[0][0]
|
||||
expect(responseCall.uptime).toBeTypeOf('number')
|
||||
expect(responseCall.uptime).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle browser pool stats with different values', async () => {
|
||||
const mockPoolStats = {
|
||||
size: 0,
|
||||
available: 0,
|
||||
pending: 5,
|
||||
borrowed: 0,
|
||||
min: 0,
|
||||
max: 10
|
||||
}
|
||||
|
||||
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
|
||||
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = healthRouter.stack.find(layer =>
|
||||
layer.route?.methods.get
|
||||
)?.route.stack[0].handle
|
||||
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
status: "ok",
|
||||
version: pkg.version,
|
||||
uptime: expect.any(Number),
|
||||
browser: mockPoolStats
|
||||
})
|
||||
})
|
||||
|
||||
it('should return consistent status format', async () => {
|
||||
mockGetPoolStats.mockReturnValueOnce({})
|
||||
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = healthRouter.stack.find(layer =>
|
||||
layer.route?.methods.get
|
||||
)?.route.stack[0].handle
|
||||
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
const responseCall = res.json.mock.calls[0][0]
|
||||
expect(responseCall).toHaveProperty('status', 'ok')
|
||||
expect(responseCall).toHaveProperty('version', pkg.version)
|
||||
expect(responseCall).toHaveProperty('uptime')
|
||||
expect(responseCall).toHaveProperty('browser')
|
||||
})
|
||||
|
||||
it('should handle browser pool errors gracefully', async () => {
|
||||
// If getPoolStats throws, we should still get a response
|
||||
mockGetPoolStats.mockImplementationOnce(() => {
|
||||
throw new Error('Browser pool error')
|
||||
})
|
||||
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = healthRouter.stack.find(layer =>
|
||||
layer.route?.methods.get
|
||||
)?.route.stack[0].handle
|
||||
|
||||
// The handler doesn't try/catch getPoolStats, so this would throw
|
||||
// But in real usage, it might be wrapped by error middleware
|
||||
await expect(async () => {
|
||||
await handler(req, res, vi.fn())
|
||||
}).rejects.toThrow('Browser pool error')
|
||||
})
|
||||
|
||||
it('should not require authentication', async () => {
|
||||
// Health endpoint should be accessible without auth
|
||||
const mockPoolStats = { size: 1, available: 1 }
|
||||
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {} // No auth headers
|
||||
})
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = healthRouter.stack.find(layer =>
|
||||
layer.route?.methods.get
|
||||
)?.route.stack[0].handle
|
||||
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
status: "ok",
|
||||
version: pkg.version,
|
||||
uptime: expect.any(Number),
|
||||
browser: mockPoolStats
|
||||
})
|
||||
})
|
||||
|
||||
it('should return health data in expected format for monitoring', async () => {
|
||||
const mockPoolStats = {
|
||||
size: 3,
|
||||
available: 2,
|
||||
pending: 1,
|
||||
borrowed: 1,
|
||||
min: 1,
|
||||
max: 5
|
||||
}
|
||||
|
||||
mockGetPoolStats.mockReturnValueOnce(mockPoolStats)
|
||||
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = healthRouter.stack.find(layer =>
|
||||
layer.route?.methods.get
|
||||
)?.route.stack[0].handle
|
||||
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
const response = res.json.mock.calls[0][0]
|
||||
|
||||
// Verify structure for monitoring systems
|
||||
expect(response.status).toBe('ok')
|
||||
expect(typeof response.version).toBe('string')
|
||||
expect(typeof response.uptime).toBe('number')
|
||||
expect(typeof response.browser).toBe('object')
|
||||
|
||||
// Browser stats should contain pool metrics
|
||||
expect(response.browser).toEqual(mockPoolStats)
|
||||
})
|
||||
})
|
||||
})
|
||||
244
src/routes/__tests__/pdf.test.ts
Normal file
244
src/routes/__tests__/pdf.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { Request, Response } from 'express'
|
||||
import { screenshotRouter } from '../screenshot.js'
|
||||
import { playgroundRouter } from '../playground.js'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/screenshot.js', () => ({
|
||||
takeScreenshot: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../services/cache.js', () => ({
|
||||
screenshotCache: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
shouldBypass: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../services/watermark.js', () => ({
|
||||
addWatermark: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../services/logger.js', () => ({
|
||||
default: {
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/auth.js', () => ({
|
||||
authMiddleware: vi.fn((req, res, next) => {
|
||||
req.apiKeyInfo = { key: 'test_key', tier: 'pro', email: 'test@test.com' }
|
||||
next()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/usage.js', () => ({
|
||||
usageMiddleware: vi.fn((req, res, next) => next())
|
||||
}))
|
||||
|
||||
vi.mock('express-rate-limit', () => ({
|
||||
default: vi.fn(() => (req: any, res: any, next: any) => next())
|
||||
}))
|
||||
|
||||
const { takeScreenshot } = await import('../../services/screenshot.js')
|
||||
const { screenshotCache } = await import('../../services/cache.js')
|
||||
const { addWatermark } = await import('../../services/watermark.js')
|
||||
const mockTakeScreenshot = vi.mocked(takeScreenshot)
|
||||
const mockCache = vi.mocked(screenshotCache)
|
||||
const mockAddWatermark = vi.mocked(addWatermark)
|
||||
|
||||
function createMockRequest(params: any = {}, overrides: any = {}): Partial<Request> {
|
||||
const method = overrides.method || 'POST'
|
||||
return {
|
||||
method,
|
||||
body: method === 'POST' ? params : {},
|
||||
query: method === 'GET' ? params : {},
|
||||
headers: { authorization: 'Bearer test_key' },
|
||||
apiKeyInfo: { key: 'test_key', tier: 'pro', email: 'test@test.com' },
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' } as any,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockResponse(): Partial<Response> {
|
||||
const res: any = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
setHeader: vi.fn().mockReturnThis()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
describe('PDF Output', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCache.shouldBypass.mockReturnValue(false)
|
||||
mockCache.get.mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('POST /v1/screenshot with format=pdf', () => {
|
||||
it('should return PDF with correct Content-Type', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4 fake pdf content')
|
||||
mockTakeScreenshot.mockResolvedValueOnce({
|
||||
buffer: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
retryCount: 0
|
||||
})
|
||||
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf' })
|
||||
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.setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
|
||||
expect(res.send).toHaveBeenCalledWith(pdfBuffer)
|
||||
})
|
||||
|
||||
it('should set Content-Disposition for PDF', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4 fake pdf content')
|
||||
mockTakeScreenshot.mockResolvedValueOnce({
|
||||
buffer: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
retryCount: 0
|
||||
})
|
||||
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf' })
|
||||
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.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="screenshot.pdf"')
|
||||
})
|
||||
|
||||
it('should pass pdfFormat option to takeScreenshot', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: pdfBuffer, contentType: 'application/pdf', retryCount: 0 })
|
||||
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfFormat: 'a4' })
|
||||
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({
|
||||
format: 'pdf',
|
||||
pdfFormat: 'a4'
|
||||
}))
|
||||
})
|
||||
|
||||
it('should pass pdfLandscape option to takeScreenshot', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: pdfBuffer, contentType: 'application/pdf', retryCount: 0 })
|
||||
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfLandscape: true })
|
||||
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({
|
||||
format: 'pdf',
|
||||
pdfLandscape: true
|
||||
}))
|
||||
})
|
||||
|
||||
it('should return 400 when format=pdf with selector', async () => {
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', selector: '#content' })
|
||||
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({ error: 'format "pdf" is mutually exclusive with selector and clip' })
|
||||
})
|
||||
|
||||
it('should return 400 when format=pdf with clip', async () => {
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', clip: { x: 0, y: 0, width: 100, height: 100 } })
|
||||
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({ error: 'format "pdf" is mutually exclusive with selector and clip' })
|
||||
})
|
||||
|
||||
it('should return 400 for invalid pdfFormat', async () => {
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfFormat: 'b5' })
|
||||
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({ error: 'pdfFormat must be one of: a4, letter, legal, a3' })
|
||||
})
|
||||
|
||||
it('should return 400 when pdfScale is out of range (too low)', async () => {
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfScale: 0.05 })
|
||||
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({ error: 'pdfScale must be between 0.1 and 2.0' })
|
||||
})
|
||||
|
||||
it('should return 400 when pdfScale is out of range (too high)', async () => {
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf', pdfScale: 3.0 })
|
||||
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({ error: 'pdfScale must be between 0.1 and 2.0' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /v1/screenshot with format=pdf', () => {
|
||||
it('should handle PDF via GET request', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: pdfBuffer, contentType: 'application/pdf', retryCount: 0 })
|
||||
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf' }, { 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(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="screenshot.pdf"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Playground PDF', () => {
|
||||
it('should return PDF without watermark in playground', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4 playground pdf')
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: pdfBuffer, contentType: 'application/pdf', retryCount: 0 })
|
||||
|
||||
const req = createMockRequest({ url: 'https://example.com', format: 'pdf' })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
// Should NOT call addWatermark for PDF
|
||||
expect(mockAddWatermark).not.toHaveBeenCalled()
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="screenshot.pdf"')
|
||||
expect(res.send).toHaveBeenCalledWith(pdfBuffer)
|
||||
})
|
||||
})
|
||||
})
|
||||
357
src/routes/__tests__/playground.test.ts
Normal file
357
src/routes/__tests__/playground.test.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { Request, Response } from 'express'
|
||||
import { playgroundRouter } from '../playground.js'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/screenshot.js', () => ({
|
||||
takeScreenshot: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../services/watermark.js', () => ({
|
||||
addWatermark: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../services/logger.js', () => ({
|
||||
default: {
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('express-rate-limit', () => ({
|
||||
default: vi.fn(() => (req: any, res: any, next: any) => next())
|
||||
}))
|
||||
|
||||
const { takeScreenshot } = await import('../../services/screenshot.js')
|
||||
const { addWatermark } = await import('../../services/watermark.js')
|
||||
const mockTakeScreenshot = vi.mocked(takeScreenshot)
|
||||
const mockAddWatermark = vi.mocked(addWatermark)
|
||||
|
||||
function createMockRequest(body: any = {}, overrides: any = {}): Partial<Request> {
|
||||
return {
|
||||
body,
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' } as any,
|
||||
method: 'POST',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockResponse(): Partial<Response> {
|
||||
const res: any = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
setHeader: vi.fn().mockReturnThis()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
describe('Playground Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('POST /v1/playground', () => {
|
||||
it('should return 400 when URL is missing', async () => {
|
||||
const req = createMockRequest({})
|
||||
const res = createMockResponse()
|
||||
|
||||
// Get the handler from the router
|
||||
const handler = playgroundRouter.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({ error: "Missing required parameter: url" })
|
||||
})
|
||||
|
||||
it('should return 400 when URL is not a string', async () => {
|
||||
const req = createMockRequest({ url: 123 })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.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({ error: "Missing required parameter: url" })
|
||||
})
|
||||
|
||||
it('should return 400 when URL is empty string', async () => {
|
||||
const req = createMockRequest({ url: "" })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.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({ error: "Missing required parameter: url" })
|
||||
})
|
||||
|
||||
it('should successfully take screenshot with valid URL and return watermarked result', async () => {
|
||||
const mockBuffer = Buffer.from('fake-screenshot-data')
|
||||
const mockWatermarkedBuffer = Buffer.from('fake-watermarked-data')
|
||||
|
||||
mockTakeScreenshot.mockResolvedValueOnce({
|
||||
buffer: mockBuffer,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
mockAddWatermark.mockResolvedValueOnce(mockWatermarkedBuffer)
|
||||
|
||||
const req = createMockRequest({ url: "https://example.com" })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith({
|
||||
url: "https://example.com",
|
||||
format: "png",
|
||||
width: 1280,
|
||||
height: 800,
|
||||
fullPage: false,
|
||||
quality: undefined,
|
||||
deviceScale: 1,
|
||||
waitUntil: "domcontentloaded",
|
||||
waitForSelector: undefined
|
||||
})
|
||||
expect(mockAddWatermark).toHaveBeenCalledWith(mockBuffer, 1280, 800)
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/png")
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", mockWatermarkedBuffer.length)
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store")
|
||||
expect(res.setHeader).toHaveBeenCalledWith("X-Playground", "true")
|
||||
expect(res.send).toHaveBeenCalledWith(mockWatermarkedBuffer)
|
||||
})
|
||||
|
||||
it('should enforce width limits (min 320, max 1920)', async () => {
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
|
||||
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://example.com",
|
||||
width: 100 // Below minimum
|
||||
})
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
width: 320 // Should be clamped to minimum
|
||||
}))
|
||||
})
|
||||
|
||||
it('should enforce height limits (min 200, max 1080)', async () => {
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
|
||||
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://example.com",
|
||||
height: 2000 // Above maximum
|
||||
})
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
height: 1080 // Should be clamped to maximum
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle format parameter correctly', async () => {
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/jpeg' })
|
||||
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://example.com",
|
||||
format: "jpeg",
|
||||
quality: 95
|
||||
})
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
format: "jpeg",
|
||||
quality: 95
|
||||
}))
|
||||
})
|
||||
|
||||
it('should sanitize waitForSelector parameter', async () => {
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
|
||||
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://example.com",
|
||||
waitForSelector: "<script>alert('xss')</script>" // Malicious selector
|
||||
})
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
waitForSelector: undefined // Should be sanitized out
|
||||
}))
|
||||
})
|
||||
|
||||
it('should allow valid CSS selector for waitForSelector', async () => {
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
|
||||
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://example.com",
|
||||
waitForSelector: "#main-content .article" // Valid CSS selector
|
||||
})
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
waitForSelector: "#main-content .article"
|
||||
}))
|
||||
})
|
||||
|
||||
it('should return 503 when service queue is full', async () => {
|
||||
mockTakeScreenshot.mockRejectedValueOnce(new Error('QUEUE_FULL'))
|
||||
|
||||
const req = createMockRequest({ url: "https://example.com" })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Service busy. Try again shortly." })
|
||||
})
|
||||
|
||||
it('should return 504 when screenshot times out', async () => {
|
||||
mockTakeScreenshot.mockRejectedValueOnce(new Error('SCREENSHOT_TIMEOUT'))
|
||||
|
||||
const req = createMockRequest({ url: "https://example.com" })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(504)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Screenshot timed out." })
|
||||
})
|
||||
|
||||
it('should return 400 when URL is blocked (SSRF protection)', async () => {
|
||||
mockTakeScreenshot.mockRejectedValueOnce(new Error('URL blocked: private IP detected'))
|
||||
|
||||
const req = createMockRequest({ url: "http://192.168.1.1" })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "URL blocked: private IP detected" })
|
||||
})
|
||||
|
||||
it('should return 400 when URL cannot be resolved', async () => {
|
||||
mockTakeScreenshot.mockRejectedValueOnce(new Error('Could not resolve hostname'))
|
||||
|
||||
const req = createMockRequest({ url: "https://nonexistent.invalid" })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Could not resolve hostname" })
|
||||
})
|
||||
|
||||
it('should return 500 for generic screenshot errors', async () => {
|
||||
mockTakeScreenshot.mockRejectedValueOnce(new Error('Unexpected browser error'))
|
||||
|
||||
const req = createMockRequest({ url: "https://example.com" })
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Screenshot failed" })
|
||||
})
|
||||
|
||||
it('should enforce device scale limits (1-3)', async () => {
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
|
||||
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://example.com",
|
||||
deviceScale: 5 // Above maximum
|
||||
})
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
deviceScale: 3 // Should be clamped to maximum
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle fullPage parameter', async () => {
|
||||
mockTakeScreenshot.mockResolvedValueOnce({ buffer: Buffer.from('test'), contentType: 'image/png' })
|
||||
mockAddWatermark.mockResolvedValueOnce(Buffer.from('watermarked'))
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://example.com",
|
||||
fullPage: true
|
||||
})
|
||||
const res = createMockResponse()
|
||||
|
||||
const handler = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route.stack[2].handle
|
||||
await handler(req, res, vi.fn())
|
||||
|
||||
expect(mockTakeScreenshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fullPage: true
|
||||
}))
|
||||
})
|
||||
|
||||
it('BUG-021 FIXED: URL validation now happens before rate limiting', async () => {
|
||||
// Test that invalid URLs get 400 validation errors without consuming rate limit
|
||||
const longUrl = 'https://example.com/' + 'a'.repeat(2100)
|
||||
|
||||
const req = createMockRequest({ url: longUrl })
|
||||
const res = createMockResponse()
|
||||
|
||||
// Get all middleware handlers for the POST route
|
||||
const route = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route
|
||||
const middlewares = route?.stack || []
|
||||
|
||||
// Should have urlValidationMiddleware first, then playgroundLimiter, then the main handler
|
||||
expect(middlewares.length).toBe(3)
|
||||
|
||||
// Test that the first middleware (URL validation) rejects long URLs
|
||||
const urlValidationMiddleware = middlewares[0].handle
|
||||
await urlValidationMiddleware(req, res, vi.fn())
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Invalid URL: must be between 1 and 2048 characters" })
|
||||
})
|
||||
|
||||
it('should allow valid URLs to pass through validation middleware', async () => {
|
||||
const req = createMockRequest({ url: 'https://example.com' })
|
||||
const res = createMockResponse()
|
||||
const next = vi.fn()
|
||||
|
||||
const route = playgroundRouter.stack.find(layer => layer.route?.methods.post)?.route
|
||||
const urlValidationMiddleware = route?.stack[0].handle
|
||||
|
||||
await urlValidationMiddleware(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
1200
src/routes/__tests__/screenshot.test.ts
Normal file
1200
src/routes/__tests__/screenshot.test.ts
Normal file
File diff suppressed because it is too large
Load diff
239
src/routes/__tests__/seo-pages.test.ts
Normal file
239
src/routes/__tests__/seo-pages.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import request from 'supertest'
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import fs from 'fs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = path.join(__dirname, '../../../public')
|
||||
|
||||
function createApp() {
|
||||
const app = express()
|
||||
|
||||
// Clean URL redirects matching index.ts
|
||||
app.get('/compare', (_req, res) => res.redirect(301, '/compare.html'))
|
||||
app.get('/guides/quick-start', (_req, res) => res.redirect(301, '/guides/quick-start.html'))
|
||||
app.get('/pricing', (_req, res) => res.redirect(301, '/pricing.html'))
|
||||
app.get('/changelog', (_req, res) => res.redirect(301, '/changelog.html'))
|
||||
|
||||
app.use(express.static(publicDir, { etag: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
describe('Compare Page', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /compare.html returns 200', async () => {
|
||||
const res = await request(app).get('/compare.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /compare redirects 301 to .html', async () => {
|
||||
const res = await request(app).get('/compare')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/compare.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/compare.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<meta property="og:type" content="website"/)
|
||||
expect(html).toMatch(/<link rel="canonical"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
expect(html).toContain('"@type":"WebPage"')
|
||||
})
|
||||
|
||||
it('mentions competitors factually', async () => {
|
||||
const res = await request(app).get('/compare.html')
|
||||
const html = res.text
|
||||
for (const competitor of ['ScreenshotOne', 'URLBox', 'ApiFlash', 'CaptureKit', 'GetScreenshot']) {
|
||||
expect(html).toContain(competitor)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Quick-Start Guide', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /guides/quick-start.html returns 200', async () => {
|
||||
const res = await request(app).get('/guides/quick-start.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /guides/quick-start redirects 301 to .html', async () => {
|
||||
const res = await request(app).get('/guides/quick-start')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/guides/quick-start.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/guides/quick-start.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<link rel="canonical"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
expect(html).toContain('"@type":"HowTo"')
|
||||
})
|
||||
|
||||
it('contains step-by-step content', async () => {
|
||||
const res = await request(app).get('/guides/quick-start.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('curl')
|
||||
expect(html).toContain('API key')
|
||||
expect(html).toContain('GET')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pricing Page', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /pricing.html returns 200', async () => {
|
||||
const res = await request(app).get('/pricing.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /pricing redirects 301 to .html', async () => {
|
||||
const res = await request(app).get('/pricing')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/pricing.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/pricing.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<meta property="og:type" content="website"/)
|
||||
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/pricing"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
})
|
||||
|
||||
it('contains JSON-LD Product schema with offers', async () => {
|
||||
const res = await request(app).get('/pricing.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('"@type":"Product"')
|
||||
expect(html).toContain('"Offer"')
|
||||
expect(html).toContain('"9.00"')
|
||||
expect(html).toContain('"29.00"')
|
||||
expect(html).toContain('"79.00"')
|
||||
})
|
||||
|
||||
it('contains all three pricing tiers', async () => {
|
||||
const res = await request(app).get('/pricing.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('Starter')
|
||||
expect(html).toContain('Pro')
|
||||
expect(html).toContain('Business')
|
||||
expect(html).toContain('€9')
|
||||
expect(html).toContain('€29')
|
||||
expect(html).toContain('€79')
|
||||
})
|
||||
|
||||
it('contains feature comparison matrix', async () => {
|
||||
const res = await request(app).get('/pricing.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('PNG')
|
||||
expect(html).toContain('Full-page')
|
||||
expect(html).toContain('Custom viewport')
|
||||
})
|
||||
|
||||
it('contains FAQ section', async () => {
|
||||
const res = await request(app).get('/pricing.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('FAQ')
|
||||
expect(html).toContain('billing')
|
||||
})
|
||||
|
||||
it('contains checkout CTA buttons', async () => {
|
||||
const res = await request(app).get('/pricing.html')
|
||||
const html = res.text
|
||||
expect(html).toContain("checkout('starter')")
|
||||
expect(html).toContain("checkout('pro')")
|
||||
expect(html).toContain("checkout('business')")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Changelog Page', () => {
|
||||
const app = createApp()
|
||||
|
||||
it('GET /changelog.html returns 200', async () => {
|
||||
const res = await request(app).get('/changelog.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('GET /changelog redirects 301 to .html', async () => {
|
||||
const res = await request(app).get('/changelog')
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe('/changelog.html')
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get('/changelog.html')
|
||||
const html = res.text
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/changelog"/)
|
||||
expect(html).toMatch(/<meta name="twitter:card"/)
|
||||
})
|
||||
|
||||
it('contains JSON-LD Blog schema', async () => {
|
||||
const res = await request(app).get('/changelog.html')
|
||||
const html = res.text
|
||||
expect(html).toContain('"@type":"Blog"')
|
||||
})
|
||||
|
||||
it('contains all version entries in order', async () => {
|
||||
const res = await request(app).get('/changelog.html')
|
||||
const html = res.text
|
||||
const v06 = html.indexOf('v0.6.0')
|
||||
const v05 = html.indexOf('v0.5.0')
|
||||
const v04 = html.indexOf('v0.4.0')
|
||||
const v03 = html.indexOf('v0.3.0')
|
||||
const v02 = html.indexOf('v0.2.0')
|
||||
const v01 = html.indexOf('v0.1.0')
|
||||
expect(v06).toBeGreaterThan(-1)
|
||||
expect(v05).toBeGreaterThan(v06)
|
||||
expect(v04).toBeGreaterThan(v05)
|
||||
expect(v03).toBeGreaterThan(v04)
|
||||
expect(v02).toBeGreaterThan(v03)
|
||||
expect(v01).toBeGreaterThan(v02)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sitemap & Index updates', () => {
|
||||
it('sitemap contains new URLs', () => {
|
||||
const sitemap = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf-8')
|
||||
expect(sitemap).toContain('https://snapapi.eu/compare')
|
||||
expect(sitemap).toContain('https://snapapi.eu/guides/quick-start')
|
||||
expect(sitemap).toContain('https://snapapi.eu/pricing')
|
||||
expect(sitemap).toContain('https://snapapi.eu/changelog')
|
||||
})
|
||||
|
||||
it('index.html has links to new pages', () => {
|
||||
const index = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf-8')
|
||||
expect(index).toContain('/compare')
|
||||
expect(index).toContain('/guides/quick-start')
|
||||
expect(index).toContain('/pricing')
|
||||
})
|
||||
|
||||
it('footer has changelog link', () => {
|
||||
const index = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf-8')
|
||||
expect(index).toContain('/changelog')
|
||||
})
|
||||
})
|
||||
42
src/routes/__tests__/signup-removed.test.ts
Normal file
42
src/routes/__tests__/signup-removed.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import request from "supertest";
|
||||
|
||||
// Mock dependencies before importing app
|
||||
vi.mock("../../services/db.js", () => ({
|
||||
initDatabase: vi.fn(),
|
||||
pool: { query: vi.fn(), end: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../services/browser.js", () => ({
|
||||
initBrowser: vi.fn(),
|
||||
closeBrowser: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../services/keys.js", () => ({
|
||||
loadKeys: vi.fn(),
|
||||
getAllKeys: vi.fn().mockReturnValue([]),
|
||||
findKey: vi.fn(),
|
||||
createKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../middleware/usage.js", () => ({
|
||||
loadUsageData: vi.fn(),
|
||||
usageMiddleware: vi.fn((_req: any, _res: any, next: any) => next()),
|
||||
}));
|
||||
|
||||
let app: any;
|
||||
beforeAll(async () => {
|
||||
const mod = await import("../../index.js");
|
||||
app = mod.app;
|
||||
});
|
||||
|
||||
describe("Free signup removal (v0.3.0)", () => {
|
||||
it("POST /v1/signup/free should return 404 — free tier removed", async () => {
|
||||
const res = await request(app)
|
||||
.post("/v1/signup/free")
|
||||
.send({ email: "test@example.com" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /v1/signup should return 404", async () => {
|
||||
const res = await request(app).get("/v1/signup");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
60
src/routes/__tests__/status.test.ts
Normal file
60
src/routes/__tests__/status.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import request from 'supertest'
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = path.join(__dirname, '../../../public')
|
||||
|
||||
function createBuggyApp() {
|
||||
const app = express()
|
||||
app.use(express.static(publicDir, { etag: true }))
|
||||
|
||||
// Simulate the old buggy behavior - serve file directly instead of redirect
|
||||
app.get("/status", (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, 'status.html'))
|
||||
})
|
||||
|
||||
// Clean URLs for other pages
|
||||
for (const page of ["privacy", "terms", "impressum", "usage"]) {
|
||||
app.get(`/${page}`, (_req, res) => res.redirect(301, `/${page}.html`));
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
function createFixedApp() {
|
||||
const app = express()
|
||||
app.use(express.static(publicDir, { etag: true }))
|
||||
|
||||
// The FIX: Remove statusRouter, let redirect loop handle it
|
||||
// Clean URLs for legal pages (redirect /status → /status.html, etc.)
|
||||
for (const page of ["privacy", "terms", "impressum", "status", "usage"]) {
|
||||
app.get(`/${page}`, (_req, res) => res.redirect(301, `/${page}.html`));
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
describe('Status Route BUG-020', () => {
|
||||
it('CURRENT BUGGY BEHAVIOR: GET /status returns 200 instead of 301 redirect', async () => {
|
||||
const buggyApp = createBuggyApp()
|
||||
const res = await request(buggyApp).get('/status')
|
||||
expect(res.status).toBe(200) // This is the bug - should be 301
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it('EXPECTED BEHAVIOR: GET /status should redirect to /status.html with 301', async () => {
|
||||
const fixedApp = createFixedApp()
|
||||
const res = await request(fixedApp).get('/status').expect(301)
|
||||
expect(res.headers['location']).toBe('/status.html')
|
||||
})
|
||||
|
||||
it('GET /status.html should always return 200', async () => {
|
||||
const app = createBuggyApp() // This works the same in both cases
|
||||
const res = await request(app).get('/status.html')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
})
|
||||
168
src/routes/__tests__/usage.test.ts
Normal file
168
src/routes/__tests__/usage.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock dependencies before imports
|
||||
vi.mock('../../services/keys.js', () => ({
|
||||
isValidKey: vi.fn(),
|
||||
getKeyInfo: vi.fn(),
|
||||
getTierLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/usage.js', () => ({
|
||||
getUsageForKey: vi.fn(),
|
||||
}))
|
||||
|
||||
const { isValidKey, getKeyInfo, getTierLimit } = await import('../../services/keys.js')
|
||||
const { getUsageForKey } = await import('../../middleware/usage.js')
|
||||
const mockIsValidKey = vi.mocked(isValidKey)
|
||||
const mockGetKeyInfo = vi.mocked(getKeyInfo)
|
||||
const mockGetTierLimit = vi.mocked(getTierLimit)
|
||||
const mockGetUsageForKey = vi.mocked(getUsageForKey)
|
||||
|
||||
// Import after mocks
|
||||
const { usageRouter } = await import('../usage.js')
|
||||
|
||||
function createMockRequest(overrides: any = {}): any {
|
||||
return { method: 'GET', headers: {}, query: {}, ...overrides }
|
||||
}
|
||||
|
||||
function createMockResponse(): any {
|
||||
const res: any = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function getHandler() {
|
||||
return usageRouter.stack.find((layer: any) =>
|
||||
layer.route?.methods.get && layer.route.path === '/'
|
||||
)?.route.stack
|
||||
}
|
||||
|
||||
describe('GET /v1/usage', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('returns 401 without API key', async () => {
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
const stack = getHandler()!
|
||||
// First handler is auth middleware
|
||||
const authHandler = stack[0].handle
|
||||
await authHandler(req, res, vi.fn())
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
})
|
||||
|
||||
it('returns usage data with valid key', async () => {
|
||||
const now = new Date()
|
||||
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
mockIsValidKey.mockResolvedValue(true)
|
||||
mockGetKeyInfo.mockResolvedValue({
|
||||
key: 'snap_test123',
|
||||
tier: 'starter',
|
||||
email: 'test@example.com',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
mockGetTierLimit.mockReturnValue(1000)
|
||||
mockGetUsageForKey.mockReturnValue({ count: 42, monthKey })
|
||||
|
||||
const req = createMockRequest({ headers: { authorization: 'Bearer snap_test123' } })
|
||||
const res = createMockResponse()
|
||||
|
||||
// Run through all handlers in the stack
|
||||
const stack = getHandler()!
|
||||
let idx = 0
|
||||
const runNext = async () => {
|
||||
if (idx < stack.length) {
|
||||
const handler = stack[idx++].handle
|
||||
await handler(req, res, runNext)
|
||||
}
|
||||
}
|
||||
await runNext()
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
used: 42,
|
||||
limit: 1000,
|
||||
plan: 'starter',
|
||||
month: monthKey,
|
||||
remaining: 958,
|
||||
percentUsed: 4.2,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 0 usage for key with no records', async () => {
|
||||
const now = new Date()
|
||||
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
mockIsValidKey.mockResolvedValue(true)
|
||||
mockGetKeyInfo.mockResolvedValue({
|
||||
key: 'snap_newkey',
|
||||
tier: 'free',
|
||||
email: 'new@example.com',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
mockGetTierLimit.mockReturnValue(100)
|
||||
mockGetUsageForKey.mockReturnValue(undefined)
|
||||
|
||||
const req = createMockRequest({ headers: { authorization: 'Bearer snap_newkey' } })
|
||||
const res = createMockResponse()
|
||||
|
||||
const stack = getHandler()!
|
||||
let idx = 0
|
||||
const runNext = async () => {
|
||||
if (idx < stack.length) {
|
||||
const handler = stack[idx++].handle
|
||||
await handler(req, res, runNext)
|
||||
}
|
||||
}
|
||||
await runNext()
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
used: 0,
|
||||
limit: 100,
|
||||
plan: 'free',
|
||||
month: monthKey,
|
||||
remaining: 100,
|
||||
percentUsed: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('includes all required fields', async () => {
|
||||
const now = new Date()
|
||||
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
mockIsValidKey.mockResolvedValue(true)
|
||||
mockGetKeyInfo.mockResolvedValue({
|
||||
key: 'snap_abc',
|
||||
tier: 'pro',
|
||||
email: 'pro@example.com',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
mockGetTierLimit.mockReturnValue(5000)
|
||||
mockGetUsageForKey.mockReturnValue({ count: 2500, monthKey })
|
||||
|
||||
const req = createMockRequest({ headers: { authorization: 'Bearer snap_abc' } })
|
||||
const res = createMockResponse()
|
||||
|
||||
const stack = getHandler()!
|
||||
let idx = 0
|
||||
const runNext = async () => {
|
||||
if (idx < stack.length) {
|
||||
const handler = stack[idx++].handle
|
||||
await handler(req, res, runNext)
|
||||
}
|
||||
}
|
||||
await runNext()
|
||||
|
||||
const data = res.json.mock.calls[0][0]
|
||||
expect(data).toHaveProperty('used')
|
||||
expect(data).toHaveProperty('limit')
|
||||
expect(data).toHaveProperty('plan')
|
||||
expect(data).toHaveProperty('month')
|
||||
expect(data).toHaveProperty('remaining')
|
||||
expect(data).toHaveProperty('percentUsed')
|
||||
expect(typeof data.used).toBe('number')
|
||||
expect(typeof data.percentUsed).toBe('number')
|
||||
})
|
||||
})
|
||||
84
src/routes/__tests__/use-cases.test.ts
Normal file
84
src/routes/__tests__/use-cases.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import request from 'supertest'
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import fs from 'fs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = path.join(__dirname, '../../../public')
|
||||
|
||||
// Build a minimal app that mirrors index.ts routing
|
||||
function createApp() {
|
||||
const app = express()
|
||||
|
||||
// Clean URLs for use-case pages
|
||||
const useCasePages = ['social-media-previews', 'website-monitoring', 'pdf-reports']
|
||||
for (const page of useCasePages) {
|
||||
app.get(`/use-cases/${page}`, (_req, res) => res.redirect(301, `/use-cases/${page}.html`))
|
||||
}
|
||||
|
||||
app.use(express.static(publicDir, { etag: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
const useCases = [
|
||||
{ slug: 'social-media-previews', title: 'OG Images', keywords: ['og image', 'social media preview'] },
|
||||
{ slug: 'website-monitoring', title: 'Website Monitoring', keywords: ['website screenshot monitoring', 'visual regression'] },
|
||||
{ slug: 'pdf-reports', title: 'Reports', keywords: ['thumbnail', 'web page preview'] },
|
||||
]
|
||||
|
||||
describe('Use Case Pages', () => {
|
||||
const app = createApp()
|
||||
|
||||
for (const uc of useCases) {
|
||||
describe(uc.slug, () => {
|
||||
it(`GET /use-cases/${uc.slug}.html returns 200`, async () => {
|
||||
const res = await request(app).get(`/use-cases/${uc.slug}.html`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
|
||||
it(`GET /use-cases/${uc.slug} redirects 301 to .html`, async () => {
|
||||
const res = await request(app).get(`/use-cases/${uc.slug}`)
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.location).toBe(`/use-cases/${uc.slug}.html`)
|
||||
})
|
||||
|
||||
it('contains required SEO elements', async () => {
|
||||
const res = await request(app).get(`/use-cases/${uc.slug}.html`)
|
||||
const html = res.text
|
||||
|
||||
// Title tag
|
||||
expect(html).toMatch(/<title>.+<\/title>/)
|
||||
// Meta description
|
||||
expect(html).toMatch(/<meta name="description" content=".+"/)
|
||||
// OG tags
|
||||
expect(html).toMatch(/<meta property="og:title"/)
|
||||
expect(html).toMatch(/<meta property="og:description"/)
|
||||
expect(html).toMatch(/<meta property="og:type" content="article"/)
|
||||
// JSON-LD
|
||||
expect(html).toContain('"@type":"Article"')
|
||||
// H1
|
||||
expect(html).toMatch(/<h1[^>]*>.+<\/h1>/)
|
||||
// Canonical
|
||||
expect(html).toMatch(/<link rel="canonical"/)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('sitemap contains all use case URLs', () => {
|
||||
const sitemap = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf-8')
|
||||
for (const uc of useCases) {
|
||||
expect(sitemap).toContain(`https://snapapi.eu/use-cases/${uc.slug}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('index.html contains use cases section', () => {
|
||||
const index = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf-8')
|
||||
expect(index).toContain('id="use-cases"')
|
||||
for (const uc of useCases) {
|
||||
expect(index).toContain(`/use-cases/${uc.slug}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
235
src/routes/batch.ts
Normal file
235
src/routes/batch.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { Router } from "express";
|
||||
import { takeScreenshot } from "../services/screenshot.js";
|
||||
import { getUsageForKey, incrementUsage } from "../middleware/usage.js";
|
||||
import { getTierLimit } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
export const batchRouter = Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/screenshots/batch:
|
||||
* post:
|
||||
* tags: [Screenshots]
|
||||
* summary: Take multiple screenshots in a single request
|
||||
* description: >
|
||||
* Capture multiple URLs in one API call. Each URL counts as one screenshot
|
||||
* toward your usage limits. All parameters except `urls` are shared across
|
||||
* all screenshots. Returns partial results if some URLs fail.
|
||||
* operationId: batchScreenshots
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* - ApiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [urls]
|
||||
* properties:
|
||||
* urls:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: uri
|
||||
* minItems: 1
|
||||
* maxItems: 10
|
||||
* description: Array of URLs to capture (1-10)
|
||||
* example: ["https://example.com", "https://example.org"]
|
||||
* format:
|
||||
* type: string
|
||||
* enum: [png, jpeg, webp]
|
||||
* default: png
|
||||
* width:
|
||||
* type: integer
|
||||
* minimum: 320
|
||||
* maximum: 3840
|
||||
* default: 1280
|
||||
* height:
|
||||
* type: integer
|
||||
* minimum: 200
|
||||
* maximum: 2160
|
||||
* default: 800
|
||||
* fullPage:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* quality:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 80
|
||||
* darkMode:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* css:
|
||||
* type: string
|
||||
* maxLength: 5000
|
||||
* js:
|
||||
* type: string
|
||||
* maxLength: 5000
|
||||
* selector:
|
||||
* type: string
|
||||
* maxLength: 200
|
||||
* userAgent:
|
||||
* type: string
|
||||
* maxLength: 500
|
||||
* delay:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* maximum: 5000
|
||||
* waitForSelector:
|
||||
* type: string
|
||||
* hideSelectors:
|
||||
* oneOf:
|
||||
* - type: string
|
||||
* - type: array
|
||||
* items:
|
||||
* type: string
|
||||
* maxItems: 10
|
||||
* clip:
|
||||
* type: object
|
||||
* properties:
|
||||
* x:
|
||||
* type: integer
|
||||
* y:
|
||||
* type: integer
|
||||
* width:
|
||||
* type: integer
|
||||
* height:
|
||||
* type: integer
|
||||
* examples:
|
||||
* basic:
|
||||
* summary: Two URLs
|
||||
* value:
|
||||
* urls: ["https://example.com", "https://example.org"]
|
||||
* with_options:
|
||||
* summary: With shared options
|
||||
* value:
|
||||
* urls: ["https://example.com", "https://example.org"]
|
||||
* format: jpeg
|
||||
* width: 1920
|
||||
* height: 1080
|
||||
* quality: 90
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Batch results (may include partial failures)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [success, error]
|
||||
* image:
|
||||
* type: string
|
||||
* description: Base64-encoded image (only on success)
|
||||
* error:
|
||||
* type: string
|
||||
* description: Error message (only on error)
|
||||
* 400:
|
||||
* description: Invalid request
|
||||
* content:
|
||||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
* 401:
|
||||
* description: Missing API key
|
||||
* 429:
|
||||
* description: Usage limit exceeded
|
||||
*/
|
||||
batchRouter.post("/", async (req: any, res: any) => {
|
||||
const { urls, ...sharedParams } = req.body;
|
||||
|
||||
// Validate urls
|
||||
if (!urls || !Array.isArray(urls) || urls.length === 0) {
|
||||
res.status(400).json({ error: "Missing required parameter: urls (array of 1-10 URLs)" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.length > 10) {
|
||||
res.status(400).json({ error: "Maximum 10 URLs per batch request" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check usage quota for all URLs before starting
|
||||
const keyInfo = req.apiKeyInfo;
|
||||
if (keyInfo) {
|
||||
const key = keyInfo.key;
|
||||
const limit = getTierLimit(keyInfo.tier);
|
||||
const currentUsage = getUsageForKey(key);
|
||||
const monthKey = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
|
||||
const currentCount = (currentUsage && currentUsage.monthKey === monthKey) ? currentUsage.count : 0;
|
||||
|
||||
if (currentCount + urls.length > limit) {
|
||||
res.status(429).json({
|
||||
error: `Monthly limit would be exceeded. Need ${urls.length} screenshots but only ${limit - currentCount} remaining (${limit} limit for ${keyInfo.tier} tier).`,
|
||||
usage: currentCount,
|
||||
limit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract shared screenshot params
|
||||
const {
|
||||
format, width, height, fullPage, quality, darkMode, css, js,
|
||||
selector, userAgent, delay, waitForSelector, hideSelectors, clip,
|
||||
waitUntil, deviceScale,
|
||||
} = sharedParams;
|
||||
|
||||
// Process all URLs concurrently
|
||||
const settled = await Promise.allSettled(
|
||||
urls.map((url: string) =>
|
||||
takeScreenshot({
|
||||
url,
|
||||
format: format || undefined,
|
||||
width: width ? parseInt(width, 10) || width : undefined,
|
||||
height: height ? parseInt(height, 10) || height : undefined,
|
||||
fullPage,
|
||||
quality: quality ? parseInt(quality, 10) || quality : undefined,
|
||||
darkMode,
|
||||
css,
|
||||
js,
|
||||
selector,
|
||||
userAgent,
|
||||
delay: delay ? parseInt(delay, 10) || delay : undefined,
|
||||
waitForSelector,
|
||||
hideSelectors,
|
||||
clip,
|
||||
waitUntil,
|
||||
deviceScale,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Build results and track usage
|
||||
const results = settled.map((result, i) => {
|
||||
if (result.status === "fulfilled") {
|
||||
// Increment usage for successful screenshot
|
||||
if (keyInfo) {
|
||||
incrementUsage(keyInfo.key);
|
||||
}
|
||||
return {
|
||||
url: urls[i],
|
||||
status: "success" as const,
|
||||
image: result.value.buffer.toString("base64"),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
url: urls[i],
|
||||
status: "error" as const,
|
||||
error: result.reason?.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ results });
|
||||
});
|
||||
|
|
@ -1,16 +1,36 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import Stripe from "stripe";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import logger from "../services/logger.js";
|
||||
import { createPaidKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js";
|
||||
import { createPaidKey, downgradeByCustomer, updateEmailByCustomer, getCustomerIdByEmail, getKeyByEmail } from "../services/keys.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2025-01-27.acacia" as any,
|
||||
const billingLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: process.env.NODE_ENV === "test" ? 1000 : 10,
|
||||
message: { error: "Too many requests. Please try again later." },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req: Request) => req.path === "/webhook",
|
||||
});
|
||||
router.use(billingLimiter);
|
||||
|
||||
let _stripe: Stripe | null = null;
|
||||
function getStripe(): Stripe {
|
||||
if (!_stripe) {
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
throw new Error("STRIPE_SECRET_KEY environment variable is not set");
|
||||
}
|
||||
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2025-01-27.acacia" as any,
|
||||
});
|
||||
}
|
||||
return _stripe;
|
||||
}
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || "https://snapapi.eu";
|
||||
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || "";
|
||||
|
||||
// DocFast product ID — NEVER process events for this
|
||||
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
|
||||
|
|
@ -33,27 +53,27 @@ async function getOrCreatePrice(name: string, amount: number, description: strin
|
|||
if (priceCache[name]) return priceCache[name];
|
||||
|
||||
// Search for existing product by name
|
||||
const products = await stripe.products.search({ query: `name:"${name}"` });
|
||||
const products = await getStripe().products.search({ query: `name:"${name}"` });
|
||||
let product: Stripe.Product;
|
||||
|
||||
if (products.data.length > 0) {
|
||||
product = products.data[0];
|
||||
logger.info({ productId: product.id, name }, "Found existing Stripe product");
|
||||
} else {
|
||||
product = await stripe.products.create({ name, description });
|
||||
product = await getStripe().products.create({ name, description });
|
||||
logger.info({ productId: product.id, name }, "Created Stripe product");
|
||||
}
|
||||
|
||||
snapapiProductIds.add(product.id);
|
||||
|
||||
// Check for existing price
|
||||
const prices = await stripe.prices.list({ product: product.id, active: true, limit: 1 });
|
||||
const prices = await getStripe().prices.list({ product: product.id, active: true, limit: 1 });
|
||||
if (prices.data.length > 0 && prices.data[0].unit_amount === amount) {
|
||||
priceCache[name] = prices.data[0].id;
|
||||
return prices.data[0].id;
|
||||
}
|
||||
|
||||
const price = await stripe.prices.create({
|
||||
const price = await getStripe().prices.create({
|
||||
product: product.id,
|
||||
unit_amount: amount,
|
||||
currency: "eur",
|
||||
|
|
@ -77,7 +97,9 @@ async function initPrices() {
|
|||
logger.info({ productIds: [...snapapiProductIds], prices: { ...priceCache } }, "SnapAPI Stripe products initialized");
|
||||
}
|
||||
|
||||
initPrices().catch(err => logger.error({ err }, "Failed to initialize Stripe prices"));
|
||||
if (process.env.STRIPE_SECRET_KEY) {
|
||||
initPrices().catch(err => logger.error({ err }, "Failed to initialize Stripe prices"));
|
||||
}
|
||||
|
||||
// Helper: check if event belongs to SnapAPI
|
||||
async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
|
||||
|
|
@ -91,7 +113,7 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
|
|||
if (event.type === "checkout.session.completed") {
|
||||
const session = obj as Stripe.Checkout.Session;
|
||||
if (session.subscription) {
|
||||
const sub = await stripe.subscriptions.retrieve(session.subscription as string, { expand: ["items.data.price.product"] });
|
||||
const sub = await getStripe().subscriptions.retrieve(session.subscription as string, { expand: ["items.data.price.product"] });
|
||||
const item = sub.items.data[0];
|
||||
const prod = item?.price?.product;
|
||||
productId = typeof prod === "string" ? prod : prod?.id;
|
||||
|
|
@ -107,7 +129,7 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
|
|||
productId = typeof prod === "string" ? prod : (prod as any)?.id;
|
||||
// If product not expanded, fetch it
|
||||
if (!productId && item.price?.id) {
|
||||
const price = await stripe.prices.retrieve(item.price.id);
|
||||
const price = await getStripe().prices.retrieve(item.price.id);
|
||||
productId = typeof price.product === "string" ? price.product : (price.product as any)?.id;
|
||||
}
|
||||
}
|
||||
|
|
@ -175,7 +197,7 @@ router.post("/checkout", async (req: Request, res: Response) => {
|
|||
const planDef = PLANS[plan];
|
||||
const priceId = await getOrCreatePrice(planDef.name, planDef.amount, planDef.description);
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
const session = await getStripe().checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
|
|
@ -222,7 +244,7 @@ router.get("/success", async (req: Request, res: Response) => {
|
|||
const sessionId = req.query.session_id as string;
|
||||
if (!sessionId) return res.status(400).send("Missing session_id");
|
||||
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["subscription"] });
|
||||
const session = await getStripe().checkout.sessions.retrieve(sessionId, { expand: ["subscription"] });
|
||||
const email = session.customer_details?.email || session.customer_email || "unknown@snapapi.eu";
|
||||
const plan = session.metadata?.plan || "starter";
|
||||
const tier = PLANS[plan]?.tier || "starter";
|
||||
|
|
@ -271,6 +293,146 @@ Use it with <code>X-API-Key</code> header or <code>?key=</code> param.<br><br>
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/billing/portal:
|
||||
* post:
|
||||
* tags: [Billing]
|
||||
* summary: Create Stripe customer portal session
|
||||
* description: Create a billing portal session for API key recovery and subscription management
|
||||
* operationId: billingPortal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [email]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: Customer email address
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Portal session created
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* format: uri
|
||||
* description: Stripe customer portal URL
|
||||
* 400:
|
||||
* description: Missing or invalid email
|
||||
* content:
|
||||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
* 404:
|
||||
* description: No subscription found for email
|
||||
* content:
|
||||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
* 500:
|
||||
* description: Portal creation failed
|
||||
* content:
|
||||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
*/
|
||||
router.post("/portal", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email || typeof email !== 'string' || email.trim() === '') {
|
||||
return res.status(400).json({ error: "Email address is required" });
|
||||
}
|
||||
|
||||
const customerId = await getCustomerIdByEmail(email.trim());
|
||||
if (!customerId) {
|
||||
return res.status(404).json({
|
||||
error: "No subscription found for this email address. Please contact support if you believe this is an error."
|
||||
});
|
||||
}
|
||||
|
||||
const session = await getStripe().billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: `${BASE_URL}/#billing`
|
||||
});
|
||||
|
||||
res.json({ url: session.url });
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, "Portal creation error");
|
||||
res.status(500).json({ error: "Failed to create portal session" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/billing/recover:
|
||||
* get:
|
||||
* tags: [Billing]
|
||||
* summary: Recover API key by email
|
||||
* description: Recover API key for a customer by email address. Returns masked key for security.
|
||||
* operationId: billingRecover
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: email
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: Customer email address
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recovery processed (always returns success to prevent email enumeration)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* description: Status message
|
||||
* maskedKey:
|
||||
* type: string
|
||||
* description: Masked API key (only if key exists)
|
||||
* 400:
|
||||
* description: Missing or invalid email
|
||||
* content:
|
||||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
*/
|
||||
router.get("/recover", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const email = req.query.email as string;
|
||||
|
||||
if (!email || typeof email !== 'string' || email.trim() === '') {
|
||||
return res.status(400).json({ error: "Email address is required" });
|
||||
}
|
||||
|
||||
const keyInfo = await getKeyByEmail(email.trim());
|
||||
const message = "If an account exists with this email, the API key has been sent.";
|
||||
|
||||
if (!keyInfo) {
|
||||
return res.json({ message });
|
||||
}
|
||||
|
||||
// Mask the API key: show snap_ prefix + first 4 chars + ... + last 4 chars
|
||||
const key = keyInfo.key;
|
||||
const masked = `${key.substring(0, 9)}...${key.substring(key.length - 4)}`;
|
||||
|
||||
// For now, just log the full key (TODO: implement email sending)
|
||||
logger.info({ email: keyInfo.email }, "API key recovery requested");
|
||||
|
||||
res.json({ message, maskedKey: masked });
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, "Recovery error");
|
||||
res.status(500).json({ error: "Failed to process recovery request" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/billing/webhook:
|
||||
|
|
@ -306,7 +468,7 @@ router.post("/webhook", async (req: Request, res: Response) => {
|
|||
const sig = req.headers["stripe-signature"] as string;
|
||||
if (!sig) return res.status(400).send("Missing signature");
|
||||
|
||||
const event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
|
||||
const event = getStripe().webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
|
||||
|
||||
// Filter: only process SnapAPI events
|
||||
if (event.type !== "customer.updated") {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { Router } from "express";
|
||||
import { createRequire } from "module";
|
||||
import { getPoolStats } from "../services/browser.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../../package.json");
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
/**
|
||||
|
|
@ -27,7 +31,7 @@ healthRouter.get("/", (_req, res) => {
|
|||
const pool = getPoolStats();
|
||||
res.json({
|
||||
status: "ok",
|
||||
version: "0.1.0",
|
||||
version: pkg.version,
|
||||
uptime: process.uptime(),
|
||||
browser: pool,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,24 @@ import rateLimit from "express-rate-limit";
|
|||
|
||||
export const playgroundRouter = Router();
|
||||
|
||||
// URL validation middleware - runs BEFORE rate limiting
|
||||
const urlValidationMiddleware = (req: any, res: any, next: any) => {
|
||||
const { url } = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({ error: "Missing required parameter: url" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check URL length (same validation that happens in SSRF service)
|
||||
if (url.length > 2048) {
|
||||
res.status(400).json({ error: "Invalid URL: must be between 1 and 2048 characters" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// 5 requests per hour per IP
|
||||
const playgroundLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
|
|
@ -83,31 +101,59 @@ const playgroundLimiter = rateLimit({
|
|||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
*/
|
||||
playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
|
||||
const { url, format, width, height } = req.body;
|
||||
playgroundRouter.post("/", urlValidationMiddleware, playgroundLimiter, async (req, res) => {
|
||||
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, waitUntil, pdfFormat, pdfLandscape, pdfPrintBackground, pdfScale, pdfMargin } = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({ error: "Missing required parameter: url" });
|
||||
return;
|
||||
}
|
||||
// URL validation is now handled by middleware before rate limiting
|
||||
|
||||
// Enforce reasonable limits for playground
|
||||
const safeWidth = Math.min(Math.max(parseInt(width, 10) || 1280, 320), 1920);
|
||||
const safeHeight = Math.min(Math.max(parseInt(height, 10) || 800, 200), 1080);
|
||||
const safeFormat = ["png", "jpeg", "webp"].includes(format) ? format : "png";
|
||||
const safeFormat = ["png", "jpeg", "webp", "pdf"].includes(format) ? format : "png";
|
||||
const safeFullPage = fullPage === true;
|
||||
const safeQuality = safeFormat === "png" ? undefined : Math.min(Math.max(parseInt(quality, 10) || 80, 1), 100);
|
||||
const safeDeviceScale = Math.min(Math.max(parseInt(deviceScale, 10) || 1, 1), 3);
|
||||
const validWaitUntil = ["load", "domcontentloaded", "networkidle0", "networkidle2"];
|
||||
const safeWaitUntil = validWaitUntil.includes(waitUntil) ? waitUntil : "domcontentloaded";
|
||||
// Sanitize waitForSelector — allow simple CSS selectors only (no script injection)
|
||||
const safeWaitForSelector = typeof waitForSelector === "string" && /^[a-zA-Z0-9\s\-_.#\[\]=:"'>,+~()]+$/.test(waitForSelector) && waitForSelector.length <= 200
|
||||
? waitForSelector : undefined;
|
||||
|
||||
try {
|
||||
const result = await takeScreenshot({
|
||||
const screenshotOpts: any = {
|
||||
url,
|
||||
format: safeFormat as "png" | "jpeg" | "webp",
|
||||
format: safeFormat as "png" | "jpeg" | "webp" | "pdf",
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
fullPage: false,
|
||||
quality: safeFormat === "png" ? undefined : 70,
|
||||
deviceScale: 1,
|
||||
});
|
||||
fullPage: safeFullPage,
|
||||
quality: safeQuality,
|
||||
deviceScale: safeDeviceScale,
|
||||
waitUntil: safeWaitUntil as any,
|
||||
waitForSelector: safeWaitForSelector,
|
||||
};
|
||||
|
||||
// Add watermark
|
||||
if (safeFormat === "pdf") {
|
||||
if (pdfFormat) screenshotOpts.pdfFormat = pdfFormat;
|
||||
if (pdfLandscape !== undefined) screenshotOpts.pdfLandscape = pdfLandscape;
|
||||
if (pdfPrintBackground !== undefined) screenshotOpts.pdfPrintBackground = pdfPrintBackground;
|
||||
if (pdfScale !== undefined) screenshotOpts.pdfScale = pdfScale;
|
||||
if (pdfMargin) screenshotOpts.pdfMargin = pdfMargin;
|
||||
}
|
||||
|
||||
const result = await takeScreenshot(screenshotOpts);
|
||||
|
||||
// Skip watermark for PDF (can't watermark a PDF the same way)
|
||||
if (safeFormat === "pdf") {
|
||||
res.setHeader("Content-Type", result.contentType);
|
||||
res.setHeader("Content-Length", result.buffer.length);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("X-Playground", "true");
|
||||
res.setHeader("Content-Disposition", 'attachment; filename="screenshot.pdf"');
|
||||
res.send(result.buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add watermark for image formats
|
||||
const watermarked = await addWatermark(result.buffer, safeWidth, safeHeight);
|
||||
|
||||
res.setHeader("Content-Type", result.contentType);
|
||||
|
|
@ -126,7 +172,7 @@ playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
|
|||
res.status(504).json({ error: "Screenshot timed out." });
|
||||
return;
|
||||
}
|
||||
if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL")) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -70,6 +71,75 @@ export const screenshotRouter = Router();
|
|||
* 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 }"
|
||||
* js:
|
||||
* type: string
|
||||
* maxLength: 5000
|
||||
* description: Custom JavaScript code to execute on the page before capture (max 5000 chars, 5-second timeout)
|
||||
* example: "document.querySelector('.modal').remove(); window.scrollTo(0, 500);"
|
||||
* selector:
|
||||
* type: string
|
||||
* maxLength: 200
|
||||
* description: CSS selector for element to capture instead of full page/viewport (max 200 chars)
|
||||
* example: "#main-content"
|
||||
* userAgent:
|
||||
* type: string
|
||||
* maxLength: 500
|
||||
* description: Custom User-Agent string for HTTP requests (max 500 chars, no newlines allowed)
|
||||
* example: "Mozilla/5.0 (compatible; SnapAPI/1.0)"
|
||||
* clip:
|
||||
* type: object
|
||||
* description: Crop a rectangular area from the screenshot (mutually exclusive with fullPage and selector)
|
||||
* required: [x, y, width, height]
|
||||
* properties:
|
||||
* x:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* description: X coordinate of the top-left corner (pixels)
|
||||
* y:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* description: Y coordinate of the top-left corner (pixels)
|
||||
* width:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 3840
|
||||
* description: Width of the clipping area (pixels)
|
||||
* height:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 2160
|
||||
* description: Height of the clipping area (pixels)
|
||||
* example: { "x": 100, "y": 50, "width": 800, "height": 600 }
|
||||
* 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.
|
||||
* cache:
|
||||
* type: boolean
|
||||
* default: true
|
||||
* description: Set to false to bypass response cache
|
||||
* examples:
|
||||
* simple:
|
||||
* summary: Simple screenshot
|
||||
|
|
@ -80,6 +150,15 @@ export const screenshotRouter = Router();
|
|||
* mobile:
|
||||
* summary: Mobile viewport
|
||||
* value: { "url": "https://example.com", "width": 375, "height": 812, "deviceScale": 2 }
|
||||
* element:
|
||||
* summary: Element screenshot
|
||||
* value: { "url": "https://github.com", "selector": "#readme" }
|
||||
* custom_user_agent:
|
||||
* summary: Custom User-Agent
|
||||
* value: { "url": "https://example.com", "userAgent": "Mozilla/5.0 (compatible; SnapAPI/1.0)" }
|
||||
* clipped:
|
||||
* summary: Crop a specific area
|
||||
* value: { "url": "https://example.com", "clip": { "x": 100, "y": 50, "width": 800, "height": 600 } }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Screenshot image binary
|
||||
|
|
@ -120,31 +199,435 @@ 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: 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: js
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* maxLength: 5000
|
||||
* description: Custom JavaScript code to execute on the page before capture (max 5000 chars, 5-second timeout)
|
||||
* example: "document.querySelector('.modal').remove();"
|
||||
* - name: selector
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* maxLength: 200
|
||||
* description: CSS selector for element to capture instead of full page/viewport (max 200 chars)
|
||||
* example: "#main-content"
|
||||
* - name: userAgent
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* maxLength: 500
|
||||
* description: Custom User-Agent string for HTTP requests (max 500 chars, no newlines allowed)
|
||||
* example: "Mozilla/5.0 (compatible; SnapAPI/1.0)"
|
||||
* - name: clipX
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* description: X coordinate of the clipping area (pixels, used with clipY, clipW, clipH - mutually exclusive with fullPage and selector)
|
||||
* example: 100
|
||||
* - name: clipY
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* description: Y coordinate of the clipping area (pixels, used with clipX, clipW, clipH)
|
||||
* example: 50
|
||||
* - name: clipW
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 3840
|
||||
* description: Width of the clipping area (pixels, used with clipX, clipY, clipH)
|
||||
* example: 800
|
||||
* - name: clipH
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 2160
|
||||
* description: Height of the clipping area (pixels, used with clipX, clipY, clipW)
|
||||
* example: 600
|
||||
* - 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" }
|
||||
*/
|
||||
screenshotRouter.post("/", async (req: any, res) => {
|
||||
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay } = 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,
|
||||
darkMode,
|
||||
hideSelectors,
|
||||
css,
|
||||
js,
|
||||
selector,
|
||||
userAgent,
|
||||
clip,
|
||||
clipX,
|
||||
clipY,
|
||||
clipW,
|
||||
clipH,
|
||||
pdfFormat,
|
||||
pdfLandscape,
|
||||
pdfPrintBackground,
|
||||
pdfScale,
|
||||
pdfMargin,
|
||||
} = source;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({ error: "Missing required parameter: url" });
|
||||
return;
|
||||
}
|
||||
|
||||
// PDF-specific validation
|
||||
if (format === "pdf") {
|
||||
if (selector || clip || (clipX || clipY || clipW || clipH)) {
|
||||
res.status(400).json({ error: 'format "pdf" is mutually exclusive with selector and clip' });
|
||||
return;
|
||||
}
|
||||
if (pdfFormat && !["a4", "letter", "legal", "a3"].includes(pdfFormat)) {
|
||||
res.status(400).json({ error: "pdfFormat must be one of: a4, letter, legal, a3" });
|
||||
return;
|
||||
}
|
||||
const scale = pdfScale !== undefined ? parseFloat(pdfScale) : undefined;
|
||||
if (scale !== undefined && (scale < 0.1 || scale > 2.0)) {
|
||||
res.status(400).json({ error: "pdfScale must be between 0.1 and 2.0" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate userAgent parameter
|
||||
if (userAgent && typeof userAgent === 'string') {
|
||||
if (userAgent.length > 500) {
|
||||
res.status(400).json({ error: "userAgent: maximum 500 characters allowed" });
|
||||
return;
|
||||
}
|
||||
if (/[\r\n]/.test(userAgent)) {
|
||||
res.status(400).json({ error: "userAgent: newlines are not allowed (HTTP header injection prevention)" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate css parameter
|
||||
if (css && typeof css === 'string' && css.length > 5000) {
|
||||
res.status(400).json({ error: "css: maximum 5000 characters allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate js parameter
|
||||
if (js && typeof js === 'string' && js.length > 5000) {
|
||||
res.status(400).json({ error: "js: maximum 5000 characters allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle clip parameter from GET query parameters (clipX, clipY, clipW, clipH)
|
||||
let normalizedClip = clip;
|
||||
if (req.method === 'GET' && (clipX || clipY || clipW || clipH)) {
|
||||
normalizedClip = {
|
||||
x: clipX ? parseInt(clipX, 10) : 0,
|
||||
y: clipY ? parseInt(clipY, 10) : 0,
|
||||
width: clipW ? parseInt(clipW, 10) : 0,
|
||||
height: clipH ? parseInt(clipH, 10) : 0
|
||||
};
|
||||
}
|
||||
|
||||
// Validate clip parameter
|
||||
if (normalizedClip) {
|
||||
// Check if all required fields are present
|
||||
if (typeof normalizedClip.x !== 'number' || typeof normalizedClip.y !== 'number' ||
|
||||
typeof normalizedClip.width !== 'number' || typeof normalizedClip.height !== 'number') {
|
||||
res.status(400).json({ error: "clip: all four fields (x, y, width, height) must be provided" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check x, y >= 0
|
||||
if (normalizedClip.x < 0 || normalizedClip.y < 0) {
|
||||
res.status(400).json({ error: "clip: x and y coordinates must be >= 0" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check width, height > 0
|
||||
if (normalizedClip.width <= 0 || normalizedClip.height <= 0) {
|
||||
res.status(400).json({ error: "clip: width and height must be > 0" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check maximum dimensions
|
||||
if (normalizedClip.width > 3840 || normalizedClip.height > 2160) {
|
||||
res.status(400).json({ error: "clip: width must not exceed 3840, height must not exceed 2160" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check mutual exclusivity with fullPage and selector
|
||||
if (fullPage || selector) {
|
||||
res.status(400).json({ error: "clip is mutually exclusive with fullPage and selector" });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Check mutual exclusivity of selector and fullPage
|
||||
if (selector && (fullPage === true || fullPage === "true")) {
|
||||
res.status(400).json({ error: "selector and fullPage are mutually exclusive" });
|
||||
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,
|
||||
js: js || undefined,
|
||||
selector: selector || undefined,
|
||||
userAgent: userAgent || undefined,
|
||||
clip: normalizedClip || undefined,
|
||||
...(format === "pdf" ? {
|
||||
pdfFormat: pdfFormat || undefined,
|
||||
pdfLandscape: pdfLandscape === true || pdfLandscape === "true" || undefined,
|
||||
pdfPrintBackground: pdfPrintBackground === false || pdfPrintBackground === "false" ? false : undefined,
|
||||
pdfScale: pdfScale ? parseFloat(pdfScale) : undefined,
|
||||
pdfMargin: pdfMargin || undefined,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
// 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.setHeader("X-Retry-Count", String(result.retryCount ?? 0));
|
||||
if (format === "pdf") {
|
||||
res.setHeader("Content-Disposition", 'attachment; filename="screenshot.pdf"');
|
||||
}
|
||||
res.send(result.buffer);
|
||||
} catch (err: any) {
|
||||
logger.error({ err: err.message, url }, "Screenshot failed");
|
||||
|
|
@ -157,11 +640,19 @@ screenshotRouter.post("/", async (req: any, res) => {
|
|||
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")) {
|
||||
if (err.message === "SELECTOR_NOT_FOUND") {
|
||||
res.status(400).json({ error: `Element not found: ${selector}` });
|
||||
return;
|
||||
}
|
||||
if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL") || err.message.includes("Could not resolve") || err.message.includes("JS_EXECUTION_ERROR")) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { createKey } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
export const signupRouter = Router();
|
||||
|
||||
// Simple signup: email → instant API key (no verification for now)
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/signup/free:
|
||||
* post:
|
||||
* tags: [Signup]
|
||||
* summary: Create a free account
|
||||
* description: Sign up with an email to get a free API key (100 screenshots/month).
|
||||
* operationId: signupFree
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [email]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: Your email address
|
||||
* example: "user@example.com"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: API key created
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* description: Your new API key
|
||||
* tier:
|
||||
* type: string
|
||||
* example: free
|
||||
* limit:
|
||||
* type: integer
|
||||
* example: 100
|
||||
* message:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Invalid email
|
||||
* content:
|
||||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
* 500:
|
||||
* description: Signup failed
|
||||
* content:
|
||||
* application/json:
|
||||
* schema: { $ref: "#/components/schemas/Error" }
|
||||
*/
|
||||
signupRouter.post("/free", async (req, res) => {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email || typeof email !== "string" || !email.includes("@")) {
|
||||
res.status(400).json({ error: "Valid email required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await createKey(email.toLowerCase().trim(), "free");
|
||||
logger.info({ email: email.slice(0, 3) + "***" }, "Free signup");
|
||||
res.json({
|
||||
apiKey: key.key,
|
||||
tier: "free",
|
||||
limit: 100,
|
||||
message: "Your API key is ready! 100 free screenshots/month.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, "Signup failed");
|
||||
res.status(500).json({ error: "Signup failed" });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const router = Router();
|
||||
router.get("/", (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../../public/status.html"));
|
||||
});
|
||||
export { router as statusRouter };
|
||||
64
src/routes/usage.ts
Normal file
64
src/routes/usage.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Router } from "express";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { getUsageForKey } from "../middleware/usage.js";
|
||||
import { getTierLimit } from "../services/keys.js";
|
||||
|
||||
export const usageRouter = Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/usage:
|
||||
* get:
|
||||
* tags: [Usage]
|
||||
* summary: Get current usage for your API key
|
||||
* description: Returns usage statistics for the authenticated API key in the current billing month.
|
||||
* operationId: getUsage
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* - ApiKeyAuth: []
|
||||
* - QueryKeyAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Usage statistics
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* used:
|
||||
* type: integer
|
||||
* description: Screenshots used this month
|
||||
* limit:
|
||||
* type: integer
|
||||
* description: Monthly screenshot limit for your plan
|
||||
* plan:
|
||||
* type: string
|
||||
* description: Current plan name
|
||||
* month:
|
||||
* type: string
|
||||
* description: Current billing month (YYYY-MM)
|
||||
* remaining:
|
||||
* type: integer
|
||||
* description: Screenshots remaining this month
|
||||
* percentUsed:
|
||||
* type: number
|
||||
* description: Percentage of limit used
|
||||
* 401:
|
||||
* description: Missing API key
|
||||
* 403:
|
||||
* description: Invalid API key
|
||||
*/
|
||||
usageRouter.get("/", authMiddleware, (req, res) => {
|
||||
const keyInfo = (req as any).apiKeyInfo;
|
||||
const limit = getTierLimit(keyInfo.tier);
|
||||
|
||||
const now = new Date();
|
||||
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
const record = getUsageForKey(keyInfo.key);
|
||||
const used = record && record.monthKey === monthKey ? record.count : 0;
|
||||
const remaining = Math.max(0, limit - used);
|
||||
const percentUsed = limit > 0 ? Math.round((used / limit) * 1000) / 10 : 0;
|
||||
|
||||
res.json({ used, limit, plan: keyInfo.tier, month: monthKey, remaining, percentUsed });
|
||||
});
|
||||
208
src/services/__tests__/browser.test.ts
Normal file
208
src/services/__tests__/browser.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockPage = () => ({
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
setViewport: vi.fn().mockResolvedValue(undefined),
|
||||
screenshot: vi.fn().mockResolvedValue(Buffer.from('fake')),
|
||||
waitForSelector: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
|
||||
const mockBrowser = (pages: any[]) => ({
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
newPage: vi.fn().mockImplementation(() => {
|
||||
const p = mockPage()
|
||||
pages.push(p)
|
||||
return Promise.resolve(p)
|
||||
}),
|
||||
})
|
||||
|
||||
let launchedBrowsers: any[] = []
|
||||
let allPages: any[][] = []
|
||||
|
||||
vi.mock('puppeteer', () => ({
|
||||
default: {
|
||||
launch: vi.fn().mockImplementation(() => {
|
||||
const pages: any[] = []
|
||||
allPages.push(pages)
|
||||
const b = mockBrowser(pages)
|
||||
launchedBrowsers.push(b)
|
||||
return Promise.resolve(b)
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
describe('Browser Pool Service', () => {
|
||||
let mod: any
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
launchedBrowsers = []
|
||||
allPages = []
|
||||
vi.doMock('puppeteer', () => ({
|
||||
default: {
|
||||
launch: vi.fn().mockImplementation(() => {
|
||||
const pages: any[] = []
|
||||
allPages.push(pages)
|
||||
const b = mockBrowser(pages)
|
||||
launchedBrowsers.push(b)
|
||||
return Promise.resolve(b)
|
||||
}),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../logger.js', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
mod = await import('../browser.js')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
try { await mod.closeBrowser() } catch {}
|
||||
})
|
||||
|
||||
describe('initBrowser()', () => {
|
||||
it('creates correct number of browsers (default BROWSER_COUNT=2)', async () => {
|
||||
await mod.initBrowser()
|
||||
expect(launchedBrowsers).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('creates PAGES_PER_BROWSER pages per browser (default 4)', async () => {
|
||||
await mod.initBrowser()
|
||||
for (const pages of allPages) {
|
||||
expect(pages).toHaveLength(4)
|
||||
}
|
||||
})
|
||||
|
||||
it('pool stats reflect correct totals after init', async () => {
|
||||
await mod.initBrowser()
|
||||
const stats = mod.getPoolStats()
|
||||
expect(stats.browsers).toBe(2)
|
||||
expect(stats.pagesPerBrowser).toBe(4)
|
||||
expect(stats.totalPages).toBe(8)
|
||||
expect(stats.availablePages).toBe(8)
|
||||
expect(stats.queueDepth).toBe(0)
|
||||
expect(stats.totalJobs).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('acquirePage()', () => {
|
||||
it('returns a page from the pool', async () => {
|
||||
await mod.initBrowser()
|
||||
const { page } = await mod.acquirePage()
|
||||
expect(page).toBeDefined()
|
||||
expect(page.evaluate).toBeDefined()
|
||||
})
|
||||
|
||||
it('uses round-robin across browser instances', async () => {
|
||||
await mod.initBrowser()
|
||||
await mod.acquirePage()
|
||||
await mod.acquirePage()
|
||||
const stats = mod.getPoolStats()
|
||||
expect(stats.availablePages).toBe(6)
|
||||
})
|
||||
|
||||
it('decrements available pages on acquire', async () => {
|
||||
await mod.initBrowser()
|
||||
await mod.acquirePage()
|
||||
const stats = mod.getPoolStats()
|
||||
expect(stats.availablePages).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('releasePage()', () => {
|
||||
it('increments job count after release', async () => {
|
||||
await mod.initBrowser()
|
||||
const { page, instance } = await mod.acquirePage()
|
||||
expect(mod.getPoolStats().totalJobs).toBe(0)
|
||||
mod.releasePage(page, instance)
|
||||
expect(mod.getPoolStats().totalJobs).toBe(1)
|
||||
})
|
||||
|
||||
it('returns page to pool (available count increases)', async () => {
|
||||
await mod.initBrowser()
|
||||
const { page, instance } = await mod.acquirePage()
|
||||
expect(mod.getPoolStats().availablePages).toBe(7)
|
||||
mod.releasePage(page, instance)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(mod.getPoolStats().availablePages).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Queue behavior', () => {
|
||||
it('queues request when all pages are busy and resolves when page released', async () => {
|
||||
await mod.initBrowser()
|
||||
const acquired = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
acquired.push(await mod.acquirePage())
|
||||
}
|
||||
expect(mod.getPoolStats().availablePages).toBe(0)
|
||||
|
||||
let resolved = false
|
||||
const pending = mod.acquirePage().then((r: any) => { resolved = true; return r })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(resolved).toBe(false)
|
||||
expect(mod.getPoolStats().queueDepth).toBe(1)
|
||||
|
||||
mod.releasePage(acquired[0].page, acquired[0].instance)
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
expect(resolved).toBe(true)
|
||||
|
||||
const result = await pending
|
||||
mod.releasePage(result.page, result.instance)
|
||||
for (let i = 1; i < acquired.length; i++) {
|
||||
mod.releasePage(acquired[i].page, acquired[i].instance)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects with QUEUE_FULL after 30s timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mod.initBrowser()
|
||||
const acquired = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
acquired.push(await mod.acquirePage())
|
||||
}
|
||||
|
||||
// Catch the rejection immediately to avoid unhandled rejection
|
||||
let error: Error | null = null
|
||||
const pending = mod.acquirePage().catch((e: Error) => { error = e })
|
||||
await vi.advanceTimersByTimeAsync(31_000)
|
||||
await pending
|
||||
expect(error).toBeTruthy()
|
||||
expect(error!.message).toBe('QUEUE_FULL')
|
||||
|
||||
for (const a of acquired) mod.releasePage(a.page, a.instance)
|
||||
// Let async cleanup finish
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPoolStats()', () => {
|
||||
it('returns accurate counts during operations', async () => {
|
||||
await mod.initBrowser()
|
||||
const { page, instance } = await mod.acquirePage()
|
||||
const stats = mod.getPoolStats()
|
||||
expect(stats.availablePages).toBe(7)
|
||||
expect(stats.totalJobs).toBe(0)
|
||||
mod.releasePage(page, instance)
|
||||
expect(mod.getPoolStats().totalJobs).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Staggered restart', () => {
|
||||
it('only one browser restarts at a time (restarting flag)', async () => {
|
||||
await mod.initBrowser()
|
||||
const stats = mod.getPoolStats()
|
||||
// After init, no browsers should be restarting
|
||||
expect(stats.browsers).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
304
src/services/__tests__/cache.test.ts
Normal file
304
src/services/__tests__/cache.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { ScreenshotCache } from '../cache.js'
|
||||
|
||||
describe('ScreenshotCache', () => {
|
||||
let cache: ScreenshotCache
|
||||
let originalEnv: NodeJS.ProcessEnv
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original env
|
||||
originalEnv = process.env
|
||||
|
||||
// Set test environment variables
|
||||
process.env.CACHE_TTL_MS = '1000' // 1 second for fast tests
|
||||
process.env.CACHE_MAX_MB = '1' // 1MB for testing eviction
|
||||
|
||||
// Create new cache instance with test settings
|
||||
cache = new ScreenshotCache()
|
||||
|
||||
// Clear any existing timers
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
process.env = originalEnv
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
describe('Cache operations', () => {
|
||||
it('should return null for cache miss', () => {
|
||||
const params = { url: 'https://example.com', format: 'png' }
|
||||
const result = cache.get(params)
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('should return cached item for cache hit', () => {
|
||||
const params = { url: 'https://example.com', format: 'png' }
|
||||
const buffer = Buffer.from('fake image data')
|
||||
const contentType = 'image/png'
|
||||
|
||||
cache.put(params, buffer, contentType)
|
||||
const result = cache.get(params)
|
||||
|
||||
expect(result).not.toBe(null)
|
||||
expect(result!.buffer).toEqual(buffer)
|
||||
expect(result!.contentType).toBe(contentType)
|
||||
expect(result!.size).toBe(buffer.length)
|
||||
})
|
||||
|
||||
it('should generate deterministic cache keys for same params', () => {
|
||||
const params1 = { url: 'https://example.com', format: 'png', width: 1280 }
|
||||
const params2 = { url: 'https://example.com', format: 'png', width: 1280 }
|
||||
const buffer = Buffer.from('test data')
|
||||
|
||||
cache.put(params1, buffer, 'image/png')
|
||||
const result = cache.get(params2)
|
||||
|
||||
expect(result).not.toBe(null)
|
||||
expect(result!.buffer).toEqual(buffer)
|
||||
})
|
||||
|
||||
it('should generate different cache keys for different params', () => {
|
||||
const params1 = { url: 'https://example.com', format: 'png', width: 1280 }
|
||||
const params2 = { url: 'https://example.com', format: 'jpeg', width: 1280 }
|
||||
const buffer1 = Buffer.from('test data 1')
|
||||
const buffer2 = Buffer.from('test data 2')
|
||||
|
||||
cache.put(params1, buffer1, 'image/png')
|
||||
cache.put(params2, buffer2, 'image/jpeg')
|
||||
|
||||
const result1 = cache.get(params1)
|
||||
const result2 = cache.get(params2)
|
||||
|
||||
expect(result1!.buffer).toEqual(buffer1)
|
||||
expect(result2!.buffer).toEqual(buffer2)
|
||||
expect(result1!.contentType).toBe('image/png')
|
||||
expect(result2!.contentType).toBe('image/jpeg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TTL expiry', () => {
|
||||
it('should return null for expired items', async () => {
|
||||
const params = { url: 'https://example.com', format: 'png' }
|
||||
const buffer = Buffer.from('test data')
|
||||
|
||||
cache.put(params, buffer, 'image/png')
|
||||
|
||||
// Fast forward past TTL
|
||||
await new Promise(resolve => setTimeout(resolve, 1100))
|
||||
|
||||
const result = cache.get(params)
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('should update lastAccessed time on get', async () => {
|
||||
const params = { url: 'https://example.com', format: 'png' }
|
||||
const buffer = Buffer.from('test data')
|
||||
|
||||
cache.put(params, buffer, 'image/png')
|
||||
const firstGet = cache.get(params)!
|
||||
const firstAccessed = firstGet.lastAccessed
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
const secondGet = cache.get(params)
|
||||
if (secondGet) {
|
||||
expect(secondGet.lastAccessed).toBeGreaterThan(firstAccessed)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Size limits and eviction', () => {
|
||||
it('should track current cache size correctly', () => {
|
||||
const buffer1 = Buffer.from('a'.repeat(100))
|
||||
const buffer2 = Buffer.from('b'.repeat(200))
|
||||
|
||||
cache.put({ url: 'https://example1.com' }, buffer1, 'image/png')
|
||||
cache.put({ url: 'https://example2.com' }, buffer2, 'image/png')
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.sizeBytes).toBe(300)
|
||||
expect(stats.items).toBe(2)
|
||||
})
|
||||
|
||||
it('should not cache items larger than 50% of max cache size', () => {
|
||||
// Cache max is 1MB (1048576 bytes), so 50% is ~524KB
|
||||
const largeBuffer = Buffer.from('x'.repeat(600000)) // 600KB
|
||||
const params = { url: 'https://example.com' }
|
||||
|
||||
cache.put(params, largeBuffer, 'image/png')
|
||||
|
||||
const result = cache.get(params)
|
||||
expect(result).toBe(null)
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.sizeBytes).toBe(0)
|
||||
expect(stats.items).toBe(0)
|
||||
})
|
||||
|
||||
it('should evict oldest items when cache is full', () => {
|
||||
// Add multiple items that will exceed the cache limit (1MB)
|
||||
const bufferSize = 300000 // 300KB each, 4 items = 1.2MB (exceeds 1MB limit)
|
||||
const buffer1 = Buffer.from('1'.repeat(bufferSize))
|
||||
const buffer2 = Buffer.from('2'.repeat(bufferSize))
|
||||
const buffer3 = Buffer.from('3'.repeat(bufferSize))
|
||||
const buffer4 = Buffer.from('4'.repeat(bufferSize))
|
||||
|
||||
// Add first three items (900KB total)
|
||||
cache.put({ url: 'https://example1.com' }, buffer1, 'image/png')
|
||||
cache.put({ url: 'https://example2.com' }, buffer2, 'image/png')
|
||||
cache.put({ url: 'https://example3.com' }, buffer3, 'image/png')
|
||||
|
||||
const statsAfterThree = cache.getStats()
|
||||
expect(statsAfterThree.items).toBe(3)
|
||||
expect(statsAfterThree.sizeBytes).toBe(900000)
|
||||
|
||||
// Access first item to make it more recently used than second item
|
||||
const accessed = cache.get({ url: 'https://example1.com' })
|
||||
expect(accessed).not.toBe(null)
|
||||
|
||||
// Add fourth item (300KB), total would be 1.2MB, should trigger eviction
|
||||
cache.put({ url: 'https://example4.com' }, buffer4, 'image/png')
|
||||
|
||||
const finalStats = cache.getStats()
|
||||
expect(finalStats.sizeBytes).toBeLessThanOrEqual(finalStats.maxSizeBytes)
|
||||
|
||||
// The newly added item should be present
|
||||
expect(cache.get({ url: 'https://example4.com' })).not.toBe(null)
|
||||
|
||||
// At least one older item should have been evicted to make space
|
||||
const remaining = [
|
||||
cache.get({ url: 'https://example1.com' }),
|
||||
cache.get({ url: 'https://example2.com' }),
|
||||
cache.get({ url: 'https://example3.com' })
|
||||
].filter(item => item !== null)
|
||||
|
||||
expect(remaining.length).toBeLessThan(3) // Some old items should be evicted
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cache bypass logic', () => {
|
||||
it('should bypass cache when cache=false in params', () => {
|
||||
const params = { url: 'https://example.com', cache: false }
|
||||
expect(cache.shouldBypass(params)).toBe(true)
|
||||
})
|
||||
|
||||
it('should bypass cache when cache="false" in params', () => {
|
||||
const params = { url: 'https://example.com', cache: 'false' }
|
||||
expect(cache.shouldBypass(params)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not bypass cache by default', () => {
|
||||
const params = { url: 'https://example.com' }
|
||||
expect(cache.shouldBypass(params)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not bypass cache when cache=true', () => {
|
||||
const params = { url: 'https://example.com', cache: true }
|
||||
expect(cache.shouldBypass(params)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not bypass cache when cache="true"', () => {
|
||||
const params = { url: 'https://example.com', cache: 'true' }
|
||||
expect(cache.shouldBypass(params)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cache key generation', () => {
|
||||
it('should include all relevant parameters in cache key', () => {
|
||||
const params1 = {
|
||||
url: 'https://example.com',
|
||||
format: 'png',
|
||||
width: 1280,
|
||||
height: 800,
|
||||
fullPage: false,
|
||||
quality: 80,
|
||||
waitForSelector: '.content',
|
||||
deviceScale: 1,
|
||||
delay: 0,
|
||||
waitUntil: 'domcontentloaded'
|
||||
}
|
||||
|
||||
const params2 = { ...params1, width: 1920 }
|
||||
const buffer = Buffer.from('test')
|
||||
|
||||
cache.put(params1, buffer, 'image/png')
|
||||
cache.put(params2, buffer, 'image/png')
|
||||
|
||||
// Should be able to get both separately
|
||||
expect(cache.get(params1)).not.toBe(null)
|
||||
expect(cache.get(params2)).not.toBe(null)
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.items).toBe(2) // Two different cache entries
|
||||
})
|
||||
|
||||
it('should handle undefined/null parameters consistently', () => {
|
||||
const params1 = { url: 'https://example.com', format: 'png', width: undefined }
|
||||
const params2 = { url: 'https://example.com', format: 'png' }
|
||||
const buffer = Buffer.from('test')
|
||||
|
||||
cache.put(params1, buffer, 'image/png')
|
||||
|
||||
// Should be able to retrieve with equivalent params
|
||||
const result = cache.get(params2)
|
||||
expect(result).not.toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Statistics', () => {
|
||||
it('should return accurate cache statistics', () => {
|
||||
const buffer1 = Buffer.from('a'.repeat(100))
|
||||
const buffer2 = Buffer.from('b'.repeat(200))
|
||||
|
||||
cache.put({ url: 'https://example1.com' }, buffer1, 'image/png')
|
||||
cache.put({ url: 'https://example2.com' }, buffer2, 'image/png')
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.items).toBe(2)
|
||||
expect(stats.sizeBytes).toBe(300)
|
||||
expect(stats.maxSizeBytes).toBe(1048576) // 1MB in bytes
|
||||
expect(stats.ttlMs).toBe(1000) // 1 second as set in beforeEach
|
||||
})
|
||||
|
||||
it('should update statistics after eviction', () => {
|
||||
const buffer = Buffer.from('x'.repeat(400000)) // 400KB
|
||||
|
||||
cache.put({ url: 'https://example1.com' }, buffer, 'image/png')
|
||||
cache.put({ url: 'https://example2.com' }, buffer, 'image/png')
|
||||
cache.put({ url: 'https://example3.com' }, buffer, 'image/png') // Should trigger eviction
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.items).toBeLessThan(3) // Some items should be evicted
|
||||
expect(stats.sizeBytes).toBeLessThanOrEqual(stats.maxSizeBytes)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should clean up expired items when accessed after TTL', async () => {
|
||||
const params1 = { url: 'https://example1.com' }
|
||||
const params2 = { url: 'https://example2.com' }
|
||||
const buffer = Buffer.from('test')
|
||||
|
||||
// Add first item
|
||||
cache.put(params1, buffer, 'image/png')
|
||||
|
||||
// Wait for first item to be cached, then add second
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
cache.put(params2, buffer, 'image/png')
|
||||
|
||||
// Wait past TTL (1 second + buffer)
|
||||
await new Promise(resolve => setTimeout(resolve, 1200))
|
||||
|
||||
// First item should be expired when accessed
|
||||
expect(cache.get(params1)).toBe(null)
|
||||
|
||||
// Second item might also be expired depending on timing
|
||||
const result2 = cache.get(params2)
|
||||
// We can't guarantee timing in tests, so just check it's either null or valid
|
||||
expect(result2 === null || result2.buffer.equals(buffer)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
147
src/services/__tests__/keys.test.ts
Normal file
147
src/services/__tests__/keys.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { getTierLimit, getKeyByEmail, getCustomerIdByEmail, downgradeByCustomer } from '../keys.js'
|
||||
|
||||
// Mock the db module
|
||||
vi.mock('../db.js', () => ({
|
||||
queryWithRetry: vi.fn()
|
||||
}))
|
||||
|
||||
import { queryWithRetry } from '../db.js'
|
||||
|
||||
describe('getTierLimit', () => {
|
||||
it('should return 100 for free tier', () => {
|
||||
expect(getTierLimit('free')).toBe(100)
|
||||
})
|
||||
|
||||
it('should return 1000 for starter tier', () => {
|
||||
expect(getTierLimit('starter')).toBe(1000)
|
||||
})
|
||||
|
||||
it('should return 5000 for pro tier', () => {
|
||||
expect(getTierLimit('pro')).toBe(5000)
|
||||
})
|
||||
|
||||
it('should return 25000 for business tier', () => {
|
||||
expect(getTierLimit('business')).toBe(25000)
|
||||
})
|
||||
|
||||
it('should return 0 for cancelled tier', () => {
|
||||
expect(getTierLimit('cancelled')).toBe(0)
|
||||
})
|
||||
|
||||
it('should return 100 for unknown tier', () => {
|
||||
expect(getTierLimit('enterprise')).toBe(100)
|
||||
})
|
||||
|
||||
it('should return 100 for empty string', () => {
|
||||
expect(getTierLimit('')).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getKeyByEmail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return key info when email exists', async () => {
|
||||
const mockRow = {
|
||||
key: 'snap_abcd1234efgh5678',
|
||||
tier: 'pro',
|
||||
email: 'user@example.com',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
stripe_customer_id: 'cus_123456'
|
||||
}
|
||||
|
||||
vi.mocked(queryWithRetry).mockResolvedValue({
|
||||
rows: [mockRow]
|
||||
})
|
||||
|
||||
const result = await getKeyByEmail('user@example.com')
|
||||
|
||||
expect(result).toEqual({
|
||||
key: 'snap_abcd1234efgh5678',
|
||||
tier: 'pro',
|
||||
email: 'user@example.com',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
stripeCustomerId: 'cus_123456'
|
||||
})
|
||||
|
||||
expect(queryWithRetry).toHaveBeenCalledWith(
|
||||
'SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1',
|
||||
['user@example.com']
|
||||
)
|
||||
})
|
||||
|
||||
it('should return undefined when email does not exist', async () => {
|
||||
vi.mocked(queryWithRetry).mockResolvedValue({
|
||||
rows: []
|
||||
})
|
||||
|
||||
const result = await getKeyByEmail('nonexistent@example.com')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
vi.mocked(queryWithRetry).mockRejectedValue(new Error('Database error'))
|
||||
|
||||
const result = await getKeyByEmail('user@example.com')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCustomerIdByEmail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return customer ID when email exists and has stripe customer', async () => {
|
||||
vi.mocked(queryWithRetry).mockResolvedValue({
|
||||
rows: [{ stripe_customer_id: 'cus_123456' }]
|
||||
})
|
||||
|
||||
const result = await getCustomerIdByEmail('user@example.com')
|
||||
|
||||
expect(result).toBe('cus_123456')
|
||||
expect(queryWithRetry).toHaveBeenCalledWith(
|
||||
'SELECT stripe_customer_id FROM api_keys WHERE email = $1 AND stripe_customer_id IS NOT NULL',
|
||||
['user@example.com']
|
||||
)
|
||||
})
|
||||
|
||||
it('should return undefined when email does not exist', async () => {
|
||||
vi.mocked(queryWithRetry).mockResolvedValue({
|
||||
rows: []
|
||||
})
|
||||
|
||||
const result = await getCustomerIdByEmail('nonexistent@example.com')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
vi.mocked(queryWithRetry).mockRejectedValue(new Error('Database error'))
|
||||
|
||||
const result = await getCustomerIdByEmail('user@example.com')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('downgradeByCustomer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should set tier to cancelled instead of free', async () => {
|
||||
vi.mocked(queryWithRetry).mockResolvedValue({ rows: [] })
|
||||
|
||||
await downgradeByCustomer('cus_test_123')
|
||||
|
||||
expect(queryWithRetry).toHaveBeenCalledWith(
|
||||
expect.stringContaining("'cancelled'"),
|
||||
['cus_test_123']
|
||||
)
|
||||
})
|
||||
})
|
||||
133
src/services/__tests__/pdf.test.ts
Normal file
133
src/services/__tests__/pdf.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { takeScreenshot } from '../screenshot.js'
|
||||
|
||||
// Mock browser
|
||||
vi.mock('../browser.js', () => ({
|
||||
acquirePage: vi.fn(),
|
||||
releasePage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ssrf.js', () => ({
|
||||
validateUrl: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
default: { warn: vi.fn(), error: vi.fn() }
|
||||
}))
|
||||
|
||||
const { acquirePage, releasePage } = await import('../browser.js')
|
||||
const mockAcquirePage = vi.mocked(acquirePage)
|
||||
|
||||
describe('takeScreenshot - PDF format', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should call page.pdf() for format=pdf and return application/pdf', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4 test')
|
||||
const mockPage = {
|
||||
setViewport: vi.fn(),
|
||||
goto: vi.fn(),
|
||||
pdf: vi.fn().mockResolvedValue(pdfBuffer),
|
||||
emulateMediaFeatures: vi.fn(),
|
||||
setUserAgent: vi.fn(),
|
||||
addStyleTag: vi.fn(),
|
||||
waitForSelector: vi.fn(),
|
||||
evaluate: vi.fn(),
|
||||
$: vi.fn(),
|
||||
screenshot: vi.fn()
|
||||
}
|
||||
mockAcquirePage.mockResolvedValue({ page: mockPage as any, instance: {} as any })
|
||||
|
||||
const result = await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
format: 'pdf' as any
|
||||
})
|
||||
|
||||
expect(mockPage.pdf).toHaveBeenCalled()
|
||||
expect(mockPage.screenshot).not.toHaveBeenCalled()
|
||||
expect(result.contentType).toBe('application/pdf')
|
||||
expect(result.buffer.toString().startsWith('%PDF')).toBe(true)
|
||||
})
|
||||
|
||||
it('should pass PDF options to page.pdf()', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||
const mockPage = {
|
||||
setViewport: vi.fn(),
|
||||
goto: vi.fn(),
|
||||
pdf: vi.fn().mockResolvedValue(pdfBuffer),
|
||||
emulateMediaFeatures: vi.fn(),
|
||||
setUserAgent: vi.fn(),
|
||||
addStyleTag: vi.fn(),
|
||||
waitForSelector: vi.fn(),
|
||||
evaluate: vi.fn(),
|
||||
$: vi.fn(),
|
||||
screenshot: vi.fn()
|
||||
}
|
||||
mockAcquirePage.mockResolvedValue({ page: mockPage as any, instance: {} as any })
|
||||
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
format: 'pdf' as any,
|
||||
pdfFormat: 'letter',
|
||||
pdfLandscape: true,
|
||||
pdfPrintBackground: false,
|
||||
pdfScale: 1.5,
|
||||
pdfMargin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' }
|
||||
} as any)
|
||||
|
||||
expect(mockPage.pdf).toHaveBeenCalledWith({
|
||||
format: 'letter',
|
||||
landscape: true,
|
||||
printBackground: false,
|
||||
scale: 1.5,
|
||||
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' }
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default PDF options when none specified', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4')
|
||||
const mockPage = {
|
||||
setViewport: vi.fn(),
|
||||
goto: vi.fn(),
|
||||
pdf: vi.fn().mockResolvedValue(pdfBuffer),
|
||||
emulateMediaFeatures: vi.fn(),
|
||||
setUserAgent: vi.fn(),
|
||||
addStyleTag: vi.fn(),
|
||||
waitForSelector: vi.fn(),
|
||||
evaluate: vi.fn(),
|
||||
$: vi.fn(),
|
||||
screenshot: vi.fn()
|
||||
}
|
||||
mockAcquirePage.mockResolvedValue({ page: mockPage as any, instance: {} as any })
|
||||
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
format: 'pdf' as any
|
||||
})
|
||||
|
||||
expect(mockPage.pdf).toHaveBeenCalledWith({
|
||||
format: 'a4',
|
||||
landscape: false,
|
||||
printBackground: true,
|
||||
scale: 1.0,
|
||||
margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject format=pdf with selector', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
format: 'pdf' as any,
|
||||
selector: '#content'
|
||||
})).rejects.toThrow('format "pdf" is mutually exclusive with selector and clip')
|
||||
})
|
||||
|
||||
it('should reject format=pdf with clip', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
format: 'pdf' as any,
|
||||
clip: { x: 0, y: 0, width: 100, height: 100 }
|
||||
})).rejects.toThrow('format "pdf" is mutually exclusive with selector and clip')
|
||||
})
|
||||
})
|
||||
56
src/services/__tests__/retry.test.ts
Normal file
56
src/services/__tests__/retry.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { isRetryableError } from '../retry.js'
|
||||
|
||||
describe('isRetryableError', () => {
|
||||
it('returns true for TimeoutError', () => {
|
||||
const err = new Error('TimeoutError: Navigation timeout of 20000ms exceeded')
|
||||
err.name = 'TimeoutError'
|
||||
expect(isRetryableError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for Protocol error', () => {
|
||||
expect(isRetryableError(new Error('Protocol error (Runtime.callFunctionOn): Session closed.'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for Target closed', () => {
|
||||
expect(isRetryableError(new Error('Target closed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for Session closed', () => {
|
||||
expect(isRetryableError(new Error('Session closed. Most likely the page has been closed.'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for Navigation failed', () => {
|
||||
expect(isRetryableError(new Error('Navigation failed because browser has disconnected!'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for net::ERR_ errors', () => {
|
||||
expect(isRetryableError(new Error('net::ERR_CONNECTION_RESET'))).toBe(true)
|
||||
expect(isRetryableError(new Error('net::ERR_CONNECTION_REFUSED'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for SCREENSHOT_TIMEOUT (overall timeout, not transient)', () => {
|
||||
expect(isRetryableError(new Error('SCREENSHOT_TIMEOUT'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for SSRF_BLOCKED', () => {
|
||||
expect(isRetryableError(new Error('SSRF_BLOCKED'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for validation errors', () => {
|
||||
expect(isRetryableError(new Error('hideSelector contains dangerous characters'))).toBe(false)
|
||||
expect(isRetryableError(new Error('selector and fullPage are mutually exclusive'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for SELECTOR_NOT_FOUND', () => {
|
||||
expect(isRetryableError(new Error('SELECTOR_NOT_FOUND'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for JS_EXECUTION_ERROR', () => {
|
||||
expect(isRetryableError(new Error('JS_EXECUTION_ERROR: foo is not defined'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for generic errors', () => {
|
||||
expect(isRetryableError(new Error('Something went wrong'))).toBe(false)
|
||||
})
|
||||
})
|
||||
668
src/services/__tests__/screenshot.test.ts
Normal file
668
src/services/__tests__/screenshot.test.ts
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// All mocks must use inline values since vi.mock is hoisted
|
||||
vi.mock('../browser.js', () => ({
|
||||
acquirePage: vi.fn(),
|
||||
releasePage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../ssrf.js', () => ({
|
||||
validateUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
import { takeScreenshot } from '../screenshot.js'
|
||||
import { acquirePage, releasePage } from '../browser.js'
|
||||
import { validateUrl } from '../ssrf.js'
|
||||
|
||||
function createMockPage() {
|
||||
return {
|
||||
setViewport: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
waitForSelector: vi.fn().mockResolvedValue(undefined),
|
||||
screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-screenshot')),
|
||||
emulateMediaFeatures: vi.fn().mockResolvedValue(undefined),
|
||||
addStyleTag: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
describe('Screenshot Service', () => {
|
||||
let mockPage: ReturnType<typeof createMockPage>
|
||||
const mockInstance = { id: 0 }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPage = createMockPage()
|
||||
vi.mocked(acquirePage).mockResolvedValue({ page: mockPage as any, instance: mockInstance as any })
|
||||
vi.mocked(validateUrl).mockResolvedValue({ hostname: 'example.com', resolvedIp: '1.2.3.4' } as any)
|
||||
})
|
||||
|
||||
describe('URL validation', () => {
|
||||
it('calls validateUrl with the provided URL', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(validateUrl).toHaveBeenCalledWith('https://example.com')
|
||||
})
|
||||
|
||||
it('propagates SSRF validation errors', async () => {
|
||||
vi.mocked(validateUrl).mockRejectedValueOnce(new Error('SSRF_BLOCKED'))
|
||||
await expect(takeScreenshot({ url: 'http://localhost' })).rejects.toThrow('SSRF_BLOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default options', () => {
|
||||
it('uses format=png, width=1280, height=800, fullPage=false', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(mockPage.setViewport).toHaveBeenCalledWith({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
deviceScaleFactor: 1,
|
||||
})
|
||||
expect(mockPage.screenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'png',
|
||||
fullPage: false,
|
||||
encoding: 'binary',
|
||||
})
|
||||
)
|
||||
const screenshotArg = mockPage.screenshot.mock.calls[0][0]
|
||||
expect(screenshotArg.quality).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns image/png content type by default', async () => {
|
||||
const result = await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(result.contentType).toBe('image/png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom options', () => {
|
||||
it('respects custom width and height', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', width: 800, height: 600 })
|
||||
expect(mockPage.setViewport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ width: 800, height: 600 })
|
||||
)
|
||||
})
|
||||
|
||||
it('caps width at MAX_WIDTH (3840)', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', width: 5000 })
|
||||
expect(mockPage.setViewport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ width: 3840 })
|
||||
)
|
||||
})
|
||||
|
||||
it('caps height at MAX_HEIGHT (2160)', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', height: 5000 })
|
||||
expect(mockPage.setViewport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ height: 2160 })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses jpeg format with quality', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', format: 'jpeg', quality: 90 })
|
||||
expect(mockPage.screenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'jpeg', quality: 90 })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns correct content type for jpeg', async () => {
|
||||
const result = await takeScreenshot({ url: 'https://example.com', format: 'jpeg' })
|
||||
expect(result.contentType).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('uses webp format with quality', async () => {
|
||||
const result = await takeScreenshot({ url: 'https://example.com', format: 'webp', quality: 75 })
|
||||
expect(mockPage.screenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'webp', quality: 75 })
|
||||
)
|
||||
expect(result.contentType).toBe('image/webp')
|
||||
})
|
||||
|
||||
it('does not set quality for png even if provided', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', format: 'png', quality: 90 })
|
||||
const arg = mockPage.screenshot.mock.calls[0][0]
|
||||
expect(arg.quality).toBeUndefined()
|
||||
})
|
||||
|
||||
it('respects fullPage option', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', fullPage: true })
|
||||
expect(mockPage.screenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fullPage: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('caps deviceScale at 3', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', deviceScale: 5 })
|
||||
expect(mockPage.setViewport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deviceScaleFactor: 3 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForSelector', () => {
|
||||
it('calls waitForSelector when provided', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', waitForSelector: '#content' })
|
||||
expect(mockPage.waitForSelector).toHaveBeenCalledWith('#content', { timeout: 10_000 })
|
||||
})
|
||||
|
||||
it('does not call waitForSelector when not provided', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(mockPage.waitForSelector).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delay', () => {
|
||||
it('creates a pause when delay is provided', async () => {
|
||||
vi.useFakeTimers()
|
||||
const promise = takeScreenshot({ url: 'https://example.com', delay: 1000 })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await promise
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timeout', () => {
|
||||
it('rejects with SCREENSHOT_TIMEOUT after 30s', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mockPage.goto.mockImplementation(() => new Promise(() => {}))
|
||||
let error: Error | null = null
|
||||
const promise = takeScreenshot({ url: 'https://example.com' }).catch((e: Error) => { error = e })
|
||||
await vi.advanceTimersByTimeAsync(31_000)
|
||||
await promise
|
||||
expect(error).toBeTruthy()
|
||||
expect(error!.message).toBe('SCREENSHOT_TIMEOUT')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('darkMode', () => {
|
||||
it('emulates prefers-color-scheme: dark when darkMode is true', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', darkMode: true })
|
||||
expect(mockPage.emulateMediaFeatures).toHaveBeenCalledWith([
|
||||
{ name: 'prefers-color-scheme', value: 'dark' }
|
||||
])
|
||||
})
|
||||
|
||||
it('calls emulateMediaFeatures before goto', async () => {
|
||||
const callOrder: string[] = []
|
||||
mockPage.emulateMediaFeatures.mockImplementation(async () => { callOrder.push('emulate') })
|
||||
mockPage.goto.mockImplementation(async () => { callOrder.push('goto') })
|
||||
await takeScreenshot({ url: 'https://example.com', darkMode: true })
|
||||
expect(callOrder).toEqual(['emulate', 'goto'])
|
||||
})
|
||||
|
||||
it('does not emulate media features when darkMode is false', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', darkMode: false })
|
||||
expect(mockPage.emulateMediaFeatures).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not emulate media features when darkMode is not set', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(mockPage.emulateMediaFeatures).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hideSelectors', () => {
|
||||
it('injects style tag to hide selectors after page load', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', hideSelectors: ['.ad', '#banner'] })
|
||||
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
|
||||
content: '.ad { display: none !important }\n#banner { display: none !important }'
|
||||
})
|
||||
})
|
||||
|
||||
it('calls addStyleTag after goto', async () => {
|
||||
const callOrder: string[] = []
|
||||
mockPage.goto.mockImplementation(async () => { callOrder.push('goto') })
|
||||
mockPage.addStyleTag.mockImplementation(async () => { callOrder.push('addStyleTag') })
|
||||
await takeScreenshot({ url: 'https://example.com', hideSelectors: ['.ad'] })
|
||||
expect(callOrder.indexOf('goto')).toBeLessThan(callOrder.indexOf('addStyleTag'))
|
||||
})
|
||||
|
||||
it('does not inject style tag when hideSelectors is empty', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', hideSelectors: [] })
|
||||
expect(mockPage.addStyleTag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not inject style tag when hideSelectors is not set', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(mockPage.addStyleTag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles single selector', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', hideSelectors: ['.cookie-banner'] })
|
||||
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
|
||||
content: '.cookie-banner { display: none !important }'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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('CSS Injection Prevention', () => {
|
||||
describe('hideSelectors sanitization', () => {
|
||||
it('should reject hideSelectors containing curly braces', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
hideSelectors: ['.safe', 'body { background: red; }', '.another']
|
||||
})).rejects.toThrow('hideSelector contains dangerous characters')
|
||||
})
|
||||
|
||||
it('should reject hideSelectors containing angle brackets', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
hideSelectors: ['<script>alert(1)</script>']
|
||||
})).rejects.toThrow('hideSelector contains dangerous characters')
|
||||
})
|
||||
|
||||
it('should reject hideSelectors containing semicolon', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
hideSelectors: ['body; background: red']
|
||||
})).rejects.toThrow('hideSelector contains dangerous characters')
|
||||
})
|
||||
|
||||
it('should accept safe hideSelectors', async () => {
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
hideSelectors: ['.ad', '#banner', 'div.popup', 'nav ul li']
|
||||
})
|
||||
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
|
||||
content: '.ad { display: none !important }\n#banner { display: none !important }\ndiv.popup { display: none !important }\nnav ul li { display: none !important }'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForSelector sanitization', () => {
|
||||
it('should reject waitForSelector longer than 200 characters', async () => {
|
||||
const longSelector = 'div'.repeat(100) // 300 chars
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
waitForSelector: longSelector
|
||||
})).rejects.toThrow('waitForSelector is too long')
|
||||
})
|
||||
|
||||
it('should reject waitForSelector containing javascript:', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
waitForSelector: 'javascript:alert(1)'
|
||||
})).rejects.toThrow('waitForSelector contains dangerous content')
|
||||
})
|
||||
|
||||
it('should reject waitForSelector containing script tag', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
waitForSelector: '<script>alert(1)</script>'
|
||||
})).rejects.toThrow('waitForSelector contains dangerous content')
|
||||
})
|
||||
|
||||
it('should accept safe waitForSelector', async () => {
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
waitForSelector: '#main-content'
|
||||
})
|
||||
expect(mockPage.waitForSelector).toHaveBeenCalledWith('#main-content', { timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS parameter hardening', () => {
|
||||
it('should reject CSS containing @import', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
css: 'body { color: red; } @import url(http://evil.com/steal.css);'
|
||||
})).rejects.toThrow('CSS contains dangerous directives')
|
||||
})
|
||||
|
||||
it('should reject CSS containing url() with http scheme', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
css: 'body { background: url(http://evil.com/image.png); }'
|
||||
})).rejects.toThrow('CSS contains dangerous directives')
|
||||
})
|
||||
|
||||
it('should reject CSS containing url() with https scheme', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
css: 'body { background: url(https://evil.com/image.png); }'
|
||||
})).rejects.toThrow('CSS contains dangerous directives')
|
||||
})
|
||||
|
||||
it('should allow CSS with data: URLs', async () => {
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
css: 'body { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==); }'
|
||||
})
|
||||
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
|
||||
content: 'body { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==); }'
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow safe CSS without external references', async () => {
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
css: 'body { color: red; margin: 0; padding: 10px; }'
|
||||
})
|
||||
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
|
||||
content: 'body { color: red; margin: 0; padding: 10px; }'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Page lifecycle', () => {
|
||||
it('always releases page after successful screenshot', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(releasePage).toHaveBeenCalledWith(mockPage, mockInstance)
|
||||
})
|
||||
|
||||
it('releases page even when an error occurs', async () => {
|
||||
mockPage.goto.mockRejectedValueOnce(new Error('NAV_FAILED'))
|
||||
await expect(takeScreenshot({ url: 'https://example.com' })).rejects.toThrow('NAV_FAILED')
|
||||
expect(releasePage).toHaveBeenCalledWith(mockPage, mockInstance)
|
||||
})
|
||||
|
||||
it('does not release page on SSRF error (before acquire)', async () => {
|
||||
vi.mocked(validateUrl).mockRejectedValueOnce(new Error('SSRF_BLOCKED'))
|
||||
await expect(takeScreenshot({ url: 'http://10.0.0.1' })).rejects.toThrow('SSRF_BLOCKED')
|
||||
expect(releasePage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('JavaScript injection (js parameter)', () => {
|
||||
beforeEach(() => {
|
||||
mockPage.evaluate = vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('executes custom JavaScript when js parameter is provided', async () => {
|
||||
const jsCode = 'document.body.style.background = "red";'
|
||||
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
js: jsCode
|
||||
})
|
||||
|
||||
expect(mockPage.evaluate).toHaveBeenCalledWith(jsCode)
|
||||
})
|
||||
|
||||
it('does not call page.evaluate when js parameter is not provided', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com' })
|
||||
|
||||
expect(mockPage.evaluate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('executes JavaScript after delay but before CSS injection', async () => {
|
||||
const jsCode = 'window.scrollTo(0, 100);'
|
||||
const cssCode = 'body { color: red; }'
|
||||
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
delay: 1000,
|
||||
js: jsCode,
|
||||
css: cssCode
|
||||
})
|
||||
|
||||
// Check that methods were called in the right order
|
||||
expect(mockPage.goto).toHaveBeenCalledBefore(mockPage.evaluate as any)
|
||||
expect(mockPage.evaluate).toHaveBeenCalledBefore(mockPage.addStyleTag as any)
|
||||
})
|
||||
|
||||
it('throws JS_EXECUTION_ERROR when JavaScript execution fails', async () => {
|
||||
mockPage.evaluate = vi.fn().mockRejectedValueOnce(new Error('ReferenceError: foo is not defined'))
|
||||
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
js: 'console.log(foo);'
|
||||
})).rejects.toThrow('JS_EXECUTION_ERROR: ReferenceError: foo is not defined')
|
||||
})
|
||||
|
||||
it('throws JS_TIMEOUT error when JavaScript execution takes too long', async () => {
|
||||
// Mock a long-running script that never resolves
|
||||
mockPage.evaluate = vi.fn().mockReturnValue(new Promise(() => {}))
|
||||
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
js: 'while(true) {}'
|
||||
})).rejects.toThrow('JS_EXECUTION_ERROR: JS_TIMEOUT')
|
||||
})
|
||||
|
||||
it('handles JavaScript execution with hideSelectors and CSS', async () => {
|
||||
const jsCode = 'document.querySelector(".modal").remove();'
|
||||
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
js: jsCode,
|
||||
hideSelectors: ['.popup'],
|
||||
css: 'body { font-size: 16px; }'
|
||||
})
|
||||
|
||||
expect(mockPage.evaluate).toHaveBeenCalledWith(jsCode)
|
||||
expect(mockPage.addStyleTag).toHaveBeenCalledTimes(2) // Once for CSS, once for hideSelectors
|
||||
})
|
||||
})
|
||||
|
||||
describe('selector parameter for element screenshots', () => {
|
||||
let mockElement: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockElement = {
|
||||
screenshot: vi.fn().mockResolvedValue(Buffer.from('element-screenshot'))
|
||||
}
|
||||
mockPage.$ = vi.fn().mockResolvedValue(mockElement)
|
||||
})
|
||||
|
||||
it('uses element.screenshot() when selector is provided', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', selector: '#main-content' })
|
||||
expect(mockPage.$).toHaveBeenCalledWith('#main-content')
|
||||
expect(mockElement.screenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'png',
|
||||
encoding: 'binary'
|
||||
})
|
||||
)
|
||||
expect(mockPage.screenshot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes through format and quality to element.screenshot()', async () => {
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
selector: '.widget',
|
||||
format: 'jpeg',
|
||||
quality: 85
|
||||
})
|
||||
expect(mockElement.screenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'jpeg',
|
||||
quality: 85,
|
||||
encoding: 'binary'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('throws SELECTOR_NOT_FOUND when element is not found', async () => {
|
||||
mockPage.$.mockResolvedValueOnce(null)
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
selector: '#nonexistent'
|
||||
})).rejects.toThrow('SELECTOR_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('validates selector length (max 200 chars)', async () => {
|
||||
const longSelector = 'div'.repeat(100) // 300 chars
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
selector: longSelector
|
||||
})).rejects.toThrow('selector is too long')
|
||||
})
|
||||
|
||||
it('validates selector for dangerous content (javascript:)', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
selector: 'javascript:alert(1)'
|
||||
})).rejects.toThrow('selector contains dangerous content')
|
||||
})
|
||||
|
||||
it('validates selector for dangerous content (script tag)', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
selector: '<script>alert(1)</script>'
|
||||
})).rejects.toThrow('selector contains dangerous content')
|
||||
})
|
||||
|
||||
it('rejects selector and fullPage used together', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
selector: '#content',
|
||||
fullPage: true
|
||||
})).rejects.toThrow('selector and fullPage are mutually exclusive')
|
||||
})
|
||||
|
||||
it('works with all other parameters (delay, css, hideSelectors, etc.)', async () => {
|
||||
await takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
selector: '#main',
|
||||
delay: 1000,
|
||||
css: 'body { color: red; }',
|
||||
hideSelectors: ['.ad'],
|
||||
darkMode: true
|
||||
})
|
||||
|
||||
expect(mockPage.emulateMediaFeatures).toHaveBeenCalledWith([
|
||||
{ name: 'prefers-color-scheme', value: 'dark' }
|
||||
])
|
||||
expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: 'body { color: red; }' })
|
||||
expect(mockPage.addStyleTag).toHaveBeenCalledWith({ content: '.ad { display: none !important }' })
|
||||
expect(mockPage.$).toHaveBeenCalledWith('#main')
|
||||
expect(mockElement.screenshot).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not use page.screenshot() when selector is provided', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com', selector: '.element' })
|
||||
expect(mockPage.screenshot).not.toHaveBeenCalled()
|
||||
expect(mockElement.screenshot).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses page.screenshot() when selector is not provided', async () => {
|
||||
await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(mockPage.screenshot).toHaveBeenCalled()
|
||||
expect(mockPage.$).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retry logic', () => {
|
||||
it('returns retryCount 0 on first-attempt success', async () => {
|
||||
const result = await takeScreenshot({ url: 'https://example.com' })
|
||||
expect(result.retryCount).toBe(0)
|
||||
})
|
||||
|
||||
it('retries on transient error and returns retryCount 1', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mockPage.goto
|
||||
.mockImplementationOnce(async () => { throw new Error('net::ERR_CONNECTION_RESET') })
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
const promise = takeScreenshot({ url: 'https://example.com' })
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
const result = await promise
|
||||
expect(result.retryCount).toBe(1)
|
||||
expect(result.buffer).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns original error after all 3 attempts fail with retryCount 2', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mockPage.goto
|
||||
.mockRejectedValueOnce(new Error('Target closed'))
|
||||
.mockRejectedValueOnce(new Error('Target closed'))
|
||||
.mockRejectedValueOnce(new Error('Target closed'))
|
||||
|
||||
const promise = takeScreenshot({ url: 'https://example.com' }).catch((err: any) => err)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
const err = await promise
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.message).toBe('Target closed')
|
||||
expect(err.retryCount).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does NOT retry validation errors', async () => {
|
||||
await expect(takeScreenshot({
|
||||
url: 'https://example.com',
|
||||
selector: '#content',
|
||||
fullPage: true,
|
||||
})).rejects.toThrow('selector and fullPage are mutually exclusive')
|
||||
expect(acquirePage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT retry SSRF errors', async () => {
|
||||
vi.mocked(validateUrl).mockRejectedValueOnce(new Error('SSRF_BLOCKED'))
|
||||
await expect(takeScreenshot({ url: 'http://10.0.0.1' })).rejects.toThrow('SSRF_BLOCKED')
|
||||
expect(acquirePage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases page on each failed attempt before retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mockPage.goto
|
||||
.mockRejectedValueOnce(new Error('Protocol error'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
const promise = takeScreenshot({ url: 'https://example.com' })
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await promise
|
||||
expect(releasePage).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
310
src/services/__tests__/ssrf.test.ts
Normal file
310
src/services/__tests__/ssrf.test.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { validateUrl } from '../ssrf.js'
|
||||
|
||||
// Mock dns/promises to control DNS resolution for testing
|
||||
vi.mock('dns/promises', () => ({
|
||||
lookup: vi.fn()
|
||||
}))
|
||||
|
||||
const { lookup } = await import('dns/promises')
|
||||
const mockLookup = vi.mocked(lookup)
|
||||
|
||||
describe('SSRF Validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset all mock implementations
|
||||
mockLookup.mockReset()
|
||||
})
|
||||
|
||||
describe('URL validation', () => {
|
||||
it('should accept valid HTTP URLs', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '8.8.8.8', family: 4 })
|
||||
|
||||
const result = await validateUrl('http://example.com')
|
||||
expect(result.hostname).toBe('example.com')
|
||||
expect(result.resolvedIp).toBe('8.8.8.8')
|
||||
})
|
||||
|
||||
it('should accept valid HTTPS URLs', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '1.1.1.1', family: 4 })
|
||||
|
||||
const result = await validateUrl('https://cloudflare.com')
|
||||
expect(result.hostname).toBe('cloudflare.com')
|
||||
expect(result.resolvedIp).toBe('1.1.1.1')
|
||||
})
|
||||
|
||||
it('should reject javascript: URLs', async () => {
|
||||
await expect(validateUrl('javascript:alert(1)')).rejects.toThrow(
|
||||
'URL protocol not allowed: only HTTP and HTTPS are supported'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject ftp: URLs', async () => {
|
||||
await expect(validateUrl('ftp://files.example.com')).rejects.toThrow(
|
||||
'URL protocol not allowed: only HTTP and HTTPS are supported'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject data: URLs', async () => {
|
||||
await expect(validateUrl('data:text/html,<h1>Test</h1>')).rejects.toThrow(
|
||||
'URL protocol not allowed: only HTTP and HTTPS are supported'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject file: URLs', async () => {
|
||||
await expect(validateUrl('file:///etc/passwd')).rejects.toThrow(
|
||||
'URL protocol not allowed: only HTTP and HTTPS are supported'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL length validation', () => {
|
||||
it('should reject empty URLs', async () => {
|
||||
await expect(validateUrl('')).rejects.toThrow(
|
||||
'Invalid URL: must be between 1 and 2048 characters'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject null URLs', async () => {
|
||||
await expect(validateUrl(null as any)).rejects.toThrow(
|
||||
'Invalid URL: must be between 1 and 2048 characters'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject undefined URLs', async () => {
|
||||
await expect(validateUrl(undefined as any)).rejects.toThrow(
|
||||
'Invalid URL: must be between 1 and 2048 characters'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject URLs over 2048 characters', async () => {
|
||||
const longUrl = 'https://example.com/' + 'a'.repeat(2100)
|
||||
await expect(validateUrl(longUrl)).rejects.toThrow(
|
||||
'Invalid URL: must be between 1 and 2048 characters'
|
||||
)
|
||||
})
|
||||
|
||||
it('should accept URLs exactly 2048 characters', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '8.8.8.8', family: 4 })
|
||||
const exactUrl = 'https://example.com/' + 'a'.repeat(2048 - 'https://example.com/'.length)
|
||||
|
||||
const result = await validateUrl(exactUrl)
|
||||
expect(result.hostname).toBe('example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Private IP blocking', () => {
|
||||
it('should block loopback IP 127.0.0.1', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '127.0.0.1', family: 4 })
|
||||
|
||||
// Use a domain that won't be caught by hostname filtering
|
||||
await expect(validateUrl('http://example-internal.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block private IP 10.0.0.1', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '10.0.0.1', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://internal.company.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block private IP 172.16.0.1', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '172.16.0.1', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://internal2.company.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block private IP 172.31.255.255', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '172.31.255.255', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://internal3.company.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block private IP 192.168.1.1', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '192.168.1.1', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://router.local')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block cloud metadata IP 169.254.169.254', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '169.254.169.254', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://metadata.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block zero IP 0.0.0.0', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '0.0.0.0', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://zero.example.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow public IPs', async () => {
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockResolvedValueOnce({ address: '8.8.8.8', family: 4 })
|
||||
|
||||
const result = await validateUrl('http://dns.google')
|
||||
expect(result.resolvedIp).toBe('8.8.8.8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Kubernetes service DNS blocking', () => {
|
||||
it('should block .svc domains', async () => {
|
||||
await expect(validateUrl('http://kubernetes.default.svc')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block .svc.cluster.local domains', async () => {
|
||||
await expect(validateUrl('http://api.default.svc.cluster.local')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block .cluster.local domains', async () => {
|
||||
await expect(validateUrl('http://node1.cluster.local')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block .internal domains', async () => {
|
||||
await expect(validateUrl('http://service.internal')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block localhost', async () => {
|
||||
await expect(validateUrl('http://localhost')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block kubernetes prefixed domains', async () => {
|
||||
await expect(validateUrl('http://kubernetes-dashboard')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DNS resolution errors', () => {
|
||||
it('should reject URLs that fail DNS resolution', async () => {
|
||||
// Clear any previous mocks and set up a proper rejection
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockRejectedValueOnce(new Error('ENOTFOUND'))
|
||||
|
||||
await expect(validateUrl('http://nonexistent.invalid')).rejects.toThrow(
|
||||
'Could not resolve hostname'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('IPv4-mapped IPv6 addresses', () => {
|
||||
it('should block ::ffff:127.0.0.1 (IPv4-mapped loopback)', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '::ffff:127.0.0.1', family: 6 })
|
||||
|
||||
await expect(validateUrl('http://ipv6-mapped.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block ::ffff:10.0.0.1 (IPv4-mapped private)', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '::ffff:10.0.0.1', family: 6 })
|
||||
|
||||
await expect(validateUrl('http://ipv6-private.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block ::ffff:192.168.1.1 (IPv4-mapped private)', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '::ffff:192.168.1.1', family: 6 })
|
||||
|
||||
await expect(validateUrl('http://ipv6-home.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block ::ffff:172.16.0.1 (IPv4-mapped private)', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '::ffff:172.16.0.1', family: 6 })
|
||||
|
||||
await expect(validateUrl('http://ipv6-corp.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block ::ffff:169.254.169.254 (IPv4-mapped metadata)', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '::ffff:169.254.169.254', family: 6 })
|
||||
|
||||
await expect(validateUrl('http://ipv6-metadata.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block ::ffff:0:127.0.0.1 (alternative notation)', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '::ffff:0:127.0.0.1', family: 6 })
|
||||
|
||||
await expect(validateUrl('http://ipv6-alt.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block :: (IPv6 unspecified)', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '::', family: 6 })
|
||||
|
||||
await expect(validateUrl('http://ipv6-unspecified.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow legitimate IPv6 addresses', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '2606:4700:4700::1111', family: 6 })
|
||||
|
||||
const result = await validateUrl('http://ipv6.cloudflare.com')
|
||||
expect(result.resolvedIp).toBe('2606:4700:4700::1111')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle URLs with ports', async () => {
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockResolvedValueOnce({ address: '1.2.3.4', family: 4 })
|
||||
|
||||
const result = await validateUrl('https://example.com:8080/path')
|
||||
expect(result.hostname).toBe('example.com')
|
||||
expect(result.resolvedIp).toBe('1.2.3.4')
|
||||
})
|
||||
|
||||
it('should handle URLs with query parameters', async () => {
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockResolvedValueOnce({ address: '5.6.7.8', family: 4 })
|
||||
|
||||
const result = await validateUrl('https://api.example.com/v1/data?key=value&test=123')
|
||||
expect(result.hostname).toBe('api.example.com')
|
||||
expect(result.resolvedIp).toBe('5.6.7.8')
|
||||
})
|
||||
|
||||
it('should handle malformed URLs', async () => {
|
||||
await expect(validateUrl('not-a-url')).rejects.toThrow('Invalid URL')
|
||||
})
|
||||
|
||||
it('should handle URLs with userinfo', async () => {
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockResolvedValueOnce({ address: '9.10.11.12', family: 4 })
|
||||
|
||||
const result = await validateUrl('https://user:pass@example.com/path')
|
||||
expect(result.hostname).toBe('example.com')
|
||||
expect(result.resolvedIp).toBe('9.10.11.12')
|
||||
})
|
||||
})
|
||||
})
|
||||
262
src/services/__tests__/watermark.test.ts
Normal file
262
src/services/__tests__/watermark.test.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { addWatermark } from '../watermark.js'
|
||||
|
||||
// Mock browser service
|
||||
vi.mock('../browser.js', () => ({
|
||||
acquirePage: vi.fn(),
|
||||
releasePage: vi.fn()
|
||||
}))
|
||||
|
||||
const { acquirePage, releasePage } = await import('../browser.js')
|
||||
const mockAcquirePage = vi.mocked(acquirePage)
|
||||
const mockReleasePage = vi.mocked(releasePage)
|
||||
|
||||
function createMockPage() {
|
||||
return {
|
||||
setViewport: vi.fn(),
|
||||
setContent: vi.fn(),
|
||||
screenshot: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createMockInstance() {
|
||||
return { id: 'test-instance' }
|
||||
}
|
||||
|
||||
describe('Watermark Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('addWatermark', () => {
|
||||
it('should add watermark to image buffer', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('input-image-data')
|
||||
const outputBuffer = Buffer.from('watermarked-image-data')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(outputBuffer as any)
|
||||
|
||||
const result = await addWatermark(inputBuffer, 1280, 800)
|
||||
|
||||
expect(mockAcquirePage).toHaveBeenCalledOnce()
|
||||
expect(mockPage.setViewport).toHaveBeenCalledWith({ width: 1280, height: 800 })
|
||||
expect(mockPage.setContent).toHaveBeenCalledWith(
|
||||
expect.stringContaining('data:image/png;base64,'),
|
||||
{ waitUntil: "load" }
|
||||
)
|
||||
expect(mockPage.screenshot).toHaveBeenCalledWith({
|
||||
type: "png",
|
||||
encoding: "binary"
|
||||
})
|
||||
expect(mockReleasePage).toHaveBeenCalledWith(mockPage, mockInstance)
|
||||
expect(result).toBeInstanceOf(Buffer)
|
||||
expect(result).toEqual(outputBuffer)
|
||||
})
|
||||
|
||||
it('should set viewport to specified dimensions', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test-image')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
await addWatermark(inputBuffer, 1920, 1080)
|
||||
|
||||
expect(mockPage.setViewport).toHaveBeenCalledWith({ width: 1920, height: 1080 })
|
||||
})
|
||||
|
||||
it('should include base64 encoded image in HTML content', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test-image-data')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
await addWatermark(inputBuffer, 800, 600)
|
||||
|
||||
const expectedBase64 = inputBuffer.toString('base64')
|
||||
const setContentCall = mockPage.setContent.mock.calls[0][0]
|
||||
|
||||
expect(setContentCall).toContain(`data:image/png;base64,${expectedBase64}`)
|
||||
})
|
||||
|
||||
it('should include watermark text in HTML content', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
await addWatermark(inputBuffer, 1000, 700)
|
||||
|
||||
const setContentCall = mockPage.setContent.mock.calls[0][0]
|
||||
|
||||
expect(setContentCall).toContain('snapapi.eu — upgrade for clean screenshots')
|
||||
})
|
||||
|
||||
it('should scale font size based on image width', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
await addWatermark(inputBuffer, 2000, 1000)
|
||||
|
||||
const setContentCall = mockPage.setContent.mock.calls[0][0]
|
||||
const expectedFontSize = Math.max(2000 / 20, 24) // 100px for 2000px width
|
||||
|
||||
expect(setContentCall).toContain(`font-size: ${expectedFontSize}px`)
|
||||
})
|
||||
|
||||
it('should enforce minimum font size', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
// Small width that would result in font size < 24px
|
||||
await addWatermark(inputBuffer, 400, 300)
|
||||
|
||||
const setContentCall = mockPage.setContent.mock.calls[0][0]
|
||||
|
||||
// Should use minimum font size of 24px
|
||||
expect(setContentCall).toContain('font-size: 24px')
|
||||
})
|
||||
|
||||
it('should include CSS styling for watermark', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
await addWatermark(inputBuffer, 1200, 800)
|
||||
|
||||
const setContentCall = mockPage.setContent.mock.calls[0][0]
|
||||
|
||||
// Check for key CSS properties
|
||||
expect(setContentCall).toContain('transform: rotate(-30deg)')
|
||||
expect(setContentCall).toContain('color: rgba(255, 255, 255, 0.35)')
|
||||
expect(setContentCall).toContain('text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.5)')
|
||||
expect(setContentCall).toContain('font-weight: 900')
|
||||
expect(setContentCall).toContain('pointer-events: none')
|
||||
})
|
||||
|
||||
it('should wait for page load before taking screenshot', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
await addWatermark(inputBuffer, 1000, 600)
|
||||
|
||||
expect(mockPage.setContent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ waitUntil: "load" }
|
||||
)
|
||||
})
|
||||
|
||||
it('should release page even if screenshot fails', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockRejectedValueOnce(new Error('Screenshot failed'))
|
||||
|
||||
await expect(addWatermark(inputBuffer, 800, 600)).rejects.toThrow('Screenshot failed')
|
||||
|
||||
expect(mockReleasePage).toHaveBeenCalledWith(mockPage, mockInstance)
|
||||
})
|
||||
|
||||
it('should release page even if setContent fails', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.setContent.mockRejectedValueOnce(new Error('SetContent failed'))
|
||||
|
||||
await expect(addWatermark(inputBuffer, 800, 600)).rejects.toThrow('SetContent failed')
|
||||
|
||||
expect(mockReleasePage).toHaveBeenCalledWith(mockPage, mockInstance)
|
||||
})
|
||||
|
||||
it('should handle page acquisition failure', async () => {
|
||||
mockAcquirePage.mockRejectedValueOnce(new Error('No pages available'))
|
||||
|
||||
await expect(addWatermark(Buffer.from('test'), 800, 600))
|
||||
.rejects.toThrow('No pages available')
|
||||
|
||||
expect(mockReleasePage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should generate valid HTML structure', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
await addWatermark(inputBuffer, 1000, 700)
|
||||
|
||||
const html = mockPage.setContent.mock.calls[0][0]
|
||||
|
||||
// Check HTML structure
|
||||
expect(html).toContain('<!DOCTYPE html>')
|
||||
expect(html).toContain('<html>')
|
||||
expect(html).toContain('<head>')
|
||||
expect(html).toContain('<style>')
|
||||
expect(html).toContain('<body>')
|
||||
expect(html).toContain('<img')
|
||||
expect(html).toContain('<div class="watermark">')
|
||||
expect(html).toContain('</html>')
|
||||
})
|
||||
|
||||
it('should set correct body dimensions', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const inputBuffer = Buffer.from('test')
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
await addWatermark(inputBuffer, 1500, 900)
|
||||
|
||||
const html = mockPage.setContent.mock.calls[0][0]
|
||||
|
||||
expect(html).toContain('width: 1500px; height: 900px')
|
||||
})
|
||||
|
||||
it('should handle various image buffer sizes', async () => {
|
||||
const mockPage = createMockPage()
|
||||
const mockInstance = createMockInstance()
|
||||
const largeBuffer = Buffer.alloc(1024 * 1024, 'test') // 1MB buffer
|
||||
|
||||
mockAcquirePage.mockResolvedValueOnce({ page: mockPage, instance: mockInstance })
|
||||
mockPage.screenshot.mockResolvedValueOnce(Buffer.from('result') as any)
|
||||
|
||||
const result = await addWatermark(largeBuffer, 2000, 1200)
|
||||
|
||||
expect(mockPage.setContent).toHaveBeenCalled()
|
||||
expect(result).toBeInstanceOf(Buffer)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -33,7 +33,12 @@ export function getPoolStats() {
|
|||
|
||||
async function recyclePage(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.goto("about:blank", { timeout: 5000 }).catch(() => {});
|
||||
// Fast reset: evaluate clears DOM without a full navigation round-trip
|
||||
await page.evaluate(() => {
|
||||
document.open();
|
||||
document.write("<html><head></head><body></body></html>");
|
||||
document.close();
|
||||
}).catch(() => page.goto("about:blank", { timeout: 3000 }).catch(() => {}));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
@ -94,9 +99,21 @@ export function releasePage(page: Page, inst: BrowserInstance): void {
|
|||
async function scheduleRestart(inst: BrowserInstance): Promise<void> {
|
||||
if (inst.restarting) return;
|
||||
inst.restarting = true;
|
||||
logger.info(`Scheduling browser ${inst.id} restart`);
|
||||
logger.info(`Scheduling browser ${inst.id} restart (hot-swap)`);
|
||||
|
||||
// Wait for pages to drain (max 30s)
|
||||
// Launch new browser FIRST so we never have zero capacity
|
||||
const newBrowser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
|
||||
"--disable-background-networking", "--disable-default-apps", "--disable-extensions",
|
||||
"--disable-sync", "--disable-translate", "--metrics-recording-only",
|
||||
"--no-first-run", "--safebrowsing-disable-auto-update"],
|
||||
});
|
||||
const newPages = await createPages(newBrowser, PAGES_PER_BROWSER);
|
||||
|
||||
// Wait for in-flight pages to drain (max 30s)
|
||||
const oldBrowser = inst.browser;
|
||||
await Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
const check = () => {
|
||||
|
|
@ -108,20 +125,24 @@ async function scheduleRestart(inst: BrowserInstance): Promise<void> {
|
|||
new Promise<void>(r => setTimeout(r, 30000)),
|
||||
]);
|
||||
|
||||
for (const page of inst.availablePages) await page.close().catch(() => {});
|
||||
inst.availablePages.length = 0;
|
||||
try { await inst.browser.close(); } catch {}
|
||||
|
||||
inst.browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
});
|
||||
inst.availablePages.push(...await createPages(inst.browser, PAGES_PER_BROWSER));
|
||||
// Swap: install new browser and pages, then clean up old
|
||||
const oldPages = inst.availablePages.splice(0);
|
||||
inst.browser = newBrowser;
|
||||
inst.availablePages.push(...newPages);
|
||||
inst.jobCount = 0;
|
||||
inst.lastRestartTime = Date.now();
|
||||
inst.restarting = false;
|
||||
logger.info(`Browser ${inst.id} restarted`);
|
||||
logger.info(`Browser ${inst.id} restarted (hot-swap complete)`);
|
||||
|
||||
// Clean up old browser in background
|
||||
for (const page of oldPages) await page.close().catch(() => {});
|
||||
try { await oldBrowser.close(); } catch {}
|
||||
|
||||
// Drain any waiters now that pages are available
|
||||
while (waitingQueue.length > 0 && inst.availablePages.length > 0) {
|
||||
const waiter = waitingQueue.shift()!;
|
||||
waiter.resolve({ page: inst.availablePages.pop()!, instance: inst });
|
||||
}
|
||||
}
|
||||
|
||||
export async function initBrowser(): Promise<void> {
|
||||
|
|
@ -129,7 +150,10 @@ export async function initBrowser(): Promise<void> {
|
|||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
|
||||
"--disable-background-networking", "--disable-default-apps", "--disable-extensions",
|
||||
"--disable-sync", "--disable-translate", "--metrics-recording-only",
|
||||
"--no-first-run", "--safebrowsing-disable-auto-update"],
|
||||
});
|
||||
const pages = await createPages(browser, PAGES_PER_BROWSER);
|
||||
const staggerMs = i * (RESTART_AFTER_MS / BROWSER_COUNT);
|
||||
|
|
|
|||
195
src/services/cache.ts
Normal file
195
src/services/cache.ts
Normal 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();
|
||||
|
|
@ -4,7 +4,7 @@ import { queryWithRetry } from "./db.js";
|
|||
|
||||
export interface ApiKey {
|
||||
key: string;
|
||||
tier: "free" | "starter" | "pro" | "business";
|
||||
tier: "free" | "cancelled" | "starter" | "pro" | "business";
|
||||
email: string;
|
||||
createdAt: string;
|
||||
stripeCustomerId?: string;
|
||||
|
|
@ -130,6 +130,7 @@ export function getAllKeys(): ApiKey[] {
|
|||
export function getTierLimit(tier: string): number {
|
||||
switch (tier) {
|
||||
case "free": return 100;
|
||||
case "cancelled": return 0;
|
||||
case "starter": return 1000;
|
||||
case "pro": return 5000;
|
||||
case "business": return 25000;
|
||||
|
|
@ -184,16 +185,16 @@ export async function createPaidKey(email: string, tier: "starter" | "pro" | "bu
|
|||
|
||||
export async function downgradeByCustomer(customerId: string): Promise<void> {
|
||||
await queryWithRetry(
|
||||
"UPDATE api_keys SET tier = 'free', stripe_customer_id = NULL WHERE stripe_customer_id = $1",
|
||||
"UPDATE api_keys SET tier = 'cancelled', stripe_customer_id = NULL WHERE stripe_customer_id = $1",
|
||||
[customerId]
|
||||
);
|
||||
for (const k of keysCache) {
|
||||
if (k.stripeCustomerId === customerId) {
|
||||
k.tier = "free";
|
||||
k.tier = "cancelled";
|
||||
k.stripeCustomerId = undefined;
|
||||
}
|
||||
}
|
||||
logger.info({ customerId }, "Downgraded customer to free");
|
||||
logger.info({ customerId }, "Downgraded customer to cancelled");
|
||||
}
|
||||
|
||||
export async function updateEmailByCustomer(customerId: string, newEmail: string): Promise<void> {
|
||||
|
|
@ -205,3 +206,39 @@ export async function updateEmailByCustomer(customerId: string, newEmail: string
|
|||
if (k.stripeCustomerId === customerId) k.email = newEmail;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getKeyByEmail(email: string): Promise<ApiKey | undefined> {
|
||||
try {
|
||||
const result = await queryWithRetry(
|
||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1",
|
||||
[email]
|
||||
);
|
||||
if (result.rows.length === 0) return undefined;
|
||||
|
||||
const r = result.rows[0];
|
||||
return {
|
||||
key: r.key,
|
||||
tier: r.tier,
|
||||
email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to get key by email");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomerIdByEmail(email: string): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await queryWithRetry(
|
||||
"SELECT stripe_customer_id FROM api_keys WHERE email = $1 AND stripe_customer_id IS NOT NULL",
|
||||
[email]
|
||||
);
|
||||
if (result.rows.length === 0) return undefined;
|
||||
return result.rows[0].stripe_customer_id;
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to get customer ID by email");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
src/services/retry.ts
Normal file
17
src/services/retry.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const RETRYABLE_PATTERNS = [
|
||||
'TimeoutError',
|
||||
'Protocol error',
|
||||
'Target closed',
|
||||
'Session closed',
|
||||
'Navigation failed',
|
||||
'net::ERR_',
|
||||
];
|
||||
|
||||
export function isRetryableError(error: Error): boolean {
|
||||
// Check error name
|
||||
if (error.name === 'TimeoutError') return true;
|
||||
|
||||
// Check message patterns
|
||||
const msg = error.message || '';
|
||||
return RETRYABLE_PATTERNS.some(pattern => msg.includes(pattern));
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { Page } from "puppeteer";
|
||||
import { acquirePage, releasePage } from "./browser.js";
|
||||
import { validateUrl } from "./ssrf.js";
|
||||
import { isRetryableError } from "./retry.js";
|
||||
import logger from "./logger.js";
|
||||
|
||||
export interface ScreenshotOptions {
|
||||
url: string;
|
||||
format?: "png" | "jpeg" | "webp";
|
||||
format?: "png" | "jpeg" | "webp" | "pdf";
|
||||
width?: number;
|
||||
height?: number;
|
||||
fullPage?: boolean;
|
||||
|
|
@ -13,36 +14,160 @@ export interface ScreenshotOptions {
|
|||
waitForSelector?: string;
|
||||
deviceScale?: number;
|
||||
delay?: number;
|
||||
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
|
||||
darkMode?: boolean;
|
||||
hideSelectors?: string[];
|
||||
css?: string;
|
||||
js?: string;
|
||||
selector?: string;
|
||||
userAgent?: string;
|
||||
clip?: { x: number; y: number; width: number; height: number };
|
||||
pdfFormat?: string;
|
||||
pdfLandscape?: boolean;
|
||||
pdfPrintBackground?: boolean;
|
||||
pdfScale?: number;
|
||||
pdfMargin?: { top?: string; right?: string; bottom?: string; left?: string };
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
const MAX_WIDTH = 3840;
|
||||
const MAX_HEIGHT = 2160;
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
// CSS injection prevention
|
||||
function validateHideSelectors(selectors: string[]): void {
|
||||
const dangerousChars = /[{}<>;]/;
|
||||
for (const selector of selectors) {
|
||||
if (dangerousChars.test(selector)) {
|
||||
throw new Error("hideSelector contains dangerous characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateWaitForSelector(selector: string): void {
|
||||
if (selector.length > 200) {
|
||||
throw new Error("waitForSelector is too long");
|
||||
}
|
||||
if (selector.includes("javascript:") || selector.includes("<script")) {
|
||||
throw new Error("waitForSelector contains dangerous content");
|
||||
}
|
||||
}
|
||||
|
||||
function validateCSS(css: string): void {
|
||||
// Check for @import
|
||||
if (/@import\s+/i.test(css)) {
|
||||
throw new Error("CSS contains dangerous directives");
|
||||
}
|
||||
|
||||
// Check for url() with non-data: schemes
|
||||
const urlPattern = /url\s*\(\s*(?!data:)[^)]*\)/gi;
|
||||
if (urlPattern.test(css)) {
|
||||
throw new Error("CSS contains dangerous directives");
|
||||
}
|
||||
}
|
||||
|
||||
function validateSelector(selector: string): void {
|
||||
if (selector.length > 200) {
|
||||
throw new Error("selector is too long");
|
||||
}
|
||||
if (selector.includes("javascript:") || selector.includes("<script")) {
|
||||
throw new Error("selector contains dangerous content");
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const BACKOFF_MS = [500, 1000];
|
||||
|
||||
export async function takeScreenshot(opts: ScreenshotOptions): Promise<ScreenshotResult> {
|
||||
// Validate URL for SSRF
|
||||
// Validate URL for SSRF — not retried
|
||||
await validateUrl(opts.url);
|
||||
|
||||
// Validate CSS injection prevention — not retried
|
||||
if (opts.hideSelectors && opts.hideSelectors.length > 0) {
|
||||
validateHideSelectors(opts.hideSelectors);
|
||||
}
|
||||
|
||||
if (opts.waitForSelector) {
|
||||
validateWaitForSelector(opts.waitForSelector);
|
||||
}
|
||||
|
||||
if (opts.css && opts.css.trim()) {
|
||||
validateCSS(opts.css);
|
||||
}
|
||||
|
||||
if (opts.selector) {
|
||||
validateSelector(opts.selector);
|
||||
}
|
||||
|
||||
// Check PDF mutual exclusivity with selector and clip
|
||||
if (opts.format === "pdf" && (opts.selector || opts.clip)) {
|
||||
throw new Error('format "pdf" is mutually exclusive with selector and clip');
|
||||
}
|
||||
|
||||
// Check mutual exclusivity of selector and fullPage
|
||||
if (opts.selector && opts.fullPage) {
|
||||
throw new Error("selector and fullPage are mutually exclusive");
|
||||
}
|
||||
|
||||
// Check mutual exclusivity of clip with fullPage and selector
|
||||
if (opts.clip && (opts.fullPage || opts.selector)) {
|
||||
throw new Error("clip is mutually exclusive with fullPage and selector");
|
||||
}
|
||||
|
||||
let lastError: any;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
const delay = BACKOFF_MS[attempt - 1];
|
||||
logger.warn({ attempt, delay, url: opts.url, error: lastError?.message }, "Retrying screenshot");
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeBrowserScreenshot(opts);
|
||||
return { ...result, retryCount: attempt };
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
if (!isRetryableError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted
|
||||
lastError.retryCount = MAX_RETRIES;
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function executeBrowserScreenshot(opts: ScreenshotOptions): Promise<Omit<ScreenshotResult, 'retryCount'>> {
|
||||
const format = opts.format || "png";
|
||||
const width = Math.min(opts.width || 1280, MAX_WIDTH);
|
||||
const height = Math.min(opts.height || 800, MAX_HEIGHT);
|
||||
const fullPage = opts.fullPage ?? false;
|
||||
const quality = format === "png" ? undefined : Math.min(Math.max(opts.quality || 80, 1), 100);
|
||||
const deviceScale = Math.min(opts.deviceScale || 1, 3);
|
||||
const waitUntil = opts.waitUntil || "domcontentloaded";
|
||||
|
||||
const { page, instance } = await acquirePage();
|
||||
|
||||
try {
|
||||
await page.setViewport({ width, height, deviceScaleFactor: deviceScale });
|
||||
|
||||
if (opts.userAgent) {
|
||||
await page.setUserAgent(opts.userAgent);
|
||||
}
|
||||
|
||||
if (opts.darkMode) {
|
||||
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(opts.url, { waitUntil: "networkidle2", timeout: 20_000 });
|
||||
await page.goto(opts.url, { waitUntil, timeout: 20_000 });
|
||||
|
||||
if (opts.waitForSelector) {
|
||||
await page.waitForSelector(opts.waitForSelector, { timeout: 10_000 });
|
||||
|
|
@ -51,20 +176,66 @@ export async function takeScreenshot(opts: ScreenshotOptions): Promise<Screensho
|
|||
if (opts.delay && opts.delay > 0) {
|
||||
await new Promise(r => setTimeout(r, Math.min(opts.delay!, 5000)));
|
||||
}
|
||||
|
||||
if (opts.js) {
|
||||
try {
|
||||
await Promise.race([
|
||||
page.evaluate(opts.js),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("JS_TIMEOUT")), 5000))
|
||||
]);
|
||||
} catch (err: any) {
|
||||
throw new Error("JS_EXECUTION_ERROR: " + (err.message || "Script failed"));
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
});
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("SCREENSHOT_TIMEOUT")), TIMEOUT_MS)),
|
||||
]);
|
||||
|
||||
// PDF output branch
|
||||
if (format === "pdf") {
|
||||
const pdfResult = await page.pdf({
|
||||
format: (opts.pdfFormat || 'a4') as any,
|
||||
landscape: opts.pdfLandscape ?? false,
|
||||
printBackground: opts.pdfPrintBackground ?? true,
|
||||
scale: opts.pdfScale ?? 1.0,
|
||||
margin: opts.pdfMargin || { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
|
||||
});
|
||||
const buffer = Buffer.from(pdfResult as unknown as ArrayBuffer);
|
||||
return { buffer, contentType: 'application/pdf' };
|
||||
}
|
||||
|
||||
const screenshotOpts: any = {
|
||||
type: format === "webp" ? "webp" : format,
|
||||
fullPage,
|
||||
encoding: "binary",
|
||||
};
|
||||
if (!opts.selector) {
|
||||
screenshotOpts.fullPage = fullPage;
|
||||
}
|
||||
if (quality !== undefined) screenshotOpts.quality = quality;
|
||||
if (opts.clip) screenshotOpts.clip = opts.clip;
|
||||
|
||||
let result: any;
|
||||
if ((opts as any).selector) {
|
||||
const element = await page.$((opts as any).selector);
|
||||
if (!element) {
|
||||
throw new Error("SELECTOR_NOT_FOUND");
|
||||
}
|
||||
result = await element.screenshot(screenshotOpts);
|
||||
} else {
|
||||
result = await page.screenshot(screenshotOpts);
|
||||
}
|
||||
|
||||
const result = await page.screenshot(screenshotOpts);
|
||||
const buffer = Buffer.from(result as unknown as ArrayBuffer);
|
||||
|
||||
const contentType = format === "png" ? "image/png" : format === "jpeg" ? "image/jpeg" : "image/webp";
|
||||
|
||||
return { buffer, contentType };
|
||||
|
|
|
|||
|
|
@ -13,6 +13,22 @@ const BLOCKED_RANGES = [
|
|||
/^fe80:/i,
|
||||
/^fc00:/i,
|
||||
/^fd00:/i,
|
||||
// IPv6 unspecified
|
||||
/^::$/,
|
||||
// IPv4-mapped IPv6 addresses - block dangerous IPv4 ranges mapped to IPv6
|
||||
/^::ffff:127\./i, // loopback
|
||||
/^::ffff:10\./i, // private 10.x.x.x
|
||||
/^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i, // private 172.16-31.x.x
|
||||
/^::ffff:192\.168\./i, // private 192.168.x.x
|
||||
/^::ffff:169\.254\./i, // link-local/metadata
|
||||
/^::ffff:0\./i, // zero network
|
||||
// Alternative IPv4-mapped notation
|
||||
/^::ffff:0:127\./i, // ::ffff:0:127.x.x.x
|
||||
/^::ffff:0:10\./i, // ::ffff:0:10.x.x.x
|
||||
/^::ffff:0:172\.(1[6-9]|2[0-9]|3[01])\./i,
|
||||
/^::ffff:0:192\.168\./i,
|
||||
/^::ffff:0:169\.254\./i,
|
||||
/^::ffff:0:0\./i,
|
||||
];
|
||||
|
||||
const BLOCKED_HOSTS = [
|
||||
|
|
@ -24,7 +40,13 @@ const BLOCKED_HOSTS = [
|
|||
/^kubernetes/,
|
||||
];
|
||||
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
|
||||
export async function validateUrl(urlStr: string): Promise<{ hostname: string; resolvedIp: string }> {
|
||||
if (!urlStr || urlStr.length > MAX_URL_LENGTH) {
|
||||
throw new Error(`Invalid URL: must be between 1 and ${MAX_URL_LENGTH} characters`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(urlStr);
|
||||
|
|
@ -33,7 +55,7 @@ export async function validateUrl(urlStr: string): Promise<{ hostname: string; r
|
|||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
throw new Error("Only HTTP and HTTPS URLs are allowed");
|
||||
throw new Error("URL protocol not allowed: only HTTP and HTTPS are supported");
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
|
|
|||
|
|
@ -13,5 +13,5 @@
|
|||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/__tests__/**", "src/**/*.test.*"]
|
||||
}
|
||||
|
|
|
|||
21
vitest.config.ts
Normal file
21
vitest.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
]
|
||||
},
|
||||
testTimeout: 10000,
|
||||
hookTimeout: 10000
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue