Turn any web page into an RSS feed by watching it for changes.
A lot of pages change quietly — a price drops, an out-of-stock item comes back, a new job shows up on a board, opening hours get edited — but they never offer a feed or a notification to tell you. So you end up reloading the page by hand.
PagePulse does that for you: point it at a URL, tell it which part of the page matters (via a CSS selector), and it emits an RSS/Atom feed entry whenever that part actually changes. Hang the feed in any reader, or have it fire a webhook.
- Price watching — get an entry when a product's price changes.
- Back in stock — watch an availability label flip from "Sold out".
- Job boards — notice a new posting on a careers page that has no feed.
- Anything that quietly edits a page — opening hours, version numbers, status banners, a changing list.
- Watch any number of pages, all configured in one YAML file.
- Extract just the relevant region with a CSS selector (not the whole page, so you get far fewer false alarms).
- Generate an RSS or Atom feed of the detected changes.
- Optional webhook per watcher (Discord, Slack, your own endpoint).
- Snapshot history kept in a local SQLite file.
- Ignore patterns to drop volatile noise (timestamps, view counters).
- Run once (
run) or continuously (watch).
PagePulse needs Node.js 20 or newer.
# global
npm install -g pagepulse
# or one-off, without installing
npx pagepulse run# 1. scaffold a config
pagepulse init
# 2. edit pagepulse.yaml: set the url and the CSS selector you care about
# 3. run a pass — writes feeds into ./feeds
pagepulse runThe first run records an initial snapshot for each watcher; from then on you only get an entry when the selected content actually changes.
A config is a YAML file (default pagepulse.yaml). Top-level keys:
| Key | Default | Meaning |
|---|---|---|
database |
./data/pagepulse.db |
SQLite file for snapshots and the change log. |
feedDir |
./feeds |
Directory the feed files are written to. |
interval |
1h |
Default poll interval for watch. |
userAgent |
a PagePulse UA string | Sent on every request. |
watchers |
— | The list of pages to watch (see below). |
Each watcher:
| Field | Required | Meaning |
|---|---|---|
id |
yes | Stable id; used for the SQLite rows and the feed file name. |
name |
no | Label shown in the feed and in pagepulse list. |
url |
yes | The page to fetch. |
selector |
yes | CSS selector for the part of the page that matters. |
attr |
no | Read this attribute instead of the element's text. |
interval |
no | Per-watcher poll interval, overrides the global one. |
ignore |
no | List of regexes stripped before comparing (volatile noise). |
webhook |
no | { url, method?, headers? } fired on every detected change. |
Durations accept s, m, h suffixes: 90s, 30m, 2h.
The selector is what keeps PagePulse from crying wolf on every layout tweak.
- Open the page, right-click the bit you care about, Inspect.
- Look for a stable hook: an
id(#price), a class (.availability), or adata-attribute. - Prefer something specific and stable over a long auto-generated path. In
your browser console,
document.querySelector("#price")?.textContentshould return exactly the text you want to watch. - If the value lives in an attribute (e.g.
data-price="19.99"), setattron the watcher to read that instead of the visible text.
pagepulse run writes one feed per watcher plus a combined all.xml into
feedDir:
feeds/
├── example-price.xml
├── example-stock.xml
└── all.xml
Serve that directory (or sync it somewhere your reader can reach) and subscribe
to the file. Your reader will then receive a new entry each time the watched
content changes. Prefer Atom? Pass --format atom.
Give a watcher a webhook and PagePulse POSTs a JSON payload on every change:
watchers:
- id: widget-price
url: https://shop.example.com/widget
selector: "#price"
webhook:
url: https://discord.com/api/webhooks/xxx/yyyThe payload looks like:
{
"watcherId": "widget-price",
"name": "Widget price",
"url": "https://shop.example.com/widget",
"previous": "$19.99",
"current": "$17.49",
"detectedAt": "2026-04-28T18:20:00.000Z"
}Failed deliveries are retried a couple of times with a short backoff.
A few starting points — adjust the selectors to the actual page.
Price drop
watchers:
- id: widget-price
name: "Widget price"
url: https://shop.example.com/widget
selector: "#price"
attr: data-price # often cleaner than the formatted text
interval: 2hBack in stock
watchers:
- id: console-stock
name: "Console availability"
url: https://shop.example.com/console
selector: ".availability"
interval: 30m
webhook:
url: https://discord.com/api/webhooks/xxx/yyyNew job posting
watchers:
- id: careers
name: "Careers page"
url: https://example.com/careers
# watch the list container; a new item changes its text
selector: "#openings"
interval: 6h
ignore:
- "Last updated.*" # the page stamps an update time we don't care aboutpagepulse watch keeps polling each watcher on its interval and rewrites the
feeds in place. Stop it with Ctrl+C — it finishes the current pass first.
pagepulse watch -c pagepulse.yamlPrefer cron? Run a single pass on a schedule and let cron be the loop:
# every 30 minutes
*/30 * * * * cd /srv/pagepulse && /usr/bin/pagepulse run >> pagepulse.log 2>&1A Dockerfile is included. Build the image, then mount your config and the
data/feed directories:
docker build -t pagepulse .
docker run --rm \
-v "$PWD/pagepulse.yaml:/app/pagepulse.yaml:ro" \
-v "$PWD/data:/app/data" \
-v "$PWD/feeds:/app/feeds" \
pagepulseBy default the container runs watch; override the command to run a single
pass instead (docker run ... pagepulse run).
- JavaScript rendering for pages that build their content client-side (v1 only reads the static HTML the server returns).
- More notifier targets out of the box.
MIT — see LICENSE.