# Website Change Detection: Page Diff Alerts (`fernandosord/website-change-monitor`) Actor

Watch many web pages for changes and get the exact lines or links that were added or removed. Scope it to one part of a page, ignore timestamps, render JavaScript when needed, and get alerts by email, Slack, Discord or webhook. Failed checks are free.

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

## Pricing

from $2.00 / 1,000 page 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 is a website change detection Actor. Give it a list of URLs and run it on a schedule: it monitors website changes by comparing each page with the copy it saved on the previous run, and returns the exact lines or links that were added or removed. You can also get an alert by email, Slack, Discord or webhook, but only when something actually changed.

It works as a Visualping alternative for people who want the diff as data (a dataset, an API response or a webhook) and prefer to pay per page checked. It compares text and links, not pixels, so it is built for pricing pages, changelogs, terms of service, listings and feeds.

### What data can Website Change Monitor extract?

Every watched page produces one row per run.

| Field | What it contains |
|---|---|
| `status` | `baseline`, `changed`, `unchanged`, `selector_missing`, `failed`, `site_blocked`, `not_allowed` or `skipped` |
| `changeSummary` | One line such as "2 lines added, 1 line removed" or "5 new links, 6 links gone" |
| `addedText`, `removedText` | The lines that appeared or disappeared (text mode). Up to 50 lines per row, each cut at 500 characters |
| `addedLinks`, `removedLinks` | New and removed links as `{ url, text }` (links mode). Up to 100 per row |
| `charactersAdded`, `charactersRemoved` | Size of the change |
| `url`, `finalUrl`, `label` | The page you gave, where it ended up after redirects, and your own label |
| `title`, `httpStatus` | Page title and HTTP status |
| `selector`, `compareMode`, `renderJavaScript` | The settings used for this page |
| `screenshotUrl` | Link to a JPEG of the changed area, when screenshots are on |
| `textPreview` | On a baseline row, the first 1,000 characters of what is being watched |
| `checkedAt`, `previousCheckAt`, `lastChangedAt`, `watchedSince` | Timestamps for this check, the one before, the last change and the first check |
| `error` | The reason a page could not be checked |

The run also writes a summary to the `OUTPUT` record of its key-value store, with counts per status and the list of changed pages.

### How to use Website Change Monitor

1. Open the Actor on Apify and click **Try for free**.
2. Paste the pages you want to watch into **Pages to watch**.
3. Optional: set **Part of the page to watch** to a CSS selector such as `main` or `#pricing`, and add noisy elements or text patterns (dates, counters) to the ignore lists.
4. Click **Start**. The first run saves a baseline for every page and reports each one with the status `baseline`.
5. Create a schedule in Apify Console (hourly, daily or weekly) with the same input. Every later run compares with the previous one and returns `changed` or `unchanged`.
6. Read the results in the dataset, or add email addresses or a Slack, Discord or webhook URL to be told when a page changes.

Saved versions live in a key-value store named `website-change-monitor-state` in your account, grouped by **Watch list name**. Use a different name for each task so separate lists keep separate history.

### How much does it cost to monitor website changes?

You pay per event, not for compute time.

| Event | Price |
|---|---|
| Actor start | $0.002 per GB of run memory (the default is 1 GB, so $0.002 per run) |
| Page checked, plain HTTP | $0.002 |
| Page checked with JavaScript rendering | $0.004 |
| Change detected | $0.005 per changed page, on top of the page check |
| Screenshot of a change | $0.003 |

Email alerts and webhook messages have no event price. Pages that fail, are blocked, are disallowed by robots.txt or whose selector matches nothing are not charged.

Worked examples, from the prices above:

- 1,000 pages checked once without JavaScript, no changes: $0.002 + 1,000 x $0.002 = $2.002.
- 20 pages checked daily for 30 days, with about one change a week: 30 x ($0.002 + 20 x $0.002) + 4 x $0.005 = $1.28 a month.
- The same 1,000 pages with JavaScript rendering: $0.002 + 1,000 x $0.004 = $4.002.

If a run reaches the maximum cost you set, the remaining pages are marked `skipped`, their saved versions are kept, and they are checked again on the next run.

### Input

The only required field is **Pages to watch**. This example watches two pages with their own selectors, emails the changes and posts them to Slack:

```json
{
    "pages": [
        { "url": "https://www.python.org/downloads/", "selector": ".download-list-widget", "label": "Python releases" },
        { "url": "https://nodejs.org/en/blog", "selector": "main", "compareMode": "links", "label": "Node.js blog" }
    ],
    "ignorePatterns": ["Updated: .*"],
    "notificationEmails": ["you@example.com"],
    "emailSubjectPrefix": "[Releases]",
    "webhookUrl": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
    "webhookFormat": "slack",
    "stateKey": "release-pages"
}
```

All settings:

| Field | Default | What it does |
|---|---|---|
| **Pages to watch** (`urls`) | none | List of page URLs. |
| **Pages with their own settings** (`pages`) | none | JSON list for pages that need their own `selector`, `compareMode`, `label`, `ignoreSelectors`, `ignorePatterns`, `renderJavaScript`, `waitForSelector`, `waitSecs` or `screenshotSelector`. |
| **Part of the page to watch** (`selector`) | whole page | A CSS selector such as `main`, `#pricing` or `.release-list`. A smaller area means fewer false alerts from menus, footers and banners. |
| **What to compare** (`compareMode`) | `text` | `text` reports added and removed lines. `links` reports new and removed links. |
| **Ignore elements** (`ignoreSelectors`) | none | CSS selectors removed before comparing, such as `.ad` or `time`. |
| **Ignore text matching** (`ignorePatterns`) | none | Regular expressions deleted before comparing, such as `\d+ (minutes\|hours) ago`. Matching ignores case. |
| **Minimum change size** (`minChangedCharacters`) | `1` | Smaller changes are not reported. The saved version is kept, so small edits add up until they count. |
| **Render JavaScript** (`renderJavaScript`) | `false` | Loads pages in Chrome. Needed for pages that build their content in the browser. |
| **Wait for element** (`waitForSelector`) | empty | With JavaScript rendering, waits up to 20 s for this selector before reading the page. |
| **Extra wait** (`waitSecs`) | `0` | With JavaScript rendering, seconds to wait after the page loads (0 to 30). |
| **Screenshot changed pages** (`screenshotOnChange`) | `false` | Saves a JPEG of the changed area and adds its link to the row. |
| **Screenshot area** (`screenshotSelector`) | the watched part | CSS selector of the element to capture. |
| **Email alerts to** (`notificationEmails`) | none | Up to 5 addresses. One email per run, sent only when at least one page changed. |
| **Email subject prefix** (`emailSubjectPrefix`) | empty | Text put in front of the subject, up to 60 characters. |
| **Webhook URL** (`webhookUrl`) | empty | Sends a POST to this https URL when at least one page changed. |
| **Webhook format** (`webhookFormat`) | `json` | `json` (full rows), `slack` or `discord` (a readable message). |
| **Also notify about pages that failed** (`notifyOnErrors`) | `false` | Also sends the email and webhook message when pages could not be checked, and lists those pages. |
| **Only output changed pages** (`onlyChanges`) | `false` | Off: one row per page every run. On: rows only for changed pages. |
| **Watch list name** (`stateKey`) | `default` | Saved versions are kept per watch list. |
| **Respect robots.txt** (`respectRobotsTxt`) | `true` | Skips pages the site's robots.txt disallows. Turn off only for sites you own or may monitor. |
| **Pages checked at once** (`maxConcurrency`) | `5` | 1 to 20. Requests to the same site stay at least 1 s apart, and at most 2 pages render in Chrome at a time. |
| **Page timeout** (`timeoutSecs`) | `30` | Seconds to wait for each page (5 to 120) before reporting it as failed. |

### Output

This is a real row from a test run on 2026-10-28. It watched the links in the Hacker News "newest" list (selector `.athing .titleline`, `compareMode` set to `links`), and two runs about 40 seconds apart found changes. The `addedLinks` and `removedLinks` lists are cut to two entries here; the real row had more.

```json
{
  "url": "https://news.ycombinator.com/newest",
  "label": null,
  "status": "changed",
  "changeSummary": "5 new links, 6 links gone",
  "addedText": [],
  "removedText": [],
  "addedLinks": [
    {
      "url": "https://chrispattle.com/avatars",
      "text": "Show HN: I made my own version of Google Doodles"
    },
    {
      "url": "https://news.ycombinator.com/from?site=chrispattle.com",
      "text": "chrispattle.com"
    }
  ],
  "removedLinks": [
    {
      "url": "https://ao.news/live/n/US",
      "text": "Show HN: Breaking abroad, not yet in [your country]"
    },
    {
      "url": "https://news.ycombinator.com/from?site=ao.news",
      "text": "ao.news"
    }
  ],
  "charactersAdded": 252,
  "charactersRemoved": 276,
  "title": "New Links | Hacker News",
  "finalUrl": "https://news.ycombinator.com/newest",
  "httpStatus": 200,
  "selector": ".athing .titleline",
  "compareMode": "links",
  "renderJavaScript": false,
  "screenshotUrl": null,
  "textPreview": null,
  "checkedAt": "2026-10-28T22:05:56.178Z",
  "previousCheckAt": "2026-10-28T22:05:19.977Z",
  "lastChangedAt": "2026-10-28T22:05:56.178Z",
  "watchedSince": "2026-10-28T22:05:19.977Z",
  "error": null
}
```

Statuses:

- `baseline`: first check, the version is saved.
- `changed`: see the added and removed fields.
- `unchanged`: nothing changed, or the change was below the minimum size.
- `selector_missing`: the selector matched nothing, so the layout may have changed. Not charged.
- `failed`, `site_blocked`, `not_allowed`, `skipped`: the reason is in `error`.

### What people use website change detection for

- Competitor pricing and plan pages.
- Changelogs, release pages and status pages.
- Terms of service, privacy policies and other legal pages.
- Product, stock or job listing pages. Use **Links only** to see which items are new.
- RSS and Atom feeds, JSON endpoints and plain-text files.
- Documentation pages that your own product depends on.

### FAQ

#### How does it tell what changed?

It saves the text (or the links) of the watched part of each page, then compares the next fetch with that copy line by line. Lines that only moved position are not reported as a change. A saved page text is cut at 400,000 characters.

#### Can it detect visual changes like a new image or colour?

No. The comparison is on text and links. It can save a screenshot when a change is found, but a change that only affects pixels will not be detected.

#### Why do I get `selector_missing` or "no text"?

The page probably builds its content with JavaScript. Turn on **Render JavaScript**, and if needed set **Wait for element** to the selector you watch.

#### I get alerts for changes I do not care about. What can I do?

Narrow **Part of the page to watch**, add the noisy parts to **Ignore elements**, or add an **Ignore text matching** pattern for counters and dates. You can also raise **Minimum change size**.

#### How do email alerts work?

The run starts Apify's `apify/send-mail` Actor in your account with one message listing every changed page (up to 30), each change summary, the first 5 added and removed lines, the screenshot link and a link to the run. If nothing changed, no email is sent. On the Apify free plan, `apify/send-mail` can only send to your account's own email address, so check the run log and the `emailError` field of the `OUTPUT` record if nothing arrives, and check your spam folder.

#### How do I schedule it?

Create a task with your pages, then add it to a schedule in Apify Console (Schedules, then Create new). Each run compares with the previous one.

#### How do I send changes to Slack, Discord, Make, Zapier or n8n?

For Slack, create an incoming webhook, paste its URL into **Webhook URL** and set the format to Slack. For Discord, create a channel webhook and set the format to Discord. For Make, Zapier or n8n, use the `json` format to post full rows to their webhook trigger, or use the task's Integrations tab in Apify Console. Turn on **Only output changed pages** so integrations that act on new dataset rows only fire for real changes.

#### Can I call it from code or an AI agent?

Yes. Use the Apify API or the Apify MCP server (`https://mcp.apify.com`) with the same input. With the JavaScript client: `client.actor("fernandosord/website-change-monitor").call({ urls: ["https://example.com/pricing"] })`.

#### How do I start over?

Change the **Watch list name**. The next run saves a new baseline.

#### Is it polite to the sites it visits?

Robots.txt is respected by default, and requests to one site are at least 1 second apart (more if the site sets a `Crawl-delay`). The Actor identifies itself as `PlainsourceChangeMonitor`.

#### What does it not do?

- No proxies, logins or bot-check workarounds. Pages that block automated visitors are reported as `site_blocked` and are not charged.
- LinkedIn, Facebook, Instagram, X and TikTok are not supported, because their terms forbid automated access.
- Private and local addresses (such as `localhost` and `10.x.x.x`) are refused, including through redirects.
- Up to 2,000 pages per run, and pages up to 10 MB.
- Screenshots are stored in the run's default key-value store, which follows your account's data retention period.

Found a bug or need an option? Open an issue on this Actor's Issues tab.

### Other Actors by this developer

- [Website Screenshot & URL to PDF](https://apify.com/fernandosord/website-screenshot-pdf): bulk page captures, a good partner when you want a picture of a page you are watching.
- [Website Tech Stack Detector](https://apify.com/fernandosord/website-tech-stack-detector): a BuiltWith alternative that lists the technologies a site uses.
- [WHOIS & DNS Lookup](https://apify.com/fernandosord/domain-profile-whois-dns): domain age, SSL and email security for a list of domains.
- [Career Site Jobs API](https://apify.com/fernandosord/career-site-jobs): open jobs from Greenhouse, Lever and Ashby career sites, with a daily new-jobs feed.
- [PDF to Text & Markdown](https://apify.com/fernandosord/document-to-markdown): turns PDFs, Office files and scans into text.

# Actor input Schema

## `urls` (type: `array`):

Full URLs of the pages to check, for example a pricing page, a changelog, a terms page or a product page. Each run compares every page with the version saved by the previous run under the same watch list name. The first run saves a baseline.

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

Only this part of each page is compared, for example <code>main</code>, <code>#pricing</code> or <code>.release-list</code>. Leave empty to watch the whole page body. Watching a smaller part avoids alerts from menus, footers and rotating banners.

## `compareMode` (type: `string`):

<b>Visible text</b> reports lines that were added or removed. <b>Links only</b> reports new and removed links, which suits listing pages such as blogs, job pages, product catalogs and RSS or Atom feeds.

## `ignoreSelectors` (type: `array`):

Elements removed before comparing, for example <code>.ad</code>, <code>#related-posts</code> or <code>time</code>. Scripts, styles, hidden elements and common cookie banners are always ignored.

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

Text that matches is deleted before comparing, so it can't trigger an alert. Useful for counters and timestamps, for example <code>\d+ (minutes|hours) ago</code> or <code>Updated: .\*</code>. Matching ignores case.

## `minChangedCharacters` (type: `integer`):

A change smaller than this many added plus removed characters is not reported. The saved version is kept, so small edits add up until they reach the limit.

## `renderJavaScript` (type: `boolean`):

Load pages in a real Chrome browser. Turn this on for pages that build their content with JavaScript (the result shows "no text" or a missing selector without it). It is slower and has a higher per-page price.

## `waitForSelector` (type: `string`):

With "Render JavaScript" on, wait up to 20 s for this CSS selector to appear before reading the page.

## `waitSecs` (type: `integer`):

With "Render JavaScript" on, wait this many seconds after the page loads before reading it.

## `screenshotOnChange` (type: `boolean`):

When a page changes, save a JPEG of the watched part (or of the top of the page) and add its link to the result. Works with or without "Render JavaScript". Charged per screenshot.

## `screenshotSelector` (type: `string`):

Element to capture in the screenshot. Leave empty to use the watched part.

## `notificationEmails` (type: `array`):

Up to 5 email addresses, one per line. When at least one page changed, the run sends one email with each changed page, its change summary, the first added and removed lines and the screenshot link. No email is sent when nothing changed. Emails go out through Apify's free <code>apify/send-mail</code> Actor from your account; on the Apify free plan it can only send to your account's own email address.

## `emailSubjectPrefix` (type: `string`):

Optional text put in front of the email subject, for example <code>\[Competitors]</code>, so you can filter the alerts. Up to 60 characters.

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

Optional. When at least one page changed, the run sends a POST to this https URL: a Slack or Discord incoming-webhook URL, or any endpoint that accepts JSON (Zapier, Make, n8n, your own server).

## `webhookFormat` (type: `string`):

<b>JSON</b> sends the full change rows. <b>Slack</b> and <b>Discord</b> send a readable message with the added and removed lines.

## `notifyOnErrors` (type: `boolean`):

Also send the email and webhook message when pages could not be checked (blocked, missing selector, not found), and list those pages in it.

## `onlyChanges` (type: `boolean`):

Leave off to get one row per page every run (changed, unchanged, baseline or failed). Turn on to get rows only for pages that changed, which suits integrations that act on every new dataset row.

## `stateKey` (type: `string`):

Saved versions are kept per watch list. Use a different name for each task, so separate lists don't share history. Renaming starts a fresh baseline.

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

Optional JSON list for pages that need different settings, for example <code>\[{"url": "https://example.com/pricing", "selector": "#plans", "label": "Pricing"}]</code>. Each item can set <code>url</code>, <code>label</code>, <code>selector</code>, <code>compareMode</code>, <code>ignoreSelectors</code>, <code>ignorePatterns</code>, <code>renderJavaScript</code>, <code>waitForSelector</code>, <code>waitSecs</code> and <code>screenshotSelector</code>. Anything left out uses the settings above.

## `respectRobotsTxt` (type: `boolean`):

Skip pages that the site's robots.txt asks automated visitors not to open. Turn off only for sites you own or have permission to monitor.

## `maxConcurrency` (type: `integer`):

Requests to the same site are always spaced at least 1 s apart (or by the site's Crawl-delay). At most 2 pages render in Chrome at a time.

## `timeoutSecs` (type: `integer`):

How long to wait for each page before reporting it as failed.

## Actor input object example

```json
{
  "urls": [
    "https://www.python.org/downloads/",
    "https://nodejs.org/en/blog"
  ],
  "compareMode": "text",
  "minChangedCharacters": 1,
  "renderJavaScript": false,
  "waitSecs": 0,
  "screenshotOnChange": false,
  "webhookFormat": "json",
  "notifyOnErrors": false,
  "onlyChanges": false,
  "stateKey": "default",
  "respectRobotsTxt": true,
  "maxConcurrency": 5,
  "timeoutSecs": 30
}
```

# Actor output Schema

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

One row per page: changed, unchanged, baseline (first check) or the reason it could not be checked.

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

Counts per status and the list of changed pages.

# 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 = {
    "urls": [
        "https://www.python.org/downloads/",
        "https://nodejs.org/en/blog"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("fernandosord/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 = { "urls": [
        "https://www.python.org/downloads/",
        "https://nodejs.org/en/blog",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("fernandosord/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 '{
  "urls": [
    "https://www.python.org/downloads/",
    "https://nodejs.org/en/blog"
  ]
}' |
apify call fernandosord/website-change-monitor --silent --output-dataset

```

## MCP server setup

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