How to Build a Website Change Monitor That Screenshots and Diffs on a Schedule
Uptime monitors check status codes. They don't catch: a competitor updating their pricing page, a third-party embed breaking your layout, a CSS regression from a deploy two days ago, or a widget that loaded fine yesterday but now shows an error.
Visual change monitoring captures screenshots on a schedule and diffs each one against the previous capture. If pixels change beyond a threshold, you get an alert.
The architecture
cron → screenshot all watched URLs → compare to stored baseline → if diff > threshold → alert → update baseline
Core monitor script
import fs from "fs/promises";
import path from "path";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
import nodemailer from "nodemailer";
const CONFIG = {
pages: [
{ name: "Competitor pricing", url: "https://competitor.com/pricing" },
{ name: "Your homepage", url: "https://yourapp.com" },
{ name: "Status page", url: "https://status.yourapp.com" },
{ name: "Stripe checkout", url: "https://yourapp.com/checkout" },
],
threshold: 0.02, // 2% pixel change triggers alert
baselinesDir: "./baselines",
alertEmail: process.env.ALERT_EMAIL,
};
async function captureScreenshot(url) {
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
fullPage: false,
blockBanners: true,
blockAds: true,
}),
});
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
function diffImages(baselineBuffer, currentBuffer) {
const img1 = PNG.sync.read(baselineBuffer);
const img2 = PNG.sync.read(currentBuffer);
const { width, height } = img1;
const diff = new PNG({ width, height });
const changed = pixelmatch(img1.data, img2.data, diff.data, width, height, {
threshold: 0.1,
});
return {
changedPixels: changed,
totalPixels: width * height,
ratio: changed / (width * height),
diffPng: PNG.sync.write(diff),
};
}
async function checkPage(page) {
const slug = page.name.toLowerCase().replace(/\s+/g, "-");
const baselinePath = path.join(CONFIG.baselinesDir, `${slug}.png`);
const diffPath = path.join(CONFIG.baselinesDir, `${slug}-diff.png`);
const currentPath = path.join(CONFIG.baselinesDir, `${slug}-current.png`);
const current = await captureScreenshot(page.url);
await fs.writeFile(currentPath, current);
// First run — save baseline and exit
let baseline;
try {
baseline = await fs.readFile(baselinePath);
} catch {
await fs.writeFile(baselinePath, current);
console.log(`[${page.name}] Baseline saved.`);
return { page, status: "baseline_saved" };
}
const { ratio, diffPng } = diffImages(baseline, current);
const pct = (ratio * 100).toFixed(2);
if (ratio > CONFIG.threshold) {
await fs.writeFile(diffPath, diffPng);
console.log(`[${page.name}] CHANGED — ${pct}% of pixels differ`);
return { page, status: "changed", pct, current, diffPng };
}
console.log(`[${page.name}] OK — ${pct}% change (within threshold)`);
return { page, status: "ok", pct };
}
async function sendAlert(changes) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
const attachments = changes.flatMap(({ page, current, diffPng }) => {
const slug = page.name.toLowerCase().replace(/\s+/g, "-");
return [
{ filename: `${slug}-current.png`, content: current },
{ filename: `${slug}-diff.png`, content: diffPng },
];
});
await transporter.sendMail({
from: "monitor@yourapp.com",
to: CONFIG.alertEmail,
subject: `Visual change detected on ${changes.length} page(s)`,
text: changes
.map((c) => `${c.page.name} (${c.page.url}): ${c.pct}% changed`)
.join("\n"),
attachments,
});
}
async function run() {
await fs.mkdir(CONFIG.baselinesDir, { recursive: true });
const results = await Promise.allSettled(
CONFIG.pages.map(checkPage)
);
const changes = results
.filter((r) => r.status === "fulfilled" && r.value.status === "changed")
.map((r) => r.value);
if (changes.length > 0) {
console.log(`\n${changes.length} change(s) detected — sending alert`);
await sendAlert(changes);
// Update baselines to current after alerting
for (const { page } of changes) {
const slug = page.name.toLowerCase().replace(/\s+/g, "-");
const currentPath = path.join(CONFIG.baselinesDir, `${slug}-current.png`);
const baselinePath = path.join(CONFIG.baselinesDir, `${slug}.png`);
await fs.copyFile(currentPath, baselinePath);
}
}
}
run().catch(console.error);
Schedule with node-cron
import cron from "node-cron";
// Check every day at 9am
cron.schedule("0 9 * * *", () => {
console.log(`[${new Date().toISOString()}] Running visual monitor...`);
run().catch(console.error);
});
console.log("Visual monitor running. Checks at 9am daily.");
GitHub Actions schedule (no server needed)
# .github/workflows/visual-monitor.yml
name: Visual change monitor
on:
schedule:
- cron: "0 9 * * 1-5" # Weekdays at 9am UTC
workflow_dispatch:
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore baselines cache
uses: actions/cache@v4
with:
path: baselines/
key: visual-baselines-${{ github.run_number }}
restore-keys: visual-baselines-
- name: Run visual monitor
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
ALERT_EMAIL: ${{ vars.ALERT_EMAIL }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
run: node scripts/visual-monitor.js
- name: Upload diff artifacts on change
if: failure() || success()
uses: actions/upload-artifact@v4
with:
name: visual-diffs-${{ github.run_id }}
path: baselines/*-diff.png
if-no-files-found: ignore
The baselines persist between runs via Actions cache — no external storage needed.
Slack alert instead of email
async function sendSlackAlert(changes) {
const blocks = changes.map(({ page, pct }) => ({
type: "section",
text: {
type: "mrkdwn",
text: `*${page.name}* changed by ${pct}%\n${page.url}`,
},
}));
await fetch(process.env.SLACK_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `👁 Visual change detected on ${changes.length} page(s)`,
blocks,
}),
});
}
Monitor competitor pages
const COMPETITOR_PAGES = [
{ name: "Competitor A — Pricing", url: "https://competitorA.com/pricing" },
{ name: "Competitor B — Pricing", url: "https://competitorB.com/pricing" },
{ name: "Competitor A — Homepage", url: "https://competitorA.com" },
];
Add these to CONFIG.pages and you get an automatic alert when a competitor updates their pricing, rewrites their homepage, or launches a new feature page.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)