DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to build a website change monitor that screenshots and diffs on a schedule

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

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" },
];
Enter fullscreen mode Exit fullscreen mode

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)