Automate Documentation Screenshots So They Never Go Stale
If you maintain product documentation, you know the pain: screenshots go stale the moment your UI changes. A button moves, a color shifts, a new feature appears — and suddenly your docs show something that doesn't match reality.
Most teams handle this manually. Someone takes new screenshots, resizes them, uploads them. It's tedious, error-prone, and always deprioritized. Here's how to automate it entirely with a screenshot API.
The Problem with Manual Screenshots
Documentation screenshots break silently. Nobody gets an alert when a UI change makes a docs image outdated. The feedback loop is:
- UI changes in a release
- Docs screenshots become stale
- A user notices (weeks later)
- Someone manually retakes screenshots
- Repeat
The fix is to make screenshot capture part of your build or deploy process.
Capturing Screenshots on Deploy
Here's a Python script that captures screenshots of key pages and saves them to your docs directory:
import requests
import os
from pathlib import Path
API_BASE = "https://hermesforge.dev/api"
DOCS_IMG_DIR = Path("docs/images/screenshots")
DOCS_IMG_DIR.mkdir(parents=True, exist_ok=True)
# Define the pages you need screenshots of
PAGES = {
"dashboard": {
"url": "https://app.example.com/dashboard",
"width": 1280,
"height": 800,
"delay": 2000, # wait for charts to render
},
"settings": {
"url": "https://app.example.com/settings",
"width": 1280,
"height": 900,
},
"login": {
"url": "https://app.example.com/login",
"width": 800,
"height": 600,
},
"mobile-nav": {
"url": "https://app.example.com/dashboard",
"width": 375,
"height": 812,
"delay": 1000,
},
}
def capture_page(name, config):
"""Capture a single page screenshot."""
params = {
"url": config["url"],
"width": config.get("width", 1280),
"height": config.get("height", 800),
"format": "webp",
"block_ads": "true",
}
if "delay" in config:
params["delay"] = config["delay"]
resp = requests.get(f"{API_BASE}/screenshot", params=params, timeout=30)
if resp.status_code == 200:
output = DOCS_IMG_DIR / f"{name}.webp"
output.write_bytes(resp.content)
size_kb = len(resp.content) / 1024
print(f" {name}: {size_kb:.1f}KB -> {output}")
return True
else:
print(f" {name}: FAILED ({resp.status_code})")
return False
def main():
print(f"Capturing {len(PAGES)} documentation screenshots...")
results = {name: capture_page(name, cfg) for name, cfg in PAGES.items()}
succeeded = sum(results.values())
failed = len(results) - succeeded
print(f"\nDone: {succeeded} captured, {failed} failed")
if failed > 0:
exit(1)
if __name__ == "__main__":
main()
Integrating with Your Build System
In a Makefile
docs-screenshots:
python scripts/capture_docs_screenshots.py
docs-build: docs-screenshots
mkdocs build
deploy: docs-build
rsync -az site/ server:/var/www/docs/
In a CI Pipeline (GitHub Actions)
name: Update Docs Screenshots
on:
push:
branches: [main]
paths: ['src/**'] # only when source code changes
jobs:
update-screenshots:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Capture screenshots
run: python scripts/capture_docs_screenshots.py
- name: Check for changes
id: diff
run: |
git diff --quiet docs/images/screenshots/ || echo "changed=true" >> $GITHUB_OUTPUT
- name: Commit updated screenshots
if: steps.diff.outputs.changed == 'true'
run: |
git config user.name "docs-bot"
git config user.email "docs-bot@example.com"
git add docs/images/screenshots/
git commit -m "docs: update screenshots [automated]"
git push
This workflow runs whenever source code changes, captures fresh screenshots, and commits them only if they've actually changed.
Handling Dynamic Content
Real apps have dynamic content — user names, dates, notification counts. You don't want your docs screenshots showing "Welcome, test-user-47" or yesterday's date.
Option 1: Custom JavaScript Injection
Use the js parameter to normalize dynamic content before the screenshot:
params = {
"url": "https://app.example.com/dashboard",
"width": 1280,
"height": 800,
"js": """
// Replace dynamic user name
document.querySelectorAll('.user-name').forEach(el => {
el.textContent = 'Jane Developer';
});
// Normalize dates
document.querySelectorAll('.date-display').forEach(el => {
el.textContent = 'March 15, 2026';
});
// Clear notification badges
document.querySelectorAll('.notification-badge').forEach(el => {
el.remove();
});
""",
"delay": 1000,
"format": "webp",
}
Option 2: Dedicated Docs Environment
Point your screenshot script at a staging environment with seeded data:
PAGES = {
"dashboard": {
"url": os.getenv("DOCS_APP_URL", "https://docs-staging.example.com") + "/dashboard",
# ...
},
}
Comparing Old and New Screenshots
Sometimes you want to know what changed, not just that something changed. Add a comparison step:
import hashlib
def file_hash(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def capture_with_diff_detection(name, config):
output = DOCS_IMG_DIR / f"{name}.webp"
old_hash = file_hash(output) if output.exists() else None
success = capture_page(name, config)
if not success:
return "failed"
new_hash = file_hash(output)
if old_hash is None:
return "new"
elif old_hash != new_hash:
return "changed"
else:
return "unchanged"
Optimizing Image Size
Documentation sites serve a lot of images. WebP format (supported by the screenshot API via format=webp) typically produces files 30-50% smaller than PNG with no visible quality loss.
For a docs site with 20 screenshots: - PNG: ~4MB total - WebP: ~2MB total
That's 2MB less for your readers to download on every page load.
| Format | Typical Size (1280x800) | Browser Support | Best For |
|---|---|---|---|
| PNG | 200-400KB | Universal | Pixel-perfect accuracy |
| WebP | 100-200KB | 97%+ browsers | General documentation |
| JPEG | 80-150KB | Universal | Photos, not UI screenshots |
When This Approach Works Best
This pattern is ideal when:
- Your UI changes frequently — manual screenshots can't keep up
- You have many pages to document — manual capture doesn't scale
- Accuracy matters — stale screenshots confuse users and erode trust
- You use a static site generator — MkDocs, Docusaurus, Jekyll, Hugo all work with this approach
It's less useful when: - Your UI rarely changes - You need annotated screenshots (arrows, callouts) — though you can layer those on with a tool like Pillow - Your app requires authentication that can't be bypassed in a staging environment
Full Script with All Features
Here's the complete version combining everything above:
#!/usr/bin/env python3
"""Capture documentation screenshots automatically."""
import requests
import hashlib
import json
import sys
from pathlib import Path
from datetime import datetime
API_BASE = "https://hermesforge.dev/api"
DOCS_IMG_DIR = Path("docs/images/screenshots")
MANIFEST_FILE = DOCS_IMG_DIR / "manifest.json"
def load_config(config_file="docs-screenshots.json"):
return json.loads(Path(config_file).read_text())
def file_hash(path):
if not path.exists():
return None
return hashlib.sha256(path.read_bytes()).hexdigest()
def capture(name, config):
params = {
"url": config["url"],
"width": config.get("width", 1280),
"height": config.get("height", 800),
"format": config.get("format", "webp"),
"block_ads": "true",
}
for opt in ("delay", "js", "scale"):
if opt in config:
params[opt] = config[opt]
output = DOCS_IMG_DIR / f"{name}.{params['format']}"
old_hash = file_hash(output)
try:
resp = requests.get(f"{API_BASE}/screenshot",
params=params, timeout=30)
if resp.status_code != 200:
return {"status": "failed", "code": resp.status_code}
output.write_bytes(resp.content)
new_hash = file_hash(output)
status = "new" if old_hash is None else (
"changed" if old_hash != new_hash else "unchanged"
)
return {
"status": status,
"size_kb": round(len(resp.content) / 1024, 1),
"path": str(output),
}
except requests.Timeout:
return {"status": "timeout"}
def main():
DOCS_IMG_DIR.mkdir(parents=True, exist_ok=True)
pages = load_config()
print(f"Capturing {len(pages)} screenshots...")
results = {}
for name, config in pages.items():
result = capture(name, config)
results[name] = result
icon = {"new": "+", "changed": "~", "unchanged": "=",
"failed": "!", "timeout": "T"}
print(f" [{icon.get(result['status'], '?')}] {name}: {result['status']}")
# Write manifest for tracking
manifest = {
"captured_at": datetime.utcnow().isoformat() + "Z",
"results": results,
}
MANIFEST_FILE.write_text(json.dumps(manifest, indent=2))
changed = sum(1 for r in results.values() if r["status"] == "changed")
failed = sum(1 for r in results.values()
if r["status"] in ("failed", "timeout"))
print(f"\nSummary: {changed} changed, {failed} failed, "
f"{len(results) - changed - failed} unchanged/new")
sys.exit(1 if failed > 0 else 0)
if __name__ == "__main__":
main()
Save your page definitions in docs-screenshots.json:
{
"dashboard": {
"url": "https://app.example.com/dashboard",
"width": 1280,
"height": 800,
"delay": 2000
},
"settings": {
"url": "https://app.example.com/settings",
"width": 1280,
"height": 900
}
}
Then run python capture_docs_screenshots.py in your CI pipeline and never manually take a docs screenshot again.
Built with the Screenshot API — capture any webpage as an image with a single HTTP call.