DEV Community

Custodia-Admin
Custodia-Admin

Posted on Originally published at pagebolt.dev

How to screenshot every page in your sitemap automatically

How to Screenshot Every Page in Your Sitemap Automatically

Visual audits, site archives, client deliverables, pre-migration snapshots — all require screenshots of every page on a site. Doing it manually takes hours. Doing it with Puppeteer means managing a browser pool and handling concurrency yourself.

Here's a script that parses your sitemap.xml and captures every URL with controlled concurrency and automatic retry.

Basic sitemap crawler

import fs from "fs/promises";
import path from "path";

const PAGEBOLT_API_KEY = process.env.PAGEBOLT_API_KEY;
const SITEMAP_URL = process.env.SITEMAP_URL || "https://yoursite.com/sitemap.xml";
const OUTPUT_DIR = "screenshots";
const CONCURRENCY = 3; // parallel requests
const DELAY_MS = 500;  // between batches

async function fetchSitemap(url) {
  const res = await fetch(url);
  const xml = await res.text();

  // Extract all <loc> URLs
  const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim());

  // Handle sitemap index (sitemap of sitemaps)
  if (xml.includes("<sitemapindex")) {
    const nested = await Promise.all(urls.map(fetchSitemap));
    return nested.flat();
  }

  return urls;
}

async function screenshot(url) {
  const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
    method: "POST",
    headers: {
      "x-api-key": PAGEBOLT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url,
      fullPage: true,
      blockBanners: true,
      blockAds: true,
      blockTrackers: true,
    }),
  });

  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return Buffer.from(await res.arrayBuffer());
}

function urlToFilename(url) {
  const { pathname } = new URL(url);
  const clean = pathname.replace(/^\/|\/$/g, "").replace(/\//g, "__") || "index";
  return `${clean}.png`;
}

async function processBatch(urls) {
  return Promise.allSettled(
    urls.map(async (url) => {
      const filename = urlToFilename(url);
      const filepath = path.join(OUTPUT_DIR, filename);

      try {
        const image = await screenshot(url);
        await fs.writeFile(filepath, image);
        console.log(`✓ ${url}${filename}`);
        return { url, filename, ok: true };
      } catch (err) {
        console.error(`✗ ${url}: ${err.message}`);
        return { url, filename, ok: false, error: err.message };
      }
    })
  );
}

async function main() {
  await fs.mkdir(OUTPUT_DIR, { recursive: true });

  console.log(`Fetching sitemap: ${SITEMAP_URL}`);
  const urls = await fetchSitemap(SITEMAP_URL);
  console.log(`Found ${urls.length} URLs\n`);

  const results = [];

  // Process in batches to control concurrency
  for (let i = 0; i < urls.length; i += CONCURRENCY) {
    const batch = urls.slice(i, i + CONCURRENCY);
    const batchResults = await processBatch(batch);
    results.push(...batchResults.map((r) => r.value ?? r.reason));

    if (i + CONCURRENCY < urls.length) {
      await new Promise((r) => setTimeout(r, DELAY_MS));
    }
  }

  // Write summary
  const summary = {
    total: results.length,
    succeeded: results.filter((r) => r.ok).length,
    failed: results.filter((r) => !r.ok).length,
    urls: results,
  };

  await fs.writeFile(
    path.join(OUTPUT_DIR, "_results.json"),
    JSON.stringify(summary, null, 2)
  );

  console.log(`\nDone: ${summary.succeeded}/${summary.total} succeeded`);
  if (summary.failed > 0) {
    console.log("Failed URLs:");
    results.filter((r) => !r.ok).forEach((r) => console.log(`  ${r.url}: ${r.error}`));
  }
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode
PAGEBOLT_API_KEY=your_key SITEMAP_URL=https://yoursite.com/sitemap.xml node crawl.js
Enter fullscreen mode Exit fullscreen mode

With retry on failure

async function screenshotWithRetry(url, retries = 2) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await screenshot(url);
    } catch (err) {
      if (attempt === retries) throw err;
      console.log(`  Retry ${attempt + 1}/${retries} for ${url}`);
      await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Mobile + desktop side by side

async function screenshotBoth(url) {
  const [desktop, mobile] = await Promise.all([
    screenshot(url),
    fetch("https://pagebolt.dev/api/v1/screenshot", {
      method: "POST",
      headers: { "x-api-key": PAGEBOLT_API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ url, viewportDevice: "iphone_14_pro", blockBanners: true }),
    }).then((r) => r.arrayBuffer()).then((b) => Buffer.from(b)),
  ]);

  const base = urlToFilename(url).replace(".png", "");
  await fs.writeFile(path.join(OUTPUT_DIR, `${base}-desktop.png`), desktop);
  await fs.writeFile(path.join(OUTPUT_DIR, `${base}-mobile.png`), mobile);
}
Enter fullscreen mode Exit fullscreen mode

GitHub Actions — scheduled weekly archive

name: Weekly site archive

on:
  schedule:
    - cron: "0 3 * * 1"  # Every Monday at 3am
  workflow_dispatch:

jobs:
  screenshot-sitemap:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Screenshot all pages
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
          SITEMAP_URL: https://yoursite.com/sitemap.xml
        run: node scripts/screenshot-sitemap.js

      - name: Upload archive
        uses: actions/upload-artifact@v4
        with:
          name: site-archive-${{ github.run_id }}
          path: screenshots/
          retention-days: 90
Enter fullscreen mode Exit fullscreen mode

Skip already-captured URLs

async function main() {
  const existing = new Set(
    (await fs.readdir(OUTPUT_DIR).catch(() => []))
      .filter((f) => f.endsWith(".png"))
  );

  const urls = await fetchSitemap(SITEMAP_URL);
  const pending = urls.filter((url) => !existing.has(urlToFilename(url)));

  console.log(`${urls.length} total, ${pending.length} to capture (${existing.size} already done)`);
  // process only pending...
}
Enter fullscreen mode Exit fullscreen mode

For a 500-page site at 3 concurrent requests: roughly 3–4 minutes, zero browser setup, no memory management.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)