# Website Change Monitor: Detect Page & Text Changes (`opendata-tools/website-change-monitor`) Actor

Checks a list of URLs and returns only the pages whose content changed since the last run: unified diff, added/removed lines, SHA-256 hashes. Optional CSS selector, keyword triggers and ignore regexes. Plain HTTP, respects robots.txt. $0.001 per URL checked, $0.005 per change.

- **URL**: https://apify.com/opendata-tools/website-change-monitor.md
- **Developed by:** [Open Data Tools](https://apify.com/opendata-tools) (community)
- **Categories:** Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 url checkeds

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

This tool checks a list of web pages and tells you only about the ones that changed since the last check. For each changed page you get a diff (a before-and-after list of the lines that were added or removed), so you can see what changed without reading the whole page. Set it to run on a schedule and you have a simple change alert for prices, stock, policies, job lists or notices.

#### Who it's for

- Businesses keeping an eye on competitor pricing or product pages.
- Shoppers and resellers waiting for "In stock" to appear or "Sold out" to disappear.
- Compliance and legal teams watching terms, policy and regulation pages.
- Anyone who follows careers pages, documentation, release pages or government notices.

#### Why use it

- **Only real changes**: it compares the visible text after normalising it. Whitespace, scripts, styles and HTML comments are ignored, so a change to the page code alone does not trigger an alert.
- **Watch part of a page**: add a CSS selector, such as `#pricing`, `main` or `.job-list`, for all pages or for each page separately.
- **Keyword triggers**: only alert when, say, "In stock" appears or "Sold out" disappears.
- **Ignore patterns**: strip dates, clocks, counters and other text that changes on every visit, using regular expressions.
- **Separate watch lists**: each monitor name keeps its own saved snapshots in a store in your own Apify account.
- **Run summary**: each run records how many pages were checked, changed, unchanged, failed or blocked.
- **Fits into your tools**: works with Apify schedules, webhooks and integrations (Slack, email, Zapier, Make, Google Sheets) to send changes wherever you want them.

#### How to use it

1. Open the Actor in Apify Console and add the pages you want to watch under **Pages to watch**.
2. Optionally add a **CSS selector (all pages)**, **Keyword triggers** or **Ignore patterns**.
3. Give the watch list a **Monitor name**, for example `competitor-pricing`. Keep the same name for every run of this list.
4. Click **Start**. The first run records a baseline (the starting version of each page) and reports no changes, unless you tick **Output baseline on first run**.
5. Open **Schedules** in Apify Console and run it hourly or daily with the same input. Every later run outputs only the pages that changed. Open the **Output** tab to see them, and export them as JSON, CSV or Excel.
6. Optionally add a webhook or integration (Slack, email, Google Sheets, Zapier or Make) that fires when the run finishes, so you get an alert.

#### Pricing

You pay $0.001 for each page checked and $0.005 for each change found. A page counts as checked once it has been fetched and compared. Pages that are blocked, fail or are not checked are free, and so are baseline rows. You can set a maximum total charge for a run, and the run stops cleanly when it is reached.

Example: checking 100 pages once a day costs $0.10 a day for the checks. If 5 of those pages change, add $0.025, so about $0.125 for the day, or about $3.75 over 30 days.

### API and reference

This section is for developers and AI agents. It lists every input and output field, a sample record and code examples.

#### Summary

Fetches a list of URLs over plain HTTP, normalises the content, and compares it with the snapshot saved on the previous run. Only pages whose content changed are written to the dataset, each with a unified diff, added/removed lines and SHA-256 hashes of both versions. You can limit the comparison to a CSS selector, report only when keywords appear or disappear, and strip dynamic text with regular expressions. Snapshots persist between runs in a named key-value store in your account, so the Actor is meant to run on a schedule. robots.txt is respected. No browser, proxies or anti-bot workarounds are used.

#### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `startUrls` | array | required | URLs to check. Items can be strings, `{"url": "..."}`, `{"url": "...", "selector": "#main"}` or `{"url": "...", "userData": {"selector": "..."}}`. |
| `monitorName` | string | `default` | Watch-list name. Snapshots go to the key-value store `website-change-monitor-<name>`. Keep it the same across scheduled runs. |
| `selector` | string | none | CSS selector applied to every URL. A per-URL selector overrides it. |
| `mode` | `text`, `html` | `text` | `text` compares visible text (whitespace, scripts, styles and comments ignored). `html` compares markup. |
| `keywords` | string\[] | `[]` | If set, a change is reported only when a keyword's count changes (case-insensitive). |
| `keywordTrigger` | `any`, `appears`, `disappears` | `any` | Which direction of keyword count change counts. |
| `ignorePatterns` | string\[] | `[]` | Case-insensitive regexes removed before comparing. Invalid regexes are matched as literal text. |
| `outputBaseline` | boolean | `false` | Also write a `changeType: "baseline"` row on the first check of a page. Not charged as a change. |
| `resetBaseline` | boolean | `false` | Ignore saved snapshots and record a new baseline. |
| `maxUrls` | integer | `100` | 1 to 1000. Only the first N URLs are checked. |
| `concurrency` | integer | `5` | 1 to 20. Requests to the same host are always sequential. |
| `perHostDelaySecs` | number | `1` | 0.5 to 30. Gap between requests to one host. A longer robots.txt `Crawl-delay` is used, up to 10 s. |
| `maxDiffChars` | integer | `4000` | 500 to 50000. Max length of `diff`. |

The first run for a given `monitorName` records baselines and returns no rows, unless `outputBaseline` is `true`.

#### Output

One dataset row per changed page (plus baseline rows if `outputBaseline` is on).

| Field | Type | Description |
|---|---|---|
| `url` | string | Watched URL |
| `finalUrl` | string | URL after redirects |
| `selector` | string or null | CSS selector compared, `null` for the whole page |
| `monitorName` | string | Watch-list name |
| `pageTitle` | string or null | HTML `<title>` |
| `httpStatus` | integer | HTTP status |
| `changeType` | string | `changed` or `baseline` |
| `changedAt` | string or null | ISO 8601 UTC time the change was detected |
| `checkedAt` | string | ISO 8601 UTC time of this check |
| `previousCheckedAt` | string or null | Time of the snapshot compared against |
| `previousHash` / `currentHash` | string | SHA-256 of the normalised content |
| `diff` | string | Unified diff, capped at `maxDiffChars` |
| `diffTruncated` | boolean | `true` if `diff` was cut |
| `addedLines` / `removedLines` | string\[] | Changed lines, up to 200 each |
| `addedCount` / `removedCount` | integer | Number of lines added / removed |
| `keywordsMatched` | string\[] | Keywords that triggered (for baseline rows: keywords present) |
| `keywordEvents` | object\[] | `{keyword, event, before, after}`, where `event` is `appeared` or `disappeared` |
| `lineCount` | integer | Lines in the normalised content |

Sample record (from a test run; URL replaced):

```json
{
  "url": "https://example.com/product",
  "finalUrl": "https://example.com/product",
  "selector": "main",
  "monitorName": "cloud-test",
  "pageTitle": null,
  "httpStatus": 200,
  "currentHash": "617a923d3a91fde26988f373269559be8ae081907d8740ada892310ccbff0aee",
  "changeType": "changed",
  "changedAt": "2026-10-20T12:20:29+00:00",
  "checkedAt": "2026-10-20T12:20:29+00:00",
  "previousCheckedAt": "2026-10-20T12:20:17+00:00",
  "previousHash": "38bc39eaa56895cd748e771d74971e9062017ca7915fb47232e0f24a9006ed37",
  "diff": "--- previous\n+++ current\n@@ -4 +4,2 @@\n New line added\n+SOLD OUT",
  "diffTruncated": false,
  "addedLines": ["SOLD OUT"],
  "removedLines": [],
  "addedCount": 1,
  "removedCount": 0,
  "keywordsMatched": ["sold out"],
  "keywordEvents": [{"keyword": "sold out", "event": "appeared", "before": 0, "after": 1}],
  "lineCount": 5
}
```

The run's default key-value store gets a `SUMMARY` record with counts (`checked`, `changed`, `unchanged`, `baseline`, `changedNoKeywordTrigger`, `failed`, `blocked`, `notChecked`), `stoppedEarly`, and a per-URL status list.

Per-URL statuses that produce no dataset row:

- `blocked`: disallowed by robots.txt, HTTP 401/403/407/429/451, or an anti-bot challenge page.
- `failed`: network error, invalid URL, parse error, the selector matched nothing, or the page suddenly came back with no text (e.g. a maintenance page). The previous snapshot is kept.
- `notChecked`: skipped because the run was close to its timeout or the max total charge was reached. The next run with the same input starts from these URLs.

#### Pricing details

Pay per event: $0.001 per URL checked (`url-checked`) and $0.005 per change detected (`change-detected`). A URL is charged once it has been fetched and compared, whether the result is baseline, unchanged or changed. Blocked, failed and not-checked URLs are not charged, and neither are baseline rows. The run stops cleanly when the max total charge is reached.

#### Usage

Apify API (synchronous run, returns dataset items):

```bash
curl -X POST "https://api.apify.com/v2/acts/opendata-tools~website-change-monitor/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "startUrls": [
      {"url": "https://www.python.org/downloads/", "selector": ".download-list-widget"},
      {"url": "https://www.iana.org/domains/reserved"}
    ],
    "monitorName": "release-pages",
    "ignorePatterns": ["\\d{1,2}:\\d{2}(:\\d{2})?"]
  }'
```

Python (`pip install apify-client`):

```python
import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("opendata-tools/website-change-monitor").call(run_input={
    "startUrls": [
        {"url": "https://www.python.org/downloads/", "selector": ".download-list-widget"},
        {"url": "https://www.iana.org/domains/reserved"},
    ],
    "monitorName": "release-pages",
    "keywords": ["Python 3.15"],
    "keywordTrigger": "appears",
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["url"], item["addedCount"], item["removedCount"])
```

JavaScript (`npm install apify-client`):

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

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('opendata-tools/website-change-monitor').call({
    startUrls: [
        { url: 'https://www.python.org/downloads/', selector: '.download-list-widget' },
        { url: 'https://www.iana.org/domains/reserved' },
    ],
    monitorName: 'release-pages',
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const item of items) console.log(item.url, item.diff);
```

For recurring checks, create an Apify schedule with a fixed input and attach a webhook on `ACTOR.RUN.SUCCEEDED` to receive results.

#### Limits

- Plain HTTP only. Pages that render content with JavaScript may compare as empty or unchanged. Use a selector on server-rendered content, or watch the site's RSS/JSON endpoint.
- No logins, proxies or captcha solving. Blocked pages are reported, not retried.
- User-Agent token: `WebsiteChangeMonitor`. robots.txt is checked for every host, including after redirects.
- Snapshots contain the normalised text of watched pages and are stored only in your account. The Actor does not extract contact details or other personal data.

### FAQ

#### Why did my first run return nothing?

The first run for a monitor name saves a baseline of each page and has nothing to compare against yet, so it returns no rows. Tick **Output baseline on first run** (`outputBaseline`) if you want a row for each page on that first run.

#### Does it work on pages built with JavaScript?

Not always. It fetches pages over plain HTTP without a browser, so pages that render their content with JavaScript may compare as empty or unchanged. Use a selector on content the server sends, or watch the site's RSS or JSON feed instead.

#### What happens if a page is blocked or fails?

It is listed in the run summary as `blocked` or `failed`, no row is written and you are not charged. For a failed check the previous snapshot is kept, so a temporary error does not wipe your baseline.

#### Where are my snapshots stored?

In a named key-value store, `website-change-monitor-<name>`, in your own Apify account. Snapshots hold the normalised text of watched pages, and the Actor does not extract contact details or other personal data.

Issues and feature requests: use the Issues tab.

# Actor input Schema

## `startUrls` (type: `array`):

Pages to check. To watch only part of one page, add a selector to that URL via the API/JSON input as {"url": "...", "selector": "#main"} (or userData.selector).

## `monitorName` (type: `string`):

Name of this watch list. Snapshots are kept in a named key-value store in your own account (website-change-monitor-<name>), so different watch lists don't clash. Keep it the same between scheduled runs.

## `selector` (type: `string`):

Optional. Only compare the part of each page matching this CSS selector, e.g. main, #pricing, .job-list. A per-URL selector overrides this.

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

Visible text ignores whitespace, scripts, styles and comments. HTML compares the markup itself.

## `keywords` (type: `array`):

Optional. If set, a change is only reported when one of these keywords appears or disappears (case-insensitive). Leave empty to report every change.

## `keywordTrigger` (type: `string`):

Which keyword movement counts as a change.

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

Optional regular expressions (case-insensitive) removed from the text before comparing, for dates, counters, timestamps, etc. Example: \d{1,2}:\d{2} or Updated .\* ago. Invalid regexes are matched as plain text.

## `outputBaseline` (type: `boolean`):

On the first check of a page there is nothing to compare with. By default it is silently recorded. Turn on to also output it (changeType = baseline, not charged as a change).

## `resetBaseline` (type: `boolean`):

Ignore saved snapshots and record a fresh baseline for every page in this run.

## `maxUrls` (type: `integer`):

Only the first N pages are checked.

## `concurrency` (type: `integer`):

Pages checked in parallel. Requests to the same site are always made one at a time.

## `perHostDelaySecs` (type: `number`):

Politeness gap per host. A longer Crawl-delay in robots.txt is respected (up to 10 s).

## `maxDiffChars` (type: `integer`):

The unified diff in each result is cut to this length.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://www.iana.org/domains/reserved"
    },
    {
      "url": "https://www.python.org/downloads/"
    }
  ],
  "monitorName": "default",
  "mode": "text",
  "keywordTrigger": "any",
  "outputBaseline": false,
  "resetBaseline": false,
  "maxUrls": 100,
  "concurrency": 5,
  "perHostDelaySecs": 1,
  "maxDiffChars": 4000
}
```

# Actor output Schema

## `changes` (type: `string`):

One row per page that changed since the last run.

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

Counts of pages checked, changed, unchanged, failed and blocked, with a status per URL.

# 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 = {
    "startUrls": [
        {
            "url": "https://www.iana.org/domains/reserved"
        },
        {
            "url": "https://www.python.org/downloads/"
        }
    ],
    "monitorName": "default"
};

// Run the Actor and wait for it to finish
const run = await client.actor("opendata-tools/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 = {
    "startUrls": [
        { "url": "https://www.iana.org/domains/reserved" },
        { "url": "https://www.python.org/downloads/" },
    ],
    "monitorName": "default",
}

# Run the Actor and wait for it to finish
run = client.actor("opendata-tools/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 '{
  "startUrls": [
    {
      "url": "https://www.iana.org/domains/reserved"
    },
    {
      "url": "https://www.python.org/downloads/"
    }
  ],
  "monitorName": "default"
}' |
apify call opendata-tools/website-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,opendata-tools/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/92XSfSWfsb3eHhVl7/builds/Dk3ehVi0rliG41Vga/openapi.json
