# Website Change Monitor: Page & Selector Alerts (`hollowayco/website-change-monitor`) Actor

Checks web pages or CSS selectors on a schedule and reports each page as changed, unchanged, baseline, selector missing or fetch error, with a line diff. Sends one alert per run to Slack, Discord or a webhook.

- **URL**: https://apify.com/hollowayco/website-change-monitor.md
- **Developed by:** [Holloway & Co.](https://apify.com/hollowayco) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 page checks

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Website Change Monitor: Page & Selector Change Alerts (Slack, Discord, Webhook)

Website Change Monitor checks a list of web pages on a schedule and tells you what changed since the last run. It can watch a whole page or only the part that matches a CSS selector, such as a price, a table, a job list or a terms-of-service section. Every run stores one result per page with a line-by-line diff, and sends one combined message to Slack, Discord or any webhook.

### What it does

- Fetches each page and compares it with the copy saved by the previous run.
- Compares the visible text (default), the HTML, or only the elements that match your CSS selector.
- Removes text that changes on every visit before comparing: timestamps, dates, "5 minutes ago", CSRF and nonce tokens, cache-busting query parameters and hashed file names. You can edit this list.
- Reports each page as `changed`, `unchanged`, `baseline` (first check), `selector_missing` or `fetch_error`, with the HTTP status, the added and removed lines and the share of the content that changed.
- Optionally watches a `sitemap.xml`, a sitemap index, or an RSS or Atom feed and reports URLs that were added since the last run.
- Sends one message per run to Slack, Discord and/or a JSON webhook. You choose whether to be told about changes, about every run, or only about problems.
- Can render pages in headless Chrome when the content is created by JavaScript.

### Why it does not break when a page layout changes

A monitor that watches a CSS selector usually breaks when the site is redesigned: the selector no longer matches, the run fails, and you stop getting results without being told why.

This Actor treats that as a result, not a crash:

- If the selector matches nothing, the page gets the status `selector_missing` with the message `No element matches the selector "#price".` You get an alert, and the other pages are still checked.
- The last good copy of the page is kept. When the selector matches again, the page is compared with that copy.
- Timeouts, DNS errors, refused connections, HTTP 404, HTTP 500 and similar problems give the status `fetch_error` with the HTTP status code and the error message. Timeouts, network errors and HTTP 429 or 5xx responses are tried once more before they are reported.
- The run finishes as **Succeeded** with a status message such as `Checked 12 of 12 pages: 1 changed, 10 unchanged, 1 selector missing.` Only input that cannot work, for example a URL without `https://` or an invalid regular expression, fails the run, and the error message lists every problem.

### Use cases

- Price and stock changes on product pages (watch the price element only).
- Terms of service, privacy policy and pricing pages of vendors you depend on.
- Competitor pages: plans, features, landing pages, job openings.
- Government, regulatory and grant pages that publish notices without a feed.
- Documentation and changelog pages of tools you use.
- New pages on a site, through its sitemap or RSS feed.
- Your own site: catch content that disappears after a deploy (a missing selector is reported).

### How to run it on a schedule

Each run compares with the previous run of the same monitor, so the Actor is meant to run on a schedule.

1. Open the Actor in Apify Console, enter your pages and notification URLs, and click **Start** once. The first run stores a baseline.
2. Click **Save as a new task** so the input is kept as a task.
3. Go to **Schedules**, click **Create new**, and set how often to run, for example every hour (`0 * * * *`) or every morning (`0 8 * * *`). Choose your time zone.
4. Click **Add**, choose **Actor task**, and pick the task you saved.
5. Click **Enable**. New schedules are created disabled.

Runs started by the schedule use the same monitor ID as your first run, so they compare against it. If you edit the page list later and want to keep the history, set **Monitor ID** to a fixed name such as `shop-prices` before you edit.

### Input example

```json
{
    "pages": [
        { "url": "https://www.example-shop.com/products/blue-mug", "selector": ".product-info", "label": "Blue mug" },
        { "url": "https://www.example-vendor.com/legal/terms", "label": "Vendor terms" },
        { "url": "https://www.example-agency.gov/notices", "selector": "main", "label": "Agency notices" }
    ],
    "mode": "text",
    "minChangePercent": 0,
    "watchSitemap": "https://www.example-vendor.com/sitemap.xml",
    "notifyOn": "changes",
    "slackWebhookUrl": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
    "monitorId": "vendor-watch"
}
```

The main fields:

| Field | What it does | Default |
|---|---|---|
| `pages` | URL, optional CSS `selector`, optional `label` for each page | required |
| `mode` | `text` (visible text), `html` (HTML without scripts and styles) or `selector` (text of the selector; every page needs one) | `text` |
| `ignorePatterns` | Regular expressions for text that should not count as a change | timestamps, dates, tokens, cache-busting parameters |
| `minChangePercent` | Report a change only when at least this share of the content changed | `0` |
| `maxDiffLines` | Most added and most removed lines kept per result | `50` |
| `watchSitemap` | Sitemap, sitemap index, RSS or Atom URL to watch for new URLs | empty |
| `notifyOn` | `changes`, `always` or `errors` | `changes` |
| `webhookUrl`, `slackWebhookUrl`, `discordWebhookUrl` | Where to send the run's message (stored encrypted) | empty |
| `monitorId` | Name of the stored history | derived from the page list |
| `renderJs` | Load pages in headless Chrome | `false` |
| `requestTimeoutSecs` | Time limit per page | `30` |
| `proxyConfiguration` | Optional proxy | no proxy |

### Output example

One dataset item per page per run:

```json
{
    "type": "page",
    "url": "https://www.example-shop.com/products/blue-mug",
    "finalUrl": "https://www.example-shop.com/products/blue-mug",
    "label": "Blue mug",
    "checkedAt": "2026-10-13T08:00:04.512Z",
    "status": "changed",
    "httpStatus": 200,
    "previousCheckedAt": "2026-10-13T07:00:03.981Z",
    "previousStatus": "unchanged",
    "changePercent": 43.75,
    "diff": {
        "added": ["Price: $12.50"],
        "removed": ["Price: $10.00"],
        "addedCount": 1,
        "removedCount": 1
    },
    "selector": ".product-info",
    "mode": "text",
    "contentLength": 31,
    "message": "1 line added and 1 line removed (43.75% of the content).",
    "error": null
}
```

A page whose selector stopped matching:

```json
{
    "type": "page",
    "url": "https://www.example-vendor.com/pricing",
    "status": "selector_missing",
    "httpStatus": 200,
    "selector": "#plans",
    "message": "The selector was not found, so the page layout probably changed. The last good snapshot is kept and used again once the selector matches.",
    "error": "No element matches the selector \"#plans\"."
}
```

With sitemap watching on, each run adds one item with `"type": "sitemap"`. The first run has the status `baseline`. Later runs have the status `new_urls` with `newUrls`, `newUrlCount`, `removedUrlCount` and `totalUrls`.

The run summary (counts per status, pages not checked, notification results) is saved as `OUTPUT` in the run's key-value store. The JSON webhook receives the same summary plus every result.

### Pricing

You pay $0.002 per page check, which is $2 for 1,000 checks. A page check is one page fetched and compared, including a page where the selector was not found. Pages that could not be fetched (`fetch_error`) are not charged, and a sitemap check counts as one page check.

Example: checking 20 pages once a day is about 600 page checks a month, or $1.20.

If you set a maximum cost for a run, the Actor stops starting new page checks when the next one would go over it. The run still finishes as Succeeded, the status message says that it stopped early, and the pages that were not checked are listed in the run summary.

### FAQ

**Why do I get `selector_missing` instead of a failed run?**
Because a selector that stops matching is information you need: the page changed its layout. The run finishes, the other pages are checked, and the message tells you which selector to fix.

**The first run did not alert me. Is that right?**
Yes. The first check of a page stores a baseline. With `notifyOn` set to `changes` you get a message only when something changed or failed. Set `notifyOn` to `always` to get a message after every run.

**How do I stop alerts caused by dates or counters on the page?**
Add a regular expression to `ignorePatterns`, or use a selector that covers only the part you care about. The default patterns already remove most timestamps and dates. If you do want to know when a date changes, remove the date patterns.

**What does `changePercent` mean?**
The share of the content, by characters, that sits on added or removed lines. Changing one line out of a hundred similar lines is about 1%. Use `minChangePercent` to ignore small edits; small edits add up, because the stored copy is only replaced when a change is reported.

**Where is the history stored, and how do I reset it?**
In a named key-value store in your Apify account called `website-change-monitor-<monitor ID>`. To start from a fresh baseline, change `monitorId` or delete that store.

**Can it check pages that need a login?**
No. It checks public pages only.

**The page builds its content with JavaScript. What do I do?**
Turn on `renderJs`. The page is then loaded in headless Chrome before it is compared. This is slower, and the default memory goes from 256 MB to 2 GB.

**Can I watch a JSON API or a file?**
Yes. JSON responses are formatted and compared line by line. Other files, such as PDFs, are compared by their content hash: you learn that the file changed, without a line diff.

**Does it send one message per page?**
No. Each run sends one message per channel with all pages that need attention, so a run that checks 50 pages does not flood your channel.

### Limitations

- Public pages only: no logins, cookies, forms or clicks.
- Without `renderJs`, "visible text" is read from the HTML. Elements hidden by the `hidden` attribute or by an inline `display: none` style are skipped; elements hidden by CSS classes are still included.
- Only the first 250,000 characters of a page (after cleanup) are stored and diffed. A change after that point is still detected, but without a line diff.
- Responses larger than 10 MB are reported as `fetch_error`.
- With the default 256 MB of memory, whole pages up to 10 MB can be watched. A CSS selector needs more memory on very large pages (roughly 3 MB of HTML or more); such a page is reported as `fetch_error` with a message that says so, and is not charged. Give the run more memory to check selectors on those pages.
- Sitemap watching reads up to 50 sitemap files and 50,000 URLs.
- Pages with bot protection may return a block page instead of the content. That shows up as a change. Try a proxy or `renderJs`.
- Two runs with the same monitor ID at the same time can overwrite each other's saved copies. Give overlapping tasks different monitor IDs.
- How often pages are checked depends on your schedule. This is not a real-time monitor.

# Changelog

This Actor's version history is a separate document: https://apify.com/hollowayco/website-change-monitor/changelog.md

# Actor input Schema

## `pages` (type: `array`):

One entry per page. Give the full URL. Add a CSS selector to watch only part of the page, for example a price, a table or an article. If the selector stops matching (for example after a redesign), that page is reported as "selector_missing" and the run still finishes. The label is shown in results and alerts.

## `mode` (type: `string`):

text: the visible text of the page, or of the elements that match the selector. Markup, scripts and styles are ignored. html: the HTML of the page or of the matched elements, without scripts and styles; use it to catch changed links, images or attributes. selector: the visible text of the matched elements, and every page must have a selector.

## `ignorePatterns` (type: `array`):

Regular expressions for content that should not count as a change. Matching text is removed before the comparison. The defaults remove timestamps, dates, clock times, "5 minutes ago" texts, Unix timestamps, CSRF and nonce tokens, cache-busting query parameters and hashed asset file names. Write /pattern/i to ignore letter case. Remove the date patterns if you want to know when a date on the page changes.

## `minChangePercent` (type: `number`):

Report a page as changed only when at least this share of its content changed (0 to 100). With 0, every change is reported. A smaller change is reported as "unchanged" with its percent, and the stored copy is kept, so small changes add up until they pass the threshold.

## `maxDiffLines` (type: `integer`):

The most added lines and the most removed lines to include in each result and in the JSON webhook. The total counts are always included.

## `watchSitemap` (type: `string`):

Also watch a sitemap.xml, a sitemap index, or an RSS or Atom feed, and report URLs added since the last run. Each run adds one result for it: "baseline" on the first run, then "new_urls" with the list of new URLs.

## `notifyOn` (type: `string`):

changes: when a page changed, new sitemap URLs appeared, a selector was not found, a page could not be fetched, or the run stopped at its charge limit. always: after every run, with a summary. errors: only when a selector was not found, a page or the sitemap could not be fetched, or the run stopped at its charge limit.

## `webhookUrl` (type: `string`):

Any URL that accepts a POST request with a JSON body, for example Zapier, Make, n8n or your own server. The body contains the run summary and every result with its diff. Stored encrypted.

## `slackWebhookUrl` (type: `string`):

A Slack incoming webhook URL (https://hooks.slack.com/services/...). Stored encrypted.

## `discordWebhookUrl` (type: `string`):

A Discord channel webhook URL (https://discord.com/api/webhooks/...). Stored encrypted.

## `monitorId` (type: `string`):

Name of the stored history. Runs with the same monitor ID compare against each other. If empty, the ID is derived from the page list, so editing the list starts a new history. Set your own ID, for example shop-prices, to keep the history when you add or remove pages. Use different IDs for tasks that must not share history.

## `renderJs` (type: `boolean`):

Load each page in headless Chrome so that content created by JavaScript is included. Slower, and the default memory goes from 256 MB to 2 GB. Leave it off for normal pages.

## `requestTimeoutSecs` (type: `integer`):

How long to wait for each page. A page that takes longer is tried once more and then reported as "fetch_error".

## `proxyConfiguration` (type: `object`):

Optional. Use a proxy when a site blocks requests from data centers.

## Actor input object example

```json
{
  "pages": [
    {
      "url": "https://www.iana.org/help/example-domains",
      "selector": "main",
      "label": "IANA example domains"
    }
  ],
  "mode": "text",
  "ignorePatterns": [
    "\\b\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}(?::\\d{2}(?:[.,]\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})?\\b",
    "\\b\\d{4}-\\d{2}-\\d{2}\\b",
    "\\b\\d{1,2}[/.-]\\d{1,2}[/.-](?:\\d{4}|\\d{2})\\b",
    "/\\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?,?\\s+\\d{4}\\b/i",
    "/\\b\\d{1,2}(?:st|nd|rd|th)?\\s+(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\.?,?\\s+\\d{4}\\b/i",
    "/\\b\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\s*[ap]\\.?m\\.?)?/i",
    "/\\b\\d+\\s+(?:seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?)\\s+ago\\b/i",
    "/\\b(?:just now|an? (?:second|minute|hour|day|week|month|year) ago)\\b/i",
    "\\b1[5-9]\\d{8}(?:\\d{3})?\\b",
    "/\\b(?:nonce|integrity)=[\"'][^\"']*[\"']/i",
    "/(?:csrf|xsrf)[\\w-]*[\"']\\s+content=[\"'][^\"']*[\"']/i",
    "/\\bname=[\"'](?:_token|authenticity_token|csrfmiddlewaretoken|__requestverificationtoken|[\\w-]*(?:csrf|xsrf)[\\w-]*)[\"'][^>]*?\\bvalue=[\"'][^\"']*[\"']/i",
    "/\\bdata-[\\w-]*(?:csrf|xsrf|nonce|token)[\\w-]*=[\"'][^\"']*[\"']/i",
    "/[?&](?:v|ver|version|_|t|ts|cb|cache|cachebust|cachebuster|rev|hash|timestamp|nocache)=[^&\"'\\s<>]*/i",
    "/[.-][0-9a-f]{8,32}(?=\\.(?:js|mjs|css|map|woff2?|ttf|png|jpe?g|gif|svg|webp|ico)\\b)/i"
  ],
  "minChangePercent": 0,
  "maxDiffLines": 50,
  "notifyOn": "changes",
  "renderJs": false,
  "requestTimeoutSecs": 30,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `results` (type: `string`):

One item per watched page (and one for the sitemap when it is watched) with the status, diff and error of this run.

## `summary` (type: `string`):

JSON summary of the run: counts per status, pages not checked, charged page checks and notification results.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "pages": [
        {
            "url": "https://www.iana.org/help/example-domains",
            "selector": "main",
            "label": "IANA example domains"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("hollowayco/website-change-monitor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "pages": [{
            "url": "https://www.iana.org/help/example-domains",
            "selector": "main",
            "label": "IANA example domains",
        }] }

# Run the Actor and wait for it to finish
run = client.actor("hollowayco/website-change-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "pages": [
    {
      "url": "https://www.iana.org/help/example-domains",
      "selector": "main",
      "label": "IANA example domains"
    }
  ]
}' |
apify call hollowayco/website-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,hollowayco/website-change-monitor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/DSzoff56I42nHlm1r/builds/XeDlqofpxGxqBLZaV/openapi.json
