A minimal clone of changedetection.io using Python & SQLite
Welcome to another official UVF IT build guide. In the next 45 minutes you’ll spin up a tiny change‑detection service that polls a URL, stores the page hash in SQLite, and alerts you via a simple web UI – inspired by changedetection.io.
We keep the stack free and local: Python 3, Flask, and the built‑in sqlite3 module. No external APIs, no Docker, just a few commands and a bit of code.
First we set up an isolated Python environment so our dependencies don't clash with system packages. Then we install Flask, the lightweight web framework that will serve our UI and handle requests. Finally we create a simple folder layout that keeps code, templates, and the SQLite file tidy.
The folder layout mirrors a typical Flask app: a top‑level app package for source, a templates directory for HTML, and a static folder for any CSS or images you might add later. This gives us a clean starting point to add features step‑by‑step.
# Create project directory and enter it
mkdir change-detector && cd change-detector
# Set up a virtual environment
python3 -m venv venv
source venv/bin/activate
# Upgrade pip and install Flask
pip install --upgrade pip
pip install Flask
# Scaffold folder structure
mkdir -p app/templates app/static
touch app/__init__.py app/models.py app/routes.pyWe need a tiny persistence layer to remember which URL we are watching and what its last known content hash was. SQLite is perfect because it requires no external server and ships with Python's standard library.
The helper below creates a pages table (if it doesn't exist) and provides two functions: save_page(url, hash) to upsert a record, and get_hash(url) to retrieve the stored hash. All interactions are wrapped in a context manager so connections close cleanly.
Later phases will call these helpers when polling the target page and when rendering the UI.
app/models.py
import sqlite3
from pathlib import Path
DB_PATH = Path(__file__).parent / "change_detector.db"
def _get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# Ensure the table exists
with _get_conn() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS pages (
url TEXT PRIMARY KEY,
hash TEXT NOT NULL
)
"""
)
conn.commit()
def save_page(url: str, hash_val: str) -> None:
"""Insert or update the hash for *url*.
Args:
url: The monitored page URL.
hash_val: SHA256 hex digest of the page content.
"""
with _get_conn() as conn:
conn.execute(
"INSERT INTO pages (url, hash) VALUES (?, ?) ON CONFLICT(url) DO UPDATE SET hash=excluded.hash",
(url, hash_val),
)
conn.commit()
def get_hash(url: str) -> str | None:
"""Return the stored hash for *url* or ``None`` if not present."""
with _get_conn() as conn:
cur = conn.execute("SELECT hash FROM pages WHERE url = ?", (url,))
row = cur.fetchone()
return row["hash"] if row else NoneIn this step we add the heart of the clone: fetching a remote page, turning its contents into a reproducible hash, and storing that hash in our SQLite database. When a user submits a URL through the web form we compare the new hash with the previously saved one and tell them if anything changed. The route also returns a tiny HTML page so you can see the result without any front‑end framework.
We keep everything tiny – a single Flask blueprint, a tiny helper function for hashing, and a very small SQLite schema that we created in earlier phases. This keeps the code runnable with just python -m flask run and makes it easy to extend later with background jobs or email alerts.
app/routes.py
from flask import Blueprint, request, render_template_string, flash, redirect, url_for
import hashlib, requests, sqlite3
bp = Blueprint('main', __name__)
def get_db():
conn = sqlite3.connect('data.db')
conn.row_factory = sqlite3.Row
return conn
def compute_hash(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
@bp.route('/', methods=['GET', 'POST'])
def index():
if request.method == 'POST':
url = request.form['url']
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
except Exception as e:
flash(f'Error fetching URL: {e}')
return redirect(url_for('main.index'))
new_hash = compute_hash(resp.content)
db = get_db()
cur = db.cursor()
cur.execute('SELECT hash FROM pages WHERE url = ?', (url,))
row = cur.fetchone()
if row:
if row['hash'] == new_hash:
flash('No change detected.')
else:
flash('Change detected!')
cur.execute('UPDATE pages SET hash = ? WHERE url = ?', (new_hash, url))
else:
cur.execute('INSERT INTO pages (url, hash) VALUES (?, ?)', (url, new_hash))
flash('URL added and monitored.')
db.commit()
return redirect(url_for('main.index'))
return render_template_string('''
<!doctype html><title>Change Detector</title>
<h1>Enter a URL to monitor</h1>
<form method=post><input name=url placeholder='https://example.com' size=50>
<input type=submit value='Check'></form>
{% with messages = get_flashed_messages() %}
{% if messages %}<ul>{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
{% endwith %}
''')Now that the route logic is in place we simply start Flask’s development server. The app is configured to look for the app package, so the command below will import our blueprint and launch the UI on localhost. Open a browser to the printed address and you’ll see the tiny form we just coded.
If you need to tweak the host or enable reloading, Flask’s CLI flags make that painless. This phase is just about confirming the server boots without errors and the UI appears.
export FLASK_APP=app
export FLASK_ENV=development
flask runWith the server running, use a browser or a simple curl command to submit a known page like https://example.com. The first submission stores the hash and reports “URL added and monitored.” Submitting the same URL again should give you “No change detected.” If you edit the page (or point to a different URL) you’ll see the “Change detected!” message.
From here you can start adding features: schedule periodic checks with APScheduler, send email alerts via SMTP, or render a diff view with the difflib module. The scaffold we built is intentionally minimal so you can plug in any of those extensions without fighting legacy code.
curl -s -X POST -d 'url=https://example.com' http://127.0.0.1:5000/ | grep -i 'added\|no change\|change'| Check | Command / Why it matters |
|---|---|
| Server starts without errors | flask run → should display *Running on http://127.0.0.1:5000/* |
| Web UI loads and shows the form | Open http://127.0.0.1:5000/ in a browser – form appears |
| Submitting a URL reports "No change detected." | Enter https://example.com → page shows that message |
You now have a functional change detector. Consider adding periodic background jobs (Celery or APScheduler), multi‑user support, and richer diff rendering to approach the full changedetection.io feature set.