# Website change monitoring with screenshots

> How to get told when a web page changes: the capture and compare loop, three ways to detect a change, a noise checklist, a cron and Node.js setup, and tools.

Playbook by Serghei Pogor, founder of CurlShot (https://curlshot.com/about/serghei-pogor). Published 29 October 2026. Page for people: https://curlshot.com/blog/website-change-monitoring-with-screenshots

To get told when a web page changes, run a loop: a scheduler starts a capture of the same page at a fixed interval, the new screenshot is compared with the last good one, and a message goes out when the difference is over a threshold and is still there on the next run. Capture one element instead of the whole page and most false alarms disappear. CurlShot takes the screenshots in that loop; it does not schedule, compare or alert, so you add a cron job and a short script, or use a dedicated monitoring tool.

## Key facts

- The loop has four steps: schedule, capture, compare with the last good capture, decide and notify. The first capture becomes the baseline.
- In the illustrative month on this page, the change that matters most (a price, 3,840 pixels, 0.38% of the screenshot) is smaller than the noise (a rotated promo, 9,600 pixels), so no whole-page threshold separates them.
- Capturing one element by CSS selector removes most false alarms, but the monitor is then blind to everything outside that element.
- Checks per month = pages x checks per day x 30. Ten pages checked daily is 300 checks; one page checked every 30 minutes is 1,440.
- A crontab line has five time fields: minute, hour, day of the month, month, day of the week. `0 7 * * *` runs every day at 07:00.
- CurlShot renders fresh on every request unless you send cache=true, and each successful render uses one screenshot of the monthly allowance.

## A month of one page, as a calendar

Website change monitoring with screenshots is a loop: capture the same page on a schedule, compare each capture with the last good one, and send a message when the difference is large enough and still there on the next run. CurlShot takes the screenshots in that loop. It does not schedule them, compare them or send alerts: a cron job and a short script do that, or a dedicated monitoring tool.

The month below is an illustration, not real data. One pricing page is captured once a day for 30 days as a 1280 x 800 screenshot. On 25 days it is identical to the day before. On 5 days something changed, and each change stays on the page afterwards. The size of a change is the area of the region it touches.

| Day | What changed | Region | Share of the page | Share of the #plans element | Verdict | Why |
| --- | --- | --- | --- | --- | --- | --- |
| 5 | The promo strip rotated to another message | 400 x 24 = 9,600 px | 0.94% | not seen | Noise | Nothing about the plans or their prices changed. An alert here trains you to ignore the next one. |
| 11 | The price of the middle plan went from $29 to $35 | 96 x 40 = 3,840 px | 0.38% | 1.15% | Worth an alert | This is the change the monitor exists for, and it is the smallest one of the month. |
| 16 | The money-back guarantee section was removed | 1280 x 240 = 307,200 px | 30% | not seen | Worth an alert | A promise to customers disappeared from the page. Someone should know, whether it was planned or not. |
| 22 | The layout broke: the third card dropped under the first | 320 x 320 + 1280 x 260 = 435,200 px | 42.5% | size changed | Worth an alert | The page still loads and an uptime check still passes. Only a look at the page shows that it is broken. |
| 27 | A sale banner was added above the navigation | 1280 x 800 = 1,024,000 px | 100% | not seen | Worth an alert | A sale changes what customers pay, even though no number in the pricing cards changed. |

The smallest change of the month is the one that matters most (the price, 0.38% of the page), and the noise is larger than it (the promo strip, 0.94%). No threshold on the whole page can let one through and stop the other.

### Which changes send an alert at each sensitivity setting

Watching the whole page (1280 x 800):

| Alert when the changed region is | Alerts sent | False alarms | Missed changes |
| --- | --- | --- | --- |
| Any difference | day 5, day 11, day 16, day 22, day 27 | day 5 | none |
| More than 0.5% | day 5, day 16, day 22, day 27 | day 5 | day 11 |
| More than 1% | day 16, day 22, day 27 | none | day 11 |
| More than 35% | day 22, day 27 | none | day 11, day 16 |
| More than 50% | day 27 | none | day 11, day 16, day 22 |

Watching only the pricing cards (`#plans`, 1040 x 320):

| Alert when the changed region is | Alerts sent | False alarms | Missed changes |
| --- | --- | --- | --- |
| Any difference | day 11, day 22 | none | day 16, day 27 |
| More than 0.5% | day 11, day 22 | none | day 16, day 27 |
| More than 1% | day 11, day 22 | none | day 16, day 27 |
| More than 35% | day 22 | none | day 11, day 16, day 27 |
| More than 50% | day 22 | none | day 11, day 16, day 27 |

The element capture never sends a false alarm and catches the price at any sensible setting, but it cannot see the removed section or the banner. Watch a second element, or add a slower whole-page check, for what happens around it.

## How website change monitoring works: the loop

1. **Schedule.** A cron job or a scheduled workflow starts a run at a fixed time. Store: The list of pages, each with its selector and capture options.
2. **Capture.** A browser opens the page with the same options as every other run and takes a fresh screenshot. Store: The image file, named with the time it was taken.
3. **Compare.** The new capture is compared with the baseline: the last capture you accepted as good. Store: The baseline, and how large the difference was.
4. **Decide and notify.** A difference over the threshold that is still there on the next run sends a message with both images. The new capture becomes the baseline. Store: A change seen once and waiting to be confirmed, and a log of alerts sent.

Then the loop starts again. The first run has nothing to compare with: its capture becomes the baseline.

## Three ways to detect a change on a web page

| Method | What is compared | Catches | Misses or gets wrong | Best for |
| --- | --- | --- | --- | --- |
| Compare the screenshots, pixel by pixel | Two images of the whole page. | Anything a visitor can see: a broken layout, a missing image, a new banner, a changed colour. | Nothing visible, which is the problem: ads, animations, dates and font loading all count as changes. | Your own pages, where the question is "does it still look right?" |
| Compare the text or the HTML | The words or the markup of the page, as a line-by-line diff. | Prices, wording, stock notices, dates of effect. The alert can quote the exact words that changed. | Layout, images, text inside images, and anything a script draws after loading unless a real browser renders the page first. | Terms, policies, notices and price lists, where the words are the content. |
| Compare one element only | A screenshot or the text of one element, picked with a CSS selector. | Every change to the thing you care about, with almost nothing else in the way. | Everything outside the element. A renamed class makes the selector match nothing, so treat "element not found" as an alert. | One price, one stock badge, one table: the best signal for the least noise. |

How pixel comparison, colour thresholds and pixel budgets work is covered in [the visual regression testing guide](https://curlshot.com/blog/visual-regression-testing-guide). How to pick a selector that survives a redesign is covered in [CSS selectors for element screenshots](https://curlshot.com/blog/css-selectors-for-element-screenshots).

## How to stop false alarms: a checklist

- [ ] **Capture one element, not the page.** Everything outside the element stops mattering: the promo strip, the footer date, a banner that pushes the page down. In CurlShot: `selector`.
- [ ] **Block cookie banners, ads and chat widgets.** A consent dialog, an ad slot or a chat bubble is different on every visit and covers what you are watching. In CurlShot: `block_cookie_banners`, `block_ads`, `block_chats`.
- [ ] **Fix the viewport and the device scale.** A page lays out differently at every width, and a different pixel density changes every pixel. Send the same numbers on every run. In CurlShot: `viewport_width`, `viewport_height`, `device_scale_factor`.
- [ ] **Freeze animations.** A carousel or a fade caught at a different instant looks like a change. In CurlShot: `reduced_motion`.
- [ ] **Hide dates, counters and rotating promos.** A "last updated" stamp, a visitor counter or a countdown changes on its own. Hide it before the capture. In CurlShot: `hide_selectors`, or `styles` to hide it and keep its space.
- [ ] **Wait until the element is there.** A capture taken before a script has filled in the price shows a placeholder, and the next run shows the price. In CurlShot: `wait_for_selector`.
- [ ] **Capture at the same time of day, in one time zone.** Pages that show "today", opening hours or a daily deal differ between morning and night. In CurlShot: `time_zone`, plus a fixed hour in the schedule.
- [ ] **Require a change to persist for two runs.** A half-loaded page, a failed image or a test variant shown to one visit in ten is gone on the next run. A real change is still there.

## How often should you check a page for changes?

| Situation | Sensible interval | Checks per month for one page | Why |
| --- | --- | --- | --- |
| Legal or terms page | Once a week | 4 | These pages change a few times a year, and a week of delay rarely matters. |
| Competitor pricing page | Once a day | 30 | A price change matters within days, not minutes. A daily capture at a fixed hour also makes a tidy calendar. |
| News or regulator page | Every 6 hours | 120 | Notices appear during working hours and you want them the same day. |
| Your own home page, as an "is it broken?" check | Every hour | 720 | An hour is how long you accept a broken page going unnoticed. A plain uptime check is cheaper for "is it down?"; the screenshot answers "is it up but wrong?" |
| Stock or availability notice | Every 30 minutes, while it matters | 1,440 | The item can sell out again quickly. Stop the monitor once you have what you wanted. |

```
checks per month = pages x checks per day x 30
```

- 10 pages, once a day: 10 x 1 x 30 = 300 checks a month.
- 50 pages, once a week: 50 x 1/7 x 30 = 214 checks a month.
- 3 pages, every hour: 3 x 24 x 30 = 2,160 checks a month.
- 1 page, every 30 minutes: 1 x 48 x 30 = 1,440 checks a month.

Once a week is 1/7 of a check per day. Each check is one page load on the watched site and, with a screenshot service, one screenshot of your monthly allowance.

## A minimal setup: cron and a short Node.js script

A crontab line has five time fields and then the command [1]:

| Field | Allowed values | In the example |
| --- | --- | --- |
| Minute | 0 to 59 | `0` |
| Hour | 0 to 23 | `7` |
| Day of the month | 1 to 31 | `*` |
| Month | 1 to 12 | `*` |
| Day of the week | 0 to 6, 0 is Sunday | `*` |

```
0 7 * * * cd $HOME/page-watch && . ./.env && node watch.mjs >> watch.log 2>&1
```

That line runs the script every day at 07:00, in the time zone of the machine. The file `.env` holds one line, `export SCREENSHOT_ACCESS_KEY=...`. POSIX defines `*` for all values, lists with commas and ranges with a hyphen. It does not define step values such as `*/15`; many cron programs and GitHub Actions accept them [1][2].

| Expression | Meaning |
| --- | --- |
| `0 7 * * *` | Every day at 07:00. |
| `0 7 * * 1` | Every Monday at 07:00. |
| `0 * * * *` | Every hour, on the hour. |
| `0,30 * * * *` | Every 30 minutes, written as a list so it works in any cron. |
| `0 0,6,12,18 * * *` | Every 6 hours. |

The script uses only the Node.js standard library. `fetch` is built in from Node.js 18 and marked stable in Node.js 21 [3].

```js
// watch.mjs: tell me when one element of one page changes.
// Node.js 18 or newer. No packages to install.
import { createHash } from 'node:crypto'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { pathToFileURL } from 'node:url'

const API = 'https://curlshot.com/api/v1'
const DIR = 'captures'
const OPTIONS = {
  url: 'https://example.com/pricing',
  selector: '#plans',
  wait_for_selector: '#plans',
  format: 'png',
  viewport_width: '1280',
  viewport_height: '1024',
  device_scale_factor: '1',
  block_cookie_banners: 'true',
  block_ads: 'true',
  block_chats: 'true',
  reduced_motion: 'true',
  time_zone: 'UTC',
}

export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')

// The whole decision, with no network and no disk in it.
// state.baseline is the hash of the last good capture,
// state.pending the hash of a change that was seen once.
export function decide(state, hash) {
  if (!state.baseline) return { event: 'first', state: { baseline: hash, pending: null } }
  if (hash === state.baseline) return { event: 'same', state: { baseline: state.baseline, pending: null } }
  if (hash !== state.pending) return { event: 'seen-once', state: { baseline: state.baseline, pending: hash } }
  return { event: 'changed', state: { baseline: hash, pending: null } }
}

async function capture() {
  const response = await fetch(API + '/screenshot?' + new URLSearchParams(OPTIONS), {
    headers: { 'X-Access-Key': process.env.SCREENSHOT_ACCESS_KEY ?? '' },
    signal: AbortSignal.timeout(90_000),
  })
  if (!response.ok) {
    throw new Error('Capture failed: HTTP ' + response.status + ' ' + (await response.text()).slice(0, 300))
  }
  return Buffer.from(await response.arrayBuffer())
}

async function notify(text) {
  console.log(text)
  const hook = process.env.ALERT_WEBHOOK_URL
  if (!hook) return
  await fetch(hook, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  })
}

async function main() {
  await mkdir(DIR, { recursive: true })
  const stateFile = DIR + '/state.json'
  const state = JSON.parse(await readFile(stateFile, 'utf8').catch(() => '{}'))

  const png = await capture()
  const result = decide(state, sha256(png))

  await writeFile(DIR + '/latest.png', png)
  if (result.event === 'changed') await rename(DIR + '/baseline.png', DIR + '/previous.png')
  if (result.event === 'first' || result.event === 'changed') await writeFile(DIR + '/baseline.png', png)
  await writeFile(stateFile, JSON.stringify(result.state, null, 2) + '\n')

  if (result.event === 'changed') {
    await notify(
      'Changed: ' + OPTIONS.url + ' ' + OPTIONS.selector +
        '. Compare ' + DIR + '/previous.png with ' + DIR + '/baseline.png.',
    )
  } else {
    const lines = {
      first: 'First capture saved as the baseline.',
      same: 'No change.',
      'seen-once': 'Different from the baseline. Waiting for the next run to confirm.',
    }
    console.log(lines[result.event])
  }
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
  main().catch((error) => {
    console.error(error.message)
    process.exitCode = 1
  })
}
```

| Event | When | What the script does |
| --- | --- | --- |
| `first` | There is no baseline yet. | Saves `baseline.png` and its hash. No alert. |
| `same` | The hash equals the baseline hash. | Clears any pending change. No alert. |
| `seen-once` | The hash is new and was not seen on the run before. | Remembers it as pending. No alert yet. |
| `changed` | The same new hash arrives on two runs in a row. | Moves the old baseline to `previous.png`, saves the new `baseline.png`, prints the alert and posts it to `ALERT_WEBHOOK_URL` if that is set. |

A hash of the bytes has no tolerance: one different pixel gives a different hash. It only works on a stable element saved as lossless PNG, and it can fire with no visible change if the browser or the image encoder behind the capture is updated. Run the script twice in a row first: the second run must print "No change." For tolerance, compare the pixels of `previous.png` and `baseline.png` with an image-diff library and a threshold. A failed capture exits with an error and leaves the state alone; the error `selector_not_found` means the element is gone, which deserves a look.

The same check as a GitHub Actions workflow, in a private repository that holds `watch.mjs`:

```yaml
name: Watch a page
on:
  schedule:
    - cron: '17 7 * * *'
  workflow_dispatch:

permissions:
  contents: write

jobs:
  watch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: node watch.mjs
        env:
          SCREENSHOT_ACCESS_KEY: ${{ secrets.SCREENSHOT_ACCESS_KEY }}
          ALERT_WEBHOOK_URL: ${{ secrets.ALERT_WEBHOOK_URL }}
      - name: Keep the captures for the next run
        run: |
          git config user.name "page-watch"
          git config user.email "page-watch@users.noreply.github.com"
          git add captures
          git diff --cached --quiet || git commit -m "Capture of $(date -u +%F)"
          git push
```

- GitHub Actions reads the same five fields and adds `/` for steps, so `*/15` is valid there. [2]
- The shortest interval GitHub runs a scheduled workflow at is once every 5 minutes. [2]
- Times are in UTC unless you add a `timezone` key with an IANA name next to the `cron` line. [2]
- A scheduled run can be delayed when GitHub is busy, and the start of every hour is a busy time. That is why the sample runs at minute 17. [2]
- In a public repository, scheduled workflows are disabled automatically after 60 days without repository activity. [2]

## Website change monitoring tools

In alphabetical order, described from each project's own site.

| Tool | Kind | Where it runs | What it watches | Alerts |
| --- | --- | --- | --- | --- |
| changedetection.io [4][5] | Open source (Apache-2.0), with a paid hosted version | On your own machine with Docker or pip, or in the vendor's cloud. | Text changes with filters and CSS or XPath rules, chosen elements, restock and price changes on product pages. It can attach a screenshot to an alert. | Email, Slack, Discord, Telegram and many more, through the Apprise library. |
| Distill [7] | Browser extension and hosted service | Local monitors in your browser (Chrome, Firefox, Edge, Opera) or cloud monitors that run around the clock. | The parts of a page you select, plus PDFs and password-protected pages. | Browser notification, email, SMS and mobile apps. |
| urlwatch [9][10] | Open source (BSD 3-Clause), command line | A Python program you add to your own crontab. Its documentation suggests checking no more often than every 30 minutes. | A URL, a page loaded in a headless browser, or the output of a shell command. The alert contains a unified diff of the text. | Email, the terminal and third-party services. |
| Visualping [6] | Hosted service | In the vendor's cloud. Its site says getting started is free. | Visual changes (it compares the current and the prior screenshot), text changes and code changes. | Email, Slack, Microsoft Teams, webhooks and an API. |
| Wachete [8] | Hosted service | In the vendor's cloud, around the clock. | A whole page or the part you care about, including pages that need a login. | Email, phone notification, Slack, Teams, Discord and Telegram. |

A dedicated tool is the better choice when:

- You watch more than a handful of pages and want a list of them with a history, without keeping a server.
- Someone who does not write code has to add pages and read the alerts.
- You need a text diff in the alert: the exact words that changed, not two images.
- You need alerts in several channels with rules, quiet hours or a digest.

Building the loop yourself fits when:

- You watch a few pages and already have a place to run a scheduled job.
- The check is part of something larger: your deploy pipeline, a report, an archive of dated captures.
- You want the images themselves, kept in your own storage, more than the alert.

## Limits and manners

- **A capture is a signed-out visitor from a data centre.** The page is loaded without your cookies, from a server, often in another country. Prices shown per region, content behind a login and anything personalised differ from what you see at your desk.
- **A time zone is not a location.** Setting the time zone changes the clock the page reads. It does not change where the request comes from, so a site that picks content by the visitor's address still sees the server.
- **Some pages refuse automated visits.** A site can answer a server with a challenge page or an error. The monitor then sees the challenge, not the page: look at the first capture before you trust the alerts.
- **Check no more often than you need.** Every check is a full page load on someone else's server. Read the site's terms of use, and pick the slowest interval that still answers your question.

Whether you may monitor someone else's site, and what you may do with the captures, depends on the site's terms and on the law where you are. This article is not legal advice.

## What CurlShot does in a monitoring setup

It does:

- Renders a URL to a PNG, JPEG or WebP screenshot each time you ask.
- Captures one element with `selector`, cropped to its edges.
- Blocks cookie banners, ads and chat widgets, hides elements you name, and stops animations.
- Renders the page fresh on every request unless you send `cache=true`.

It does not:

- Run on a schedule. Your cron job or workflow sends each request.
- Keep a baseline or compare two screenshots. Your script does.
- Send an alert when a page changes. `webhook_url` reports that an asynchronous render finished, which is a different thing.
- Change where the request comes from. `time_zone` sets the clock, not the location.

| Option | Value | What it keeps steady |
| --- | --- | --- |
| `selector` | a CSS selector | Captures the first element that matches, and nothing around it. No match returns the error `selector_not_found` with status 422, which is not counted. |
| `wait_for_selector` | a CSS selector | Waits until the element is visible, for prices that a script fills in late. |
| `viewport_width`, `viewport_height` | 1280 and 1024 by default | The window the page is laid out in. Send them on every run so the layout never depends on a default. |
| `device_scale_factor` | 1 by default | The pixel density. At 2, the same element is twice as wide and every pixel differs. |
| `block_cookie_banners`, `block_ads`, `block_chats` | false by default | Removes the three things most likely to differ between two visits. |
| `reduced_motion` | false by default | Asks the site for no animation and stops CSS animations and transitions at the moment of capture. |
| `hide_selectors` | up to 50 selectors | Hides a date stamp, a counter or a promo strip. A hidden element takes up no space. |
| `time_zone` | an IANA name such as `UTC` | Fixes the clock the page reads. There is no locale option: send an `Accept-Language` header with `headers` for the language. |
| `format` | `png` by default | PNG is lossless, so equal pixels give equal pixel data. JPEG and WebP are for viewing, not for comparing. |

- The response cache is off unless you send `cache=true`. Without it, every request opens the page again, which is what a monitor needs.
- With `cache=true`, a repeat request inside `cache_ttl` (14,400 seconds, 4 hours, by default) returns the stored copy with the header `X-Cache: HIT`. That is the old look of the page, not a new one.
- To force a fresh render while keeping the cache on, change `cache_key`, for example to the date of the run. Changing `cache_ttl` does not refresh anything.
- One successful render uses one screenshot of the monthly allowance. A cache hit and a failed request use none.

```bash
curl "https://curlshot.com/api/v1/screenshot?url=https://example.com/pricing\
&selector=%23plans\
&wait_for_selector=%23plans\
&format=png\
&viewport_width=1280\
&viewport_height=1024\
&device_scale_factor=1\
&block_cookie_banners=true\
&block_ads=true\
&block_chats=true\
&reduced_motion=true\
&time_zone=UTC" \
  -H "X-Access-Key: YOUR_ACCESS_KEY" \
  --output plans.png
```

The result is a PNG of the element `#plans` alone. More: [screenshot one element of a page](https://curlshot.com/tools/element-screenshot), [capturing an element](https://curlshot.com/docs/element), [caching](https://curlshot.com/docs/caching), [blocking](https://curlshot.com/docs/blocking).

## Glossary

- **Baseline:** The last capture you accepted as correct. Every new capture is compared with it.
- **Diff:** The difference between two captures: an image with the changed pixels marked, or a list of added and removed lines of text.
- **False positive:** An alert for a change that does not matter, such as a rotated promo or a new date stamp. Also called a false alarm.
- **Missed change:** A change that mattered and sent no alert, because it was under the threshold or outside the watched element. Also called a false negative.
- **Threshold:** How large a difference must be before it counts as a change, as a number of pixels or a share of the image.
- **Cron:** The scheduler on Unix systems. A cron expression is five fields (minute, hour, day of the month, month, day of the week) that say when a command runs.
- **Element capture:** A screenshot of one element of a page, picked with a CSS selector and cropped to its edges.
- **Debounce:** Waiting for a change to still be there on the next run before acting on it, so that a one-off glitch sends no alert.

## Questions

### How do I get notified when a website changes?

Capture the page on a schedule, compare each capture with the previous good one, and send yourself a message when they differ. You can build that with a cron job, a screenshot request and a short script, or use a dedicated service such as changedetection.io, Distill, urlwatch, Visualping or Wachete, which do the scheduling, the comparison and the alert for you.

### Does CurlShot monitor websites for changes?

No. CurlShot takes a screenshot each time you ask for one. It has no scheduler, keeps no baseline, does not compare two screenshots and sends no change alerts. In a monitoring setup it is the capture step: your cron job sends the request and your script does the comparison.

### How often should I check a web page for changes?

As rarely as still answers your question. Once a week suits a terms page, once a day a pricing page, every 6 hours a news or regulator page, every hour your own home page, and every 30 minutes a stock notice while it matters. Multiply pages by checks per day by 30 to get the checks per month.

### Why does my page monitor send false alarms?

Because something on the page changes on its own: a cookie banner, an ad, a chat bubble, an animation, a date stamp or a rotating promo. Capture one element instead of the page, block banners, ads and chats, fix the viewport size, stop animations, hide dates and counters, and only alert when a change is still there on the next run.

### Is it better to compare screenshots or the text of a page?

Compare text when the words are the content: prices, terms, notices. A text diff quotes exactly what changed and ignores layout. Compare screenshots when the look is the content: a broken layout or a missing image never shows up in a text diff. Comparing one element, as an image or as text, gives the least noise of all.

### Can I compare two screenshots by hashing the files?

Only as a strict equal-or-not test. A hash changes when a single pixel changes, so it suits a stable element saved as lossless PNG and nothing else. It cannot tell a small difference from a large one. For a tolerance, decode both images and count the pixels that differ, as visual regression tools do.

### Is it legal to monitor a competitor's website?

It depends on the site's terms of use, on what you capture and on the law where you and the site are. Loading a public page at a modest rate is ordinary web traffic, but terms can forbid automated access. This is not legal advice: ask a lawyer when the answer matters to your business.

## Sources

1. The Open Group, "crontab: schedule periodic background work (Base Specifications Issue 7, 2018 edition)": https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html (checked 11 October 2026)
2. GitHub Docs, "Events that trigger workflows: schedule": https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows (checked 11 October 2026)
3. Node.js, "Global objects: fetch": https://nodejs.org/api/globals.html (checked 11 October 2026)
4. changedetection.io, "changedetection.io: website change detection and monitoring": https://changedetection.io/ (checked 11 October 2026)
5. GitHub, "dgtlmoon/changedetection.io: README and license": https://github.com/dgtlmoon/changedetection.io (checked 11 October 2026)
6. Visualping, "Visualping: website change detection, monitoring and alerts": https://visualping.io/ (checked 11 October 2026)
7. Distill, "Distill: monitor website changes": https://distill.io/ (checked 11 October 2026)
8. Wachete, "Wachete: monitor web page changes": https://www.wachete.com/ (checked 11 October 2026)
9. urlwatch, "urlwatch documentation: introduction": https://urlwatch.readthedocs.io/en/latest/introduction.html (checked 11 October 2026)
10. GitHub, "thp/urlwatch: README and COPYING": https://github.com/thp/urlwatch (checked 11 October 2026)

## Go deeper

- [Screenshot one element of a page](https://curlshot.com/tools/element-screenshot)
- [Screenshot a list of URLs at once](https://curlshot.com/tools/bulk-screenshots)
- [Screenshot without ads, trackers and chats](https://curlshot.com/tools/screenshot-without-ads)
- [A daily screenshot from an n8n workflow](https://curlshot.com/tools/n8n-screenshot)
- [Docs: capturing an element](https://curlshot.com/docs/element)
- [Docs: caching and fresh renders](https://curlshot.com/docs/caching)
- [Docs: blocking banners, ads and chats](https://curlshot.com/docs/blocking)

## Read next

- [Visual regression testing, explained in pictures](https://curlshot.com/blog/visual-regression-testing-guide.md): How screenshot comparison works, why visual tests go flaky and how to fix each cause, the tools, and a set-up checklist with a Playwright example.
- [CSS selectors for element screenshots](https://curlshot.com/blog/css-selectors-for-element-screenshots.md): Type a CSS selector, see what it matches on a specimen page and what a capture returns. With a cheat sheet, a robustness ranking and fixes.
- [Capture a web page as evidence that holds up](https://curlshot.com/blog/web-page-as-evidence.md): A checklist for saving a web page as a credible record: what to capture, how to fix the time with a hash and timestamp, and what makes a screenshot weak.
