You're about to relaunch a site and someone asks for a screenshot of every page as it stands today. Or a client wants to sign off 140 pages of copy, or legal wants a dated record of what the pricing page said. Doing it by hand means opening each URL, waiting for the lazy images, closing the cookie banner and saving the file under a name you'll still understand next month. Two minutes a page adds up to most of a working day for a 200-page site.

So I wrote one Node.js script that reads the sitemap, captures every page at desktop and mobile widths in real Chrome, and leaves a folder of named PNGs, a CSV manifest and a contact sheet. Last run: 38 full-page captures in 35 seconds, no failures.

Disclosure: I run HTML to Image, and the script uses its Screenshot API for the captures. This is a cross-post of the full guide on the HTML to Image blog, which is the canonical version and goes further into webhooks, costs and compliance archives.

Prerequisites

  • Node.js 18 or later, for the built-in fetch.
  • An HTML to Image API key. The free credits are enough to try this on a handful of pages.
  • A site with a sitemap, or a text file of URLs from a crawl.
npm install @html2img/client export HTML2IMG_API_KEY=your_api_key 

Step 1: Find the sitemap

Most sites declare their sitemap in robots.txt, so check there first:

curl -s https://example.com/robots.txt | grep -i '^sitemap:' 

If nothing comes back, try /sitemap.xml, then /sitemap_index.xml (Yoast and Rank Math on WordPress), then /wp-sitemap.xml (WordPress core since 5.5). Big sites use a sitemap index that points at child sitemaps, some of them gzipped. The script follows all of that, so you only ever pass the top-level URL.

One caveat: a sitemap is the list the CMS chooses to publish. Orphaned landing pages and anything marked noindex won't be in it. For a migration archive, cross-check against a crawl and feed the script a text file instead.

Step 2: The script

Save this as capture-site.mjs. It's long-ish because it handles the boring parts properly: sitemap indexes, .gz files, duplicates, a credit check before spending anything, four captures at a time, retries for errors that can recover, and the reports at the end.

// capture-site.mjs // node capture-site.mjs https://example.com/sitemap.xml [--dry-run] // node capture-site.mjs urls.txt import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { gunzipSync } from 'node:zlib'; import { Html2img, InsufficientCreditsError, TimeoutError, ServerError, ConnectionError, } from '@html2img/client'; const SOURCE = process.argv[2]; const DRY_RUN = process.argv.includes('--dry-run'); const OUT = 'shots'; const CONCURRENCY = 4; const MAX_ATTEMPTS = 3; // One credit per page per variant. const VARIANTS = [ { name: 'desktop', width: 1440 }, { name: 'mobile', width: 390 }, // { name: 'pdf', format: 'pdf' }, // A4 document with a real text layer ]; // Sent with every capture. const CAPTURE = { fullpage: true, css: `#onetrust-banner-sdk, .onetrust-pc-dark-filter, #CybotCookiebotDialog, .qc-cmp2-container, .osano-cm-window, #cookie-law-info-bar, .intercom-frame, #crisp-chatbox { display: none !important; }`, }; const SKIP = /\.(pdf|jpe?g|png|gif|webp|svg|zip|mp4|xml)$/i; const apiKey = process.env.HTML2IMG_API_KEY; const client = DRY_RUN ? null : new Html2img(apiKey); // 1. Every <loc> in a sitemap, following sitemap indexes and .gz files. async function readSitemap(url, seen = new Set()) { if (seen.has(url)) return []; seen.add(url); const res = await fetch(url); if (!res.ok) throw new Error(`${url} returned ${res.status}`); let body = Buffer.from(await res.arrayBuffer()); if (body[0] === 0x1f && body[1] === 0x8b) body = gunzipSync(body); const xml = body.toString('utf8'); const locs = [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?\s*<\/loc>/gs)] .map((m) => m[1].replace(/&(amp|apos|quot|lt|gt);/g, (_, e) => ({ amp: '&', apos: "'", quot: '"', lt: '<', gt: '>' })[e])); if (!/<sitemapindex[\s>]/.test(xml)) return locs; const children = await Promise.all(locs.map((loc) => readSitemap(loc, seen))); return children.flat(); } // 2. Same site only, no files, no duplicates. const bare = (host) => host.replace(/^www\./, ''); function toPages(urls, onlyHost) { const pages = new Set(); for (const raw of urls) { let u; try { u = new URL(raw.trim()); } catch { continue; } // header rows, blanks if (onlyHost && bare(u.hostname) !== bare(onlyHost)) continue; if (SKIP.test(u.pathname)) continue; u.hash = ''; pages.add(u.href); } return [...pages]; } // "/blog/launch-notes/" -> "blog__launch-notes" let multiSite = false; function fileName(page) { const { hostname, pathname, search } = new URL(page); const slug = (pathname + search) .replace(/^\/+|\/+$/g, '') .split('/') .map((part) => part.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '')) .join('__') .toLowerCase() .slice(0, 150) || 'home'; return multiSite ? `${bare(hostname)}__${slug}` : slug; } async function creditsLeft() { const res = await fetch('https://app.html2img.com/api/me', { headers: { 'X-API-Key': apiKey }, }); if (!res.ok) throw new Error(`Key check failed with ${res.status}`); return (await res.json()).credits_remaining; } const retryable = (err) => err instanceof TimeoutError || err instanceof ServerError || err instanceof ConnectionError || err.errorCode === 'page_load_timeout'; // 3. One capture, retried on errors that are worth a second attempt. async function capture({ page, variant }) { const { name, ...options } = variant; const ext = options.format === 'pdf' ? 'pdf' : 'png'; const row = { page, variant: name, status: 'failed', file: '', image_url: '', error: '' }; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { const shot = await client.screenshot({ url: page, ...CAPTURE, ...options }); row.image_url = shot.url; const img = await fetch(shot.url); if (!img.ok) throw new Error(`download returned ${img.status}`); row.file = `${name}/${fileName(page)}.${ext}`; await writeFile(`${OUT}/${row.file}`, Buffer.from(await img.arrayBuffer())); return { ...row, status: 'ok', error: '' }; } catch (err) { if (err instanceof InsufficientCreditsError) throw err; row.error = err.errorCode ?? err.message; if (!retryable(err) || attempt === MAX_ATTEMPTS) return row; await new Promise((resolve) => setTimeout(resolve, 3000 * attempt)); } } } // 4. A fixed number of captures in flight at once. async function runAll(jobs) { const rows = []; let next = 0; let stopped = false; async function lane() { while (!stopped && next < jobs.length) { const job = jobs[next++]; try { const row = await capture(job); rows.push(row); console.log(`${rows.length}/${jobs.length} ${row.status} ${row.variant} ${job.page}`); } catch (err) { if (!stopped) console.error(`Stopping early: ${err.message}`); stopped = true; } } } await Promise.all(Array.from({ length: CONCURRENCY }, lane)); return rows; } // 5. A manifest for spreadsheets and an index.html for people. async function writeReports(rows) { const cols = ['page', 'variant', 'status', 'file', 'image_url', 'error']; const cell = (v) => `"${String(v).replaceAll('"', '""')}"`; const csv = [cols, ...rows.map((r) => cols.map((c) => r[c]))] .map((line) => line.map(cell).join(',')) .join('\n'); await writeFile(`${OUT}/manifest.csv`, csv); const esc = (s) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`); const cards = rows .filter((r) => r.status === 'ok' && r.file.endsWith('.png')) .map((r) => `<a href="${esc(r.file)}"><img src="${esc(r.file)}" loading="lazy"><span>${r.variant} · ${esc(r.page)}</span></a>`) .join('\n'); await writeFile(`${OUT}/index.html`, `<!doctype html><meta charset="utf-8"><title>Site capture</title> <style>body{font:14px system-ui;margin:24px;display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:20px} a{color:#334155;text-decoration:none}img{width:100%;height:320px;object-fit:cover;object-position:top;border:1px solid #e2e6ec;border-radius:8px} span{display:block;margin-top:6px;word-break:break-all}</style>\n${cards}`); } const fromSitemap = /^https?:\/\//.test(SOURCE); const urls = fromSitemap ? await readSitemap(SOURCE) : (await readFile(SOURCE, 'utf8')).split(/\r?\n/); const pages = toPages(urls, fromSitemap ? new URL(SOURCE).hostname : null); multiSite = new Set(pages.map((page) => bare(new URL(page).hostname))).size > 1; if (DRY_RUN) { console.log(pages.join('\n')); process.exit(0); } const jobs = pages.flatMap((page) => VARIANTS.map((variant) => ({ page, variant }))); const credits = await creditsLeft(); console.log(`${pages.length} pages x ${VARIANTS.length} variants = ${jobs.length} credits. Balance: ${credits}.`); if (credits < jobs.length) { console.error('Not enough credits for the whole run. Trim the list or top up first.'); process.exit(1); } for (const { name } of VARIANTS) await mkdir(`${OUT}/${name}`, { recursive: true }); const rows = await runAll(jobs); await writeReports(rows); const failed = rows.filter((r) => r.status !== 'ok').length; const skipped = jobs.length - rows.length; console.log(`Saved ${rows.length - failed}, failed ${failed}, skipped ${skipped}. Open ${OUT}/index.html`); 

The css string is doing quiet but important work. The renderer arrives as a first-time visitor with no cookies, so every page shows its consent banner. Injecting display: none !important for the common consent providers and chat widgets takes them out before the shot. If a site uses something else, find the banner's ID in dev tools and add it.

Step 3: Dry run first, then capture

--dry-run prints the URLs the script would capture. It doesn't need a key and spends nothing, so use it to filter out tag archives, paginated listings and anything else you don't need a picture of:

node capture-site.mjs https://html2img.com/sitemap.xml --dry-run | grep '/tools/' > urls.txt node capture-site.mjs urls.txt 

That's the real run I mentioned at the top, against the free tools section of my own site:

19 pages x 2 variants = 38 credits. Balance: 100. 1/38 ok desktop https://html2img.com/docs/mcp/tools/ 2/38 ok mobile https://html2img.com/docs/mcp/tools/ 3/38 ok desktop https://html2img.com/tools/ 4/38 ok mobile https://html2img.com/tools/ ... 38/38 ok desktop https://html2img.com/tools/youtube-thumbnai...
Source:https://dev.to/accreditly/screenshot-every-page-of-a-website-from-its-sitemap-with-one-nodejs-script-51ad