ScreenshotNeo

BlogHow-to

How to Schedule Website Screenshots for Recurring Client Reports

Build a repeatable screenshot workflow for client reports with Playwright and GitHub Actions, or use a screenshot API to handle browser capture.

By the ScreenshotNeo team4 October 202610 min read

To schedule website screenshots for recurring client reports, separate the work into two parts: use a browser automation tool such as Playwright to capture each page, and use a scheduler such as GitHub Actions to run the capture script on a recurring schedule. Save each image with the site name and capture date, keep browser settings consistent between runs, and make failures visible so a missing screenshot does not quietly reach a client.

This guide builds that workflow with Node.js, Playwright, and GitHub Actions. It covers viewport, full-page, and element captures, page readiness, output naming, troubleshooting, and an API alternative.

1. Decide what each report needs

Before writing code, make a capture inventory. For each page, record the URL, capture scope, viewport, readiness signal, and whether the page requires authentication. These choices matter because a screenshot taken at a different viewport or before a page has finished rendering may be difficult to compare with last month’s image.

Choice Use it when Trade-off
Viewport screenshot The report needs a consistent visible-window overview. Content below the fold is not included.
Full-page screenshot The report needs the whole scrollable page. Very long pages create tall images and may load additional content as the page scrolls.
Element screenshot The report tracks a particular section or component. The selector must continue to match the intended element.
Buffer capture A later step will upload, transform, or attach the image. The workflow must handle the image bytes instead of a file path.

Playwright supports screenshots saved to a file, full-page screenshots, in-memory buffers, and screenshots of a selected element. A full-page capture represents the whole scrollable page, as if it were a very tall screen. See the Playwright screenshot documentation.

2. Create a Playwright capture script

Use a fixed browser, viewport, timezone, and other settings for every run. This example reads URLs from an environment variable, captures a viewport image by default, and optionally captures the full page or an element. It writes dated files under artifacts/.

npm init -y
npm install playwright
npx playwright install chromium

Create capture.mjs:

import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';

const rawUrls = process.env.SCREENSHOT_URLS ?? 'https://example.com';
const urls = rawUrls.split(',').map((url) => url.trim()).filter(Boolean);
const mode = process.env.CAPTURE_MODE ?? 'viewport';
const selector = process.env.CAPTURE_SELECTOR;
const width = Number(process.env.VIEWPORT_WIDTH ?? 1440);
const height = Number(process.env.VIEWPORT_HEIGHT ?? 1000);
const waitSelector = process.env.WAIT_FOR_SELECTOR;
const outputDir = process.env.OUTPUT_DIR ?? 'artifacts';

if (!['viewport', 'full-page', 'element'].includes(mode)) {
  throw new Error(`CAPTURE_MODE must be viewport, full-page, or element; got: ${mode}`);
}
if (mode === 'element' && !selector) {
  throw new Error('CAPTURE_SELECTOR is required when CAPTURE_MODE=element');
}
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
  throw new Error('Viewport width and height must be positive integers');
}

const date = new Date().toISOString().slice(0, 10);
const safeName = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';

await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
let failed = false;

try {
  for (const target of urls) {
    let page;
    try {
      const parsed = new URL(target);
      if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Only http and https URLs are supported');
      const siteName = safeName(parsed.hostname);
      page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
      page.setDefaultNavigationTimeout(45_000);
      page.setDefaultTimeout(15_000);

      const response = await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 45_000 });
      if (!response) throw new Error('Navigation returned no main document response');
      if (!response.ok()) throw new Error(`Main document returned HTTP ${response.status()}`);
      if (waitSelector) await page.locator(waitSelector).waitFor({ state: 'visible' });

      const file = path.join(outputDir, `${siteName}-${date}.png`);
      if (mode === 'element') {
        await page.locator(selector).screenshot({ path: file, animations: 'disabled' });
      } else {
        await page.screenshot({ path: file, fullPage: mode === 'full-page', animations: 'disabled' });
      }
      console.log(`Saved ${file}`);
    } catch (error) {
      failed = true;
      console.error(`Capture failed for ${target}:`, error);
    } finally {
      await page?.close();
    }
  }
} finally {
  await browser.close();
}

if (failed) process.exitCode = 1;

Try it locally:

SCREENSHOT_URLS='https://example.com,https://www.iana.org/' node capture.mjs
CAPTURE_MODE=full-page SCREENSHOT_URLS='https://example.com' node capture.mjs
CAPTURE_MODE=element CAPTURE_SELECTOR='main' SCREENSHOT_URLS='https://example.com' node capture.mjs

The script waits for the main document’s DOM to be ready, then optionally waits for a selector that is visible. This is a starting point, not a universal definition of readiness. A site may need an application-specific selector or a short delay for a known animation. Avoid waiting for network idle without checking the site: pages with analytics, streaming updates, or persistent connections may never reach that state.

Capture images in memory

When another step will upload the result, use the returned buffer instead of writing a file. For example:

const imageBytes = await page.screenshot({ fullPage: true, type: 'png' });
// Pass imageBytes to your chosen storage or report-delivery step.

Choose and configure storage, retention, client access, and delivery to match your organization’s requirements. The capture and scheduling tools do not decide those policies for you.

3. Run the capture on a schedule with GitHub Actions

Create .github/workflows/client-screenshots.yml. GitHub Actions can run the script on a cron schedule and also supports manual runs. Replace the sample URLs and schedule with the sites and reporting cadence you need.

name: Client website screenshots

on:
  workflow_dispatch:
  schedule:
    # Example: every Monday at 09:00 UTC
    - cron: '0 9 * * 1'

jobs:
  capture:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      SCREENSHOT_URLS: 'https://example.com,https://www.iana.org/'
      CAPTURE_MODE: 'viewport'
      VIEWPORT_WIDTH: '1440'
      VIEWPORT_HEIGHT: '1000'
      OUTPUT_DIR: 'artifacts'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: node capture.mjs
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: website-screenshots-${{ github.run_id }}
          path: artifacts/
          if-no-files-found: warn
          retention-days: 30

The sample keeps artifacts for 30 days; adjust or replace that storage step to meet client access and retention needs. A scheduled workflow is only one way to trigger a capture: use a scheduler already approved for your team if it better fits your reporting and access requirements. The community GitHub screenshot action documents options including cron schedules, URL lists, output paths, retries, timeouts, wait settings, and parallel execution. Its README’s six-hour schedule is an example, not a recommendation for every client.

Schedule and time zone

Check the scheduler’s cron format and time-zone behavior before relying on a run time. GitHub Actions cron schedules are expressed in UTC. If a report is due at a local time that changes with daylight saving time, account for that or choose a cadence where a one-hour shift is acceptable. Run the workflow manually once to confirm the configuration before relying on its scheduled run.

4. Keep screenshots comparable and useful

  • Use the same browser engine and version, viewport dimensions, device scale factor, and color scheme for each capture.
  • Keep locale, timezone, geolocation, authentication state, and custom headers consistent where they affect page content.
  • Prefer a page-specific readiness selector over a long arbitrary sleep. Use a timeout that allows normal page variation but fails promptly when the expected state never appears.
  • Disable or wait out animations if they make captures vary between runs. Playwright screenshot options support disabling animations.
  • Use stable filenames such as hostname-YYYY-MM-DD.png; avoid filenames that overwrite the prior report image unless overwriting is intended.
  • Decide how to handle a failed page. This example continues with other URLs, logs each failure, and returns a non-zero job status so the workflow is visibly unsuccessful.
  • Keep credentials in the scheduler’s secret store. Do not put passwords, tokens, or private URLs containing secrets in committed workflow files or logs.

Rendering can vary with the host operating system, browser version, settings, hardware, power source, headless mode, and other factors. Playwright’s visual comparison guidance recommends using the same environment where baseline screenshots were generated. Treat differences across environments as possible capture noise until you have checked the settings and runtime.

5. Troubleshoot common failures

Symptom Likely cause What to change
Navigation timeout The page is slow, blocked, or keeps network connections open. Use domcontentloaded or another appropriate readiness condition, increase the timeout only when justified, and wait for a page-specific selector.
Selector wait timeout The selector is absent, hidden, changed, or appears only after a user action. Check the live DOM and choose a selector that identifies the report state; handle optional elements explicitly.
HTTP error or blank capture The site returned an error, a bot check, a consent gate, or different content to a headless browser. Inspect the response and rendered page. Confirm the site permits the capture method and whether authentication or consent is required.
Element not found The CSS selector no longer matches or the element is inside a frame. Update the selector; for framed content, locate the correct frame and target the element there.
Missing fonts or shifted layout Fonts or assets had not loaded, or the runner environment differs from the baseline. Wait for the relevant content, keep the browser environment consistent, and avoid comparing images from different rendering environments as if they were identical.
Workflow passes but artifact is empty No capture succeeded, the output path differs, or upload ran before files were written. Check the script logs and OUTPUT_DIR; retain the upload step with if: always() so partial output can aid diagnosis.
Schedule did not run Invalid cron expression, workflow file not on the expected branch, or UTC mistaken for local time. Check the workflow configuration and repository scheduler status, then invoke workflow_dispatch to validate the job independently.
One site failure stops all captures An exception escaped the per-URL handler. Keep per-site error handling, record the failed URL, and decide whether any failure should fail the overall report job.

6. Performance, reliability, and cost

For a small list of sites, sequential capture is simple and limits concurrent browser load. If the list grows, measure job duration and resource use before adding parallel workers: each browser context consumes resources, and a large parallel run can increase load on both the runner and target sites. The cited sources establish no universal capture-speed or cost figure, so estimate using your own URLs, pages, schedule, and runner.

Retries can help with transient network errors, but repeated failures may instead indicate a broken selector, access restriction, or site change. Limit retries, preserve logs, and make a failed report run visible. Consider separating independent clients or site groups when a failure in one group should not delay the others.

With self-managed Playwright, costs and operational work depend on the machine or hosted runner, storage, retention, and time spent maintaining the browser and workflow. Review the scheduler’s current billing and artifact-retention terms directly before choosing a setup. The researched sources do not establish a universal delivery or storage approach.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF, so a scheduled job can call the endpoint and save its response instead of installing and maintaining a browser runtime. See the ScreenshotNeo API documentation for parameters and configuration.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
with open("shot.webp", "wb") as image:
    image.write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', image));

Put the API key in your scheduler’s secret store. The API supports full-page and element capture, device presets and custom viewports, PDF output, wait conditions, custom headers and cookies, caching, bulk capture, and async jobs with signed webhooks. Cookie banners, newsletter popups, and chat widgets are removed before capture; each cleanup step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for MCP clients including Claude and Cursor.

ScreenshotNeo includes 1,000 screenshots a month free with no card; paid plans start at $5 for 3,000 screenshots. Sign up for the free plan to try a scheduled capture.

FAQ

How often should a client screenshot report run?

Match the cadence to the client’s reporting cycle and how often the pages change. The six-hour cron shown in an action README is only an example; it is not a general reporting recommendation.

Should I capture the whole page or just the first screen?

Use a viewport capture for a compact overview, full-page capture when below-the-fold content matters, or an element capture when the report follows one section.

Can screenshots prove that a site changed?

They show what the capture environment rendered at that time. Browser and host differences can affect rendering, so keep the environment consistent before interpreting visual differences as site changes.

Where should I keep screenshots for client access?

Choose storage, retention, and sharing based on the client’s access and privacy requirements. The browser and scheduler do not prescribe a universal delivery method.