> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zenrows.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The Zenrows API (Fetch, Extract via extract=auto, and Batch) is described in OpenAPI 3.1 at https://www.zenrows.com/openapi.json and listed in the RFC 9727 API catalog at https://www.zenrows.com/.well-known/api-catalog. Use the spec for exact parameter names and types.

# Detect changes on any page and get alerted

> Watch any web page for changes with Zenrows and get a Slack alert when its content or price changes.

Watch a page for changes: a price, a stock status, a policy page, a competitor's pricing table, a job board. Each check fetches the page, compares it with the last snapshot, and sends you a diff when it differs.

**Uses:** [Fetch](/fetch/api-reference) with [`response_type=markdown`](/fetch/features/markdown) or [`css_extractor`](/fetch/features/css-extractor), [Batch scheduled jobs](/batch/developer-guide-restapi#scheduled-jobs)

## How it works

1. **Fetch reads the page.** Fetch with `mode=auto` returns either the whole page as markdown, or only the fields you name in a `css_extractor`. Watching fields ignores changes elsewhere on the page, such as ads or timestamps.
2. **Your code hashes and diffs.** The script hashes the content and compares it with the hash stored in `snapshots.json`. When they differ, it builds a line-by-line diff.
3. **The alert goes out.** The diff goes to a Slack incoming webhook when `SLACK_WEBHOOK_URL` is set, and to the console otherwise.
4. **A schedule repeats it.** Run the script from cron, or let a scheduled Batch job fetch the pages and run the same diff on its results.

## Ask your AI agent

With the [Zenrows MCP server](/mcp/overview) connected to Claude, Cursor or any MCP client, paste this prompt. Your agent picks the right Zenrows tools and settings on its own.

```text theme={"dark"}
Use Zenrows to read https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/
and save the product name, price and description. Then read it again and
tell me exactly what changed between the two versions, or confirm that nothing did.
```

## Watch pages for changes

List what to watch in `WATCHES`. An entry with a `css_extractor` watches those fields. An entry without one watches the whole page as markdown. Save this as `watch.py` and run `python watch.py`: the first run saves a baseline, every later run compares against it.

```python Python (Requests) theme={"dark"}
import difflib
import hashlib
import json
import os
from pathlib import Path

import requests

ZENROWS_API_KEY = "YOUR_ZENROWS_API_KEY"
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")  # optional
SNAPSHOTS = Path("snapshots.json")

# What to watch: a css_extractor watches fields, no css_extractor watches the whole page as markdown
WATCHES = [
    {
        "url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/",
        "css_extractor": {"name": "h1.product_title", "price": "p.price"},
    },
    {"url": "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/"},
]

def fetch_content(watch: dict) -> str:
    params = {"apikey": ZENROWS_API_KEY, "url": watch["url"], "mode": "auto"}
    if "css_extractor" in watch:
        params["css_extractor"] = json.dumps(watch["css_extractor"])
    else:
        params["response_type"] = "markdown"
    response = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
    response.raise_for_status()
    if "css_extractor" in watch:
        return json.dumps(response.json(), indent=2, sort_keys=True)
    return response.text

def alert(message: str) -> None:
    if SLACK_WEBHOOK_URL:
        requests.post(SLACK_WEBHOOK_URL, json={"text": message}, timeout=30)
    else:
        print(message)

def main() -> None:
    snapshots = json.loads(SNAPSHOTS.read_text()) if SNAPSHOTS.exists() else {}

    for watch in WATCHES:
        url = watch["url"]
        content = fetch_content(watch)
        digest = hashlib.sha256(content.encode()).hexdigest()
        previous = snapshots.get(url)

        if previous is None:
            print(f"Baseline saved: {url}")
        elif previous["hash"] == digest:
            print(f"No change: {url}")
        else:
            diff = difflib.unified_diff(
                previous["content"].splitlines(), content.splitlines(),
                "before", "after", lineterm="", n=0,
            )
            alert(f"Change detected on {url}\n" + "\n".join(list(diff)[:40]))

        snapshots[url] = {"hash": digest, "content": content}

    SNAPSHOTS.write_text(json.dumps(snapshots, indent=2))

if __name__ == "__main__":
    main()
```

To send alerts to Slack, create an <a href="https://api.slack.com/messaging/webhooks" rel="nofollow">incoming webhook</a> and export its URL as `SLACK_WEBHOOK_URL`. The same `{"text": ...}` payload works with Microsoft Teams workflows, Discord (as `content`) and most chat tools.

## Run it on a schedule

**With cron.** Check every hour from your own server:

```bash theme={"dark"}
0 * * * * cd /path/to/watch && /usr/bin/python3 watch.py >> watch.log 2>&1
```

**With a scheduled Batch job.** Zenrows fetches the pages on its schedule, and your code only diffs the results. Each task carries its own `zenrows_params`, so one job mixes field watches and full-page watches. Run this once to create the job (`pip install zenrows`):

```python Python (SDK) theme={"dark"}
import json

from zenrows import ZenRowsBatchClient
from zenrows.batch import Calendar, Daily

client = ZenRowsBatchClient(api_key="YOUR_ZENROWS_API_KEY")

job = client.submit_scheduled(
    Calendar(times_of_day=["09:00"], cadence=Daily(), timezone="Europe/Madrid"),
    [
        {
            "url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/",
            "zenrows_params": {"css_extractor": json.dumps({"name": "h1.product_title", "price": "p.price"})},
        },
        {
            "url": "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/",
            "zenrows_params": {"response_type": "markdown"},
        },
    ],
    zenrows_params={"mode": "auto"},
    webhook={"url": "https://your-app.com/webhooks/zenrows"},
)
print("Scheduled job:", job.job_id)
```

Zenrows posts a `run.completed` event to the webhook after each run. Call this function from your webhook handler (or from cron, after the scheduled time) to diff the run's results against your snapshots:

```python Python (SDK) theme={"dark"}
import difflib
import hashlib
import json
from pathlib import Path

from zenrows import ZenRowsBatchClient

SNAPSHOTS = Path("batch-snapshots.json")  # its own file: Batch results are stored as returned
client = ZenRowsBatchClient(api_key="YOUR_ZENROWS_API_KEY")

def check_latest_run(job_id: str) -> None:
    snapshots = json.loads(SNAPSHOTS.read_text()) if SNAPSHOTS.exists() else {}
    run = client.job(job_id).run

    for task in run.results(status="successful"):
        url = str(task.url)
        content = run.download_task_to_memory(task).decode()
        digest = hashlib.sha256(content.encode()).hexdigest()
        previous = snapshots.get(url)

        if previous and previous["hash"] != digest:
            diff = difflib.unified_diff(
                previous["content"].splitlines(), content.splitlines(),
                "before", "after", lineterm="", n=0,
            )
            print(f"Change detected on {url}\n" + "\n".join(list(diff)[:40]))
        else:
            print(f"{'No change' if previous else 'Baseline saved'}: {url}")

        snapshots[url] = {"hash": digest, "content": content}

    SNAPSHOTS.write_text(json.dumps(snapshots, indent=2))
```

Zenrows delivers each webhook event at least once, so deduplicate on the `X-ZenRows-Event-Id` header. To verify that deliveries come from Zenrows, enable signed webhooks, see [Verify signed webhooks](/batch/developer-guide-restapi#verify-signed-webhooks).

## Example output

Output of `watch.py`: the first run, then a second run with no changes:

```text theme={"dark"}
Baseline saved: https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/
Baseline saved: https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/
No change: https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/
No change: https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/
```

A later run, after setting the stored prices to $79.00 and $62.00 in `snapshots.json` to simulate a change. The field watch shows the changed field, the markdown watch shows the changed line:

```diff theme={"dark"}
Change detected on https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/
--- before
+++ after
@@ -3 +3 @@
-  "price": "$79.00"
+  "price": "$69.00"
Change detected on https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/
--- before
+++ after
@@ -36 +36 @@
-$62.00
+$57.00
```

## Customize it

| Change | How |
| - | - |
| Watch a table or a list | Use `outputs=tables` or a `css_extractor` with a list selector, see [Output filters](/fetch/features/output-filters) |
| Ignore noise on the page | Watch only the fields that matter with a `css_extractor`, instead of the whole markdown |
| Watch plain text | Set `response_type=plaintext`, see [Plain text](/fetch/features/plaintext) |
| Watch content from one country | Add `proxy_country=us` (any ISO code), see [Proxy country](/fetch/features/proxy-country) |
| Check every few hours | Replace `Calendar` with `Rate(every=6, unit="hour")`, see [Scheduled jobs](/batch/developer-guide-restapi#scheduled-jobs) |
| Send email instead of Slack | Replace the body of `alert()` with your email provider's API call |

## Troubleshooting

* **Every run reports a change**: the page includes content that changes on each load, such as a timestamp, a session token or rotating ads. Switch that watch to a `css_extractor` with only the fields you care about.
* **The diff is empty but a change was reported**: the content changed only in whitespace or line order. Normalize the content before hashing, for example `" ".join(content.split())`.
* **A field comes back empty**: the selector no longer matches. Inspect the element in your browser and update the `css_extractor`.
* **Webhook events arrive twice**: deliveries are at least once. Store each `X-ZenRows-Event-Id` you process and skip repeats.

## Related recipes

<CardGroup cols={2}>
  <Card title="Build a price monitor" icon="chart-line" href="/academy/build-price-monitoring">
    Store price history and alert on drops.
  </Card>

  <Card title="Run a Batch job on a schedule" icon="calendar" href="/academy/batch/scheduled-jobs">
    Every schedule option for Batch jobs.
  </Card>

  <Card title="Get notified when a Batch job finishes" icon="webhook" href="/academy/batch/webhooks">
    Receive and verify Batch webhooks.
  </Card>

  <Card title="Turn any web page into LLM-ready markdown" icon="markdown" href="/academy/fetch/markdown-for-llms">
    More on `response_type=markdown`.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.