Monitor Website Changes with Python

Website change monitoring is one of those tasks that seems simple until you actually implement it. Fetch the page, compare to the last version, send an alert if different. Three steps.

The problem is “compare to the last version.” Raw HTML changes constantly — ad IDs, session tokens, dynamic content blocks, timestamps — even when the content you care about hasn’t changed. A naive diff of raw HTML produces so many false positives that alerts become meaningless within days.

The approach that works: convert the page to clean Markdown first, diff the Markdown, then use Claude to classify whether the diff represents a meaningful change. This eliminates HTML noise, makes diffs readable, and lets you ask specific questions like “did the price change?” rather than just “did anything change?”

Architecture

  1. Fetch: Convert the URL to clean Markdown via UnWeb — strips navigation, ads, session tokens, and structural HTML noise.
  2. Store: Save Markdown snapshots in SQLite with timestamps.
  3. Diff: Compare current snapshot to previous. Generate a text diff of the Markdown.
  4. Classify: Send the diff to Claude to determine whether it’s a meaningful change (price update, availability change, new content) or noise.
  5. Alert: Send a Slack or email notification with Claude’s summary of what changed.

Prerequisites: Python 3.11+, pip install httpx anthropic schedule, an UnWeb API key (app.unweb.info), and an Anthropic API key.

Database Setup

import sqlite3
import difflib
import hashlib
from datetime import datetime

DB_PATH = "website_monitor.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS snapshots (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            url TEXT NOT NULL,
            content_hash TEXT NOT NULL,
            markdown TEXT NOT NULL,
            captured_at TEXT NOT NULL
        )
    """)
    conn.execute("CREATE INDEX IF NOT EXISTS idx_url_time ON snapshots(url, captured_at)")
    conn.commit()
    conn.close()

def get_latest_snapshot(url: str) -> tuple[str, str] | None:
    """Returns (content_hash, markdown) or None if no snapshot exists."""
    conn = sqlite3.connect(DB_PATH)
    row = conn.execute(
        "SELECT content_hash, markdown FROM snapshots WHERE url = ? ORDER BY captured_at DESC LIMIT 1",
        (url,)
    ).fetchone()
    conn.close()
    return row

def save_snapshot(url: str, markdown: str):
    content_hash = hashlib.sha256(markdown.encode()).hexdigest()
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        "INSERT INTO snapshots (url, content_hash, markdown, captured_at) VALUES (?, ?, ?, ?)",
        (url, content_hash, markdown, datetime.utcnow().isoformat())
    )
    conn.commit()
    conn.close()
    return content_hash

Fetch and Diff

import httpx
import os

UNWEB_API_KEY = os.environ["UNWEB_API_KEY"]

async def fetch_markdown(url: str) -> str:
    async with httpx.AsyncClient(timeout=30) as client:
        response = await client.get(
            "https://api.unweb.info/v1/convert",
            params={"url": url, "format": "markdown"},
            headers={"Authorization": f"Bearer {UNWEB_API_KEY}"},
        )
        response.raise_for_status()
        markdown = response.json().get("markdown", "")
    if len(markdown) < 100:
        raise ValueError(f"Page content too short — possible bot block: {url}")
    return markdown

def compute_diff(old_markdown: str, new_markdown: str) -> str:
    """Returns a unified diff of the two Markdown versions."""
    old_lines = old_markdown.splitlines(keepends=True)
    new_lines = new_markdown.splitlines(keepends=True)
    diff_lines = list(difflib.unified_diff(
        old_lines, new_lines,
        fromfile="previous",
        tofile="current",
        n=3  # context lines
    ))
    return "".join(diff_lines)

Intelligent Change Classification with Claude

A Markdown diff still contains noise — minor rewording, punctuation changes, line-wrapping differences. Claude filters these and extracts what actually changed.

import anthropic
import json

def classify_change(url: str, diff: str, watch_for: str = "any meaningful content change") -> dict:
    """
    Returns {"significant": bool, "summary": str, "details": str}
    watch_for: natural language description of what to watch for
    Examples: "price changes", "availability changes", "new job postings"
    """
    client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

    message = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": f"""Analyze this diff from a website ({url}) and determine if it represents a significant change.

I'm watching for: {watch_for}

Diff:
{diff[:4000]}

Return a JSON object with:
- "significant": true if this is a meaningful change I should know about, false if it's noise (minor rewording, whitespace, dynamic content like ads or timestamps)
- "summary": one sentence describing what changed (or "No significant change" if not significant)
- "details": 2-3 sentences with specifics if significant, empty string if not

Return only the JSON object."""
        }]
    )

    raw = message.content[0].text.strip()
    if raw.startswith("```"):
        raw = raw.split("```")[1]
        if raw.startswith("json"):
            raw = raw[4:]
    return json.loads(raw.strip())

Alert System

import smtplib
from email.mime.text import MIMEText

def send_slack_alert(webhook_url: str, message: str):
    import urllib.request
    payload = json.dumps({"text": message}).encode()
    req = urllib.request.Request(
        webhook_url,
        data=payload,
        headers={"Content-Type": "application/json"}
    )
    urllib.request.urlopen(req)

def send_email_alert(to_email: str, subject: str, body: str):
    smtp_host = os.environ.get("SMTP_HOST", "smtp.gmail.com")
    smtp_port = int(os.environ.get("SMTP_PORT", "587"))
    smtp_user = os.environ["SMTP_USER"]
    smtp_pass = os.environ["SMTP_PASS"]

    msg = MIMEText(body)
    msg["Subject"] = subject
    msg["From"] = smtp_user
    msg["To"] = to_email

    with smtplib.SMTP(smtp_host, smtp_port) as server:
        server.starttls()
        server.login(smtp_user, smtp_pass)
        server.send_message(msg)

The Monitor Loop

import asyncio
import schedule
import time

# Configure what to monitor
WATCH_LIST = [
    {
        "url": "https://competitor.com/pricing",
        "watch_for": "pricing changes or new plan tiers",
        "alert_email": "team@company.com",
    },
    {
        "url": "https://jobs.company.com/engineering",
        "watch_for": "new job postings added or positions removed",
        "slack_webhook": os.environ.get("SLACK_WEBHOOK_URL"),
    },
    {
        "url": "https://status.dependency.com",
        "watch_for": "service status changes or new incidents",
        "alert_email": "oncall@company.com",
    },
]

async def check_url(config: dict):
    url = config["url"]
    watch_for = config.get("watch_for", "any meaningful content change")

    try:
        new_markdown = await fetch_markdown(url)
    except Exception as e:
        print(f"[FETCH ERROR] {url}: {e}")
        return

    previous = get_latest_snapshot(url)
    new_hash = hashlib.sha256(new_markdown.encode()).hexdigest()

    if previous is None:
        # First run — save baseline
        save_snapshot(url, new_markdown)
        print(f"[BASELINE] Saved initial snapshot for {url}")
        return

    prev_hash, prev_markdown = previous

    if prev_hash == new_hash:
        print(f"[NO CHANGE] {url}")
        return

    # Content changed — compute diff and classify
    diff = compute_diff(prev_markdown, new_markdown)
    result = classify_change(url, diff, watch_for)

    if result["significant"]:
        # Save new snapshot
        save_snapshot(url, new_markdown)

        # Build alert message
        alert_msg = f"🔔 Change detected: {url}\n\n{result['summary']}\n\n{result['details']}"
        print(f"[ALERT] {alert_msg}")

        if "alert_email" in config:
            send_email_alert(config["alert_email"], f"Change detected: {url}", alert_msg)

        if "slack_webhook" in config and config["slack_webhook"]:
            send_slack_alert(config["slack_webhook"], alert_msg)
    else:
        print(f"[NOISE] {url}: {result['summary']}")
        # Don't save snapshot — wait for a significant change to update baseline

async def run_all_checks():
    tasks = [check_url(config) for config in WATCH_LIST]
    await asyncio.gather(*tasks)

def scheduled_check():
    asyncio.run(run_all_checks())

if __name__ == "__main__":
    init_db()
    print("Starting website monitor...")

    # Run immediately on startup
    scheduled_check()

    # Then run every 30 minutes
    schedule.every(30).minutes.do(scheduled_check)

    while True:
        schedule.run_pending()
        time.sleep(60)

Why Markdown Diff Beats Raw HTML Diff

Raw HTML for a typical page contains 15,000–40,000 tokens of content. After UnWeb conversion, it’s 1,000–4,000 tokens of actual content. The practical consequence for diffing:

The combination of Markdown conversion + LLM classification means you can ask precise questions: “did the price change?” instead of “did any bytes change?” This shifts change monitoring from noise filtering to genuine alerting.

Extending the System

Watch for specific fields: Pass a structured schema to Claude alongside the diff — “I care about: price, availability, shipping time” — and get back a structured JSON alert instead of a text summary. This enables programmatic downstream actions (update a database, trigger a workflow) rather than just notifications.

Multiple check frequencies: Some pages change hourly (stock prices, sports scores), others weekly (competitor features, job boards). Use separate schedule entries with different intervals rather than checking everything at the same rate.

Snapshot retention: The current implementation keeps all snapshots. For long-running monitors, add a cleanup job that removes snapshots older than 30 days, keeping only the most recent per URL.

For a related use case — tracking prices specifically with change history and thresholds — see our guide to building a competitor price monitor in Python.

UnWeb: Clean Markdown from any URL

The foundation of this monitoring pipeline. One API call converts any page to clean, diffable Markdown — stripping HTML noise, dynamic content, and structural boilerplate.

Get started free →

← Back to Blog