A small self-hosted watcher for web pages and listings. It checks pages on a schedule and pings you on Telegram when something you care about shows up. For example, a used bike you want gets listed, an item comes back in stock, or a page changes.
- One Go binary with no database. State is a couple of JSON files next to the config.
- Alerts go to Telegram (any number of chats), ntfy, and a local text file.
- Pages that only fill in with JavaScript are loaded in headless Chrome.
- A small phone web app (PWA) shows watcher health and recent alerts. It's meant to be reached over Tailscale or a LAN.
- Send
/healthto the Telegram bot for a summary of all watchers.
go build -o sitewatch .
echo '{"auth_token": "'$(openssl rand -hex 24)'"}' > secrets.json && chmod 600 secrets.json
./sitewatchOpen http://localhost:8478 and paste the auth_token from secrets.json. The bundled demo-local
watcher alerts once the demo page contains its keyword. Open http://localhost:8478/demo-page?on=1 to
see what that looks like. The watcher itself always fetches the page without ?on=1, so it never fires
on its own.
Everything lives in config.json, which is reloaded every 30 seconds, so there's no need to restart
after editing it. Each watcher has a name, a url and an interval in seconds (default 300).
This is the main use. items picks the listings out of the page with CSS selectors, and each new
listing that passes the filter triggers an alert. The first check records what is already there
without alerting.
{
"name": "drivex-assured-blr",
"url": "https://www.drivex.in/buy-used-assured-bikes-in-bangalore",
"interval": 1800,
"items": {
"title": "p[class*='max-w-[220px]']",
"detail": "p[class*='text-neutral300'], p[class*='text-[18px]'][class*='text-neutral900']"
},
"include": ["duke", "\\br15(\\b|m)", "himalayan", "classic 350"]
}items.titleis required.detailcan match several elements, and their texts are joined with " · ".linkdefaults to the nearest link.itemoptionally selects each listing's card.includeandexcludeare case-insensitive regular expressions (RE2) matched against the title. A top-levelfiltersupplies defaults for every watcher that doesn't set its own.- Listings are remembered forever, so one that drops off the first page and comes back doesn't alert twice.
- Editing a filter doesn't cause an alert storm. Listings that match only because of the edit are marked as seen quietly.
- If nothing matches the selectors at all, the status becomes
selector_missing. That usually means the site changed its layout.
Use selector (optional CSS) together with a mode:
| mode | alerts when |
|---|---|
changed |
the text changes (the default) |
contains |
any of keywords appears |
not_contains |
none of keywords is present, e.g. "Out of stock" |
regex |
pattern (RE2) matches |
For example, an item coming back in stock:
{ "name": "restock", "url": "https://shop.example/item", "selector": ".buy-box",
"mode": "not_contains", "keywords": ["Out of stock", "Sold out"] }Add "render": true to load the page in headless Chrome or Chromium first. It is found on PATH, or
set "chrome" in the config. Only one browser runs at a time, and each render gets a throwaway profile.
Give render watchers a longer interval, such as 1800 seconds or more.
./sitewatch preview <watcher> runs one extraction and prints the result. It sends no alerts and saves
no state.
- Create a bot with @BotFather, open it, and press Start.
- With sitewatch stopped, run
./sitewatch telegram-setup <bot_token>. This saves the token and your chat id tosecrets.jsonand sends a test message.
Every chat in telegram.chat_ids gets the alerts and can use /health. To add another person, have
them press Start on the bot. The log then shows telegram: unlisted chat <id>, and you add that id to
chat_ids in secrets.json. The change is picked up without a restart.
config.json is safe to commit. Secrets go in secrets.json, which is gitignored and overlaid on top
of config.json:
{
"auth_token": "long random string",
"telegram": { "bot_token": "123:abc", "chat_ids": [111111111, 222222222] }
}Top-level settings:
hostandport(default0.0.0.0:8478).user_agent.notify_file, a local log of alerts.ntfy_topic.filter, the default include/exclude patterns.chrome, the browser path for render watchers.
Changing host, port or auth_token needs a restart.
# edit the paths in sitewatch.service if the checkout isn't at ~/sitewatch
ln -s "$PWD/sitewatch.service" ~/.config/systemd/user/
systemctl --user enable --now sitewatch
loginctl enable-linger "$USER" # keep running after logout
journalctl --user -u sitewatch -f./sitewatch rebaseline(service stopped) marks everything currently listed as seen, without alerts.go test ./...runs the tests.
All endpoints need Authorization: Bearer <auth_token>.
GET /api/statusreturns every watcher's last check, status and last alert.GET /api/events?limit=50returns recent alerts, newest first.POST /api/check/{name}checks a watcher now.
PolyForm Noncommercial 1.0.0. You're free to use, modify and share it for personal, hobby, research, educational or other non-commercial purposes. Commercial use needs separate permission from the author.