# Content Change Monitor: Website Change Detection & AI Alerts (`pardha.s/content-change-monitor`) Actor

Website change monitor with AI: get alerts only when a page change matters, like prices, outages, releases and policy updates. Email, Slack, Discord or webhook, with before/after screenshots. Pay per check, built for AI agents.

- **URL**: https://apify.com/pardha.s/content-change-monitor.md
- **Developed by:** [Pardha Saradhi Mallela](https://apify.com/pardha.s) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 page 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

## Content Change Monitor — get alerted only when a webpage change matters

https://www.youtube.com/watch?v=ZS6WxfxTTxc

Most website monitors alert on every timestamp, view counter and rotating ad. **Content Change
Monitor** filters that noise out and tells you only when something real changes: a price, an
outage, a new release, a policy update.

- **Noise filtered out:** timestamps, counters, ads, session tokens and reordered widgets are ignored
- **AI judge:** tell it what matters in plain English, e.g. *"only alert if a price changes"*
- **Alerts where you work:** email, Slack, Discord or webhook, with before/after screenshots
- **Pay per use:** from **$0.002 per check**, no subscription, and our own failures are free
- **Built for AI agents:** call it with just a URL through the Apify API or MCP, get a one-line answer

### What people use it for

- **Competitor pricing:** plans, tiers and limits on pricing pages
- **Outages:** status pages of the services you depend on
- **Releases:** changelogs, release notes, API docs
- **Legal:** terms of service and privacy policies
- **Prices and stock:** product pages and fares, with drop-only alerts
- **Jobs and notices:** job boards, government and regulator announcements
- **Any public webpage** you need to keep an eye on

### Quick start

1. Add the pages you want to watch and click **Start**. The first run saves a baseline of each page.
2. **Schedule** it (hourly, daily…). Every run compares against the last one.
3. Add `emailTo`, or a Slack/Discord `webhookUrl`, to get alerts.

```json
{
  "targets": [{ "url": "https://example.com/pricing", "label": "Competitor pricing" }],
  "aiInstruction": "only notify if a price changes",
  "webhookUrl": "https://hooks.slack.com/services/XXX",
  "webhookFormat": "slack"
}
```

The smallest input is `{ "targets": [{ "url": "https://example.com" }] }`; everything else has a
sensible default.

**Output:** one record per page per run.

```json
{
  "url": "https://example.com/pricing",
  "label": "Competitor pricing",
  "status": "changed",
  "significant": true,
  "category": "price_change",
  "summary": "Pro plan moved from $49 to $59 per month",
  "diff": { "removed": [{ "text": "$49" }], "added": [{ "text": "$59" }] },
  "screenshotBefore": "https://…", "screenshotAfter": "https://…"
}
```

Each run also saves a short summary (`OUTPUT`) with `changed: true/false` and a one-line
`summary`, so a script or an AI agent can answer *"did anything change?"* without reading the dataset.

### Pricing

Pay per event. No subscription, no monthly minimum, no platform-usage fees.

| Event | Price | When |
|---|---|---|
| Page checked | **$0.002** | each page fetched and compared |
| Change detected | $0.01 | a significant change was found |
| AI review | $0.004 | the built-in AI judged a change (about 5% of checks; free with your own key) |
| Screenshots | $0.002 | before/after pictures of a change (only if `includeScreenshots` is on) |
| Browser render | $0.002 | a page that needed a real browser (JavaScript-heavy sites) |
| Residential proxy | $0.002 | a check through a residential proxy (only if you enable it) |

**Example:** 100 pages checked daily for a month ≈ **$6.90**.

**Not charged:** checks that fail because of the Actor itself, and pages that `robots.txt` disallows.
Set a maximum charge per run and the Actor stops cleanly at that limit.

### How it decides what matters

1. **Noise rules** remove what changes on every visit: timestamps ("updated 3 minutes ago"),
   counters, ads, tracking parameters, session tokens, generated class names and rotating widgets.
2. **Size check:** tiny changes are ignored, *except* prices, versions, dates, service status and
   policy wording, which always get through, however small.
3. **AI review:** what's left is judged by AI, guided by your `aiInstruction`. Use the built-in AI,
   or your own OpenAI, Anthropic, Gemini or OpenAI-compatible key (`llmProvider`, `llmApiKey`).
   AI review is optional: set `useAiAdjudication: false` to use the rules alone.

Pages that keep flipping between two versions are marked `flapping` and don't alert.

### Compare modes

| `mode` | Compares | Best for |
|---|---|---|
| `text` (default) | the readable content of the page | docs, policies, news, changelogs |
| `html` | the markup, including attributes | when a link or attribute matters |
| `structured` | product data (JSON-LD, microdata, OpenGraph) | prices, stock, ratings |
| `price` | only the product's own price, currency and stock | price tracking |

**Price mode** ignores recommendation and "was" prices. Fine-tune it with `priceDirection`
(`any`, `drop` or `rise`), `minPriceChangePercent`, and `pricePick` (`first`, `lowest` or
`highest` when a page lists several prices).

Use `selector` to watch one part of a page, and `ignoreSelectors` or `ignorePatterns` to skip parts.

**Content behind a click?** Give the target `actions` (`click`, `waitFor`, `fill`, `scrollTo`,
`wait`) to reach it, for example opening a panel before reading a fare. If a step fails, the check
reports `action_failed` instead of comparing the wrong page.

### Alerts

- **Email:** up to 5 recipients, one digest per run or one email per change. With
  `includeScreenshots`, the before/after pictures are included, with the change highlighted.
  *First alert in Spam? Mark it "Not spam" and add the sender to your contacts.*
- **Slack / Discord:** set `webhookFormat` to `slack` or `discord` and paste the webhook URL.
- **Webhook (JSON):** one signed POST per run. With `webhookSecret` set, verify the
  `X-Actor-Signature` header against the raw body:

```js
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
```

### For AI agents

- **Pay per use, nothing up front:** an agent pays only for the checks it runs, and the run's
  maximum charge is always respected.
- **Minimal input:** `{ "url": "https://example.com" }` works.
- **One-line answer:** read `changed` and `summary` from the run's `OUTPUT` record.
- **Works with** the Apify API, client libraries and Apify's MCP server (Claude, Cursor and other
  MCP clients).

### Reliability

- **A problem with one page never fails the run.** A 404, a timeout or a blocked page becomes a
  record with a clear `status` and reason, and the other pages are still checked.
- **Statuses:** `baseline_created`, `baseline_migrated`, `unchanged`, `changed`, `insignificant`,
  `flapping`, `unreachable`, `blocked`, `selector_miss`, `timeout`, `skipped_robots`, `error`.
- **Upgrades don't cause false alerts:** when the noise rules improve, saved baselines are updated
  first, so you aren't alerted about the upgrade itself.

### Limits

- **Public pages only:** no logins or paywalls.
- **`robots.txt` is always respected**, with no bypass.
- **Strong bot protection** (Cloudflare challenges, DataDome and similar) is reported as `blocked`,
  not worked around. A residential proxy (`proxyConfiguration`) helps with sites that block datacenter IPs.
- **Geo-blocked pages** need a residential proxy in the right country.
- **Polite by design:** pages on the same site are checked one at a time, so 200 pages on one site
  take longer than 200 pages on different sites.
- **No crawling:** it checks the URLs you give it, up to 1,000 per run.

### Your data

Only the changed excerpt and a few surrounding words are sent to the built-in AI for review, never
the full page, your list of pages or anything that identifies you. The built-in AI runs on
business-grade AI services that never use your content to train their models. With your own key,
the excerpt goes only to your provider, and your key is never stored or logged. To keep page
content away from AI entirely, use `structured` or `price` mode, or set `useAiAdjudication: false`.

### Questions or ideas?

Open an issue on the **Issues** tab. Every report is read, and breakages are fixed quickly.

# Changelog

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

# Actor input Schema

## `targets` (type: `array`):

The pages to check. Each entry is either a URL string, or an object: {"url": "https://example.com/pricing", "label": "Competitor pricing", "selector": "main .pricing-table", "ignoreSelectors": \[".ad"], "mode": "text"}. For a product's price only, use "mode": "price", optionally with "pricePick" ("first" | "lowest" | "highest", for a selector matching a list of prices), "priceDirection" ("any" | "drop" | "rise") and "minPriceChangePercent". Only "url" is required and it must be an absolute http(s) URL. Maximum 1000 targets per run. Shorthands: a top-level {"url": "https://example.com"} or {"urls": \["...", "..."]} are accepted too, so a caller can pass one page with no structure at all.

When the thing you want to watch only appears after a click — a seat map, a size picker, a fare panel that opens over a listing without changing the URL — add "actions": a short list of steps run in a real browser before anything is compared. Steps are {"action":"waitFor"|"click"|"wait"|"fill"|"scrollTo", "selector":"...", "ms":1500, "text":"...", "optional":false}, at most 12 of them. Selectors may use Playwright syntax, so you can pick a row by what it says: ".bus:has-text("VRL Travels")". Any target with actions is always rendered in a browser. A required step that finds nothing fails the check with "action_failed" rather than quietly comparing the page you did not reach — mark a step "optional": true (a cookie banner, say) when it may legitimately be absent. Any target may also set its own "renderJs" ("never" | "auto" | "always") to override the run setting, e.g. "always" for a site that draws its interface with JavaScript so its screenshots match what visitors see.

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

How pages are compared unless a target overrides it. "text" compares readable prose and is the right default. "html" compares markup and catches attribute-level edits. "structured" compares JSON-LD, microdata, RDFa and OpenGraph fields such as offers.price — exact, cheapest, and never calls a model. "price" tracks only a product page's own price, currency and availability — recommendations, sponsored rows and "was" prices are ignored, and no model is called. Example target: {"url": "https://www.amazon.in/dp/…", "mode": "price"}.

## `minChangedChars` (type: `integer`):

A change smaller than this many characters is recorded as "insignificant" and never notified. Example: 20.

## `minChangedPercent` (type: `integer`):

A change smaller than this percentage of the page is recorded as "insignificant" and never notified. Both this and the character threshold must be exceeded. Example: 1 means 1% of the page.

## `alwaysReportValueChanges` (type: `boolean`):

Keep this on so a small change to a price, stock status, version number or deadline is reported even when it is below the size thresholds above. A price moving from $49 to $59 is six characters, and it is usually the whole reason you are monitoring the page. Turn it off to apply the size thresholds strictly.

## `alwaysReportStatusChanges` (type: `boolean`):

Keep this on so a status page changing service state — Operational to Degraded Performance, a new incident, an outage marked resolved — is reported even when it is below the size thresholds above. On a page listing a hundred services, one of them going down is a fraction of a percent of the text. Only the changed wording is inspected, so a status page's own "last updated" timestamp ticking over does not trigger it. Turn it off to apply the size thresholds strictly.

## `alwaysReportPolicyChanges` (type: `boolean`):

Keep this on so an edit to policy or contract language — a retention period, an added carve-out, a changed effective date, a new arbitration clause — is reported even when it is below the size thresholds above. A one-clause edit is a handful of characters in a document of tens of thousands. Navigation and footer links named "Privacy Policy" or "Terms of Service" are deliberately not treated as policy language, so menu churn on a legal page stays suppressed. Turn it off to apply the size thresholds strictly.

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

JavaScript regular expressions applied to the page text before comparison; every match is neutralised. Use this for site-specific volatile content the built-in ruleset does not know about. Example: \["Order #\d+", "Queue position: \d+"].

## `disabledRules` (type: `array`):

Turn off individual built-in filters when they suppress something you care about. Use a rule id such as "counter.views", or a whole class such as "timestamp". Rule ids appear in each result's appliedRules field. Example: \["timestamp.copyright_year"].

## `useAiAdjudication` (type: `boolean`):

When true, changes that pass the size thresholds get a second-pass judgement that filters out cosmetic edits. When false, every change above the thresholds is reported. Structured mode never calls a model either way.

## `aiInstruction` (type: `string`):

Tell the adjudicator what counts as important for this page, and everything else is filtered out. Example: "only notify if a price changes or a plan is removed". Leave empty to use the general definition of a significant change.

## `llmProvider` (type: `string`):

"managed" uses the built-in model and is billed per adjudication. The other options use your own API key, are not billed by this Actor, and you pay your provider directly.

## `llmApiKey` (type: `string`):

Required unless the provider is "managed". Stored encrypted, held in memory only, never logged and never written to any output. Example: an OpenAI key starting sk-.

## `llmModel` (type: `string`):

Override the default model. Defaults are pinned per provider, so an upstream model swap cannot silently change your verdicts. Required for an OpenAI-compatible endpoint, because the endpoint decides what it serves. Example: "gpt-4.1-mini" for OpenAI, "claude-haiku-4-5-20251001" for Anthropic, "gemini-3.1-flash-lite" for Google.

## `llmBaseUrl` (type: `string`):

Only for provider "openai-compatible". Must be https and a public address — vLLM, Ollama behind a tunnel, OpenRouter, Groq and similar. Example: https://openrouter.ai/api/v1

## `structuredFields` (type: `array`):

Structured mode only. Dot paths to watch, with \* wildcards. Empty means watch every extracted field. Example: \["offers.price", "offers.availability"] or \["offers.\*"].

## `priceDirection` (type: `string`):

Price mode only. "any" reports every price move, "drop" only decreases, "rise" only increases. Stock changes (e.g. out of stock to in stock) are always reported. Can be set per target too. Example: "drop".

## `minPriceChangePercent` (type: `integer`):

Price mode only. Ignore price moves smaller than this percentage of the old price. 0 reports every move. Can be set per target too. Example: 5 ignores a ₹1,299 to ₹1,280 move.

## `pricePick` (type: `string`):

Price mode only, when the selector matches several prices — a fare per bus on a route, a price per seller. "lowest" tracks the cheapest one (the route's lowest fare), "highest" the most expensive, "first" the first match. Can be set per target too. Example: "lowest".

## `notifyOnFirstRun` (type: `boolean`):

The first check of a target only records a baseline. Leave false so you are not alerted about pages you just added.

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

POST target for significant changes, sent once per run. A Slack or Discord incoming-webhook URL works directly if you set the matching format below. Example: https://hooks.slack.com/services/T000/B000/XXXX

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

"json" sends this Actor's own signed payload. "slack" and "discord" send that platform's incoming-webhook body so no middleware is needed.

## `webhookSecret` (type: `string`):

When set, every webhook carries X-Actor-Signature: sha256=<HMAC-SHA256 of the raw body>. Verify it against the exact bytes you received, before parsing. Stored encrypted and never logged. Example: a long random string such as "whsec_8f2c1d9a4b7e6f30".

## `emailTo` (type: `array`):

Up to 5 addresses to alert when a page changes. You choose recipients; the message content is fixed and cannot be customised. Example: \["alerts@yourcompany.com"].

## `emailMode` (type: `string`):

"digest" sends one message per run summarising every change. "immediate" sends one message per change, capped at 20 per run.

## `maxEmailsPerRun` (type: `integer`):

Immediate mode only. Each recipient gets at most this many emails from one run. When more pages change, the last email summarises all the remaining changes, so none are left out. Between 1 and 20. Example: 5.

## `includeScreenshots` (type: `boolean`):

Take a picture of the page before and after each change that raises an alert, with the changed words highlighted. Shown in the Console's Changes table and attached to email alerts; email recipients are not required. Pages drawn by JavaScript are pictured in a real browser so the picture matches the live site. Charged as one screenshots-captured event ($0.002) per pictured change.

## `screenshotMinorChanges` (type: `boolean`):

Also picture changes below your alert thresholds (status "insignificant"). Off by default: minor changes are not alerts, and on a busy page this pictures almost every check. Needs includeScreenshots. Each pictured change is one screenshots-captured event ($0.002).

## `rulesetVersion` (type: `string`):

Pin the built-in filter set so verdicts stay reproducible, or leave "latest". When the ruleset changes, stored baselines are migrated offline first, so an upgrade never produces a false alert. Example: "1.0.0".

## `renderJs` (type: `string`):

"auto" uses a plain HTTP fetch and only starts a browser when the page turns out to be an empty JavaScript shell. "never" is cheapest. "always" is for pages you already know need a browser.

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

How many targets are fetched at once. Requests to the same host are always serialised and rate limited regardless of this value. Example: 10.

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

How long a single page fetch may take before it is recorded as a timeout. Example: 45.

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

Discard everything this Actor remembers about these targets and start again. The run reports baseline_created for every target and no changes.

## `targetsDatasetId` (type: `string`):

Id or name of an Apify dataset whose items are targets, so a large list does not have to be pasted into this form. Each item needs at least a "url" field. Example: "my-monitored-urls".

## `targetsKeyValueStoreId` (type: `string`):

Id or name of the key-value store holding the target list. Used together with the record key below. Example: "monitoring-config".

## `targetsRecordKey` (type: `string`):

Key of the record holding a JSON array of targets. Example: "targets".

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

Named key-value store where baselines are remembered between runs. Change it to keep two independent monitoring setups apart. Example: "content-change-monitor-state".

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

Apify proxy settings. Datacenter is the default and is right for almost every page. Residential costs materially more per request, so turn it on only for sites that block datacenter IPs.

## `verboseLog` (type: `boolean`):

Log full URLs and every rule that fired. Off by default so URLs and page content stay out of the log.

## Actor input object example

```json
{
  "targets": [
    {
      "url": "https://example.com/pricing",
      "label": "Competitor pricing",
      "mode": "structured"
    }
  ],
  "mode": "text",
  "minChangedChars": 20,
  "minChangedPercent": 1,
  "alwaysReportValueChanges": true,
  "alwaysReportStatusChanges": true,
  "alwaysReportPolicyChanges": true,
  "ignorePatterns": [
    "Order #\\d+"
  ],
  "disabledRules": [
    "timestamp.copyright_year"
  ],
  "useAiAdjudication": true,
  "aiInstruction": "only notify if a price changes",
  "llmProvider": "managed",
  "llmApiKey": "sk-proj-your-key-here",
  "llmModel": "gpt-4.1-mini",
  "llmBaseUrl": "https://openrouter.ai/api/v1",
  "structuredFields": [
    "offers.price",
    "offers.availability"
  ],
  "priceDirection": "any",
  "minPriceChangePercent": 0,
  "pricePick": "first",
  "notifyOnFirstRun": false,
  "webhookUrl": "https://hooks.slack.com/services/T000/B000/XXXX",
  "webhookFormat": "json",
  "webhookSecret": "a-long-random-string-you-also-store-on-your-receiver",
  "emailTo": [
    "alerts@yourcompany.com"
  ],
  "emailMode": "digest",
  "maxEmailsPerRun": 10,
  "includeScreenshots": false,
  "screenshotMinorChanges": false,
  "rulesetVersion": "latest",
  "renderJs": "auto",
  "maxConcurrency": 10,
  "requestTimeoutSecs": 45,
  "resetBaseline": false,
  "targetsDatasetId": "my-target-list",
  "targetsKeyValueStoreId": "my-store",
  "targetsRecordKey": "targets",
  "stateStoreName": "content-change-monitor-state",
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "verboseLog": false
}
```

# Actor output Schema

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

Only the pages that changed significantly in this run: summary, category, diff, and before/after screenshot links.

## `checks` (type: `string`):

One record per monitored page per run, with its status (changed, unchanged, insignificant, blocked, ...) and the reason.

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

Compact answer to 'did anything change?': changed true/false, a one-line summary, counts by status and warnings.

## `charging` (type: `string`):

Events charged in this run, with counts and the estimated total.

# 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 = {
    "targets": [
        {
            "url": "https://apify.com/pricing",
            "label": "Apify pricing"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("pardha.s/content-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 = { "targets": [{
            "url": "https://apify.com/pricing",
            "label": "Apify pricing",
        }] }

# Run the Actor and wait for it to finish
run = client.actor("pardha.s/content-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 '{
  "targets": [
    {
      "url": "https://apify.com/pricing",
      "label": "Apify pricing"
    }
  ]
}' |
apify call pardha.s/content-change-monitor --silent --output-dataset

```

## MCP server setup

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