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
- Fetch: Convert the URL to clean Markdown via UnWeb — strips navigation, ads, session tokens, and structural HTML noise.
- Store: Save Markdown snapshots in SQLite with timestamps.
- Diff: Compare current snapshot to previous. Generate a text diff of the Markdown.
- Classify: Send the diff to Claude to determine whether it’s a meaningful change (price update, availability change, new content) or noise.
- 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:
- Ad IDs and session tokens: Raw HTML includes dynamic values in
data-attributes, script tags, and tracking pixels that change on every load. Markdown strips all of this. - Layout changes vs. content changes: A site redesign in raw HTML produces thousands of diff lines even if the text content is identical. Markdown diff shows only text changes.
- Readable diffs: A Markdown diff is readable by a human or an LLM. A raw HTML diff is not — it requires parsing to extract meaning.
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.