# Website Screenshot & Change Monitor (PDF, Markdown) (`dgn-lab/website-screenshot-batch`) Actor

Screenshot hundreds of URLs or a whole sitemap as PNG, JPEG or PDF, on desktop and mobile, and get alerted when a page changes: diff image in red, text added and removed, price and stock changes, Slack or Discord alerts. Markdown and JSON-LD for AI. Failed pages are free.

- **URL**: https://apify.com/dgn-lab/website-screenshot-batch.md
- **Developed by:** [Sinan Dogan](https://apify.com/dgn-lab) (community)
- **Categories:** Developer tools, Automation, SEO tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

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

### What does Website Screenshot & Change Monitor do?

**Screenshot hundreds of pages, and know the moment one changes.** **Website Screenshot & Change Monitor** captures **a list of URLs or a whole sitemap** as **PNG, JPEG or PDF** in a real [Playwright](https://playwright.dev/) browser, on desktop and mobile, then **compares every page with the previous run**: visual diff, text added and removed, price and stock. Three tools in one run: a **bulk website screenshot API**, a **website change monitor** with alerts, and a **web page to Markdown** extractor for AI.

- 📸 **Clean screenshots in bulk:** desktop, Retina, iPhone, Pixel and iPad presets (several devices in one run), full page or a single element, PDF in A4, Letter, Legal or A3. Cookie banners, chat widgets and ads are hidden for you.
- 🔍 **See exactly what changed:** share of changed pixels, a **diff image with the changes in red**, the lines of text added and removed, and ignore regions for clocks and carousels.
- 🏷️ **Competitor price and stock tracking:** schema.org product price and availability compared from run to run, with the old and new values.
- 🔔 **Alerts where you work:** Slack and Discord messages with the diff image, Telegram, or any webhook (n8n, Make, Zapier).
- 🤖 **AI and RAG ready:** visible text as Markdown or plain text, metadata (canonical URL, language, Open Graph) and JSON-LD structured data.
- 🔐 **Pages behind a login:** session cookies or HTTP headers, stored encrypted and sent only to the captured site.
- 💲 **$6 per 1,000 pages, everything included.** Failed pages (blocked, not found, timed out) are never charged.

**Who uses it:**

| You are                             | You use it to                                                                                                |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **E-commerce teams**                | Watch competitor product pages and get alerted when a price drops or an item goes out of stock.              |
| **Agencies and QA teams**           | Produce visual reports for clients and check every page of a site on desktop and mobile after each release.  |
| **Compliance, legal and marketing** | Archive dated captures of terms, pricing pages and ads, and be told when a competitor changes its messaging. |
| **AI builders**                     | Turn sitemaps into Markdown for RAG pipelines and keep them fresh by re-crawling only what changed.          |

### How to take website screenshots in bulk

1. Keep the two example **URLs**, or enter two public pages of your own.
2. Keep the custom 1280 × 800 viewport and PNG format for a quick first run.
3. Click **Start**, then inspect image links, dimensions and status in the dataset.
4. Open `screenshotUrl` to download each image. Download the dataset separately as JSON, HTML, CSV or Excel; these exports contain links and metadata, not an archive of image files.

```json
{
    "startUrls": [
        {
            "url": "https://docs.apify.com"
        },
        {
            "url": "https://www.wikipedia.org"
        }
    ],
    "device": "custom",
    "viewportWidth": 1280,
    "viewportHeight": 800,
    "fullPage": false,
    "format": "png"
}
```

### Why use this screenshot API?

- **One batch, explicit results:** successful files and failed URLs stay visible in one dataset.
- **Mobile and desktop presets:** iPhone, Pixel, iPad and desktop/Retina settings include pixel density, touch and browser context.
- **Several devices in one run:** select desktop and mobile presets together in **Several devices** to get every URL on each device.
- **Sitemaps:** enter a `sitemap.xml` (sitemap indexes and `.xml.gz` files included) and filter its pages with include and exclude patterns.
- **Content preparation:** optional scrolling, dark mode, hidden selectors and extra delay.
- **Clean captures:** **Hide distractions** (on by default) removes live-chat widgets (Intercom, Drift, Crisp, HubSpot, Zendesk, Tawk.to, LiveChat, Tidio and more), hides ad slots while keeping their space, finishes animations and pauses videos.
- **Dynamic sections:** wait for a visible CSS selector and optionally capture just that element as PNG/JPEG.
- **Cookie-banner handling:** overlays of common consent platforms (OneTrust, Didomi, Cookiebot, Quantcast, Usercentrics, TrustArc, Sourcepoint, Axeptio and more) are hidden before the capture, and an accept button is clicked when found, including in consent frames and open shadow roots. Best effort, not a guarantee for every website. Disable it to preserve the initial consent screen.
- **Long pages:** full-page captures taller than the image limit (about 60,000 pixels) are split into several images by default (`partUrls`), so nothing is lost. Choose **Cut at the maximum height** to keep one image, marked `truncated: true`.
- **PDF settings:** A4, Letter, Legal or A3, portrait or landscape, with or without margins.
- **Pages behind a login:** pass session **Cookies** or **HTTP headers** (stored encrypted, never logged, sent only to the captured site by default), and capture in another **Language** or **Time zone**.
- **Download everything at once:** the **Output** tab links to a ZIP archive of all screenshots of the run.
- **Change monitoring:** compare every page with the previous run, report `changed`, `changePercent`, `textChanged` and the changed lines, get a diff image, ignore dynamic regions, keep only changed pages, and get a chat or webhook notification.
- **Text, metadata and structured data for AI:** visible page text as plain text or Markdown, description, canonical URL, language, first heading, Open Graph tags, and JSON-LD with a price and availability summary for products.
- **Runs that finish:** blocked, timed-out or unreachable pages get a `failed` item with the reason; the run still succeeds, so schedules and integrations keep working.

### Capture pages from a sitemap

Add a sitemap to **URLs**: any URL whose path looks like `/sitemap.xml`, `/sitemap_index.xml`, `/post-sitemap.xml`, or any `.xml.gz` file, is read instead of captured. Sitemap indexes are followed (up to 3 levels and 50 sitemap files), and gzip-compressed sitemaps are unpacked automatically.

- **Sitemap: include URLs matching** keeps only pages whose URL matches a regular expression (case-insensitive), e.g. `/blog/` or `/products/[^/]+$`.
- **Sitemap: exclude URLs matching** drops matching pages, e.g. `/tag/|/page/\d+`.
- **Sitemap: max pages** (default 100, up to 1,000) caps the pages taken from sitemaps, in sitemap order. Ordinary start URLs are always captured on top, and a page that is both a start URL and in the sitemap is captured once.

```json
{
    "startUrls": [{ "url": "https://docs.apify.com/sitemap.xml" }],
    "urlIncludePattern": "/actors/development/",
    "urlExcludePattern": "/input-schema",
    "maxSitemapUrls": 20,
    "format": "jpeg"
}
```

Reading sitemaps is free; each page checked is charged as usual. The log and the `SUMMARY` record show how many sitemaps were read, how many pages they list and how many were selected. Sitemaps are downloaded directly, without the proxy. **HTTP headers** and **Cookies** follow the same scope as page captures (see [Capture pages behind a login](#capture-pages-behind-a-login)): headers go only to the host of the sitemap URL you entered, so a nested sitemap or a redirect on another host (a CDN, for example) never receives them unless **Send headers to every domain** is on; cookies go to the hosts they cover. A sitemap that cannot be read is logged as a warning and the run continues with the other URLs. Patterns are matched against the full URL, so check the paths the sitemap actually lists (they can differ from the address you see in the browser).

### Capture a dynamic section

Set **Wait for element** to the selector that appears when the content is ready. **Capture element** saves the first matching element and overrides full-page image capture. Clear that selector for PDF output; PDFs always cover the page.

```json
{
    "startUrls": [
        {
            "url": "https://docs.apify.com"
        }
    ],
    "waitForSelector": "main",
    "captureSelector": "main",
    "format": "png",
    "selectorTimeoutSecs": 30
}
```

An absent element produces a failed item after **Element wait timeout**. The Actor disables CSS animations during image capture for more consistent files.

### Monitor pages for changes

Turn on **Monitor changes** and run the Actor on a [schedule](https://docs.apify.com/platform/schedules), for example daily. The first run saves a baseline (`changed: null`). Each later run compares every page with its previous capture, for the same URL, device and capture settings, and reports:

- `changed`: `true` when more than **Change threshold** percent of the pixels differ (any difference with the default 0), `false` otherwise. For PDF output only the text is compared, since PDF files change on every export.
- `changePercent`: share of differing pixels, measured by the browser on the first image of the page.
- `textChanged`: whether the visible text, of the page or of **Capture element**, changed.
- `textChanges`: the lines of text `added` and `removed` since the previous run, in page order (up to 50 lines each, 300 characters per line; `truncated: true` when some were left out). The first 20,000 characters of each page's text are compared line by line. It is `null` when the text did not change, and on the first run after an update of the Actor, since earlier runs did not store the text.
- `diffImageUrl`: a copy of the screenshot with the changed pixels highlighted in red, for changed PNG/JPEG pages. Very large captures (above 10 million pixels) get no diff image; `diffImageNote` explains why.
- `previousCapturedAt` and `previousScreenshotUrl`: when and what the page looked like before (the link stays valid while the previous run's storage is retained).
- With **Extract structured data**, `priceChanged`, `previousPrice`, `priceChangePercent`, `availabilityChanged` and `previousAvailability` (see [Monitor prices and stock](#monitor-prices-and-stock)); a price or stock change also counts as a change.

**Only changed pages** keeps the dataset and storage small: unchanged pages get no item and no saved image. The previous capture of each page, with its text, is kept in a named key-value store (**Monitoring store name**, default `website-screenshot-monitor`) that persists across runs; use a different name for each independent watch list.

Clocks, counters and carousels change on every run. **Hide distractions** (on by default) already removes chat widgets, hides ad slots and stops animations. For anything else, list the dynamic areas in **Ignore regions** (e.g. `#clock`, `.hero-carousel`): they are masked in a separate comparison image and their text is left out of `textChanged`, while the delivered screenshot still shows them. You can also capture a stable section with **Capture element**, hide elements entirely with **Hide elements**, or raise **Change threshold** (e.g. `0.5`). Ads loaded by video or outstream players that use no standard ad container may not be hidden; add their selector to **Ignore regions** or **Hide elements**.

```json
{
    "startUrls": [{ "url": "https://example.com/pricing" }],
    "captureSelector": "main",
    "monitorChanges": true,
    "onlyChanges": true,
    "changeThresholdPercent": 0.5,
    "ignoreSelectors": ["#live-visitors", ".promo-carousel"],
    "stateStoreName": "competitor-pricing",
    "notificationWebhookUrl": "https://hooks.slack.com/services/..."
}
```

#### Change notifications

Set **Notification webhook URL** to receive one POST per run when at least one page changed. The URL is stored encrypted and never logged. A failing webhook is logged as a warning and never fails the run.

| Destination | URL                                                                 | Body                                                                                                   |
| ----------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Slack       | Incoming webhook `https://hooks.slack.com/services/...`             | Summary text, then the diff images (up to 10)                                                          |
| Discord     | Channel webhook `https://discord.com/api/webhooks/...`              | Summary text, then the diff images as embeds (up to 10); mentions in page text never notify anyone     |
| Telegram    | `https://api.telegram.org/bot<token>/sendMessage?chat_id=<chat id>` | Summary text with links to the screenshots and diff images                                             |
| Other       | Any HTTPS endpoint (Make, Zapier, n8n, your API)                    | JSON: `event`, `changedCount`, `checkedCount`, `actorRunId`, `datasetId`, `pages[]` with `textChanges` |

The summary lists each changed page with its share of changed pixels and its first text changes, above the links:

```text
1 of 3 monitored page(s) changed:
- https://example.com/pricing (2.1% of pixels, text changed)
  − Pro plan $20/month
  + Pro plan $24/month
  https://api.apify.com/v2/key-value-stores/.../records/screenshot-....png
  changes: https://api.apify.com/v2/key-value-stores/.../records/diff-....png
```

Up to 3 removed and 3 added lines are shown per page; the dataset has the full list. Slack and Discord display diff images only when the key-value store is reachable by them (it is on the Apify platform); if Slack refuses an image, the message is resent without images.

### Read the change report

Every run writes `REPORT` (Markdown) to the default key-value store; open it from the **Markdown report** output tab. It lists:

1. **Changed pages first**, largest change first: changed pixels (%), up to 5 removed and 5 added text lines per page, the price change (old → new) and stock change when price monitoring is on, and links to the screenshot and the diff image;
2. **failures**, with the reason for each page that failed or was skipped;
3. the **number of unchanged pages**.

Text, prices and error messages come from the captured sites, so the report escapes them: a page cannot inject links, tables or HTML into it. Full details stay in the dataset (`textChanges`, `previousPrice`, `errorMessage`).

### Extract text, metadata and structured data

**Extract page text** adds the visible text of the page, or of **Capture element**, to each result: plain text, or Markdown that keeps headings, lists, links and tables. Text longer than **Max text length** (20,000 characters by default) is cut and marked `textTruncated: true`. **Extract metadata** adds the meta description, canonical URL, language, first `h1` and Open Graph tags. All come from the same page load as the screenshot.

**Extract structured data** reads the page's JSON-LD (`<script type="application/ld+json">`, used by Shopify, WooCommerce, news sites and most CMSs) into `structuredData`:

- `product`: name, SKU, brand, `price`, `priceCurrency` and `availability` (e.g. `InStock`) of a schema.org Product. Prices like `19.99` become numbers; ambiguous text such as `19,99` or `from $10` is kept as text. An AggregateOffer gives its lowest price.
- `article`: type, headline, publication and modification dates and author of an Article, NewsArticle or BlogPosting.
- `types`, `jsonLd`: every schema.org type found, and the parsed blocks (capped at 50,000 characters; `jsonLdTruncated` tells when blocks were left out). Invalid blocks are skipped and counted in `invalidJsonLdCount`.

Combined with **Monitor changes**, this also turns on price and stock monitoring (next section).

#### Monitor prices and stock

With **Monitor changes** and **Extract structured data** both on, the product's `price`, `priceCurrency` and `availability` are stored with each page's monitoring state, and every later run reports:

- `priceChanged`: whether the price or its currency changed since the previous run; `previousPrice` is the old price and `priceChangePercent` the change in percent (null when a price is text, like `19,99`, or the old price is 0).
- `availabilityChanged` and `previousAvailability`: stock changes, e.g. `InStock` to `OutOfStock`.

All five are `null` on the first run, when a run has no structured price (or availability), and when Extract structured data was off in either run. A price or stock change sets `changed: true` even when the screenshot changed less than **Change threshold**, so it is kept by **Only changed pages** and sent to the **Notification webhook URL**, where the summary reads `price 24.99 USD → 19.99 USD (-20%)` and `availability InStock → OutOfStock`; generic webhooks get `priceChange` and `availabilityChange` objects per page. The **Prices and stock** dataset view lists these fields. Price comparison is included in the `page` event: there is no extra charge.

```json
{
    "startUrls": [{ "url": "https://shop.example.com/products/example-mug" }],
    "monitorChanges": true,
    "extractStructuredData": true,
    "onlyChanges": true,
    "changeThresholdPercent": 1,
    "stateStoreName": "price-watch"
}
```

```json
{
    "startUrls": [{ "url": "https://docs.apify.com/platform/actors" }],
    "extractText": true,
    "textFormat": "markdown",
    "maxTextLength": 20000,
    "extractMetadata": true,
    "extractStructuredData": true
}
```

### Capture pages behind a login

Use a session cookie or an HTTP header from a logged-in browser session or an API token:

```json
{
    "startUrls": [{ "url": "https://app.example.com/dashboard" }],
    "cookies": [{ "name": "session", "value": "<session cookie value>" }],
    "headers": { "Authorization": "Bearer <token>" }
}
```

- **Cookies** without `domain` are set for the host of each captured URL only. Add `"domain": ".example.com"` (and optionally `"path"`) to cover a domain and its subdomains. Cookies without a name or a text value are ignored, with a warning that gives their number, not their content.
- **HTTP headers** are sent only to the host of each captured URL (`example.com` and `www.example.com` count as the same host), so an `Authorization` header or API key never reaches third-party scripts, images, fonts, analytics or a redirect to another domain. For that, page requests are routed through the browser and service workers are blocked. Turn on **Send headers to every domain** only when the site loads protected resources from other hosts you trust.
- Sitemap downloads follow the same rules, with the sitemap URL you entered as the target host.
- **Pages found in a sitemap** get the HTTP headers only when they are on the host of the sitemap URL you entered (unless **Send headers to every domain** is on), and cookies without `domain` only when they are on that host or on the host of a URL you listed yourself, even with **Send headers to every domain**. So a sitemap that lists pages on another site never hands that site your credentials. Cookies with an explicit `domain` keep their own scope, and pages you list directly in **URLs** keep the rules above.
- Both fields are stored encrypted, and their values are never written to the log or the dataset. Header names must be valid HTTP header names and values single-line text, otherwise the run stops before loading any page.

Only capture pages you are allowed to access, and prefer a dedicated account or a short-lived token.

### What data do you get?

| Fields                                                | Meaning                                                                 |
| ----------------------------------------------------- | ----------------------------------------------------------------------- |
| `url`, `loadedUrl`, `title`, `httpStatus`             | Input page and navigation metadata                                      |
| `status`, `errorMessage`                              | `success`, `failed` or `skipped` (cost limit reached) and the reason    |
| `screenshotUrl`, `screenshotKey`                      | Download link and key-value-store record                                |
| `format`, `device`, `fullPage`                        | Capture options used                                                    |
| `width`, `height`, `fileSizeBytes`                    | Encoded image dimensions and file size; PDF dimensions are null         |
| `truncated`                                           | Whether the long-page image was cut at the height limit                 |
| `cookieBannerDismissed`                               | Whether the consent handler clicked a button                            |
| `partUrls`                                            | All images of a long page split into several parts                      |
| `changed`, `changePercent`, `textChanged`             | Change monitoring results (null without monitoring or on the first run) |
| `textChanges`                                         | Lines of text added and removed since the previous run                  |
| `diffImageUrl`, `diffImageNote`                       | Screenshot with changes highlighted, or why none was made               |
| `previousCapturedAt`, `previousScreenshotUrl`         | Previous capture of the page when monitoring                            |
| `priceChanged`, `previousPrice`, `priceChangePercent` | Structured price compared with the previous run (price monitoring)      |
| `availabilityChanged`, `previousAvailability`         | Structured stock status compared with the previous run                  |
| `text`, `textTruncated`                               | Visible text (plain or Markdown) when text extraction is on             |
| `metadata`                                            | Description, canonical URL, language, `h1`, Open Graph tags             |
| `structuredData`                                      | JSON-LD blocks with a Product (price, availability) and Article summary |
| `capturedAt`                                          | Capture timestamp                                                       |

### Example output

Illustrative excerpts with a placeholder file URL; real links are in your run's dataset.

```json
[
    {
        "url": "https://example.com/",
        "loadedUrl": "https://example.com/",
        "status": "success",
        "httpStatus": 200,
        "title": "Example page",
        "screenshotUrl": "https://api.apify.com/v2/key-value-stores/aBcDeFgHiJkLmNoPq/records/screenshot-0123456789abcdef0123.png",
        "format": "png",
        "fullPage": false,
        "truncated": false,
        "cookieBannerDismissed": false,
        "device": "custom",
        "width": 1280,
        "height": 800,
        "fileSizeBytes": 100000,
        "errorMessage": null
    },
    {
        "url": "https://example.invalid/",
        "loadedUrl": null,
        "status": "failed",
        "httpStatus": null,
        "screenshotUrl": null,
        "format": "png",
        "width": null,
        "height": null,
        "errorMessage": "Navigation failed: the host could not be resolved."
    }
]
```

### How much does it cost to capture and monitor websites?

One simple price, every feature included: **$6 per 1,000 pages checked** ($0.006 per page). Apify's free plan includes $5 of monthly credit: about **800 pages a month for free**. The **Pricing** tab shows the current price of the `page` event.

**Run start:** each run also has a small start fee of $0.001 per GB of memory, charged automatically by Apify: **$0.004 per run** at the default 4 GB. It covers starting the container and the browser, so a run that finds nothing costs you less than half a cent.

| What happens to a page                                                   | Charged?        |
| ------------------------------------------------------------------------ | --------------- |
| Captured (image or PDF saved, `success` item written)                    | Yes, one `page` |
| Found unchanged in monitoring, even when **Only changed pages** hides it | Yes, one `page` |
| Failed (blocked, not found, timed out, unreachable)                      | No              |
| Skipped because the maximum cost per run was reached                     | No              |

The unit is **one page checked on one device**: a URL captured on desktop and mobile counts as two pages. A page found unchanged since the previous run is still a check, so it counts even when **Only changed pages** leaves it out of the dataset. Everything else is included at no extra charge: full-page captures, split long pages, PDF, page text or Markdown, metadata, structured data, change detection with diff images, text, price and stock changes, notifications and sitemap reading.

Example: monitoring 500 product pages every day costs 500 × $0.006 = $3 per day, whether 2 or 200 of them changed. Set a maximum cost per run to cap a run: pages the remaining budget cannot pay for are not loaded and are reported as `skipped`. Proxies are off by default. Apify compute and Apify Proxy traffic are covered by the page price: you pay for pages, not for servers. With residential proxies, the page and its data requests go through the proxy, so the site sees a residential visitor from the country you chose, while static files (scripts, styles, images, fonts, videos), which content delivery networks serve the same to everyone, are downloaded directly. A file refused that way is fetched through the proxy instead.

Long-page image captures are capped near 60,000 image pixels in height, adjusted for the device pixel ratio. `fullPage: true` with `truncated: true` means the requested full-page image was cropped. Mobile image width can vary with page layout and pixel density; use the actual output dimensions. PDF exports retain rendered text when the source page contains text, but they do not OCR text embedded in images.

### Troubleshooting

**A page timed out?** Check the error, try DOMContentLoaded instead of network idle, or wait for a specific visible element. With **Page loaded**, a page whose load event is held back by a slow third-party resource is captured after 15 seconds, once its fonts and visible images are ready. Increasing retries will not fix an invalid hostname.

**A cookie banner remains?** Set its selector in **Hide elements**, or inspect the page for a suitable selector. Automatic dismissal cannot handle every consent system or closed shadow root.

**A site answers 403 or blocks the page?** Some sites block cloud data-center IP addresses. The URL gets a `failed` item with the reason and the rest of the batch continues. Definitive answers (HTTP 401, 403, 404, 410 and 451) are not retried, since the site would give the same answer again; failed pages are free. Retry those URLs with **Proxy configuration**, for example residential proxies (Apify Proxy is a paid feature).

**Duplicate URLs?** Each distinct normalized URL is captured once. Invalid URLs are skipped and logged; if none are valid, the run fails. When every valid URL fails (for example all blocked), the run still succeeds, and the status message and `SUMMARY` record explain it.

**Every monitored page is reported as changed?** Dynamic content such as clocks, ads or carousels changes between runs. Open `diffImageUrl` to see what changed, then list those areas in **Ignore regions**, hide them with **Hide elements**, capture a stable section with **Capture element**, or raise **Change threshold**.

**A sitemap gives no pages?** Check the log line `Sitemaps: … read, … pages listed, … selected`. If pages are listed but none selected, your include pattern does not match the URLs as the sitemap writes them. If no sitemap could be read, the site may block direct downloads; enter the page URLs instead.

**A cookie banner appears after a few seconds?** Set **Extra delay**: the Actor tries to close the banner again once the delay is over.

### Automate with n8n, Make, Zapier, Clay and AI agents (MCP)

Every run can be started from another tool, and its results read back as JSON, CSV or Excel. The Actor ID is `dgn-lab/website-screenshot-batch` (`dgn-lab~website-screenshot-batch` in API URLs). Get an API token in [Apify Console > Settings > API & Integrations](https://console.apify.com/settings/integrations) and keep it in your tool's credential store: never paste it into shared workflows, public files or prompts.

#### Apify API: run and get the results in one request

```bash
curl -X POST "https://api.apify.com/v2/actors/dgn-lab~website-screenshot-batch/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>&format=json&maxTotalChargeUsd=5" \
  -H "Content-Type: application/json" \
  -d '{"startUrls": [{"url": "https://example.com"}], "format": "png", "extractText": true, "textFormat": "markdown"}'
```

The request body is the Actor input (same fields as the **Input** tab) and the response is the dataset items. Use `format=csv` or `format=xlsx` for a file, and `maxTotalChargeUsd` to cap what one run can cost. This [synchronous endpoint](https://docs.apify.com/api/v2/actor-run-sync-get-dataset-items-post) answers with HTTP 408 when the run lasts more than 300 seconds. Each page is loaded in a real browser, so keep synchronous calls to a handful of URLs. For longer runs, start the run with `POST https://api.apify.com/v2/actors/dgn-lab~website-screenshot-batch/runs` and read `GET https://api.apify.com/v2/datasets/<defaultDatasetId>/items` once it has finished, or let a webhook tell you (below). The **API** tab shows the same calls in JavaScript, Python and cURL.

#### n8n

1. Install the official Apify node: on n8n Cloud, search **Apify** in the nodes panel; self-hosted, go to **Settings > Community Nodes** and install `@apify/n8n-nodes-apify`.
2. Add the node with the operation **Run an Actor and get dataset**, select `dgn-lab/website-screenshot-batch` and paste your input JSON in **Custom input**.
3. Use `screenshotUrl`, `diffImageUrl` or `text` in the next nodes: post changed pages to Slack, store Markdown in a vector database, or add screenshot links to Google Sheets.

To process scheduled runs instead, start the workflow with the **On new Apify Event** trigger on this Actor (or on your saved task). In an **AI Agent** workflow, add the **Apify Tool** so the agent can run the Actor itself. Guide: [Apify n8n integration](https://docs.apify.com/integrations/n8n).

#### Make

Add the Apify app, module **Run an Actor** with **Run synchronously** set to Yes, then **Get Dataset Items** with the run's default dataset ID, then for example Google Sheets **Bulk Add Rows**. Make limits how long a synchronous module can wait; for longer runs use the **Watch Actor Runs** trigger and start the Actor from a schedule. Guide: [Apify Make integration](https://docs.apify.com/integrations/make).

#### Zapier

Zapier waits at most 30 seconds for a synchronous **Run Actor** action, so for this Actor use the trigger **Finished Actor run** (or **Finished task run** for a saved task), then the search **Fetch dataset items**, then your next app (Google Sheets, a CRM, Slack, Gmail). Start the runs from an Apify schedule or from another Zap with **Run Actor** (asynchronous). Guide: [Apify Zapier integration](https://docs.apify.com/integrations/zapier).

#### Clay

There is no Apify app inside Clay; use Clay's own **HTTP API** integration with the synchronous endpoint above (method POST, token in the URL from your Clay secrets). For example, one page per row: `{"startUrls": [{"url": "{{Website}}"}], "extractText": true, "textFormat": "markdown", "extractMetadata": true}`, with your column inserted in place of `{{Website}}`, then map `screenshotUrl`, `title`, `metadata` and `text`.

#### AI agents and MCP clients

Add the [Apify MCP server](https://docs.apify.com/integrations/mcp) to Claude, ChatGPT, Cursor, VS Code or any MCP client with this URL, which loads only this Actor as a tool:

```text
https://mcp.apify.com?tools=dgn-lab/website-screenshot-batch
```

On the first connection your browser opens to sign in to Apify (OAuth), so no token is written in the client configuration. The agent can then run the Actor and read its dataset (`get-dataset-items`). With the Apify CLI, `apify mcp install <client>` writes the configuration for you.

#### Schedules and webhooks

Save your input as a **task**, then create a schedule in [Apify Console > Schedules](https://console.apify.com/schedules) with a cron expression and time zone. To push results to your own system, open the Actor's or task's **Integrations** tab and add a webhook on the `ACTOR.RUN.SUCCEEDED` event: Apify sends a POST request with the run details, including `defaultDatasetId`, to your URL. See [webhooks](https://docs.apify.com/integrations/webhooks).

### FAQ, disclaimer and support

#### Can it monitor a website for changes and send alerts?

Yes. Turn on **Monitor changes**, schedule the Actor or a task, and set a **Notification webhook URL** for Slack, Discord, Telegram or any endpoint. Each run compares every page with its previous capture. See [Monitor pages for changes](#monitor-pages-for-changes).

#### Can I convert a web page to Markdown or PDF?

Yes. **Extract page text** with **Text format** Markdown returns headings, lists, links and tables as Markdown; **Image format** PDF saves the page as a PDF. Both can run in the same batch as screenshots.

#### How do I screenshot a whole website?

Enter its sitemap (for example `https://example.com/sitemap.xml`) in **URLs**: every page listed is captured, sitemap indexes and `.xml.gz` files included, and you can keep or drop pages with include and exclude patterns. See [Capture pages from a sitemap](#capture-pages-from-a-sitemap).

#### How do I take desktop and mobile screenshots at the same time?

Select desktop and mobile presets in **Several devices**: each URL is captured once per device, and each capture counts as one page.

#### How do I track competitor prices?

Add the competitor product pages, turn on **Monitor changes** and schedule the task. Product price and availability published as schema.org data are compared from run to run, and the alert says which page changed and how. See [Monitor prices and stock](#monitor-prices-and-stock).

#### How much does a website screenshot cost?

$0.006 per page ($6 per 1,000), with full-page capture, PDF, Markdown, change detection, diff images and alerts included. A failed page costs nothing.

#### Can it capture pages behind a login?

Yes, with session **Cookies** or **HTTP headers**, stored encrypted and sent only to the captured site by default. See [Capture pages behind a login](#capture-pages-behind-a-login).

#### Is it legal to screenshot websites?

Capture only pages you are allowed to access. Screenshots and extracted text contain whatever the page shows, which can include personal data or copyrighted content. Personal data is protected by the GDPR in the European Union and by other regulations around the world. You should not scrape personal data unless you have a legitimate reason to do so. If you're unsure whether your reason is legitimate, consult your lawyers.

#### Support

Use the **API** tab for code examples. For a bug, open the **Issues** tab with the input and run ID. Remove credentials, cookies and headers from anything you share.

# Actor input Schema

## `startUrls` (type: `array`):

Web pages to capture. Each distinct URL produces one screenshot and one result item. A sitemap URL (path like /sitemap.xml, /sitemap_index.xml or any .xml.gz file) is expanded into the pages it lists.

## `urlIncludePattern` (type: `string`):

Regular expression (case-insensitive). Only sitemap pages whose URL matches are captured, e.g. "/blog/" or "/products/\[^/]+$". Empty = all pages. Start URLs are not filtered.

## `urlExcludePattern` (type: `string`):

Regular expression (case-insensitive). Sitemap pages whose URL matches are skipped, e.g. "/tag/|/page/\d+". Empty = skip nothing.

## `maxSitemapUrls` (type: `integer`):

Most pages taken from the sitemaps after filtering, in sitemap order; start URLs come on top. Reading sitemaps is free, each page checked is charged as usual.

## `device` (type: `string`):

Emulate a real device (screen size, pixel density, mobile user agent and touch) or use a custom viewport.

## `devices` (type: `array`):

Capture every URL on each of these devices in one run (e.g. desktop + iPhone). Overrides Device when set.

## `viewportWidth` (type: `integer`):

Browser window width in pixels (320-3840). Used when Device is Custom viewport.

## `viewportHeight` (type: `integer`):

Browser window height in pixels (240-2160). Used when Device is Custom viewport; also the screenshot height unless Full page is on.

## `fullPage` (type: `boolean`):

Capture the whole scrollable page instead of the visible viewport only.

## `longPages` (type: `string`):

Full-page captures taller than the image size limit (about 60,000 pixels) are split into several images, or cut at the limit.

## `format` (type: `string`):

PNG is lossless, JPEG files are smaller, PDF keeps text selectable (always the whole page).

## `jpegQuality` (type: `integer`):

JPEG quality from 1 to 100 (ignored for PNG).

## `pdfFormat` (type: `string`):

Paper size of PDF files.

## `pdfLandscape` (type: `boolean`):

Landscape orientation for PDF files.

## `pdfMargin` (type: `string`):

Margins of PDF pages.

## `darkMode` (type: `boolean`):

Ask the page to use its dark color scheme (prefers-color-scheme: dark), when the site supports it.

## `dismissCookieBanners` (type: `boolean`):

Try to accept common visible cookie-consent banners before capturing. Best effort: some banners, closed shadow roots and custom flows are unsupported. Disable to preserve the first-visit consent screen.

## `hideSelectors` (type: `array`):

CSS selectors of elements to hide before capturing, e.g. cookie banners, chat widgets or popups: "#cookie-banner", ".intercom-launcher".

## `hideDistractions` (type: `boolean`):

Hide live-chat widgets (Intercom, Drift, Crisp, HubSpot, Zendesk, Tawk.to, LiveChat, Tidio, Freshchat, Olark and more) and ad slots (Google ads, Taboola, Outbrain; their space is kept), finish CSS animations and transitions, hide the text caret and pause videos on their first frame before capturing. Gives cleaner screenshots and fewer false changes when monitoring. Turn off to capture the page exactly as a visitor sees it, e.g. to check ads.

## `cookies` (type: `array`):

Cookies set before loading the pages, e.g. a session cookie to capture pages behind a login: \[{"name": "session", "value": "..."}]. Without "domain" a cookie applies to the host of each captured URL only; with "domain": ".example.com" (and optional "path") to that domain and its subdomains. Sitemap downloads get the cookies that match their host the same way (without "domain": the host of the sitemap URL you entered). Stored encrypted and never logged.

## `headers` (type: `object`):

Extra HTTP headers, e.g. {"Authorization": "Bearer ..."} or {"X-Api-Key": "..."}. Sent only to the host of each captured URL unless "Send headers to every domain" is on. Stored encrypted and never logged.

## `headersForAllDomains` (type: `boolean`):

Off (recommended): HTTP headers are sent only to the host of each URL being captured, so an Authorization header or API key never reaches third-party scripts, images, fonts or redirects to other domains. Sitemap files follow the same rule: headers go to the host of the sitemap URL you entered, not to nested sitemaps or redirects on other hosts. On: headers go with every request the page makes and with every sitemap download.

## `locale` (type: `string`):

Browser language, e.g. fr-FR or de-DE (Accept-Language and navigator.language). Leave empty for the device default.

## `timezoneId` (type: `string`):

Browser time zone, e.g. Europe/Paris or America/New_York. Leave empty for UTC.

## `scrollToBottom` (type: `boolean`):

Scroll through the page before capturing so lazy-loaded images and sections appear. Useful with Full page.

## `waitUntil` (type: `string`):

When navigation is considered finished before capturing. With Page loaded, a load event held back by a slow third-party resource is awaited for at most 15 seconds, then the page is captured once its fonts and visible images are ready.

## `waitForSelector` (type: `string`):

Optional CSS selector to wait for before capturing, e.g. #pricing or main. Useful when the page renders content after navigation. The first matching element must be visible.

## `captureSelector` (type: `string`):

Optional CSS selector to capture only the first matching element, e.g. #pricing or article. PNG/JPEG only; overrides Full page. Leave empty for the whole page or viewport.

## `selectorTimeoutSecs` (type: `integer`):

Maximum wait for Wait for element or Capture element. A missing element produces a failed item with a reason.

## `delayMs` (type: `integer`):

Additional wait after loading, for animations or late content.

## `pageTimeoutSecs` (type: `integer`):

Maximum time to load a page.

## `monitorChanges` (type: `boolean`):

Compare each page with its capture from the previous run (same URL, device and capture settings) and report changed, changePercent and textChanged. Run on a schedule to watch competitor or own pages. The first run saves the baseline (changed = null).

## `onlyChanges` (type: `boolean`):

With Monitor changes, skip unchanged pages: they get no dataset item and no saved screenshot. First-run baselines, changed pages (including a structured price or stock change) and failures are always reported.

## `changeThresholdPercent` (type: `number`):

With Monitor changes, a page counts as changed only when more than this share of its pixels differs. Raise it (e.g. 0.5) to ignore tiny changes such as a clock or a rotating ad. 0 reports any visual change.

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

With Monitor changes, CSS selectors of dynamic areas left out of the comparison, e.g. a clock, a rotating carousel or a visitor counter: "#clock", ".hero-carousel". They are masked in a separate comparison image and their text is left out of textChanged; the delivered screenshot still shows them. PNG/JPEG only.

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

Named key-value store keeping the previous capture of each page. Use a different name per independent watch list. Letters, digits and hyphens only.

## `notificationWebhookUrl` (type: `string`):

Optional URL that receives a POST summary when at least one monitored page changed. Slack incoming webhooks and Discord webhooks get a chat message; for Telegram use https://api.telegram.org/bot<token>/sendMessage?chat_id=<id>; any other URL receives JSON. Stored encrypted; a failing webhook never fails the run.

## `extractText` (type: `boolean`):

Also return the visible text of the page, or of Capture element when set, in the text field. Useful for AI agents, search indexes and archives.

## `textFormat` (type: `string`):

Plain text, or Markdown that keeps headings, lists, links and tables.

## `maxTextLength` (type: `integer`):

Longer text is cut at this number of characters and marked textTruncated. 0 = no limit.

## `extractMetadata` (type: `boolean`):

Return the meta description, canonical URL, language, first heading and Open Graph tags in the metadata field.

## `extractStructuredData` (type: `boolean`):

Return the page's JSON-LD structured data in the structuredData field, with a flat summary: for a schema.org Product its price, priceCurrency and availability; for an Article its headline and publication date. Invalid blocks are skipped and counted; the raw blocks are capped at 50,000 characters. With Monitor changes, it also turns on price monitoring: the product's price, currency and availability are stored between runs and reported as priceChanged, previousPrice, priceChangePercent, availabilityChanged and previousAvailability; a price or stock change counts as a change for Only changed pages and notifications.

## `maxRetries` (type: `integer`):

How many times a failed page is retried before it is reported as failed. Definitive answers (HTTP 401, 403, 404, 410, 451) are reported as failed at once, without retries.

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

Maximum number of pages captured in parallel.

## `proxyConfiguration` (type: `object`):

Optional proxies, e.g. to capture a site from a given country or one that blocks data centers. With Apify residential proxies, the page and its data requests go through the proxy and its static files (scripts, styles, images, fonts, videos) are downloaded directly, which keeps residential traffic about 10 times lower; a file refused that way is fetched through the proxy.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://docs.apify.com"
    },
    {
      "url": "https://www.wikipedia.org"
    }
  ],
  "urlIncludePattern": "",
  "urlExcludePattern": "",
  "maxSitemapUrls": 100,
  "device": "custom",
  "devices": [],
  "viewportWidth": 1280,
  "viewportHeight": 800,
  "fullPage": false,
  "longPages": "split",
  "format": "png",
  "jpegQuality": 80,
  "pdfFormat": "A4",
  "pdfLandscape": false,
  "pdfMargin": "none",
  "darkMode": false,
  "dismissCookieBanners": true,
  "hideSelectors": [],
  "hideDistractions": true,
  "headersForAllDomains": false,
  "locale": "",
  "timezoneId": "",
  "scrollToBottom": false,
  "waitUntil": "load",
  "waitForSelector": "",
  "captureSelector": "",
  "selectorTimeoutSecs": 30,
  "delayMs": 0,
  "pageTimeoutSecs": 60,
  "monitorChanges": false,
  "onlyChanges": false,
  "changeThresholdPercent": 0,
  "ignoreSelectors": [],
  "stateStoreName": "website-screenshot-monitor",
  "extractText": false,
  "textFormat": "text",
  "maxTextLength": 20000,
  "extractMetadata": false,
  "extractStructuredData": false,
  "maxRetries": 2,
  "maxConcurrency": 3,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

Dataset with one item per URL: status, image link, dimensions and error

## `files` (type: `string`):

Key-value store containing the screenshot images and the run summary

## `summary` (type: `string`):

Collection coverage, result counts and errors for this run.

## `zip` (type: `string`):

ZIP archive with every screenshot and PDF of the run

## `changes` (type: `string`):

Dataset items in the Changes view: changed flag, changed pixels, text change and diff image per page

## `prices` (type: `string`):

Dataset items in the Prices and stock view: structured price and availability compared with the previous run

## `report` (type: `string`):

REPORT.md: changed pages with text, price and stock changes and links to screenshots and diff images, then failures and the unchanged count.

# 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 = {
    "startUrls": [
        {
            "url": "https://docs.apify.com"
        },
        {
            "url": "https://www.wikipedia.org"
        }
    ],
    "device": "custom",
    "viewportWidth": 1280,
    "viewportHeight": 800,
    "fullPage": false,
    "format": "png"
};

// Run the Actor and wait for it to finish
const run = await client.actor("dgn-lab/website-screenshot-batch").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 = {
    "startUrls": [
        { "url": "https://docs.apify.com" },
        { "url": "https://www.wikipedia.org" },
    ],
    "device": "custom",
    "viewportWidth": 1280,
    "viewportHeight": 800,
    "fullPage": False,
    "format": "png",
}

# Run the Actor and wait for it to finish
run = client.actor("dgn-lab/website-screenshot-batch").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 '{
  "startUrls": [
    {
      "url": "https://docs.apify.com"
    },
    {
      "url": "https://www.wikipedia.org"
    }
  ],
  "device": "custom",
  "viewportWidth": 1280,
  "viewportHeight": 800,
  "fullPage": false,
  "format": "png"
}' |
apify call dgn-lab/website-screenshot-batch --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,dgn-lab/website-screenshot-batch"
        }
    }
}
```

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/ZiGUxIZ2RY9aTIPRe/builds/YQnC87QpXZF0lQDec/openapi.json
