How to Schedule Daily Screenshots of a Website for Monitoring
Build a daily website screenshot monitor with Playwright and GitHub Actions, or use ScreenshotNeo for scheduled capture without managing a browser.
To schedule a website screenshot every day, combine a capture script with a scheduler. The example below uses Playwright to render a page and GitHub Actions to run it daily, then commits each dated screenshot to the repository. Playwright takes the screenshot; GitHub Actions supplies the recurring schedule.
For a reliable visual history, keep the browser, viewport, capture scope, wait condition, and time zone consistent. Save each run under a timestamp, and make failures visible so a missing or partial image is not mistaken for a healthy capture.
Choose what the monitor should capture
Decide what change you need to see before choosing screenshot settings:
- Viewport: the visible browser area. Use it to monitor the first impression or a fixed dashboard region.
- Full page: the complete scrollable document. This can be taller and slower, and lazy-loaded content may need to be scrolled into view before capture.
- One element: a specific chart, pricing table, or status panel. This reduces unrelated visual changes, but depends on a stable CSS selector.
Also decide whether cookie dialogs, rotating promotions, chat widgets, personalized content, or timestamps are part of what you want to monitor. Either keep those conditions consistent or deliberately remove or mask them; otherwise, they can create noise that looks like a site change.
Build a daily monitor with Playwright and GitHub Actions
This setup uses Node.js, Playwright, and a GitHub Actions scheduled workflow. It captures the target URL as a full-page PNG, records the run date in UTC, and commits the output. The schedule below is 09:00 UTC daily. GitHub Actions scheduled workflows use cron syntax; check GitHub’s current documentation for schedule behavior and repository requirements before relying on an exact start time.
1. Create the project
In a new repository, create package.json:
{
"name": "daily-site-monitor",
"private": true,
"type": "module",
"scripts": {
"capture": "node capture.mjs"
},
"dependencies": {
"playwright": "^1.0.0"
}
}
Install dependencies and create a lockfile locally with npm install. Commit both package.json and package-lock.json. The workflow uses npm ci so scheduled runs install the versions recorded in the lockfile. Playwright’s browser binaries must also be installed in the workflow.
2. Add the capture script
Create capture.mjs:
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
const url = process.env.TARGET_URL;
if (!url) throw new Error('Set TARGET_URL to the page to monitor.');
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('TARGET_URL must use http or https.');
}
const outputDir = process.env.OUTPUT_DIR || 'screenshots';
const selector = process.env.SELECTOR;
const fullPage = process.env.FULL_PAGE !== 'false';
const date = new Date().toISOString().replaceAll(':', '-');
const file = join(outputDir, `${parsed.hostname}-${date}.png`);
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1440, height: 1000 },
deviceScaleFactor: 1,
locale: 'en-US',
timezoneId: 'UTC'
});
page.setDefaultNavigationTimeout(60_000);
page.setDefaultTimeout(15_000);
const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
if (!response) throw new Error('Navigation returned no main-document response.');
if (!response.ok()) {
throw new Error(`Navigation failed: HTTP ${response.status()} ${response.statusText()}`);
}
// Replace this with a meaningful selector for the content you monitor.
if (process.env.WAIT_FOR_SELECTOR) {
await page.locator(process.env.WAIT_FOR_SELECTOR).waitFor({ state: 'visible' });
} else {
await page.locator('body').waitFor({ state: 'visible' });
}
// Optional fixed delay for a known client-rendered update; prefer a selector when possible.
const delayMs = Number(process.env.WAIT_MS || '0');
if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 60_000) {
throw new Error('WAIT_MS must be a number from 0 to 60000.');
}
if (delayMs) await page.waitForTimeout(delayMs);
const target = selector ? page.locator(selector) : page;
if (selector) await target.waitFor({ state: 'visible' });
await target.screenshot({
path: file,
fullPage: selector ? undefined : fullPage,
animations: 'disabled',
caret: 'hide',
scale: 'css'
});
console.log(`Saved ${file}`);
} finally {
await browser.close();
}
The script fails on missing configuration, navigation errors, non-success HTTP responses, invalid wait settings, and missing selectors instead of silently saving an obviously incomplete run. Some sites return an error status while still rendering a useful page; if that is intentional, change the status handling to log the status and apply your own acceptance rule.
3. Schedule it in GitHub Actions
Create .github/workflows/daily-screenshot.yml:
name: Daily website screenshot
on:
schedule:
- cron: '0 9 * * *'
workflow_dispatch:
permissions:
contents: write
jobs:
capture:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TARGET_URL: ${{ vars.TARGET_URL }}
FULL_PAGE: 'true'
# Optional repository variables:
# SELECTOR: 'main'
# WAIT_FOR_SELECTOR: '[data-monitor-ready="true"]'
# WAIT_MS: '1000'
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
- run: npm run capture
- name: Commit screenshot archive
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add screenshots/
if ! git diff --cached --quiet; then
git commit -m "Add daily website screenshot"
git push
fi
Set the repository variable TARGET_URL to the page to capture. If the page requires authentication, use repository secrets and add the required login flow to the script; do not put passwords or session cookies in source control. The workflow needs write permission to commit images. If your repository policy disallows workflow pushes, upload the files to storage you control or retain them as workflow artifacts instead.
Run the workflow once with workflow_dispatch and inspect the resulting image and commit before relying on the daily schedule. Confirm the repository’s scheduled-workflow availability and actual run timing in GitHub’s documentation.
Make captures comparable and useful
Fix the rendering conditions
Keep viewport width and height, device scale factor, locale, time zone, browser version, and authentication state stable. A change in any of these can alter line wrapping, dates, currency, personalized content, or responsive breakpoints.
The example disables CSS animations and hides the caret. For pages with continuously changing video, carousels, timestamps, or ads, decide whether those are meaningful signals. You can add a Playwright screenshot mask for known volatile elements, or inject a stylesheet to hide them. Do not mask the component you are monitoring.
Wait for the right content
domcontentloaded means the initial document was parsed; it does not guarantee that an application has finished fetching and rendering data. Prefer a selector or application state that represents the content you need. A fixed delay is simple but can waste time or still be too short under load. Waiting for all network activity to stop can also hang or mislead on sites with analytics, polling, or persistent connections.
For full-page screenshots, lazy-loaded images may not have loaded below the fold. If they matter, scroll through the page in controlled increments and wait for images or a page-specific ready signal before capture. Long pages can consume substantial memory and create large files.
Store an archive you can maintain
The example writes a UTC timestamp into each filename and commits files to the repository. This is straightforward for a small number of pages and short retention periods. Images can make repository history grow quickly because every run adds another binary file. Set a retention policy, remove old files deliberately, or store captures outside the repository if the archive is large or needs longer retention. Restrict repository access if screenshots can contain private or account-specific information.
Keep failures distinguishable from successful captures. A useful operational setup records the run status, URL, timestamp, response status, and error message, and sends a notification on failure. The sample workflow makes a failed step visible in its run history, but does not configure a separate alert destination.
Other ways to schedule captures
| Approach | Good fit | What you operate |
|---|---|---|
| Playwright plus a scheduler | You need custom browser steps, selectors, login, waits, or output handling. | Browser runtime, scheduler, archive, access controls, retention, and failure reporting. |
| shot-scraper plus GitHub Actions | You want a command-line capture tool and a repository-based archive. Its release 0.17 manual documents a workflow that runs configured captures and commits output files. | Workflow configuration, repository size and access, retention, and failure visibility. See the shot-scraper documentation. |
| Hosted recurring capture | You prefer a provider to manage recurrence and capture storage. | Check current schedule precision, pricing, limits, export, deletion and retention terms, access controls, and failure notifications directly with the provider. ScreenshotAPI.net describes cron-based recurring captures and stored results; this is the vendor’s description, not an independent assessment. See its site. |
Playwright provides browser capture controls but does not schedule a daily run by itself. A scheduler such as GitHub Actions or operating-system cron must invoke the script. Choose based on how much browser control you need, where images should live, how long they should be retained, and how you will notice failed runs. Check each scheduler’s current time-zone and delayed-run behavior in its official documentation.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. A scheduled workflow still needs to invoke it daily, but you do not need to install or operate a browser for the capture itself. The API accepts a URL and can return PNG, JPEG, WebP, or PDF. Its documentation describes the request options.
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 f:
f.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}`);
await Bun.write('shot.webp', res);
Replace the example URL with the page you monitor. Add the appropriate screenshot options to the request for your chosen scope and output. Store the API key as a scheduler secret. Your daily scheduler and archive policy still apply.
- Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers identify the page verdict and billing status.
- An MCP server exposes screenshot tools for AI agents, including Claude, Cursor, and other MCP clients.
- The free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots.
Sign up for ScreenshotNeo and get 1,000 free screenshots a month, with no card required.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No scheduled run appears | The workflow is not on the default branch, scheduling is unavailable or delayed under repository conditions, or the cron expression is invalid. | Check the workflow file, branch, repository settings, and GitHub’s current scheduled-workflow documentation. Trigger workflow_dispatch to separate capture problems from scheduling problems. |
TARGET_URL is missing or invalid |
The repository variable was not set, or the URL is not HTTP(S). | Set TARGET_URL in repository variables and confirm it includes the scheme, such as https://example.com. |
| Browser executable is missing | Playwright’s Chromium binary was not installed in the runner. | Keep npx playwright install --with-deps chromium after npm ci, and check that the Playwright package and installed browser versions match. |
| Navigation times out | The origin is slow, unreachable from the runner, or waiting for an unsuitable load event. | Check the URL and site availability, use a meaningful readiness selector, and adjust the navigation timeout only when the page legitimately needs longer. |
| Screenshot is blank or missing dynamic content | The application renders after the initial document event, requires authentication, or needs a page-specific wait. | Wait for the relevant visible selector, add a secure login flow if authorized, and fail the job if the expected content never appears. |
| Selector wait fails | The selector is wrong, changed, hidden, or not present for this account or viewport. | Inspect the page and use a stable selector. Add a clear error or fallback for expected variants rather than capturing an unrelated page region. |
| Images below the fold are absent | They are lazy-loaded and the browser never scrolled near them. | Scroll through the relevant sections, wait for the images or app-ready state, then take the full-page screenshot. |
| Commit step has no changes or cannot push | The image did not change, Git has nothing to commit, or workflow permissions and branch rules prevent pushes. | The sample skips an empty commit. Grant the required repository write permission if policy permits, or use an artifact or storage destination you control. |
| Archive is growing too quickly | Every daily binary image is retained in Git history. | Set a retention period, reduce capture frequency or scope, or move the archive to storage with suitable lifecycle rules. |
Performance, reliability, and cost
- Runtime: each run starts a browser, loads the page and resources, waits for readiness, then encodes the image. Full-page captures, large pages, and long waits increase runtime and memory use.
- Reliability: keep navigation and readiness timeouts, fail on missing expected content, and retain workflow logs. A daily schedule can be delayed or skipped by platform conditions, so do not treat the cron expression as a precise execution guarantee. Add a separate alert if someone must respond to missed captures.
- Archive cost: repository captures use repository storage and expand Git history. External storage adds its own storage and request costs; compare those with retention needs before choosing it.
- Hosted service cost: calculate expected volume as URLs multiplied by daily runs and any retries. Verify current provider pricing, retention, export, and billing rules directly; the available evidence does not support an independent price or reliability comparison across providers.
- ScreenshotNeo pricing: Free includes 1,000 shots monthly; Starter is $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, and every feature is on every plan.
FAQ
Does a screenshot API run every day automatically?
A one-shot API request captures when called. A separate scheduler must call it daily unless the service specifically offers recurring jobs.
Should I use a full-page image or a viewport image?
Use the scope that matches the question. A viewport image is easier to compare for a fixed first-screen layout; full-page capture includes lower sections but can be larger and affected by lazy loading.
How do I compare two days automatically?
Keep capture conditions fixed and compare images with a visual-diff tool or review them side by side. Choose a tolerance for dynamic pixels based on the site; no single threshold works for every page.
Can the monitor capture a page behind a login?
Yes, if your script establishes an authorized session, but credentials and session data must be stored securely and kept out of the repository. Confirm the site permits automated access.


