Website Change Monitor with Smart Diff avatar

Website Change Monitor with Smart Diff

Pricing

Pay per usage

Go to Apify Store
Website Change Monitor with Smart Diff

Website Change Monitor with Smart Diff

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.

Pricing

Pay per usage

Rating

0.0

(0)

Developer

Hussein Al-Mansori

Hussein Al-Mansori

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

9 days ago

Last modified

Share

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.

{
"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 · Why the alerts are trustworthy · Use cases · Input · Output · Run it from code · Webhooks · AI summaries · Scheduling · Pricing · Limits · Errors · 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 knowWatchWhat you get
A competitor changed their pricetheir pricing pagechangedSignals: ["price: $29 → $39"]
A product came back in stockthe product page["availability: sold out → in stock"]
A supplier changed their termsthe terms pagethe exact clause, in diffPreview
A regulator updated a rulethe policy pagea dated record in the dataset, every run
A client edited a page you ownthe pagean alert before the client asks
An agent needs a change signalanythingone self-contained JSON row per change

Input

FieldDefaultWhat it does
startUrlsPublic pages to check. Up to 50.
sensitivitymediumHow 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.
useBrowserfalseRender JavaScript. Needed for pages that build content in the browser; several times dearer per page.
notifyWebhookUrlPOST each change here as JSON. Changes only, never the quiet checks.
aiApiKeyYour own Anthropic (sk-ant-…) or OpenAI key, for a one-sentence summary of each change. Billed to you by that provider.
aiModelflagshipclaude-opus-5 or gpt-5.4 by default. Set a smaller model to spend less.
aiSummaryLanguageenSummary language. Arabic fully supported.
stateStoreNamewebsite-change-monitor-stateThe named key-value store holding baselines, on your account. Use a different name for a second, independent watch list.
respectRobotstrueSkip pages robots.txt asks crawlers not to read.
proxyConfigurationSome 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.

FieldMeaning
eventbaseline (first capture), page-checked (compared, nothing meaningful), change-detected (the alert)
similarityPercent100 = identical. Measured over content after noise filtering, so a small edit to a real value scores lower than a rewritten advert
changeMagnitudenone / minor / moderate / major — filter on this instead of doing the arithmetic
changedSignalsWhich price, percentage or stock phrase moved. Populated even when too small to cross the threshold
diffPreviewShort, readable: - old line, + new line
aiSummaryOne sentence, when a key was supplied
statusverified / warning / failed. A warning row is still real — read warnings for what was uncertain
previousCapturedAtWhen the capture it was compared against was taken
blocksComparedText blocks that survived filtering
renderedWithBrowserWhether this page was rendered rather than downloaded

A quiet check, which is most of them:

{ "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:

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

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

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.
  • No login-protected pages, CAPTCHA solving, or paywall bypasses.

Errors

CodeMeaning
INVALID_URL / INVALID_INPUTRejected before anything was fetched
BLOCKED_DESTINATIONThe URL resolved to a private or internal address
DNS_FAILUREThe hostname did not resolve. Retryable
SITE_UNREACHABLE / SITE_TIMEOUTNot reached, or no answer in time. Retryable
ROBOTS_DISALLOWEDrobots.txt asks crawlers not to read this page
CONTENT_UNUSABLENo readable text to compare — usually JavaScript-rendered; try Render JavaScript
STATE_STORE_FAILUREThe baseline store could not be opened. The run fails rather than silently re-baselining everything
STORAGE_FAILUREThe result could not be stored. Nothing charged. Retryable
BUDGET_EXHAUSTEDThe 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.