How I Screenshot 500 Documentation Pages Before a Redesign

2026-04-27 | Tags: [documentation, screenshot-api, automation, python, audit, ux, story]

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:

  1. 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.

  2. 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:

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:

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.