Convert an HTML Dashboard to PNG on a Schedule
Capture a dashboard as a consistent PNG with Playwright, then run it on a schedule using cron or GitHub Actions.
To convert an HTML dashboard to PNG on a schedule, use a browser automation script to open the page, wait for the dashboard data and layout to finish rendering, save a screenshot, and invoke the script from a scheduler. Playwright supports viewport, full-page, and element screenshots. Choose the capture scope based on how the PNG will be used. Playwright’s screenshot guide documents these capture types.
1. Choose the capture and schedule
Before writing the script, decide the following:
- Viewport or full page: viewport captures the visible browser area and produces a predictable image size. Full-page includes the scrollable page and can become very tall. Playwright documents
fullPage: truefor this case. - Whole dashboard or one panel: use a locator screenshot when only a chart or panel is needed. This avoids capturing unrelated navigation and page content.
- Readiness condition: wait for a dashboard-specific selector or application state. A generic network-idle condition can be unsuitable for dashboards that keep polling or streaming data.
- Repeatable dimensions: explicitly set a viewport and device scale factor so scheduled images have consistent dimensions.
- Schedule and destination: choose a timezone and frequency, then store the PNG as an artifact, upload it to storage, or commit it to a repository. The examples below save a local file; add destination-specific upload logic if needed.
2. Create a Playwright screenshot script
This runnable Node.js example uses Chromium. It reads the target URL and output path from environment variables, optionally adds an authenticated session cookie, waits for a dashboard selector, and writes a PNG.
mkdir dashboard-capture
cd dashboard-capture
npm init -y
npm install playwright
npx playwright install chromium
Save this as capture.mjs:
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
const url = process.env.DASHBOARD_URL;
const output = process.env.OUTPUT_PATH ?? 'artifacts/dashboard.png';
const readySelector = process.env.READY_SELECTOR ?? '[data-dashboard-ready="true"]';
const cookieName = process.env.SESSION_COOKIE_NAME;
const cookieValue = process.env.SESSION_COOKIE_VALUE;
if (!url) throw new Error('Set DASHBOARD_URL');
await mkdir(dirname(output), { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
deviceScaleFactor: 1,
colorScheme: 'light',
});
if (cookieName && cookieValue) {
const target = new URL(url);
await context.addCookies([{
name: cookieName,
value: cookieValue,
domain: target.hostname,
path: '/',
httpOnly: true,
secure: target.protocol === 'https:',
sameSite: 'Lax',
}]);
}
const page = await context.newPage();
page.setDefaultTimeout(30_000);
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
if (response && !response.ok()) {
throw new Error(`Dashboard returned HTTP ${response.status()}`);
}
// Replace this selector with a stable signal from your dashboard.
await page.locator(readySelector).waitFor({ state: 'visible', timeout: 60_000 });
await page.screenshot({ path: output, fullPage: false, animations: 'disabled' });
console.log(`Saved ${output}`);
} finally {
await browser.close();
}
The readiness selector is intentionally configurable: replace the default with an element that appears after the dashboard has loaded its data. For example, your application could render <main data-dashboard-ready="true"> only after charts and summary cards are ready. If you cannot change the application, choose a stable chart or heading selector and, if necessary, add a short delay after it appears.
Run it locally:
DASHBOARD_URL='https://dashboard.example.com/overview' \
READY_SELECTOR='main[data-dashboard-ready="true"]' \
OUTPUT_PATH='artifacts/dashboard.png' \
node capture.mjs
Authenticated dashboards
Prefer a short-lived, read-only account or a narrowly scoped session over hard-coding credentials. The example accepts a session cookie through environment variables. Add a cookie only for the dashboard host; do not print its value in logs. If the site uses an identity provider, SSO, or a complex login flow, use an approved automation account and a deliberate authentication setup, then verify that the captured page is actually the dashboard rather than a login or access-denied page.
3. Capture a panel or the full scrollable dashboard
For a specific chart, replace the page screenshot with a locator screenshot:
const chart = page.locator('#revenue-chart');
await chart.waitFor({ state: 'visible', timeout: 60_000 });
await chart.screenshot({ path: output, animations: 'disabled' });
For the complete scrollable page, use:
await page.screenshot({ path: output, fullPage: true, animations: 'disabled' });
Playwright also allows setting image type, clipping area, and other screenshot behavior. PNG is suitable when you need a lossless image. Fix the viewport dimensions for comparable daily captures; full-page output dimensions depend on the rendered page height. See the Page screenshot API for the current options.
4. Schedule the capture
Linux cron
This example runs every day at 06:00 UTC. Cron uses the machine’s configured timezone unless configured otherwise, so confirm the runner timezone and use absolute paths. Create run-capture.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/dashboard-capture
export DASHBOARD_URL='https://dashboard.example.com/overview'
export READY_SELECTOR='main[data-dashboard-ready="true"]'
export OUTPUT_PATH="artifacts/dashboard-$(date -u +%F).png"
node capture.mjs
Make it executable and add a cron entry:
chmod +x /opt/dashboard-capture/run-capture.sh
crontab -e
# Add this line to run daily at 06:00 UTC (if the host timezone is UTC):
0 6 * * * /opt/dashboard-capture/run-capture.sh >> /var/log/dashboard-capture.log 2>&1
Create the output and log directories with permissions for the scheduler account. A cron process has a smaller environment than an interactive shell, so set required variables explicitly and use absolute paths.
GitHub Actions
For a repository-based workflow, a scheduled GitHub Actions job can invoke the script and upload the PNG as an artifact. This example assumes capture.mjs and package-lock.json are committed. Add the dashboard URL and any secret cookie as repository secrets, then create .github/workflows/dashboard-shot.yml:
name: Scheduled dashboard PNG
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Capture dashboard
run: node capture.mjs
env:
DASHBOARD_URL: ${{ secrets.DASHBOARD_URL }}
READY_SELECTOR: 'main[data-dashboard-ready="true"]'
OUTPUT_PATH: artifacts/dashboard.png
SESSION_COOKIE_NAME: ${{ secrets.SESSION_COOKIE_NAME }}
SESSION_COOKIE_VALUE: ${{ secrets.SESSION_COOKIE_VALUE }}
- uses: actions/upload-artifact@v4
with:
name: dashboard-png
path: artifacts/dashboard.png
if-no-files-found: error
Check your scheduler’s current limits, timezone behavior, and artifact retention settings before relying on it. Scheduled jobs may not start at an exact second, and artifacts are retained according to the workflow service’s settings. The shot-scraper project also documents scheduled screenshot workflows; the scheduler remains a separate operational choice.
5. cURL, Python, and ScreenshotNeo
For browser-controlled capture, Playwright is useful when you need a custom login sequence, application-specific readiness logic, or code that runs in your own environment. For a hosted one-request capture, ScreenshotNeo accepts a URL and returns an image or PDF. Its API supports scheduled integrations by letting your scheduler call the endpoint and save the response bytes.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://dashboard.example.com/overview \
-o dashboard.png
Python
import os
import requests
url = os.environ["DASHBOARD_URL"]
response = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": os.environ["SCREENSHOTNEO_API_KEY"], "url": url},
timeout=90,
)
response.raise_for_status()
with open("dashboard.png", "wb") as image:
image.write(response.content)
Node.js
const q = new URLSearchParams({
access_key: process.env.SCREENSHOTNEO_API_KEY,
url: process.env.DASHBOARD_URL,
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) =>
writeFile('dashboard.png', Buffer.from(await res.arrayBuffer()))
);
For parameter names, capture options, and authentication details, see the ScreenshotNeo API documentation. Configure the scheduled job to write to a dated path or upload the returned file to your chosen storage. ScreenshotNeo supports full-page captures, element selectors, custom CSS and JavaScript, wait conditions, custom headers and cookies, caching, async jobs with signed webhooks, bulk requests, and additional capture options. Use only the options your dashboard needs.
Or skip the browser setup
ScreenshotNeo can capture a URL with one API call. Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed; response headers identify the page verdict and billing status. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://dashboard.example.com/overview -o dashboard.png
See the API documentation and sign up for 1,000 free screenshots a month, no card required.
Options that affect a scheduled dashboard capture
| Need | Approach | Trade-off |
|---|---|---|
| Stable dimensions | Set a fixed viewport and device scale factor. | A narrow viewport can change responsive layout; pick dimensions that match the report’s readers. |
| Only a chart or card | Wait for and screenshot a locator. | The selector must remain stable as the dashboard changes. |
| All scrollable content | Use full-page capture. | Tall pages can create large files and may include content that is too small to read when displayed. |
| Charts still animating | Wait for an application-ready signal, then disable animations for the capture. | Disabling animation does not wait for data; readiness must be handled separately. |
| Session-protected page | Use an approved account and secure cookie or authentication setup. | Credentials require secret storage, access controls, and rotation appropriate to your environment. |
| Repeated identical captures | Consider caching only if an identical result is acceptable. | A dashboard may change between runs; stale cached images defeat the purpose of scheduled monitoring. |
Reliability, performance, and cost
- Readiness beats arbitrary waiting: wait for a dashboard-specific state instead of relying only on a fixed sleep. Network quiet does not necessarily mean a live dashboard is ready, particularly when it polls continuously.
- Bound each run: set navigation and selector timeouts, close the browser in a
finallyblock, and let the scheduler report nonzero exit status as a failed run. - Make output unambiguous: use a dated filename for history, or a stable filename for a latest-only view. Upload atomically or publish only after a successful capture to avoid consumers reading partial output.
- Watch for silent wrong pages: a screenshot can succeed while showing a login page, stale data, or an error banner. Validate an expected heading, date, or data-ready marker before saving.
- Plan storage: full-page PNGs can be much larger than viewport captures. Set retention and remove or archive old files according to your reporting needs.
- Browser automation cost: running Playwright means maintaining a runtime, browser installation, scheduler, and output storage. The runtime itself may have hosting or CI costs according to the environment you choose; no universal amount applies.
- API cost: ScreenshotNeo’s free plan includes 1,000 shots a month without a card. Listed paid tiers are Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000; yearly billing gives two months free. Every feature is on every plan. Failed loads, blank pages, bot checks/CAPTCHAs, and cache hits are not billed.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser executable missing | Playwright package installed without its Chromium binary. | Run npx playwright install chromium; on GitHub Actions, install the browser and required dependencies in the workflow. |
| Navigation timeout | Slow page, blocked access, redirect loop, or a page that never finishes loading. | Check the URL and network access. Use domcontentloaded or another suitable navigation condition, then wait separately for the dashboard-ready selector. |
| Ready selector timeout | Selector does not exist, is misspelled, hidden, or appears only after a failed login. | Inspect the rendered page in a local headed run, choose a stable visible selector, and verify authentication and access. |
| Image shows loading skeletons | The selector appeared before charts or data finished rendering. | Wait on a stronger app-ready condition, chart completion marker, or specific data element. A fixed delay is a fallback, not a robust readiness signal. |
| PNG differs across runs | Viewport, timezone, theme, dynamic data, animation, or content timing changed. | Pin viewport and color scheme, set timezone if relevant, disable animation, and wait for the same explicit rendering state. |
| Screenshot is unexpectedly tall or tiny | Full-page capture included a long document or responsive layout changed at the chosen width. | Use viewport or locator capture, inspect the page height, and set an appropriate fixed viewport. |
| Cron works manually but not on schedule | Different working directory, PATH, environment variables, permissions, or timezone. | Use absolute paths, set environment variables in the wrapper script, check file permissions and logs, and confirm the host timezone. |
| GitHub job cannot reach dashboard | Private network access, IP allowlisting, or SSO may prevent access from the hosted runner. | Use an approved runner/network path and authentication method. Do not expose credentials in workflow logs. |
| ScreenshotNeo response is not an image | The request failed or returned a status/error response. | Check HTTP status and response headers, verify the API key and target URL, and consult the API docs for accepted options and result handling. |
FAQ
Can I schedule a screenshot every minute?
That depends on your scheduler’s minimum interval, execution limits, and the dashboard’s acceptable load. Avoid overlapping jobs by ensuring a run finishes before the next begins or by adding a lock.
Should each run overwrite the previous PNG?
Overwrite for a simple latest-state image. Use dated filenames when you need history or auditability, and pair them with a retention policy.
Can I capture a dashboard that is only available on my laptop?
A hosted scheduler cannot reach a local-only address. Run the job on a machine or runner with authorized network access, or make the dashboard reachable through an approved private network setup.
How do I know the dashboard is current?
Include or inspect an update timestamp in the page and verify it before saving. Successful navigation alone does not prove that the displayed data is fresh.


