How to Build a Reliable Website Change Monitor: Headless Rendering, DOM Diffing, and Webhooks
Tracking changes on external websites is a common requirement in modern web development and DevOps. Whether you are monitoring upstream API documentation for breaking changes, watching competitor pricing, keeping track of government tender portals without RSS feeds, or ensuring your own production landing pages haven't broken, automated website monitoring is essential.
However, writing a simple while True loop with Python's requests or BeautifulSoup quickly fails in production due to:
- Client-Side Rendering (CSR): Single Page Applications (React, Vue, Svelte) rendering empty HTML shells.
- Noise and False Positives: Dynamic timestamps, ads, CSRF tokens, or session IDs triggering false alerts.
- Anti-Bot Blocking: WAFs and CDNs blocking frequent requests with 403 Forbidden errors.
In this guide, we'll walk through the architectural blueprint and code for building a low-noise, production-ready website change monitoring pipeline.
🛠️ System Architecture
A robust website change monitor consists of four decoupled layers:
+-------------------+ +-------------------+ +-------------------+
| 1. Cloud Renderer | ---> | 2. DOM Cleaner | ---> | 3. Diff Engine |
| (Playwright/Chrome)| | (CSS Selectors) | | (Text & Visual) |
+-------------------+ +-------------------+ +-------------------+
|
v
+-------------------+
| 4. Webhook Alerts |
| (Slack/Discord/API|
+-------------------+
Step 1: Headless Rendering & Resource Optimization
To capture fully hydrated SPA pages, we use Playwright. To reduce CPU/memory overhead and speed up page loads, we block unnecessary static assets like images, fonts, and tracking scripts.
import asyncio
from playwright.async_api import async_playwright
async def render_page(url: str, selector: str = None) -> str:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox']
)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
page = await context.new_page()
# Block non-essential network requests to save bandwidth and execution time
async def intercept_route(route):
if route.request.resource_type in ["image", "media", "font"]:
await route.abort()
else:
await route.continue_()
await page.route("**/*", intercept_route)
await page.goto(url, wait_until="networkidle", timeout=30000)
# Extract only target selector if specified, otherwise fall back to body
if selector:
element = await page.query_selector(selector)
content = await element.inner_text() if element else ""
else:
content = await page.inner_text("body")
await browser.close()
return content.strip()
Step 2: DOM Cleaning and Noise Filtering
Comparing full HTML snapshots leads to constant false positives due to dynamic timestamps, session IDs, and rotating ads.
To solve this, we apply two cleaning strategies:
-
Targeted Selector Scoping: Target only critical containers (e.g.,
#pricing-tableor.api-version-info). - Regex Normalization: Strip dynamic date/time patterns and whitespace.
import re
def sanitize_content(raw_text: str) -> str:
if not raw_text:
return ""
# Normalize whitespaces and tabs
clean = re.sub(r'\s+', ' ', raw_text)
# Mask dynamic date/time strings (e.g. YYYY-MM-DD, HH:MM:SS)
clean = re.sub(r'\d{4}[-/]\d{2}[-/]\d{2}( \d{2}:\d{2}:\d{2})?', '[TIMESTAMP]', clean)
clean = re.sub(r'\b\d+\s*(mins|hours|days)\s*ago\b', '[TIME_AGO]', clean, flags=re.IGNORECASE)
return clean.strip()
Step 3: Computing Differences (Text & Visual Diffing)
For textual comparison, Myers Diff algorithm (difflib in Python) allows us to compute precise additions and deletions.
import difflib
def compute_diff(old_text: str, new_text: str) -> str:
differ = difflib.UnifiedDiff(
old_text.splitlines(),
new_text.splitlines(),
fromfile='previous_version',
tofile='current_version',
lineterm=''
)
diff_lines = []
for line in differ:
if line.startswith('+') and not line.startswith('+++'):
diff_lines.append(f"ADDED: {line[1:]}")
elif line.startswith('-') and not line.startswith('---'):
diff_lines.append(f"REMOVED: {line[1:]}")
return "\n".join(diff_lines)
Step 4: Webhook Event Dispatching
Once a diff is detected, dispatching a structured JSON payload to a Webhook allows for seamless integration with Slack, Discord, or custom backend services.
from fastapi import FastAPI, Request
import requests
app = FastAPI()
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/your-channel-id/token"
@app.post("/webhook/site-change")
async def handle_change_alert(request: Request):
payload = await request.json()
site_url = payload.get("url")
diff_summary = payload.get("diff", "Page updated.")
# Dispatch Discord alert
message = {
"content": f"🔔 **Website Change Detected!**\nURL: {site_url}\n```
{% endraw %}
diff\n{diff_summary}\n
{% raw %}
```"
}
requests.post(DISCORD_WEBHOOK_URL, json=message)
return {"status": "success"}
⚡ Self-Hosting vs Managed Monitoring Tools
Building your own change monitor gives you full control, but managing headless Chrome instances, proxy rotation, and database state can become a heavy maintenance burden.
If you prefer an out-of-the-box, no-code solution, you can use PageWatch.tech. It manages the cloud headless browser cluster, visual CSS selector picking, text/snapshot diffing, and Webhook dispatches automatically, saving hours of infrastructure setup.
💡 Summary
To build an efficient website change monitor:
- Use Headless Chromium (Playwright/Puppeteer) to handle JavaScript-rendered SPA pages.
- Target specific CSS Selectors and sanitize dynamic timestamps to eliminate false alarms.
- Use Webhooks to trigger downstream automation scripts or chat notifications.
Happy coding!

Top comments (0)