How to Monitor a Website for Visual Changes with a Raspberry Pi
Build a Raspberry Pi monitor that captures a webpage on a schedule, compares each screenshot with a baseline, and helps you spot meaningful visual changes.
The practical way to monitor a website for visual changes with a Raspberry Pi is to capture the same page at regular intervals, save each image, and compare it with a reviewed baseline. This guide uses Python, Playwright, and Pillow for capture and image comparison, then shows how to schedule the script and inspect changes. It is a configurable starting point: the exact browser rendering and change threshold depend on your page and setup.
1. Choose what to monitor
Before installing anything, decide what change matters. A full-page capture can reveal layout changes below the fold; a viewport capture is smaller and often easier to compare; an element capture isolates one component such as a price card or status panel. Playwright supports page, full-page, and locator screenshots. Playwright screenshot documentation.
- Target URL: use the exact canonical page and include any path or query parameters that affect its content.
- Capture scope: choose viewport, full page, or a CSS-selected element.
- Viewport and scale: keep width, height, device scale factor, and browser consistent between baseline and later runs.
- Page state: decide whether to monitor after consent, login, or another interaction. Only monitor pages you are authorized to access.
- Frequency and retention: choose how often to capture and how long to keep the screenshots and diffs. Storage use depends on image dimensions, compression, frequency, and retention.
Dynamic content such as clocks, rotating banners, personalized recommendations, or counters can produce differences without a meaningful design change. Identify these regions before interpreting every changed pixel as an alert.
2. Prepare the Raspberry Pi
Use a Raspberry Pi with a supported 64-bit Raspberry Pi OS installation if you want to follow the commands below. The research does not establish a minimum Pi model or benchmark capture speed, so an existing Pi is a reasonable place to start and capture time should be measured on your own page. A headless setup still needs boot media and power; Raspberry Pi commonly uses microSD boot media and documents headless network setup in its getting started guide.
For a Pi 5, Raspberry Pi recommends its 27W USB-C supply; match the supply to the actual board model. Raspberry Pi lists a microSD slot and multiple memory configurations for Pi 5, but neither a particular Pi 5 configuration nor a card capacity is established as required for this workload. See the Pi 5 product page, 27W supply information, and Raspberry Pi SD card options.
On the Pi, install system packages and make an isolated Python environment:
sudo apt update
sudo apt install -y python3 python3-venv python3-pip
mkdir -p ~/site-monitor
cd ~/site-monitor
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
pip install playwright pillow
python -m playwright install chromium
Playwright’s browser installation downloads Chromium and its required browser files. The Pi needs network access for this initial install and for pages that load remote assets. If the browser installation reports missing Linux system dependencies, install the packages Playwright identifies or use the documented system dependency install command for your OS.
3. Create the capture and comparison script
Save the following as monitor.py. It takes a baseline when invoked with --baseline. Later runs save timestamped screenshots and a difference image; they exit with status 1 when the changed-pixel ratio exceeds the configured threshold. That ratio is an implementation choice, not a universal definition of a meaningful change.
#!/usr/bin/env python3
import argparse
import asyncio
import os
from datetime import datetime, timezone
from pathlib import Path
from PIL import Image, ImageChops, ImageStat
from playwright.async_api import async_playwright
URL = "https://example.com/"
OUT = Path.home() / "site-monitor" / "captures"
BASELINE = OUT / "baseline.png"
WIDTH, HEIGHT = 1365, 900
THRESHOLD = 0.01 # fraction of pixels with a visible difference
PIXEL_TOLERANCE = 24 # ignore small per-channel rendering noise
FULL_PAGE = True
SELECTOR = None # for example: "main .pricing-card"; None captures the page
HIDE_SELECTORS = [".cookie-banner", ".chat-widget"]
async def capture(path: Path) -> None:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}, device_scale_factor=1)
await page.goto(URL, wait_until="networkidle", timeout=60000)
await page.locator("body").wait_for(state="visible", timeout=15000)
# Hide known volatile regions. Remove or change selectors for your target.
await page.add_style_tag(content="\n".join(
f"{selector} {{ visibility: hidden !important; }}" for selector in HIDE_SELECTORS
))
# Allow layout and fonts to settle after navigation and style injection.
await page.wait_for_timeout(1000)
if SELECTOR:
target = page.locator(SELECTOR).first
await target.wait_for(state="visible", timeout=15000)
await target.screenshot(path=str(path), animations="disabled")
else:
await page.screenshot(path=str(path), full_page=FULL_PAGE, animations="disabled")
await browser.close()
def compare(baseline_path: Path, current_path: Path, diff_path: Path) -> float:
base = Image.open(baseline_path).convert("RGB")
current = Image.open(current_path).convert("RGB")
if base.size != current.size:
raise ValueError(f"Image dimensions differ: baseline={base.size}, current={current.size}")
diff = ImageChops.difference(base, current)
# Treat channel changes below the tolerance as noise, then count changed pixels.
changed = diff.point(lambda value: 255 if value > PIXEL_TOLERANCE else 0)
gray = changed.convert("L")
changed_pixels = sum(1 for value in gray.getdata() if value)
ratio = changed_pixels / (base.width * base.height)
# A visible diff makes manual review easier; it is not a semantic change detector.
amplified = diff.point(lambda value: min(255, value * 4))
amplified.save(diff_path)
return ratio
async def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--baseline", action="store_true", help="capture and replace the reference image")
parser.add_argument("--threshold", type=float, default=THRESHOLD, help="changed pixel fraction that signals a change")
args = parser.parse_args()
OUT.mkdir(parents=True, exist_ok=True)
if not (0 <= args.threshold <= 1):
raise SystemExit("--threshold must be between 0 and 1")
if args.baseline:
await capture(BASELINE)
print(f"Baseline saved: {BASELINE}")
return 0
if not BASELINE.exists():
raise SystemExit(f"No baseline at {BASELINE}; run: python {Path(__file__).name} --baseline")
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
current_path = OUT / f"current-{stamp}.png"
diff_path = OUT / f"diff-{stamp}.png"
await capture(current_path)
try:
ratio = compare(BASELINE, current_path, diff_path)
except Exception as exc:
raise SystemExit(f"Comparison failed: {exc}")
print(f"changed_pixel_ratio={ratio:.6f}")
print(f"current={current_path}")
print(f"diff={diff_path}")
if ratio > args.threshold:
print("CHANGE: inspect the current and diff images")
return 1
print("No change above threshold")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
Set URL to your page. Adjust WIDTH and HEIGHT to match the browser size you want to monitor. For a component, set SELECTOR to a stable CSS selector; if it matches nothing or is hidden, capture fails with a timeout. Set FULL_PAGE to False for viewport-only capture. Replace HIDE_SELECTORS with volatile regions that are safe to omit. Hiding a region deliberately means changes inside it will not be detected.
The example waits for networkidle, which can wait too long or time out on pages with persistent requests. If that happens, use wait_until="domcontentloaded", then wait for a page-specific selector with page.locator("main").wait_for(state="visible"), and optionally add a short delay for late rendering. Keep the chosen rule identical between baseline and scheduled runs.
4. Capture a baseline and review it
- Run
cd ~/site-monitor && . .venv/bin/activate. - Capture the reference:
python monitor.py --baseline. - Open
~/site-monitor/captures/baseline.pngand confirm it shows the intended page state, viewport, and page region. - Run a comparison:
python monitor.py. Inspect both the timestamped current image and matching diff image. - When a real redesign or expected content change occurs, review it and deliberately replace the baseline with
python monitor.py --baseline.
Playwright Test’s visual comparison flow similarly creates a reference image on its first run and compares later runs with that reference. Its guidance warns that rendering can vary with the host OS, browser version, settings, hardware, power source, and headless mode. Keep the OS, Playwright version, browser, viewport, device scale factor, fonts, and power conditions as stable as practical. Read Playwright visual comparisons.
5. Schedule repeat captures and handle alerts
A scheduler is a separate choice from screenshot capture. On a Linux Pi, cron is one simple option. Edit your user’s crontab with crontab -e and add an entry like this to run every 30 minutes:
*/30 * * * * cd /home/pi/site-monitor && /home/pi/site-monitor/.venv/bin/python /home/pi/site-monitor/monitor.py >> /home/pi/site-monitor/monitor.log 2>&1
Use the actual account home path on your Pi; it may not be /home/pi. Cron does not provide an alert destination by itself. This sample logs output, and a nonzero exit status indicates that the pixel threshold was exceeded. Connect that status or log to a notification channel you already operate, or inspect the images manually. Do not send an alert for every changed pixel without reviewing noise first.
For more control, use a systemd timer or another scheduler, but choose based on your operational needs rather than assuming one is universally best. Ensure that overlapping runs cannot overwrite each other’s files or consume more resources than the Pi can spare. This script uses unique UTC timestamp filenames, but scheduling should still allow one capture to finish before starting another.
6. Tune comparison, storage, and capture reliability
Set a useful threshold
The example counts pixels whose channel differences exceed a tolerance, then divides by image area. Begin by comparing several captures with no intended site change. Review the diff images and choose a threshold that filters small rendering variation while still flagging the kinds of changes you care about. A single threshold may be unsuitable for both a mostly static page and a page with frequently changing content. Pixel comparison cannot tell whether a change is important; it only reports image differences.
Control sources of noise
- Keep browser and OS versions fixed for baseline and monitoring runs.
- Use a stable viewport and device scale factor.
- Wait for a meaningful page condition rather than relying only on a fixed sleep.
- Disable animations where possible; Playwright screenshot calls support animation handling.
- Hide or mask known volatile content only when changes there do not matter.
- Use a stable login state if monitoring a private page, and store credentials with appropriate file permissions rather than embedding secrets in a shared script.
Playwright documents screenshot styling and masking controls that can help with volatile content; see the screenshot options. The sample injects CSS to hide selected elements, which is simple but can alter layout. If the target moves when a region is hidden, use a mask or a more specific capture scope instead.
Choose retention deliberately
The script retains every current screenshot and diff, so disk usage grows with run frequency. Keep a bounded archive: for example, preserve recent captures locally and move or delete older ones according to your review needs. Before choosing a microSD capacity, estimate from your own average PNG sizes, capture interval, and retention window; the source material does not establish a recommended capacity for this workload. Keep the baseline separately from automatic cleanup.
Reduce failure ambiguity
A failed navigation should not be interpreted as a website visual change. Treat timeouts, HTTP failures, selector timeouts, and comparison dimension mismatches as monitoring errors that need separate review. Add retry logic only for transient errors and cap retries so a bad target does not create a permanent loop. Preserve the last successful image and record the error and timestamp.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Chromium will not launch | Browser files or OS libraries are missing, or the installed package does not match the OS architecture. | Re-run the Playwright Chromium install for the active environment, review missing-library output, and use a supported Raspberry Pi OS and Python architecture. |
| Navigation times out | The page keeps connections open, is slow, or the selected wait condition is too strict. | Try domcontentloaded and wait for a specific visible element. Increase the timeout only when the page genuinely needs it. |
| Screenshot is blank or incomplete | Capture occurred before the relevant content rendered, the page rejected automation, or content is below the lazy-loading threshold. | Wait for a page-specific selector; scroll relevant content into view when necessary; inspect browser errors and the saved image. Avoid treating a failed or blank capture as a valid new baseline. |
| Every run reports a change | Dynamic content, font differences, browser upgrades, animations, or viewport drift. | Stabilize the environment, hide only irrelevant volatile regions, disable animations, and tune tolerance and threshold against reviewed diffs. |
| Comparison reports different dimensions | The page height changed in full-page mode, or the capture scope/settings changed. | Check for real layout change. If the height variation is irrelevant, monitor a stable element or viewport; do not silently resize images because that can conceal layout changes. |
| Element selector times out | The selector changed, matches no element, or the element is not visible. | Inspect the live DOM and use a stable selector. Confirm the element exists in the same logged-in/page state used by the script. |
| Cron runs manually but not on schedule | Cron has a limited environment and relative paths or an assumed username path may be wrong. | Use absolute paths, redirect output to a log, verify the crontab belongs to the intended user, and check the configured system time and timezone. |
| Disk fills up | Timestamped current and diff images are accumulating. | Implement and periodically verify a retention policy; keep the baseline and any needed incident captures. |
| Page changes but no alert arrives | The script only prints and exits nonzero; it does not send notifications. | Connect the exit status or log to a notification mechanism and test that delivery path separately. |
8. Performance, reliability, and cost
Each run launches a browser, loads the page and assets, captures an image, and processes its pixels. Runtime and memory depend on the page, full-page dimensions, browser version, and Pi model; no benchmark is available here. Start with a conservative interval and measure capture duration and memory on your own device. Avoid schedules that routinely start a second browser before the first has finished.
Reliability depends on more than the comparison code: maintain network connectivity, stable power appropriate to the board, working boot media, sufficient free storage, and a consistent browser environment. A local Pi can keep the images on the device, but storage failures or loss of the device can also lose the archive; decide whether important baselines and incident captures need a separate backup.
The do-it-yourself software path uses Playwright and local image files; the practical costs are the Pi, boot media, power, electricity, and time spent maintaining browser dependencies and storage. The sources do not provide a total cost or a screenshot-specific performance figure. Check disk use and update the browser deliberately, then rebaseline after any intentional runtime change that alters rendering.
Or skip the browser setup
If you want captures without installing and maintaining Chromium on the Pi, ScreenshotNeo is a website screenshot API and MCP server for developers. It accepts one GET request for a URL and can return PNG, JPEG, WebP, or PDF. Here is the one-call version using the target page in this guide; see the ScreenshotNeo API docs for options and response details.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/ -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.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 YOUR_API_KEY with your account key. The Python and Node.js snippets save the response body; add your own status and content-type checks if you need to distinguish image output from an API error response. ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Pricing is $15 for 15,000 on Growth, $39 for 60,000 on Pro, $99 for 250,000 on Scale, and $249 for 1,000,000 on Business; yearly billing gives two months free, and every feature is on every plan. Only clean shots are billed, and response headers report the page verdict and billing status. See ScreenshotNeo and the documentation.
Sign up for 1,000 free screenshots a month, with no card required.
9. Frequently asked questions
Does the monitor need to run on the same Pi that serves the website?
No. It can run from any Pi with network access to the target website. For a public page, the monitor is simply a separate client.
Should I compare each screenshot with the baseline or the previous capture?
Use a reviewed baseline when you want to detect drift from an approved appearance. Comparing with the previous capture can reveal incremental changes but may gradually accept unwanted changes if the reference is continually replaced. Keep baseline updates deliberate.
Can this detect text changes?
It detects rendered pixel differences, including many visible text changes, but does not identify the meaning of the changed text. For semantic content monitoring, inspect the page text separately.
Can it monitor a page behind login?
Yes, if you have authorization and provide a stable authenticated browser state. Protect stored credentials and avoid committing them to source control.
Can I monitor several URLs?
Yes. Generalize the script to read a list of URLs and give each page its own baseline, capture directory, and threshold. Record failures per URL so one unavailable page does not prevent other checks.


