Website Change Monitor with Smart Diff
Pricing
Pay per usage
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
9 days ago
Last modified
Categories
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
- Paste one or more URLs into Pages to watch.
- Run it. Every page comes back as
baseline— the starting point, never an alert. - Put it on a schedule — hourly, daily, whatever suits the page.
- Read the rows where
eventischange-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,headerandfooterare not ignored by default. They are boilerplate, but they are also where a pricing link disappears or a product line gets dropped. Add them toignoreSelectorsif you would rather not hear about them.- Changing
watchSelectorsstarts a fresh baseline for that page. A capture taken under different rules cannot honestly be compared, so the next run reportsbaselineand says so. - Busy community homepages are the hard case. Pages with "latest posts" or "recent supporters"
strips genuinely change every hour. Point
watchSelectorsat 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:
{ "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 ApifyClientclient = 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
ignoreSelectorsandwatchSelectors, 512 characters each. - At most 4,000 text blocks compared per page; baselines capped at 512 KB;
diffPreviewat 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
| 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.