# Website Change Monitor with Smart Diff (`al_mansouri/verified-website-change-monitor`) Actor

Watch any public page and get told only when something meaningful changes. Rotating banners, cookie popups, and timestamps are filtered out; a price or stock edit is caught however small it is.

- **URL**: https://apify.com/al\_mansouri/verified-website-change-monitor.md
- **Developed by:** [Hussein Al-Mansori](https://apify.com/al_mansouri) (community)
- **Categories:** Automation, Developer tools, E-commerce
- **Stats:** 2 total users, 1 monthly users, 69.2% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

## 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 with Smart Diff

**Watch any public page and get told only when something that matters actually changes.** Free.

Every website monitor says it filters noise. This one publishes the number: across 32 real pages
fetched an hour apart with nothing edited, **3.1% produced a false alert at the default setting,
and 0% of pricing and terms pages did** — including seven whose raw HTML genuinely drifted in that
hour. The method is in the repo and you can run it yourself.

```json
{
  "event": "change-detected",
  "url": "https://competitor.example.com/pricing",
  "similarityPercent": 92.7,
  "changeMagnitude": "minor",
  "changedSignals": ["price: $29 → $39"],
  "diffPreview": "- Pro plan: $29/mo\n+ Pro plan: $39/mo",
  "checkedAt": "2026-09-04T08:00:12Z",
  "previousCapturedAt": "2026-09-03T08:00:09Z",
  "status": "verified"
}
```

That `$29 → $39` edit is four characters on a page of thousands. It is caught. A rotating banner on
the same page, which is the *same size*, is not.

***

### Contents

[Quick start](#quick-start) · [Why the alerts are trustworthy](#why-the-alerts-are-trustworthy) ·
[Use cases](#use-cases) · [Input](#input) · [Output](#output) ·
[Run it from code](#run-it-from-code) · [Webhooks](#webhooks-slack-discord-zapier-n8n) ·
[AI summaries](#ai-summaries-your-key-your-bill) · [Scheduling](#scheduling) ·
[Pricing](#pricing) · [Limits](#limits) · [Errors](#errors) · [FAQ](#faq)

***

### Quick start

1. Paste one or more URLs into **Pages to watch**.
2. Run it. Every page comes back as `baseline` — the starting point, never an alert.
3. Put it on a **schedule** — hourly, daily, whatever suits the page.
4. Read the rows where `event` is `change-detected`. Or have them POSTed to you.

Nothing else is required. Every other field is a refinement.

***

### Why the alerts are trustworthy

**The problem is that noise and signal are the same size.** A rotating headline and a price edit
are both a handful of characters on one line. Set a sensitivity low enough to catch the price and
the banner alerts every hour; set it high enough to silence the banner and the price slips through.
No amount of tuning fixes that, because the difference is not one of magnitude.

So two separate things happen:

**Noise is deleted before anything is compared.** Cookie and consent banners, carousels and
sliders, ad slots, live regions, testimonial and donor rotators, and rendered timestamps are
stripped from both captures. A page whose only difference is a rotating promo comes back **100%
similar** — not "similar enough to ignore".

**Prices, discounts and stock phrases always count.** A change to any of them is reported at every
sensitivity, however small. `$29 → $39` and `In stock → Sold out` are never filtered, and the exact
token that moved is named in `changedSignals` so an automation can act without parsing prose.

**Uncertainty is reported as uncertainty.** A bot-block page, a 403, a page that suddenly returns a
fraction of its content, or a `watchSelectors` value that stopped matching — each looks like a
huge change to a naive differ. Here they produce a warning, **no change claim**, and the stored
baseline is left untouched so the next real check still compares against something true.

That last one is why the numbers hold up. Most false alerts in this category are not subtle
misjudgements; they are a monitor confidently reporting that a page was rewritten when it was
actually just unreachable.

***

### Use cases

| You want to know | Watch | What you get |
|---|---|---|
| A competitor changed their price | their pricing page | `changedSignals: ["price: $29 → $39"]` |
| A product came back in stock | the product page | `["availability: sold out → in stock"]` |
| A supplier changed their terms | the terms page | the exact clause, in `diffPreview` |
| A regulator updated a rule | the policy page | a dated record in the dataset, every run |
| A client edited a page you own | the page | an alert before the client asks |
| An agent needs a change signal | anything | one self-contained JSON row per change |

***

### Input

| Field | Default | What it does |
|---|---|---|
| `startUrls` | — | Public pages to check. Up to 50. |
| `sensitivity` | `medium` | How much ordinary text must change to count: `low` only major rewrites, `high` small edits too. **Prices and stock always count regardless.** |
| `ignoreSelectors` | `[]` | CSS selectors you never want to hear about, e.g. `nav`, `footer`. The usual noise is already handled. |
| `watchSelectors` | `[]` | Compare **only** these regions, e.g. `.price-box`. Everything else is ignored. |
| `useBrowser` | `false` | Render JavaScript. Needed for pages that build content in the browser; several times dearer per page. |
| `notifyWebhookUrl` | — | POST each change here as JSON. Changes only, never the quiet checks. |
| `aiApiKey` | — | Your own Anthropic (`sk-ant-…`) or OpenAI key, for a one-sentence summary of each change. Billed to you by that provider. |
| `aiModel` | flagship | `claude-opus-5` or `gpt-5.4` by default. Set a smaller model to spend less. |
| `aiSummaryLanguage` | `en` | Summary language. Arabic fully supported. |
| `stateStoreName` | `website-change-monitor-state` | The named key-value store holding baselines, on **your** account. Use a different name for a second, independent watch list. |
| `respectRobots` | `true` | Skip pages robots.txt asks crawlers not to read. |
| `proxyConfiguration` | — | Some sites answer differently to repeated data-centre requests. |

Three things worth knowing before you tune anything:

- **`nav`, `header` and `footer` are not ignored by default.** They are boilerplate, but they are
  also where a pricing link disappears or a product line gets dropped. Add them to
  `ignoreSelectors` if you would rather not hear about them.
- **Changing `watchSelectors` starts a fresh baseline** for that page. A capture taken under
  different rules cannot honestly be compared, so the next run reports `baseline` and says so.
- **Busy community homepages are the hard case.** Pages with "latest posts" or "recent supporters"
  strips genuinely change every hour. Point `watchSelectors` at the part you care about.

***

### Output

One row per page, per run. Most say nothing happened — that is the point.

| Field | Meaning |
|---|---|
| `event` | `baseline` (first capture), `page-checked` (compared, nothing meaningful), `change-detected` (the alert) |
| `similarityPercent` | 100 = identical. Measured over content **after** noise filtering, so a small edit to a real value scores lower than a rewritten advert |
| `changeMagnitude` | `none` / `minor` / `moderate` / `major` — filter on this instead of doing the arithmetic |
| `changedSignals` | Which price, percentage or stock phrase moved. Populated even when too small to cross the threshold |
| `diffPreview` | Short, readable: `-` old line, `+` new line |
| `aiSummary` | One sentence, when a key was supplied |
| `status` | `verified` / `warning` / `failed`. A `warning` row is still real — read `warnings` for what was uncertain |
| `previousCapturedAt` | When the capture it was compared against was taken |
| `blocksCompared` | Text blocks that survived filtering |
| `renderedWithBrowser` | Whether this page was rendered rather than downloaded |

A quiet check, which is most of them:

```json
{ "event": "page-checked", "url": "https://supplier.example.com/terms",
  "similarityPercent": 100, "changeMagnitude": "none", "status": "verified" }
```

***

### Run it from code

Every row is self-contained, so a receiver can act on one row alone without fetching anything else.

**API** — start a run and wait for the dataset:

```bash
curl -X POST "https://api.apify.com/v2/acts/YOUR~verified-website-change-monitor/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"startUrls":["https://competitor.example.com/pricing"],"sensitivity":"medium"}'
```

**JavaScript**

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

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('YOUR~verified-website-change-monitor').call({
  startUrls: ['https://competitor.example.com/pricing'],
  sensitivity: 'medium',
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const row of items.filter((r) => r.event === 'change-detected')) {
  console.log(row.url, row.changedSignals, row.diffPreview);
}
```

**Python**

```python
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("YOUR~verified-website-change-monitor").call(run_input={
    "startUrls": ["https://competitor.example.com/pricing"],
    "sensitivity": "medium",
})

for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    if row["event"] == "change-detected":
        print(row["url"], row["changedSignals"])
```

**MCP / AI agents.** Apify exposes Actors over MCP, so an agent can call this one as a tool and act
on the result. `changedSignals` is the field to branch on — it is already structured, so the agent
never has to interpret the diff text.

***

### Webhooks (Slack, Discord, Zapier, n8n)

Put an endpoint in `notifyWebhookUrl` and every detected change is POSTed to it as JSON — the same
row that went to the dataset. **Only changes are sent, never the quiet checks**, which is what stops
the channel being muted.

```
POST <your endpoint>
Content-Type: application/json

{ "event": "change-detected", "url": "…", "changedSignals": ["price: $29 → $39"], … }
```

Public HTTPS addresses only, one attempt, and **redirects are not followed** — a redirect is a
second destination that nothing validated. Delivery failures are counted in the run's status
message, since a broken webhook cannot report itself.

***

### AI summaries (your key, your bill)

Optional, and the filter is designed to be good without one. Supply an Anthropic or OpenAI key and
each detected change also gets a one-sentence explanation:

> *"The Pro plan price increased from $29 to $39 and a new Enterprise tier was added."*

The provider is read from the key prefix. **Only the diff is sent — never the page** — and only when
a change was actually detected, so a quiet check never costs you a token. The key is stored as a
secret, never written into a result, and never sent anywhere but the provider it belongs to. If a
summary fails the change is still published, still complete, with a warning saying why.

***

### Scheduling

This Actor is built to run unattended. Use Apify's scheduler — **Actor → Schedules → new schedule**
— and pick a cadence that suits the page: hourly for stock and pricing, daily for terms and policy.

Baselines persist between runs in a named key-value store **on your own account**. Nothing is
retained by us, and you can inspect or delete that store at any time.

***

### Pricing

**Free.** Only Apify's platform usage applies. AI summaries, if you enable them, are billed to you
directly by Anthropic or OpenAI — this Actor never resells them.

Charge events are already built in so that adding a price later is a price change and not a
different product. There will be three, and only one row is ever charged per page per run:
`page-checked`, `change-detected`, and `ai-summary-generated`. A first `baseline` capture is
charged as a check, never as an alert.

***

### Limits

- Public pages only; ports 80, 443, 8080, 8443. Private and internal addresses are refused,
  including through a redirect.
- Up to **50 pages** per run, **4 at a time**, one at a time per host.
- **45 s** per page, **15 s** per request, at most **5 redirects**, **2 MB** read per page.
- At most **20** entries each in `ignoreSelectors` and `watchSelectors`, 512 characters each.
- At most **4,000** text blocks compared per page; baselines capped at **512 KB**; `diffPreview` at
  **1,200** characters.
- Sensitivity thresholds: **15%** of compared text for `low`, **5%** `medium`, **1%** `high`, never
  fewer than **12** changed characters. Prices, percentages and stock phrases bypass all of these.
- A page returning under **25%** of its previous content is reported as doubtful, not changed.
- Webhook: 10 s, one attempt, no redirects. AI: 30 s, 4,000 characters of diff sent.
- **No screenshots or visual diffs** — that is
  [Verified Website Screenshot & PDF](https://apify.com/apify/verified-website-screenshot-pdf).
- No login-protected pages, CAPTCHA solving, or paywall bypasses.

***

### Errors

| Code | Meaning |
|---|---|
| `INVALID_URL` / `INVALID_INPUT` | Rejected before anything was fetched |
| `BLOCKED_DESTINATION` | The URL resolved to a private or internal address |
| `DNS_FAILURE` | The hostname did not resolve. Retryable |
| `SITE_UNREACHABLE` / `SITE_TIMEOUT` | Not reached, or no answer in time. Retryable |
| `ROBOTS_DISALLOWED` | robots.txt asks crawlers not to read this page |
| `CONTENT_UNUSABLE` | No readable text to compare — usually JavaScript-rendered; try **Render JavaScript** |
| `STATE_STORE_FAILURE` | The baseline store could not be opened. The run fails rather than silently re-baselining everything |
| `STORAGE_FAILURE` | The result could not be stored. Nothing charged. Retryable |
| `BUDGET_EXHAUSTED` | The run's maximum charge budget was reached |

***

### FAQ

**How do I know the noise filter works?** Because it is measured, not asserted. `page-checked`
rows carry `similarityPercent: 100` on pages whose raw HTML genuinely moved — you can see the
filter working in your own dataset, on your own pages, on every run.

**Does it need an AI key?** No, and it never will. The filter has to be good without one. A key only
adds a sentence on top of a diff you would have got anyway.

**Why did I get the same alert twice?** Almost always because the new baseline could not be saved
after the row was published. The run's status message says so. The alternative — saving first —
would silently lose the alert, which is worse.

**Why did a page report `baseline` again?** Either it is new, or `watchSelectors` /
`ignoreSelectors` changed, which starts a fresh baseline because the old capture is no longer
comparable.

**Can I watch a JavaScript page?** Yes — turn on **Render JavaScript**. Off by default because it
costs several times more per page, and spending that without being asked is not something this
Actor does.

**Can I run two independent watch lists?** Yes — give them different `stateStoreName` values.

**What counts as a change to a price?** Any currency amount, percentage, or stock phrase that
appears on one capture and not the other, in USD, EUR, GBP, SAR, AED and others, including Arabic
availability wording.

***

### Support

Issues and feature requests go through the Actor's issues tab. `CHANGELOG.md` records what changed
between versions.

# Actor input Schema

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

The public pages to check. The first run of each page stores a starting point and reports it as "baseline"; every run after that compares against it. Put the Actor on a schedule and leave it alone.

## `sensitivity` (type: `string`):

How much of a page's text must differ before it counts as a change. Price, discount, and in-stock edits always count regardless of this setting, because those are usually tiny and always matter.

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

CSS selectors for regions you never want to hear about, for example "nav", "footer", or ".related-posts". Cookie banners, carousels, ad slots, and timestamps are already ignored for you.

## `watchSelectors` (type: `array`):

CSS selectors for the only regions that should be compared, for example ".price-box". Everything outside them is ignored. Leave empty to watch the whole page. Changing this starts a fresh baseline, because the new region cannot be compared against the old one.

## `stateStoreName` (type: `string`):

The named key-value store, on your own account, that remembers what each page looked like last time. Use a different name to run two independent watch lists that do not share history.

## `respectRobots` (type: `boolean`):

Skip pages the site's robots.txt asks crawlers not to read. Turning this off means checking pages the owner asked not to be read, which is your decision and your responsibility.

## `useBrowser` (type: `boolean`):

Open each page in a real browser instead of just downloading it. Needed for pages that build their content in the browser, which otherwise come back empty or very thin. Several times slower and more expensive per page, so it is off by default.

## `notifyWebhookUrl` (type: `string`):

If set, every change record is also POSTed here as JSON - a Slack or Discord endpoint, a Zapier or Make hook, or your own service. Only changes are sent, never the quiet checks. Public HTTPS addresses only.

## `aiApiKey` (type: `string`):

Optional. With a key, each detected change also gets a one-sentence summary of what changed. An Anthropic key (starting sk-ant-) or an OpenAI key. Calls are billed to you by that provider, not by this Actor, and the key is never stored in a result.

## `aiModel` (type: `string`):

Which model to summarise with, for example claude-opus-5 or gpt-5.4. Leave empty to use the provider's flagship. Set a smaller model here if you would rather spend less per summary.

## `aiSummaryLanguage` (type: `string`):

The language the summaries are written in, as a name or a code - for example en, ar, or Spanish. Arabic is fully supported. Only affects the AI summary; the rest of the result is language-neutral.

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

Some sites answer differently, or not at all, to repeated requests from a data centre. A proxy costs more per run but is read more like an ordinary visitor.

## Actor input object example

```json
{
  "startUrls": [
    "https://competitor.example.com/pricing",
    "https://supplier.example.com/terms"
  ],
  "sensitivity": "medium",
  "ignoreSelectors": [
    "nav",
    "footer",
    ".sidebar"
  ],
  "watchSelectors": [
    ".price-box"
  ],
  "stateStoreName": "website-change-monitor-state",
  "respectRobots": true,
  "useBrowser": false,
  "notifyWebhookUrl": "https://hooks.example.com/services/T000/B000/XXXX",
  "aiModel": "",
  "aiSummaryLanguage": "ar"
}
```

# Actor output Schema

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

One row per page, including the pages that did not change.

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

No description

# 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": [
        "https://www.apify.com/pricing"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("al_mansouri/verified-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": ["https://www.apify.com/pricing"] }

# Run the Actor and wait for it to finish
run = client.actor("al_mansouri/verified-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": [
    "https://www.apify.com/pricing"
  ]
}' |
apify call al_mansouri/verified-website-change-monitor --silent --output-dataset

```

## MCP server setup

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