Build a Website Monitoring Bot with a Screenshot API and Python
Want to know when a website changes? Not just whether it's up or down — but whether the content actually looks different? Screenshot-based monitoring catches visual changes that HTTP status checks miss: layout breaks, missing images, content swaps, and accidental deployments.
Here's how to build a simple visual monitoring bot in Python using a free screenshot API.
The Concept
- Take a screenshot of a URL at regular intervals
- Compare each new screenshot to the previous one
- Alert when the difference exceeds a threshold
This catches things that traditional monitoring misses: - CSS breaks that don't produce HTTP errors - A/B test variants accidentally shown to everyone - CDN serving stale or broken assets - Third-party widget failures (chat widgets, analytics banners) - Content management mistakes (wrong image, broken layout)
The Code
import requests
import hashlib
import os
import time
from datetime import datetime
SCREENSHOT_API = "https://hermesforge.dev/api/screenshot"
WATCH_DIR = "screenshots"
URLS = [
"https://yoursite.com",
"https://yoursite.com/pricing",
"https://yoursite.com/docs",
]
def take_screenshot(url, width=1280):
"""Capture a screenshot and return the image bytes."""
resp = requests.get(SCREENSHOT_API, params={
"url": url,
"width": width,
"format": "png",
"block_ads": "true",
}, timeout=30)
resp.raise_for_status()
return resp.content
def get_image_hash(image_bytes):
"""Get a hash of the image for quick comparison."""
return hashlib.sha256(image_bytes).hexdigest()
def monitor_url(url):
"""Take a screenshot and compare to the last known state."""
slug = url.replace("https://", "").replace("/", "_").rstrip("_")
current_path = os.path.join(WATCH_DIR, f"{slug}_current.png")
previous_path = os.path.join(WATCH_DIR, f"{slug}_previous.png")
hash_path = os.path.join(WATCH_DIR, f"{slug}.hash")
# Take new screenshot
image_bytes = take_screenshot(url)
new_hash = get_image_hash(image_bytes)
# Load previous hash
old_hash = None
if os.path.exists(hash_path):
with open(hash_path) as f:
old_hash = f.read().strip()
# Compare
changed = old_hash is not None and new_hash != old_hash
# Save current state
if os.path.exists(current_path):
os.rename(current_path, previous_path)
with open(current_path, "wb") as f:
f.write(image_bytes)
with open(hash_path, "w") as f:
f.write(new_hash)
return changed
def send_alert(url):
"""Send an alert when a change is detected."""
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
print(f"[ALERT] {timestamp}: Visual change detected on {url}")
# Add your preferred notification here:
# - Slack webhook
# - Email via SMTP
# - Discord webhook
# - PagerDuty event
def main():
os.makedirs(WATCH_DIR, exist_ok=True)
for url in URLS:
try:
changed = monitor_url(url)
if changed:
send_alert(url)
else:
print(f"[OK] No change: {url}")
except Exception as e:
print(f"[ERROR] {url}: {e}")
if __name__ == "__main__":
main()
Running It on a Schedule
Save the script as monitor.py and add a cron job:
# Check every 15 minutes
*/15 * * * * cd /path/to/monitor && python3 monitor.py >> monitor.log 2>&1
Or every hour if you don't need frequent checks:
0 * * * * cd /path/to/monitor && python3 monitor.py >> monitor.log 2>&1
Adding Slack Notifications
Replace the send_alert function:
import json
SLACK_WEBHOOK = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
def send_alert(url):
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
payload = {
"text": f":warning: Visual change detected on <{url}|{url}> at {timestamp}"
}
requests.post(SLACK_WEBHOOK, json=payload)
Pixel-Level Comparison (Advanced)
The hash-based approach detects any change, even a single pixel. For more nuanced comparison, use Pillow to calculate the percentage of changed pixels:
from PIL import Image
import io
def compare_images(img1_bytes, img2_bytes, threshold=0.01):
"""Return True if images differ by more than threshold (0.01 = 1%)."""
img1 = Image.open(io.BytesIO(img1_bytes)).convert("RGB")
img2 = Image.open(io.BytesIO(img2_bytes)).convert("RGB")
if img1.size != img2.size:
return True # Different dimensions = definitely changed
pixels1 = list(img1.getdata())
pixels2 = list(img2.getdata())
total = len(pixels1)
diff = sum(1 for p1, p2 in zip(pixels1, pixels2) if p1 != p2)
return (diff / total) > threshold
This lets you ignore minor rendering differences (anti-aliasing, font smoothing) and only alert on significant visual changes.
What This Catches That Uptime Monitors Don't
| Scenario | HTTP Status | Visual Monitor |
|---|---|---|
| Site is down | Catches it | Catches it |
| 500 error page | Catches it | Catches it |
| CSS file 404 (broken layout) | 200 OK | Catches it |
| Wrong image deployed | 200 OK | Catches it |
| JavaScript error breaks rendering | 200 OK | Catches it |
| Third-party widget disappears | 200 OK | Catches it |
| Content accidentally deleted | 200 OK | Catches it |
The HTTP status code is a necessary but not sufficient check. Visual monitoring adds the layer that catches everything the status code misses.
Cost
The screenshot API is free for basic usage — no API key required. For monitoring 3 URLs every 15 minutes, that's 288 requests per day. If you need more, get a free API key for higher rate limits.