# Page Change Monitor: Text Diff & Change Alerts API (`tallowlanelabs/page-change-monitor`) Actor

Watch any web page (or one CSS section of it) for text changes. Each run returns changed true/false, a clean line diff (added/removed lines), a short summary and stable JSON for agents, MCP, schedules and webhooks. Ignores timestamps, ads and counters. $0.001 per page check.

- **URL**: https://apify.com/tallowlanelabs/page-change-monitor.md
- **Developed by:** [Tallow Lane Labs](https://apify.com/tallowlanelabs) (community)
- **Categories:** Automation, Developer tools, AI
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.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

## Page Change Monitor: text diff and change alerts for any web page

**Watch any public web page, or one section of it, and get a clean answer on every run: `changed: true/false`, the exact lines added and removed, and a one-line summary. Built for Apify schedules, webhooks and AI agents (MCP), and it ignores noise like timestamps, ads, cookie banners and view counters.**

**Monitoring cost:** a page with no changes costs $0.001 per check, so watching 10 pages daily is about $0.32 a month, plus $0.004 per change detected.

*By Tallow Lane Labs. Checks only the pages you list, at most once per interval, politely and without unblockers. Tallow Lane Labs is not affiliated with the websites you monitor.*

### Why this one

- **A real diff, not just "something changed".** Line-level `added` and `removed` lists, exact counts, a `changeRatio`, and a summary like *Changed: 1 line added, 1 line removed. Added: "Pro $24/month". Removed: "Pro $20/month".*
- **No false alarms from noise.** Built-in rules ignore clock times, ISO timestamps, "5 minutes ago", view/like/comment counters, ad slots and cookie banners. Add your own CSS selectors or regular expressions. Calendar dates are kept, so a new "Last updated" date counts.
- **Watch just the part you care about:** a CSS selector (`#pricing`, `main article`) or a text region ("from *Changelog* to *Footer*"), set globally or per page.
- **Stable JSON for agents.** The same fields on every record, whatever happened: `status` (`ok`, `tooSoon`, `blocked`, `robotsDisallowed`, `httpError`, `error`, ...), `changed`, `firstSeen`, `changeType`, `diff`, `textHash`, `previousTextHash` and timestamps.
- **Stateful by design.** Snapshots live in a named key-value store, keyed by a hash of URL + selector + region, so a schedule simply runs the same input again.
- **Polite and honest.** One request at a time per site, a minimum check interval per page (5-minute floor), robots.txt respected, a truthful User-Agent, no proxies, no captcha solving. If a site blocks the check (403, 429, captcha or challenge page), the record says `blocked` and why, and the old snapshot is kept.

### Use cases

- **Competitor pricing and plan pages:** get the exact price line that changed.
- **Terms, privacy and policy pages:** see what changed in a ToS or privacy policy.
- **Docs, changelogs and status pages:** get new releases or incidents as diffs.
- **Government, procurement and regulatory notices.**
- **Agent memory:** let an AI agent ask "did this page change since I last read it, and how?"

### Quick start

1. Click **Try for free** and run the example page, or leave `urls` empty for a 2-page demo.
2. Add your pages, one URL per line. Optionally set a CSS selector.
3. **Schedule** the Actor (for example daily or hourly). The first run stores a baseline, and every later run reports changes.
4. Add a **webhook** or Slack/Zapier/Make integration on *Run succeeded*. `RUN_SUMMARY.changedUrls` lists what changed.

### Input example

```json
{
  "urls": ["https://www.iana.org/help/example-domains"],
  "pages": [
    { "url": "https://acme.com/pricing", "selector": "#plans", "label": "Acme pricing" },
    { "url": "https://acme.com/legal/terms", "regionStart": "Terms of Service", "regionEnd": "Contact us",
      "minCheckIntervalMinutes": 1440 }
  ],
  "ignorePatterns": ["^Sponsored.*$"],
  "outputMode": "all",
  "minCheckIntervalMinutes": 60
}
```

Main options:

- `urls`: leave empty for a demo.
- `pages`: per-page `selector`, `regionStart` / `regionEnd`, `ignoreSelectors`, `ignorePatterns`, `label` and `minCheckIntervalMinutes`.
- Global `selector`, region and ignore rules.
- `useDefaultIgnoreRules`: on by default.
- `minCheckIntervalMinutes`: 60 by default, minimum 5.
- `outputMode`: `all` (default) or `changesOnly`.
- `includeText`, `maxDiffLines` and `respectRobotsTxt`.
- `domainDelayMs`: 2,000 ms by default, minimum 1,000.
- `stateStoreName`, `stateKeyPrefix` and `resetState`.

See the **Input** tab for every field.

### Output example

An example record for a changed page (hashes shortened):

```json
{
  "recordType": "page",
  "url": "https://acme.com/pricing",
  "label": "Acme pricing",
  "finalUrl": "https://acme.com/pricing",
  "status": "ok",
  "httpStatus": 200,
  "changed": true,
  "firstSeen": false,
  "changeType": "changed",
  "summary": "Acme pricing: Changed: 1 line added, 1 line removed (25% of the text) since 2026-11-02T12:00:03.101Z. Added: \"Pro $24 per month\". Removed: \"Pro $20 per month\".",
  "diff": {
    "added": ["Pro $24 per month"],
    "removed": ["Pro $20 per month"],
    "addedCount": 1, "removedCount": 1, "truncated": false, "changeRatio": 0.25, "algorithm": "myers"
  },
  "textHash": "9f2c…", "previousTextHash": "41d8…",
  "lineCount": 4,
  "title": "Pricing - Acme",
  "selector": "#plans",
  "checkedAt": "2026-11-03T12:00:02.877Z",
  "previousCheckedAt": "2026-11-02T12:00:03.101Z",
  "lastChangedAt": "2026-11-03T12:00:04.950Z",
  "nextCheckAllowedAt": "2026-11-03T13:00:04.950Z",
  "blockedReason": null,
  "error": null
}
```

| `status` | Meaning | Charged |
|---|---|---|
| `ok` | Checked. `changeType` is `firstSeen`, `changed`, `unchanged` or `baselineReset` (ignore rules changed) | page check (+ change if `changed`) |
| `tooSoon` | Checked less than `minCheckIntervalMinutes` ago. Not fetched; `nextCheckAllowedAt` says when | no |
| `blocked` | 403, 429, captcha or bot challenge. `blockedReason` is `rateLimited`, `challenge`, `captcha`, `forbidden`, `authRequired` or `legal` | no |
| `robotsDisallowed` | The site's robots.txt disallows the page | no |
| `httpError` / `error` / `invalidUrl` / `unsupportedContent` | 404 and similar errors, network failure, a bad URL, or a PDF or image | no |
| `selectorNotFound` / `regionNotFound` | The selector or region start text is not on the page (the old snapshot is kept) | no |

- Error records, `blocked` records and `tooSoon` records are never charged.
- `RUN_SUMMARY` (key-value store) has `totals`, `changedUrls`, `blockedUrls`, a per-page list and HTTP stats.
- Dataset views: **Page checks**, **Diffs**, and **Skipped, blocked and failed**.

### Pricing (pay per event)

| Event | Price | When |
|---|---|---|
| Page checked | **$0.001** | Each page fetched and compared (first check, changed or unchanged) |
| Change detected | $0.004 | Extra, only when the page's cleaned text changed (with diff and summary) |
| Actor start | $0.0005 | Once per run (same at 256, 512 or 1,024 MB) |

Not charged: `tooSoon` skips, blocked pages, robots.txt skips, failed fetches, selector or region misses, and error records.

- 20 pages, no changes: $0.0205 per run (about $0.62 a month daily).
- 10 pages hourly with no changes: about $7.56 a month. A changed page costs $0.005 in total for that check.
- Set Apify's **maximum cost per run** for a hard cap. The Actor stops before pages it can't pay for. A change it can't pay for is reported on the next run, never lost.

### Integrations

- **Schedules:** Apify Console → *Schedules* → run this Actor with your input daily or hourly. Use a `minCheckIntervalMinutes` equal to the schedule period. A 10% early-start tolerance means an hourly schedule is never skipped as `tooSoon`.
- **Webhooks, Slack, email, Make, Zapier, n8n:** add an integration on *Run succeeded*. Use `outputMode: changesOnly` so the dataset holds only changes and problems.
- **Apify API:** `POST https://api.apify.com/v2/acts/tallowlanelabs~page-change-monitor/run-sync-get-dataset-items`, or

```python
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("tallowlanelabs/page-change-monitor").call(
    run_input={"urls": ["https://acme.com/pricing"], "selector": "#plans"})
for page in client.dataset(run["defaultDatasetId"]).iterate_items():
    if page["changed"]:
        print(page["summary"])
```

- **AI agents (MCP):** `https://mcp.apify.com?tools=tallowlanelabs/page-change-monitor` lets an assistant answer "did this page change, and what changed?"

### FAQ

**Why did my first run say `firstSeen` and not show a diff?** There was nothing to compare with yet. The first check stores the baseline, and changes are reported from the next run.

**Why `tooSoon`?** Each page is fetched at most once per `minCheckIntervalMinutes` (at least 5 minutes), however often the Actor runs. This keeps monitoring polite. The record says when the next check is allowed.

**A timestamp or counter keeps triggering changes.** Add a regular expression to `ignorePatterns` (for example `Last refreshed .*`) or a CSS selector to `ignoreSelectors`. Changing ignore rules re-baselines the page (`baselineReset`) instead of reporting a false change.

**Does it render JavaScript?** No. It reads the HTML the server sends, plus plain text, JSON and XML. Pages that build their content only in the browser may show little text. Point it at the site's server-rendered page or JSON endpoint instead.

**What if a site blocks it?** You get `status: "blocked"` with the reason. The Actor never uses unblockers, rotating proxies or captcha solvers, and it doesn't retry blocks.

**Is it legal?** It reads public pages you choose, once per interval, honouring robots.txt, with a User-Agent that names the tool. Don't monitor logged-in pages or personal profiles, and follow each site's terms. *(General information, not legal advice.)*

### Limits

- Public pages only: no logins and no JavaScript rendering.
- Up to 1,000 pages per run, and 5 MB of HTML per page.
- Stored text is capped at 1,000,000 characters per page.
- Up to 200 diff lines per side by default (counts are always exact).
- 256 MB of memory covers normal pages. Use 512 MB for many very large pages.

### Support

Open an issue on the **Issues** tab or email Tallow Lane Labs at wdeornellas+tallowlanelabs@gmail.com.

# Actor input Schema

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

One URL per line (http or https). Only these exact pages are fetched: no crawling, no link following. Each URL is compared with its own stored snapshot. Optional: leave empty to run a quick demo on two stable public example pages.

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

Optional extra pages with their own settings, e.g. \[{"url": "https://acme.com/pricing", "selector": "#plans", "label": "Acme pricing", "ignorePatterns": \["Updated .\*"], "minCheckIntervalMinutes": 1440}]. Supported keys: url, label, selector, regionStart, regionEnd, ignoreSelectors, ignorePatterns, minCheckIntervalMinutes. Per-page ignore rules are added to the global ones.

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

Only watch the part of each page matched by this CSS selector (for example main, #pricing, .changelog article:first-of-type). If it matches nothing, the page is reported as selectorNotFound, not charged, and its snapshot is kept. Leave empty for the whole visible page.

## `regionStart` (type: `string`):

Optional. Only watch text from the first line containing this text (case-insensitive) ...

## `regionEnd` (type: `string`):

... up to (not including) the next line containing this text. Leave empty to watch to the end.

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

Elements removed before comparing, e.g. .ticker, #comments, .related-posts. Added to the built-in noise list (ads, cookie banners) unless that is turned off below.

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

Text masked before comparing, matched per line (case-insensitive; or write /pattern/flags). A line that ends up empty is dropped. Example: "Last refreshed .*" or "^Sponsored.*$". Added to the built-in patterns unless turned off below.

## `useDefaultIgnoreRules` (type: `boolean`):

Built-in rules ignore clock times (12:34 PM, 17:35 UTC), ISO timestamps, relative times ("5 minutes ago"), view/like/comment counters, ad slots and cookie/consent banners. Calendar dates are NOT ignored, so a changed "Last updated" date counts as a change. Changing any ignore rule re-baselines a page (reported as baselineReset, no false change).

## `minCheckIntervalMinutes` (type: `integer`):

A page is fetched at most once per this many minutes (per URL and selector), however often the Actor runs; earlier runs report it as tooSoon (not fetched, not charged). Minimum 5. A 10% early-start tolerance (max 5 min) lets an hourly schedule work with 60.

## `outputMode` (type: `string`):

"all" (default) writes one record per page every run. "changesOnly" writes only changed pages plus pages that could not be checked; unchanged pages are still charged as page checks and listed in RUN_SUMMARY.

## `includeText` (type: `boolean`):

Add the page's cleaned text (one line per block) to each checked record as "text". Off by default to keep records small.

## `maxDiffLines` (type: `integer`):

At most this many added and this many removed lines are listed per record (counts are always exact; diff.truncated says when lines were cut).

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

Read each site's robots.txt once a day (cached) and skip pages it disallows (status robotsDisallowed, not charged). Turn off only for pages you own or have permission to monitor.

## `domainDelayMs` (type: `integer`):

Requests to one host are sent one at a time with at least this gap (minimum 1,000 ms). Different sites are checked in parallel.

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

Overall cap on requests in flight across different sites.

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

Per page. Slow pages are retried once on a network error; blocks (403/429/captcha/challenge) are never retried.

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

Named key-value store that keeps each page's last snapshot between runs (keyed by a hash of URL + selector + region). Use a different name or key prefix to run independent monitors.

## `stateKeyPrefix` (type: `string`):

Optional prefix for snapshot keys, so several schedules can share one store without sharing snapshots.

## `resetState` (type: `boolean`):

Ignore stored snapshots for the pages in this run: every page becomes a first check (new baseline). The minimum check interval still applies.

## `failIfAllUrlsFail` (type: `boolean`):

Mark the run as failed when no page could be checked because of invalid URLs, network or HTTP errors (tooSoon, blocked and robots skips never fail a run).

## Actor input object example

```json
{
  "urls": [
    "https://www.iana.org/help/example-domains"
  ],
  "pages": [],
  "ignoreSelectors": [],
  "ignorePatterns": [],
  "useDefaultIgnoreRules": true,
  "minCheckIntervalMinutes": 60,
  "outputMode": "all",
  "includeText": false,
  "maxDiffLines": 200,
  "respectRobotsTxt": true,
  "domainDelayMs": 2000,
  "maxConcurrency": 4,
  "timeoutSecs": 30,
  "stateStoreName": "page-change-monitor-state",
  "stateKeyPrefix": "",
  "resetState": false,
  "failIfAllUrlsFail": true
}
```

# Actor output Schema

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

All page records from this run in the default dataset.

## `runSummary` (type: `string`):

JSON summary of the run: totals, changedUrls, blockedUrls, per-page status and HTTP stats.

# 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.iana.org/help/example-domains"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("tallowlanelabs/page-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.iana.org/help/example-domains"] }

# Run the Actor and wait for it to finish
run = client.actor("tallowlanelabs/page-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.iana.org/help/example-domains"
  ]
}' |
apify call tallowlanelabs/page-change-monitor --silent --output-dataset

```

## MCP server setup

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