Three Detection Approaches
- Visual diff (pixel comparison) — screenshot → compare pixels → alert on visual change. Catches layout changes, new UI elements, color changes.
- Text content diff — scrape text → compare strings → alert on content change. Best for price monitoring, news, legal documents.
- DOM structure diff — compare HTML structure → alert on structural change. Catches element addition/removal without caring about content.
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 →