How I Screenshot 500 Documentation Pages Before a Redesign
The redesign request came with a mandate that made me nervous: "Don't break anything."
500 pages. Five years of documentation. No visual inventory. No way to know what "anything" even was.
Before touching a single template, I needed a complete visual record of the existing site — every page, every layout variant, every edge case that someone's workaround had quietly introduced over the years. That's not something you can do manually. I built a pipeline to do it automatically.
Why This Matters
Documentation redesigns fail in two ways:
-
You break something you didn't know existed. Edge cases, custom formatting, deeply linked pages that get a new URL. Without a baseline, you have no way to prove something was fine before your changes.
-
You can't communicate what changed. Stakeholders want to see the before and after. "Trust me, it looks better" isn't a deliverable. Side-by-side screenshots are.
A visual audit solves both. It gives you a baseline to diff against after the redesign, and a complete gallery to share with stakeholders.
The Scale Problem
The documentation site had 500 pages across six sections: getting started, API reference, tutorials, guides, integrations, and a changelog. Each section had slightly different layout behavior. Some pages were three screens tall; others were short reference cards. Some had interactive elements that needed to settle before capture.
My options:
- Manual capture: 500 pages × 30 seconds each = 4+ hours, and you'll miss things
- Browser automation (Playwright/Puppeteer): fine, but I'd spend half a day setting up the CI environment, dealing with font rendering inconsistencies, and babysitting flaky headless Chrome
- Screenshot API: call an endpoint, get an image, write it to disk. No infrastructure. No babysitting.
I picked the API approach.
Phase 1: Full Inventory
First pass: capture every page in the sitemap at full page height.
import requests
import xml.etree.ElementTree as ET
import os
import time
from pathlib import Path
from urllib.parse import urlparse
API_KEY = os.environ['SCREENSHOT_API_KEY']
BASE_URL = 'https://hermesforge.dev/api/screenshot'
OUTPUT_DIR = Path('doc-audit/before')
def get_all_urls(sitemap_url):
"""Handle both sitemap index and regular sitemaps."""
resp = requests.get(sitemap_url, timeout=10)
root = ET.fromstring(resp.text)
ns = {'sm': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
# Check if this is a sitemap index
sitemaps = root.findall('sm:sitemap/sm:loc', ns)
if sitemaps:
urls = []
for sitemap_loc in sitemaps:
urls.extend(get_all_urls(sitemap_loc.text))
return urls
return [loc.text for loc in root.findall('sm:url/sm:loc', ns)]
def url_to_path(url):
"""Convert URL to filesystem path, preserving structure."""
parsed = urlparse(url)
path = parsed.path.strip('/') or 'index'
# Preserve directory structure for organized output
return path
def capture_page(url, full_page=True, delay=800):
resp = requests.get(
BASE_URL,
params={
'url': url,
'width': 1280,
'height': 900,
'format': 'png',
'full_page': str(full_page).lower(),
'delay': delay,
},
headers={'X-API-Key': API_KEY},
timeout=60,
)
resp.raise_for_status()
return resp.content
def run_inventory(sitemap_url, resume=True):
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
urls = get_all_urls(sitemap_url)
print(f"Found {len(urls)} URLs")
stats = {'ok': 0, 'skip': 0, 'fail': 0}
failed = []
for i, url in enumerate(urls, 1):
rel_path = url_to_path(url)
out_path = OUTPUT_DIR / (rel_path.replace('/', '__') + '.png')
out_path.parent.mkdir(parents=True, exist_ok=True)
if resume and out_path.exists():
stats['skip'] += 1
print(f" [{i}/{len(urls)}] SKIP {rel_path}")
continue
try:
image = capture_page(url)
out_path.write_bytes(image)
stats['ok'] += 1
size_kb = len(image) // 1024
print(f" [{i}/{len(urls)}] OK {rel_path} ({size_kb}KB)")
except Exception as e:
stats['fail'] += 1
failed.append({'url': url, 'error': str(e)})
print(f" [{i}/{len(urls)}] FAIL {url}: {e}")
time.sleep(0.4)
print(f"\nInventory complete: {stats['ok']} captured, {stats['skip']} skipped, {stats['fail']} failed")
if failed:
import json
Path('doc-audit/failed.json').write_text(json.dumps(failed, indent=2))
print(f"Failed URLs written to doc-audit/failed.json")
return stats, failed
if __name__ == '__main__':
run_inventory('https://docs.yoursite.com/sitemap.xml')
The resume=True flag is important. A 500-page capture takes about 35 minutes at 0.4s per page. If something interrupts it (network blip, rate limit, sleep), you don't want to start over.
Phase 2: Layout Classification
After capture, I wanted to know which pages used which layout. You can eyeball this, but with 500 screenshots it's faster to classify by image dimensions.
from PIL import Image
from pathlib import Path
import json
from collections import Counter
def classify_layouts(audit_dir):
screenshots = list(Path(audit_dir).glob('*.png'))
layouts = []
for path in screenshots:
try:
img = Image.open(path)
width, height = img.size
aspect = round(height / width, 2)
layout_type = 'short' # < 2x viewport
if height > 1800:
layout_type = 'medium'
if height > 4000:
layout_type = 'long'
if height > 8000:
layout_type = 'very_long'
layouts.append({
'file': path.name,
'width': width,
'height': height,
'aspect': aspect,
'layout_type': layout_type,
})
except Exception as e:
print(f"Error reading {path.name}: {e}")
# Summary
type_counts = Counter(l['layout_type'] for l in layouts)
print("\nLayout distribution:")
for layout_type, count in sorted(type_counts.items()):
print(f" {layout_type}: {count} pages")
# Find outliers (very long pages that might be layout bugs)
outliers = [l for l in layouts if l['height'] > 10000]
if outliers:
print(f"\n⚠ Outliers (height > 10000px): {len(outliers)} pages")
for o in outliers:
print(f" {o['file']} ({o['height']}px)")
Path('doc-audit/layout-report.json').write_text(
json.dumps({'layouts': layouts, 'summary': dict(type_counts)}, indent=2)
)
return layouts
classify_layouts('doc-audit/before')
This turned up eleven pages with heights over 15,000px — not because they had that much content, but because a broken sticky header was expanding the page infinitely. We wouldn't have found those without the inventory.
Phase 3: Mobile Viewport Capture
Full-page desktop screenshots show content, but they don't show responsive breakpoints. I ran a second pass at mobile width:
def run_mobile_inventory(urls_file, output_dir):
"""Capture at mobile viewport to catch responsive issues."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
with open(urls_file) as f:
urls = [line.strip() for line in f if line.strip()]
for i, url in enumerate(urls, 1):
rel_path = url_to_path(url)
out_path = output_dir / (rel_path.replace('/', '__') + '_mobile.png')
if out_path.exists():
continue
try:
# Mobile viewport: 390px wide (iPhone 14 Pro)
resp = requests.get(
BASE_URL,
params={
'url': url,
'width': 390,
'height': 844,
'format': 'png',
'full_page': 'true',
'delay': 800,
'device_scale_factor': 2, # retina
},
headers={'X-API-Key': API_KEY},
timeout=60,
)
resp.raise_for_status()
out_path.write_bytes(resp.content)
print(f" [{i}/{len(urls)}] MOBILE OK {rel_path}")
except Exception as e:
print(f" [{i}/{len(urls)}] MOBILE FAIL {url}: {e}")
time.sleep(0.4)
This pass found 23 pages where the mobile layout was broken — nav overflow, tables wider than the viewport, code blocks without horizontal scroll. None of these were visible on desktop. All were pre-existing bugs that the redesign would need to fix.
Phase 4: After-Redesign Comparison
Once the redesign was deployed to staging, I ran the same inventory against the staging URL:
def run_comparison(sitemap_url, staging_base, prod_base, output_dir):
"""Capture staging versions of all production URLs."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
urls = get_all_urls(sitemap_url)
for i, prod_url in enumerate(urls, 1):
staging_url = prod_url.replace(prod_base, staging_base, 1)
rel_path = url_to_path(prod_url)
out_path = output_dir / (rel_path.replace('/', '__') + '.png')
if out_path.exists():
continue
try:
image = capture_page(staging_url)
out_path.write_bytes(image)
print(f" [{i}/{len(urls)}] OK {rel_path}")
except Exception as e:
print(f" [{i}/{len(urls)}] FAIL {staging_url}: {e}")
time.sleep(0.4)
Then a diff pass to find unexpected regressions:
from PIL import Image, ImageChops
import numpy as np
from pathlib import Path
import json
def compare_directories(before_dir, after_dir, diff_dir, threshold_pct=2.0):
before_dir, after_dir, diff_dir = Path(before_dir), Path(after_dir), Path(diff_dir)
diff_dir.mkdir(parents=True, exist_ok=True)
results = []
for before_file in sorted(before_dir.glob('*.png')):
after_file = after_dir / before_file.name
if not after_file.exists():
results.append({'page': before_file.stem, 'status': 'missing_after'})
continue
before = Image.open(before_file).convert('RGB')
after = Image.open(after_file).convert('RGB')
# Normalize to same height for comparison
min_height = min(before.size[1], after.size[1])
before = before.crop((0, 0, before.size[0], min_height))
after_resized = after.crop((0, 0, before.size[0], min_height))
diff = ImageChops.difference(before, after_resized)
arr = np.array(diff)
changed_pct = np.any(arr > 15, axis=2).sum() / (arr.shape[0] * arr.shape[1]) * 100
status = 'changed' if changed_pct > threshold_pct else 'ok'
results.append({
'page': before_file.stem,
'status': status,
'change_pct': round(changed_pct, 2),
})
if status == 'changed':
# Save diff visualization
diff_enhanced = Image.fromarray(
np.clip(np.array(diff).astype(int) * 8, 0, 255).astype('uint8')
)
diff_enhanced.save(diff_dir / before_file.name)
changed = [r for r in results if r['status'] == 'changed']
missing = [r for r in results if r['status'] == 'missing_after']
print(f"\nComparison: {len(results)} pages")
print(f" Changed (>{threshold_pct}%): {len(changed)}")
print(f" Missing in after: {len(missing)}")
print(f" Unchanged: {len(results) - len(changed) - len(missing)}")
Path('doc-audit/comparison-report.json').write_text(
json.dumps({'results': results, 'changed': len(changed), 'missing': len(missing)}, indent=2)
)
return results
What We Found
The pre-redesign inventory surfaced things that no one on the team knew existed:
- 11 pages with infinite-height layout bugs (broken sticky header)
- 23 pages with mobile layout failures (overflow, unscrollable code blocks)
- 6 pages that 404'd despite being in the sitemap (stale entries from a migration two years ago)
- 3 layout variants that the design team didn't know were in production (a legacy "wide" layout that some pages had opted into manually)
The comparison pass after staging deployment caught 14 pages where the new design had introduced new issues — all caught before going to production.
The deliverable to stakeholders was a simple gallery: 500 "before" screenshots, 500 "after" screenshots, and a diff report showing every page that changed. That's what "don't break anything" looks like as evidence rather than assertion.
The Total Picture
Phase 1: inventory capture ~35 min (500 pages × 4 sec each)
Phase 2: layout classification ~2 min (local image analysis)
Phase 3: mobile capture ~35 min (500 pages × 4 sec each)
Phase 4: staging comparison ~35 min (500 pages × 4 sec each)
Total pipeline time: ~107 min
Manual equivalent: 4-6 hours, with worse coverage and no machine-readable output.
The code is about 200 lines. The API did the heavy lifting — every page rendered exactly as a browser would render it, with JavaScript executed, fonts loaded, dynamic content settled. No headless browser config. No CI pipeline changes. Just HTTP.
Get Your API Key
Free API key at hermesforge.dev/screenshot. 500 pages at full-page height runs in about 35 minutes.