A self-hosted service that runs scheduled web monitors with Playwright, tracks value changes in SQLite, and sends notifications via Apprise.
Drop a Python file in monitors/, define a Monitor with a name, cron schedule, and notify channels, then decorate an async function with @monitor.check. On startup, the scheduler discovers all monitor files and runs them on schedule. The dashboard at http://localhost:8000 shows status, last value, and run history for each monitor.
uv sync
uv run playwright install chromium
uv run uvicorn app.main:app --reloadmonitors/example_price.py:
from app.helpers import Monitor, extract_text, get_last_value, set_value, notify, record_metric
monitor = Monitor(
name="example_price",
schedule="*/30 * * * *",
notify_channels=["telegram"],
url="https://example.com/product",
)
@monitor.check
async def check(page, ctx):
await page.goto(monitor.url)
raw = await extract_text(page, ".price")
price = float(raw.replace("€", "").replace(",", ".").strip())
if ctx.influx:
await record_metric(ctx.influx, "price", price, monitor=monitor.name)
last = await get_last_value(ctx.db, monitor.name)
if last is not None and price < float(last) and ctx.apprise:
await notify(
ctx.apprise,
title=f"{monitor.name}: price dropped",
body=f"€{last} → €{price}",
tags=monitor.notify_channels,
)
await set_value(ctx.db, monitor.name, str(price))Monitor(name, schedule, notify_channels, url)—scheduleis a crontab string (e.g."*/30 * * * *")@monitor.checkregisters the async function as the check to run on each scheduled invocationpageis a PlaywrightPageobject — each run gets a fresh browser contextctxcarries.db,.apprise,.influx, and.logger; check forNonebefore using optional integrations- Helpers available:
extract_text,extract_json,get_last_value,set_value,notify,record_metric
| Variable | Default | Description |
|---|---|---|
DB_PATH |
/data/state.db |
SQLite database path |
MONITORS_DIR |
./monitors |
Directory to load monitors from |
APPRISE_URL_<channel> |
— | Apprise notification URL for channel name <channel> (e.g. APPRISE_URL_TELEGRAM) |
INFLUXDB_URL |
— | InfluxDB endpoint (optional) |
INFLUXDB_TOKEN |
— | InfluxDB auth token (optional) |
INFLUXDB_ORG |
— | InfluxDB org (optional) |
INFLUXDB_BUCKET |
— | InfluxDB bucket (optional) |
Notification channels are configured by setting APPRISE_URL_<CHANNEL> environment variables. The <channel> part (lowercased) must match a name used in notify_channels when defining a monitor.
The Docker image is published to ghcr.io/stevendejongnl/changewatch:latest and also tagged vX.Y.Z for each release.
Kubernetes manifests live in k8s/. To deploy:
- Copy secret templates:
cp k8s/secrets/*.example.yaml k8s/secrets/*.yaml - Fill in values (Apprise URLs, InfluxDB credentials)
- Apply:
kubectl apply -k k8s/
A persistent volume must be mounted at DB_PATH (/data by default) to preserve state across restarts. The deployment uses strategy: Recreate — only one replica should run at a time since SQLite does not support concurrent writers.
make install-hooks # wire pre-commit / pre-push hooks
make test # run full suite (100% coverage required)Test files use the *_test.py suffix. Conventional commits (feat:, fix:, etc.) trigger automatic releases via semantic-release, which publishes a GitHub Release and a versioned Docker image to GHCR.