# Website Change Monitor (`spookyweb/website-change-monitor`) Actor

Watch any web page and get told exactly what changed since the last run: the added and removed lines, a change percentage, and a status of changed, unchanged or first\_run. CSS selectors narrow the watched region and ignore clocks, carousels and ad slots.

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

## Pricing

from $0.60 / 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.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Website Change Monitor

Watch any web page and find out exactly what changed since the last time you looked. Every run returns the added lines, the removed lines, a change percentage and a plain status, so you get told what moved rather than just that something did.

### 🔍 What does Website Change Monitor do?

Give it a list of URLs. Each run fetches every page, pulls out the part you care about, and compares it against a fingerprint stored from the previous run. You get **one row per page**, with the actual differing text.

- Watches **as many pages as you like**, up to 1,000 per run
- Narrows the comparison to a **CSS selector**, so you can watch a price, a status line or a single table
- **Ignores** the regions that change on their own: clocks, view counters, carousels, CSRF tokens and ad slots
- Reports **added and removed lines**, not just a yes or no
- Compares **visible text** by default, so a template rebuild or a CDN cache buster is not a change
- Treats the first run as **`first_run`**, never as a change
- Never fails a run because one page was down

The easiest way to try it is to press **Try for free** with the prefilled URL, run it twice, and watch the status move from `first_run` to `unchanged`.

### 📊 What data can I extract?

One row per watched page:

| Field | Description |
|---|---|
| `url` | The page that was watched, as it was requested |
| `status` | `changed`, `unchanged`, `first_run` or `error` |
| `changedAt` | When the change was detected, ISO 8601. Null on any run that did not report a change |
| `previousCheckedAt` | When the stored version this run was compared against was read, ISO 8601. Null on a first run |
| `changePercent` | How much of the watched content differs from the stored version, as a percentage of characters. Null on a first run, and on a page too large to keep in full |
| `addedText` | The lines that appeared since the stored version. Null on a page too large to keep in full |
| `removedText` | The lines that went away since the stored version. Null on a page too large to keep in full |
| `contentLength` | Length in characters of the watched content read on this run |
| `previousContentLength` | Length in characters of the stored version this run was compared against |
| `contentHash` | SHA-256 of the content read on this run, which is what the next run compares against |
| `previousContentHash` | SHA-256 of the stored version this run was compared against. Null on a first run |
| `snapshotKey` | Key in this run's key-value store holding the full watched content |
| `httpStatus` | HTTP status code the page answered with on the final attempt |
| `error` | Why the page could not be checked, in plain English |

### 💡 Why monitor a website for changes?

**Pricing pages.** Watch a plan table and get the old figure and the new one in the same row. `removedText` holds "Starter plan costs 29 pounds a month", `addedText` holds "Starter plan costs 39 pounds a month", so you have the change and the evidence together.

**Compliance and policy.** Terms of service, privacy policies, licence conditions and regulator guidance all change quietly. Watch the page, keep the dataset, and you have a dated record of every revision.

**Stock, availability and job listings.** A product page going from out of stock to in stock is a text change on a known selector. So is a new role appearing on a careers page.

**Documentation and API changes.** Watch a changelog or an endpoint reference and get told which lines were added, rather than being asked to reread the page.

**Tenders, grants and public notices.** Public sector pages are usually server rendered, which is exactly what this Actor is fastest and most reliable on.

**Feeding an agent.** The output schema means an AI agent can chain this Actor and read the result. `addedText` is the part worth handing to a model, and it is already the diff rather than the whole page.

### 🚀 How do I use Website Change Monitor?

1. Click **Try for free**.
2. Add the pages you want to watch under `urls`. Paste them in, or point at a text file with one URL per line.
3. Optional, set `contentSelector` to the part of the page that matters, for example `main#content` or `.price-table`.
4. Optional, add `ignoreSelectors` for anything that changes on its own, such as `.timestamp` or `#ad-slot-top`.
5. Click **Start**. The first run stores the baseline and reports `first_run` for every page.
6. Put it on a **schedule**, hourly or daily. From the second run onwards you get real change reports.
7. Optional, add a webhook or a Slack integration so a `changed` row reaches you without anyone opening the Console.

### ⬇️ Input

```json
{
  "urls": [
    { "url": "https://www.gov.uk/bank-holidays" },
    { "url": "https://example.com/pricing" }
  ],
  "contentSelector": "main#content",
  "ignoreSelectors": [".timestamp", "#cookie-banner"],
  "changeThreshold": 0,
  "mode": "text",
  "storeSnapshot": true,
  "maxRequestRetries": 3
}
```

| Input | Type | Default | What it does |
|---|---|---|---|
| `urls` | array | required | The pages to watch. Individual URLs, or a text file with one URL per line |
| `contentSelector` | string | empty | CSS selector for the only region that counts. Empty watches the whole body |
| `ignoreSelectors` | array | empty | CSS selectors stripped out before anything is compared |
| `changeThreshold` | integer | `0` | Percent of characters that must differ before it counts as a change |
| `mode` | string | `text` | `text` compares what a reader sees, `html` compares the markup too |
| `storeSnapshot` | boolean | `true` | Keep the full watched content in this run's key-value store |
| `maxRequestRetries` | integer | `3` | Retries on a timeout or a server error before the page is reported as an error |

### ⬆️ Output

Every row below is copied out of two real runs of this Actor on the Apify platform on 15 August 2026, runs `eFh2x9ks27CVeg7LP` and `HHCoWy4HAGjsDH6zB`, including the awkward parts. Three pages were watched twice, about thirty seconds apart, so the second run has something to compare against.

#### Table view

| url | status | changePercent | addedText | removedText |
|---|---|---|---|---|
| https://www.bbc.co.uk/news | changed | 2.04 | 45 minutes ago45min, 50 minutes ago50min, 32 minutes ago32min, and 5 more lines | 44 minutes ago44min, 49 minutes ago49min, 31 minutes ago31min, and 5 more lines |
| https://www.gov.uk/bank-holidays | unchanged | 0 | | |
| https://www.bbc.co.uk/news | first\_run | | | |
| https://httpbin.org/status/503 | error | | | |

#### JSON

A `changed` row. This is the BBC News front page watched twice about thirty seconds apart, with no ignore selectors set, so what moved is the relative timestamp on each story rather than the stories themselves. That is what watching a news homepage raw actually looks like, and it is the case `ignoreSelectors` exists for: add the selector on the timestamp element and the same watch comes back `unchanged` until a headline really moves.

```json
{
  "url": "https://www.bbc.co.uk/news",
  "status": "changed",
  "changedAt": "2026-08-15T16:54:21.827Z",
  "previousCheckedAt": "2026-08-15T16:53:49.613Z",
  "changePercent": 2.04,
  "addedText": "45 minutes ago45min\n45 minutes ago45min\n45 minutes ago45min\n45 minutes ago45min\n45 minutes ago45min\n50 minutes ago50min\n32 minutes ago32min\n52 minutes ago52min",
  "removedText": "44 minutes ago44min\n44 minutes ago44min\n44 minutes ago44min\n44 minutes ago44min\n44 minutes ago44min\n49 minutes ago49min\n31 minutes ago31min\n51 minutes ago51min",
  "contentLength": 15566,
  "previousContentLength": 15566,
  "contentHash": "e2d4264293328a22d5f7373639c69abba98e1a72ee1c3e974c2ab33aa163736e",
  "previousContentHash": "fdc59b152fc9cdf5164b5f9c7b49fc309458cc084b42cc1702ea251b0e1ef395",
  "snapshotKey": "snapshot-defc1a587061144b0595eb74328f76d4",
  "httpStatus": 200,
  "error": null
}
```

An `unchanged` row, from the same run. The two hashes match, so nothing further was done with the page.

```json
{
  "url": "https://www.gov.uk/bank-holidays",
  "status": "unchanged",
  "changedAt": null,
  "previousCheckedAt": "2026-08-15T16:53:48.733Z",
  "changePercent": 0,
  "addedText": null,
  "removedText": null,
  "contentLength": 17602,
  "previousContentLength": 17602,
  "contentHash": "6d4d4fa05c663b5b826b5293f1aa34f45ed371403ed916f526bf8887f494969a",
  "previousContentHash": "6d4d4fa05c663b5b826b5293f1aa34f45ed371403ed916f526bf8887f494969a",
  "snapshotKey": "snapshot-952500e7d20aa6a2936738ec326cda91",
  "httpStatus": 200,
  "error": null
}
```

A `first_run` row, from the run before it. Everything that describes a comparison is null, because there was nothing to compare against yet.

```json
{
  "url": "https://www.bbc.co.uk/news",
  "status": "first_run",
  "changedAt": null,
  "previousCheckedAt": null,
  "changePercent": null,
  "addedText": null,
  "removedText": null,
  "contentLength": 15566,
  "previousContentLength": null,
  "contentHash": "fdc59b152fc9cdf5164b5f9c7b49fc309458cc084b42cc1702ea251b0e1ef395",
  "previousContentHash": null,
  "snapshotKey": "snapshot-defc1a587061144b0595eb74328f76d4",
  "httpStatus": 200,
  "error": null
}
```

An `error` row. The page was fetched four times, once plus the three retries, and answered 503 every time. The stored fingerprint for this page is left exactly as it was, so the outage cannot manufacture a change on the next run.

```json
{
  "url": "https://httpbin.org/status/503",
  "status": "error",
  "changedAt": null,
  "previousCheckedAt": null,
  "changePercent": null,
  "addedText": null,
  "removedText": null,
  "contentLength": null,
  "previousContentLength": null,
  "contentHash": null,
  "previousContentHash": null,
  "snapshotKey": null,
  "httpStatus": 503,
  "error": "HTTP 503 SERVICE UNAVAILABLE after 4 attempt(s)"
}
```

### The first run is not a change

This is the single most important behaviour in the Actor.

The first time a URL is seen under a given watch configuration there is no stored history, so there is nothing to compare against. That is reported as **`first_run`**, with `changePercent`, `addedText`, `removedText`, `previousContentHash` and `previousCheckedAt` all null. It is not a change, it never appears as one, and a webhook filtered on `status == "changed"` will not fire for it.

The same applies when you edit the watch configuration. The stored fingerprint is keyed on the URL together with the content selector, the ignore rules and the mode, because changing any of those changes what is being watched. Editing a selector gives you a fresh `first_run` rather than a change nobody made to the page.

### How a page is compared

Before anything is hashed, the content is normalised, because otherwise every trivial reflow reads as an edit:

- `<script>`, `<style>`, `<noscript>` and inline SVG are removed. Build hashes and inline state churn on every deploy and are not content.
- HTML comments are removed, which is where server render timestamps usually sit.
- Everything matching `ignoreSelectors` is removed.
- Runs of whitespace collapse to a single space, non breaking and zero width spaces go, and blank lines are dropped.
- In `text` mode only the visible text survives, broken into lines at every paragraph, heading, list item, table cell and other block level boundary. The line breaks come from the structure of the page rather than from whitespace in the source, so a minified site and a pretty printed one produce the same lines and the same diff. In `html` mode the markup is kept, one tag per line so the diff is readable.

What is left is hashed with SHA-256. If the hash matches the stored one the page is `unchanged` and no further work is done. If it differs, the lines are aligned to work out what was added and what was removed, and `changePercent` is the changed characters as a share of the larger of the two versions.

`changeThreshold` is applied last. At the default of `0` every difference is reported. Set it to `5` and a page whose content moved by less than five percent comes back `unchanged`, with the measured percentage still in `changePercent` so you can see what was suppressed.

**A difference held back by the threshold is not forgotten.** The stored copy is only replaced when a change is actually reported, so the next run still compares against the last version you were told about rather than against the version you were not. A page that moves two percent a run under a five percent threshold therefore keeps adding up: the second and third runs come back `unchanged` at two and four percent, and the fourth reports one six percent change carrying all of it. Without that, every run would quietly become the new baseline and a page could be rewritten from end to end, two percent at a time, without ever crossing the threshold.

### Very large pages are watched by hash alone

The stored copy of a page is capped at two million characters, about two megabytes of plain text and far beyond any normal page. Past that the fingerprint is kept and the copy is not, because a state record that large is slower and more fragile than the thing it is protecting.

The consequences are worth stating plainly, because they are the one place the output is thinner than the field table suggests:

- The page is still watched, and a change is still detected. The hash catches any edit at all.
- `changePercent`, `addedText` and `removedText` come back **null**, because there is no previous copy to diff against.
- `changeThreshold` cannot be applied, so any difference at all is reported as `changed`.

Setting `contentSelector` to the region you actually care about brings a page of that size back under the cap and gives you the line level diff again, which is usually the better answer anyway.

### What happens when a page will not load

A page that times out, refuses the connection or answers with a server error is retried with a backoff, up to `maxRequestRetries` times. If it still will not answer, the page comes back as an `error` row with the reason in `error` and the code in `httpStatus`.

Two things follow from that, and both are deliberate:

- **The stored fingerprint is left alone.** A site that was down for ten minutes does not read as a change when it comes back.
- **The run still succeeds.** One unreachable host does not cost you the other forty nine pages, and it does not throw away the rows that were collected before it.

A page that ships an empty shell and builds itself with JavaScript in the browser is also reported as an `error`, with an explanation, rather than being silently recorded as an empty page that then "changes" the moment anything appears. This Actor is a fetch and a diff by design, which is what makes it cheap and fast. If you hit one of those pages, point `contentSelector` at a server rendered part of the site, or watch the JSON feed the page itself calls.

### 💰 How much does it cost?

This Actor is priced **per page checked**, on Apify's pay per event model, through a single event called `page-checked`. There is no monthly rental and no subscription, so a run you do not make costs nothing.

| Your Apify plan | Price per page checked |
|---|---|
| Free | $0.006 |
| Bronze | $0.0051 |
| Silver | $0.0042 |
| Gold | $0.0036 |
| Platinum | $0.003 |
| Diamond | $0.0024 |

Apify also charges its standard `Actor start` event of **$0.00005** once per run, so the run itself is effectively free and the pages are the bill.

Worked example. Ten pages on an hourly schedule is 240 checks a day. On the Bronze plan that is 240 x $0.0051 = **$1.224**, plus 24 start events at $0.00005 = $0.0012, so **$1.23 a day** or about $37 a month. On Diamond the same watch list is 240 x $0.0024 = **$0.58 a day**.

A page is charged once per run **whatever the outcome**. That includes `unchanged`, because the fetch and the comparison cost the same either way, and it includes an `error` row, because a page that answers with a server error is still fetched and retried. Fifty URLs on an hourly schedule is fifty charges an hour, whether nothing moved or everything did.

Set a charge limit on the run or the schedule to cap it, and use `changeThreshold` and `ignoreSelectors` to control the noise rather than the bill.

### 🔌 Integrations

Send results straight to Google Sheets, Slack, Airtable, Zapier, Make or your own endpoint with [Apify integrations](https://docs.apify.com/platform/integrations). A [webhook](https://docs.apify.com/platform/integrations/webhooks) on run success is the usual setup here: filter the dataset on `status == "changed"` and you have a change alert with the diff in it.

[Scheduling](https://docs.apify.com/platform/schedules) is what turns this from a one off comparison into monitoring. Hourly, daily or weekly, whatever matches how often the page you care about actually moves.

### 🔗 Using Website Change Monitor with the Apify API

```bash
curl -X POST "https://api.apify.com/v2/acts/spookyweb~website-change-monitor/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"urls": [{"url": "https://example.com/pricing"}], "contentSelector": "main#content", "ignoreSelectors": [".timestamp"]}'
```

Or with the Apify client:

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

const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('spookyweb/website-change-monitor').call({
  urls: [{ url: 'https://example.com/pricing' }],
  contentSelector: 'main#content',
  ignoreSelectors: ['.timestamp'],
  changeThreshold: 0,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const changed = items.filter((row) => row.status === 'changed');
```

Full detail is in the [Apify API reference](https://docs.apify.com/api/v2), and every run is also callable from the [Python](https://docs.apify.com/api/client/python) and [JavaScript](https://docs.apify.com/api/client/js) clients.

### ❓ FAQ

#### Why does my first run say first\_run instead of changed?

Because it is a first run. There was no stored copy of that page to compare against, so nothing changed. Run it a second time and you will get `unchanged`, then real change reports from there. This is on purpose: a monitor that calls the first sighting a change floods you with alerts on day one and teaches you to ignore it.

#### Where is the previous version kept, and for how long?

In a named key-value store on your own Apify account, called `website-change-monitor-state`, one record per watched page. It persists between runs and belongs to you. Each record holds the normalised content, its hash, its length and the time it was checked. Delete the store and every page starts again from `first_run`.

#### How do I stop a clock or a carousel triggering a change every hour?

Add its CSS selector to `ignoreSelectors`. That region is removed from the page before anything is hashed, so it can never register as a change. Timestamps, view counters, session tokens, cookie banners and ad slots are the usual suspects. If you cannot name the element, raise `changeThreshold` a few percent instead.

#### Can I watch just one part of a page?

Yes, that is what `contentSelector` is for. Any CSS selector works, for example `#price`, `main article` or `table.results tbody`. If the selector matches nothing, the page is reported as an `error` rather than falling back to the whole body, because a silent fallback would look like the entire page had changed.

#### What is the difference between text mode and html mode?

`text` compares what a reader sees, so a template change, a class rename or a new inline script is not a change. `html` compares the markup as well, so it catches an attribute, a link target or a hidden field moving. Start with `text`. Switch to `html` only when you know you need it, because it is noisier on any site with a build step.

#### Does it work on pages that need JavaScript?

No, and it says so rather than pretending. This Actor is a plain HTTP fetch and a diff, which is what makes it fast and cheap. A page that ships an empty shell comes back as an `error` with an explanation. Most of the time the fix is to watch the JSON feed the page calls, or a server rendered part of the same site.

#### Does a small change I set a threshold to ignore build up over time?

Yes. The stored copy is only replaced when a change is reported, so a page drifting under the threshold is still measured against the last version you were told about. The drift accumulates and is reported as one change the run it crosses the threshold, rather than being reset to zero every run.

#### Will a site being down show up as a change?

No. A failed fetch is an `error` row, and the stored fingerprint is left exactly as it was. When the site comes back, it is compared against the last version that was actually read, so an outage cannot manufacture a change.

#### Can I run it on a schedule?

Yes, and that is the intended use. Set an Apify [schedule](https://docs.apify.com/platform/schedules) at whatever interval matches the page, then add a webhook filtered on `status == "changed"` so you only hear from it when something actually moves.

#### How many pages can one run watch?

Up to 1,000. Past that the extras are skipped and the log says how many, rather than dropping them silently. Split a longer list across several runs or several schedules.

### ⚖️ Is it legal to monitor a website for changes?

Reading a public web page is the same act a browser performs, and comparing two versions of something you were shown is not a separate right. This Actor only fetches pages you name, it never logs in, it never submits a form, and it stores only the content of the pages you asked it to watch.

What you should still check is the target site's terms of use and its `robots.txt`, whether the content is copyrighted before you republish any of it, and whether the page carries personal data, which brings the UK GDPR and the EU GDPR into play regardless of how public the page is. Apify's [ethical scraping guide](https://blog.apify.com/is-web-scraping-legal/) covers the wider picture, and none of this is legal advice.

### 👍 Your feedback

Found a page this handles badly, or want a field that is not here yet? Open an issue on the Actor's Issues tab. Requests that make the output more useful get built, and problems get fixed quickly.

### 🔎 You might also like

| Actor | What it does |
|---|---|
| [Website Contact Scraper](https://apify.com/spookyweb/website-contact-scraper) | Emails, phone numbers, social profiles and named people from any company website |
| [Wayback Machine Scraper](https://apify.com/spookyweb/wayback-machine-scraper) | Historical snapshots of a page from the Internet Archive, for the changes that happened before you started watching |
| [UK Case Law Search and Monitor](https://apify.com/spookyweb/uk-case-law-search-monitor) | Court judgments and tribunal decisions from Find Case Law, with change monitoring built in |
| [Company Email Finder](https://apify.com/spookyweb/company-email-finder) | Contact addresses for a domain, and the address pattern behind them |
| [Company Domain Finder](https://apify.com/spookyweb/company-domain-finder) | Company names in, official website addresses out, so you have URLs to watch in the first place |
| [Company Data Enrichment](https://apify.com/spookyweb/company-enrichment) | A full profile from one domain: name, address, phones, emails, socials and the technology the site runs on |
| [Company Jobs Scraper](https://apify.com/spookyweb/company-jobs) | Every open role a company is advertising, read from its own hiring board rather than from its careers page |

# Actor input Schema

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

The web pages to watch. Add them one at a time, or point at a text file holding one URL per line. Each page is fetched, compared against what it looked like on the previous run, and reported as changed, unchanged or first\_run. Every page is charged once per run whatever the outcome.

## `contentSelector` (type: `string`):

A CSS selector for the only part of the page that matters, for example main#content or .product-price. Leave it empty to watch the whole page body. If the selector matches nothing the run reports an error for that URL rather than quietly falling back to the whole page, because a silent fallback would look like the entire page had changed.

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

CSS selectors for the parts of the page that change on their own: clocks, session or CSRF tokens, view counters, rotating carousels, cookie banners and ad slots. Everything matching these is removed before anything is compared, so it can never trigger a false change. One selector per entry, for example .timestamp or #ad-slot-top.

## `changeThreshold` (type: `integer`):

How much of the watched content has to differ before it is reported as a change, measured as a percentage of characters. Leave it at 0 to report every difference. Raise it to 5 or 10 on a page that carries small unavoidable churn you cannot name with an ignore selector. A difference under the threshold does not become the new baseline, so a page drifting slowly keeps adding up until it is worth reporting.

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

Compare the visible text, or compare the underlying HTML. Text is the right answer almost always: it ignores markup churn from a template or a CDN rebuild and only reports what a reader would see change. Use HTML when you need to catch an attribute, a link target or a hidden field moving.

## `storeSnapshot` (type: `boolean`):

Save the watched content of every page into this run's key-value store, so you can read exactly what the page said at the moment it was checked. Turn it off to keep runs lean. The comparison itself works either way, because the state used for the next run is kept separately and is never affected by this setting.

## `maxRequestRetries` (type: `integer`):

How many times to retry a page that times out or answers with a server error, before giving up and reporting it as an error row. Retries back off between attempts. A page that could not be read is never recorded as a change, so a flaky site cannot produce a false alert.

## Actor input object example

```json
{
  "urls": [
    {
      "url": "https://www.gov.uk/bank-holidays"
    }
  ],
  "contentSelector": "",
  "ignoreSelectors": [],
  "changeThreshold": 0,
  "mode": "text",
  "storeSnapshot": true,
  "maxRequestRetries": 3
}
```

# Actor output Schema

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

One row per watched page: its status, what changed, and the hashes behind the verdict.

## `snapshots` (type: `string`):

The watched content of each page as it was read on this run, kept while the snapshot option is on.

# 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": [
        {
            "url": "https://www.gov.uk/bank-holidays"
        }
    ],
    "contentSelector": "",
    "ignoreSelectors": [],
    "changeThreshold": 0,
    "mode": "text",
    "storeSnapshot": true,
    "maxRequestRetries": 3
};

// Run the Actor and wait for it to finish
const run = await client.actor("spookyweb/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": [{ "url": "https://www.gov.uk/bank-holidays" }],
    "contentSelector": "",
    "ignoreSelectors": [],
    "changeThreshold": 0,
    "mode": "text",
    "storeSnapshot": True,
    "maxRequestRetries": 3,
}

# Run the Actor and wait for it to finish
run = client.actor("spookyweb/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": [
    {
      "url": "https://www.gov.uk/bank-holidays"
    }
  ],
  "contentSelector": "",
  "ignoreSelectors": [],
  "changeThreshold": 0,
  "mode": "text",
  "storeSnapshot": true,
  "maxRequestRetries": 3
}' |
apify call spookyweb/website-change-monitor --silent --output-dataset

```

## MCP server setup

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