Website Change Detection in 2026

Monitor websites for visual changes, content updates, and price changes. Build a production-grade detector with pixel diffs, text comparison, and automated Slack/email alerts.

Node.jsPlaywrightpixelmatch Change DetectionMonitoringApril 2026

Three Detection Approaches

Visual Diff with pixelmatch

npm install pixelmatch pngjs node-fetch
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';

const SNAPAPI_KEY = process.env.SNAPAPI_KEY;

async function captureScreenshot(url) {
  const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
    method: 'POST',
    headers: { 'X-Api-Key': SNAPAPI_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url, fullPage: true, format: 'png', blockAds: true }),
  });
  return Buffer.from(await res.arrayBuffer());
}

async function visualDiff(name, url, threshold = 0.01) {
  const baselineDir = './baselines';
  const diffsDir = './diffs';
  await mkdir(baselineDir, { recursive: true });
  await mkdir(diffsDir, { recursive: true });

  const currentBuffer = await captureScreenshot(url);
  const baselinePath = `${baselineDir}/${name}.png`;

  // First run — save baseline
  if (!existsSync(baselinePath)) {
    await writeFile(baselinePath, currentBuffer);
    return { name, changed: false, firstRun: true };
  }

  const baseline = PNG.sync.read(await readFile(baselinePath));
  const current  = PNG.sync.read(currentBuffer);
  const { width, height } = baseline;

  // Images must match dimensions — if different, page layout changed drastically
  if (current.width !== width || current.height !== height) {
    await writeFile(baselinePath, currentBuffer);
    return { name, changed: true, reason: 'dimensions changed', width: { from: width, to: current.width } };
  }

  const diff = new PNG({ width, height });
  const mismatch = pixelmatch(baseline.data, current.data, diff.data, width, height, { threshold: 0.1 });
  const changeRatio = mismatch / (width * height);

  if (changeRatio > threshold) {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    await writeFile(`${diffsDir}/${name}-diff-${timestamp}.png`, PNG.sync.write(diff));
    await writeFile(`${diffsDir}/${name}-current-${timestamp}.png`, currentBuffer);
    await writeFile(baselinePath, currentBuffer);  // update baseline

    return {
      name, changed: true,
      changePercent: (changeRatio * 100).toFixed(2),
      diffPath: `${diffsDir}/${name}-diff-${timestamp}.png`,
    };
  }

  return { name, changed: false, changePercent: (changeRatio * 100).toFixed(2) };
}

Text Content Monitoring

import * as Diff from 'diff';  // npm install diff

async function scrapeText(url, selector = 'body') {
  const res = await fetch('https://api.snapapi.pics/v1/scrape', {
    method: 'POST',
    headers: { 'X-Api-Key': SNAPAPI_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url, waitUntil: 'networkidle' }),
  });
  const { html } = await res.json();

  // Extract text from HTML (simple regex approach)
  return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}

async function textDiff(name, url) {
  const currentText = await scrapeText(url);
  const baselinePath = `./baselines/${name}.txt`;

  if (!existsSync(baselinePath)) {
    await writeFile(baselinePath, currentText, 'utf-8');
    return { name, changed: false, firstRun: true };
  }

  const baselineText = await readFile(baselinePath, 'utf-8');
  const changes = Diff.diffWords(baselineText, currentText);
  const hasChanges = changes.some(c => c.added || c.removed);

  if (hasChanges) {
    await writeFile(baselinePath, currentText, 'utf-8');
    const summary = changes
      .filter(c => c.added || c.removed)
      .slice(0, 5)
      .map(c => `${c.added ? '+' : '-'} "${c.value.slice(0, 60)}"`)
      .join('\n');
    return { name, changed: true, summary };
  }

  return { name, changed: false };
}

Price Change Monitor

async function monitorPrice(name, url, selector) {
  const res = await fetch('https://api.snapapi.pics/v1/extract', {
    method: 'POST',
    headers: { 'X-Api-Key': SNAPAPI_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url,
      schema: {
        price: { type: 'string', description: 'Product price including currency symbol' },
        inStock: { type: 'boolean', description: 'Whether the product is in stock' },
        title: { type: 'string', description: 'Product name' },
      },
    }),
  });
  const { data } = await res.json();

  const baselinePath = `./baselines/${name}-price.json`;
  const current = { price: data.price, inStock: data.inStock, checkedAt: new Date().toISOString() };

  if (!existsSync(baselinePath)) {
    await writeFile(baselinePath, JSON.stringify(current, null, 2));
    return { name, changed: false, firstRun: true, current };
  }

  const baseline = JSON.parse(await readFile(baselinePath, 'utf-8'));

  if (baseline.price !== current.price || baseline.inStock !== current.inStock) {
    await writeFile(baselinePath, JSON.stringify(current, null, 2));
    return {
      name, changed: true,
      previous: { price: baseline.price, inStock: baseline.inStock },
      current: { price: current.price, inStock: current.inStock },
      title: data.title,
    };
  }

  return { name, changed: false, current };
}

Slack & Email Alerts

// Slack alert with diff image
async function alertSlack(result) {
  const emoji = result.changePercent ? `🔴 ${result.changePercent}% visual change` : '📝 Content changed';
  await fetch(process.env.SLACK_WEBHOOK, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      blocks: [
        {
          type: 'section',
          text: { type: 'mrkdwn', text: `${emoji} detected on *${result.name}*` },
        },
        result.summary && {
          type: 'section',
          text: { type: 'mrkdwn', text: `\`\`\`${result.summary}\`\`\`` },
        },
        result.previous && {
          type: 'section',
          text: { type: 'mrkdwn', text: `Price: *${result.previous.price}* → *${result.current.price}*` },
        },
      ].filter(Boolean),
    }),
  });
}

// Email alert via nodemailer
import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: 'smtp.gmail.com', port: 587, secure: false,
  auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS },
});

async function alertEmail(result) {
  await transporter.sendMail({
    from: process.env.EMAIL_USER,
    to: process.env.ALERT_RECIPIENTS,
    subject: `[Change Detected] ${result.name}`,
    html: `
      

Change detected: ${result.name}

${result.changePercent ? `${result.changePercent}% visual change` : 'Content changed'}

${result.summary ? `
${result.summary}
` : ''} `, attachments: result.diffPath ? [{ path: result.diffPath, filename: 'diff.png' }] : [], }); }

Complete Monitor Script

// monitor.js — run with node-cron or a GitHub Actions schedule
const MONITORS = [
  { name: 'competitor-pricing', url: 'https://competitor.com/pricing', type: 'visual' },
  { name: 'product-a-price',    url: 'https://shop.com/product-a',    type: 'price' },
  { name: 'terms-of-service',   url: 'https://vendor.com/terms',      type: 'text' },
  { name: 'status-page',        url: 'https://status.service.com',    type: 'visual', threshold: 0.05 },
];

async function runMonitors() {
  console.log(`Running ${MONITORS.length} monitors at ${new Date().toISOString()}`);

  for (const monitor of MONITORS) {
    try {
      let result;
      if (monitor.type === 'visual')  result = await visualDiff(monitor.name, monitor.url, monitor.threshold);
      if (monitor.type === 'text')    result = await textDiff(monitor.name, monitor.url);
      if (monitor.type === 'price')   result = await monitorPrice(monitor.name, monitor.url);

      if (result.changed) {
        console.log(`🔴 CHANGE: ${monitor.name}`);
        await alertSlack(result);
        await alertEmail(result);
      } else {
        console.log(`✓ No change: ${monitor.name} (${result.changePercent ?? ''}%)`);
      }
    } catch (err) {
      console.error(`Error monitoring ${monitor.name}:`, err.message);
    }
  }
}

runMonitors();
SnapAPI free tier gives you 200 captures/month — enough to monitor 5 URLs daily with room to spare. Get your API key →