# ShotPro — Bulk Website Screenshot & PDF Report API (`ashutoshdadu/shotpro-screenshot-pdf`) Actor

Turn a list of URLs into full-page screenshots, PDFs and one merged PDF report. Handles cookie banners and lazy loading, keeps working when networkidle never settles, and tells you why a capture failed instead of returning a blank image. Blank and failed captures are never charged.

- **URL**: https://apify.com/ashutoshdadu/shotpro-screenshot-pdf.md
- **Developed by:** [Ashutosh Dadu](https://apify.com/ashutoshdadu) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-usage

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## ShotPro — Bulk Website Screenshot & PDF Report API

**In:** a list of URLs. **Out:** full-page screenshots, per-URL results, and one merged PDF report you can hand to a client or attach to a ticket.

**Price: free while ShotPro is in launch.** Pay-per-event pricing is planned; when it arrives, blank and failed captures will never be charged.

#### Three things people use it for

1. **Agency client reports** — 40 URLs in, one PDF out, desktop and mobile side by side.
2. **QA evidence** — capture a release across viewports and attach the report to the ticket.
3. **SEO / audit documentation** — dated visual proof of what a set of pages looked like.

#### Try it

Paste a few URLs into `urls`, tick `mergedReport`, press Start. Results land in the dataset; images and `REPORT.pdf` in the key-value store.

***

### Why this one instead of a plain screenshot Actor

Most screenshot tools fail in the same few ways. These are the ones ShotPro was built to fix, each verified by a real run:

| Common failure | What ShotPro does |
|---|---|
| Waits for `networkidle`, which never arrives on sites with analytics or websockets, so the **whole run fails on a page that rendered fine** | Navigates on `domcontentloaded`, then settles separately with a bounded wait. If the network never quiets you still get the capture, flagged `degraded` with the reason. Measured: `apify.com` went from hard failure to a complete 1440×9618 capture. |
| A navigation timeout throws away a page that had already painted | Captures it anyway and records `degraded: true` plus why. |
| **Returns a blank white image and calls it success** | Blank detection scaled to pixel area, plus a DOM render check. A blank capture is reported with its likely cause — and **never charged**. |
| Cookie banner covers the hero | Dismissed via known consent-platform handles (OneTrust, Cookiebot, HubSpot, Usercentrics, cookieconsent) **and** button text. |
| Lazy images missing from a full-page shot | Auto-scrolls, waits, returns to top, then captures. |
| Error text arrives full of terminal escape codes | All text is sanitised at source, so the dataset, CSV export and PDF are readable. |

### What it produces

- **One image per URL × viewport** in the key-value store (PNG / JPEG / WebP), or a per-page PDF.
- **`REPORT.pdf`** (optional) — a cover page listing every URL with its outcome and failure reason, then every successful capture, one per page.
- **`SUMMARY.json`** — machine-readable run summary: counts, success rate, median duration, and the file manifest.
- **One dataset row per capture:**
  `url · viewport · width · height · deviceScaleFactor · ok · blank · degraded · degradedReason · httpStatus · consentDismissed · bytes · durationMs · error · key · url_public`

### Options

`urls` · `viewports` (many per run) · `fullPage` · `format` png/jpeg/webp · `quality` · `deviceScaleFactor` (2 = retina) · `colorScheme` light/dark · `selector` (element capture) · `pdf` · `mergedReport` + `reportTitle` · `dismissConsent` · `blockResources` · `waitUntil` · `waitForSelector` · `extraWaitMs` · `timeoutSecs` · `maxConcurrency`

### Sample input

```json
{
  "urls": ["https://example.com", "https://news.ycombinator.com", "https://apify.com"],
  "viewports": [
    { "name": "desktop", "width": 1440, "height": 900 },
    { "name": "mobile",  "width": 390,  "height": 844 }
  ],
  "fullPage": true,
  "format": "png",
  "mergedReport": true,
  "reportTitle": "Client Site Audit — September"
}
```

### Sample output (real run, 5 URLs × 2 viewports)

```json
{
  "requestedUrls": 5, "viewports": 2, "capturesAttempted": 10,
  "captured": 8, "degraded": 2, "blank": 0, "failed": 2,
  "successRate": 0.8, "medianDurationMs": 2686,
  "format": "png", "mergedReport": "REPORT.pdf", "mergedReportError": null
}
```

The two failures were a deliberately invalid domain; both rows carry `ERR_NAME_NOT_RESOLVED` as the reason. Neither was charged.

### Call it from code

**Apify CLI**

```bash
apify call USERNAME/shotpro-screenshot-pdf --input-file=input.json
```

**curl**

```bash
curl -X POST "https://api.apify.com/v2/acts/USERNAME~shotpro-screenshot-pdf/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"urls":["https://example.com"],"mergedReport":true}'
```

**Python**

```python
from apify_client import ApifyClient
client = ApifyClient("<TOKEN>")
run = client.actor("USERNAME/shotpro-screenshot-pdf").call(run_input={
    "urls": ["https://example.com", "https://news.ycombinator.com"],
    "viewports": [{"name": "desktop", "width": 1440, "height": 900}],
    "mergedReport": True,
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["url"], item["ok"], item.get("error"))
```

**JavaScript**

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: '<TOKEN>' });
const run = await client.actor('USERNAME/shotpro-screenshot-pdf').call({
  urls: ['https://example.com'], mergedReport: true,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

**n8n** — one HTTP Request node, no community package, no Playwright to install or maintain:

- **Method/URL:** `POST` to
  `https://api.apify.com/v2/acts/ashutoshdadu~shotpro-screenshot-pdf/run-sync-get-dataset-items?token=YOUR_TOKEN`
  `run-sync-get-dataset-items` runs the Actor **and returns the results in the same call** - no
  polling loop, no Wait node, no webhook.
- **Body:** the JSON above.
- **Set the node timeout to `300000` ms.** n8n's 10-second default kills any real batch, and this
  is the single most common reason the pattern appears "not to work".
- **Branch on `ok`, not on the HTTP status.** A run returns `200` even when individual URLs failed
  - deliberately, so one dead link never destroys a batch of 200. Each row carries `ok`, `blank`,
    `degraded` + `degradedReason`, and a readable `error`.

### Batch behaviour

Every URL is captured at every viewport. `maxConcurrency` controls parallelism (default 4). One URL failing never affects the others — each capture is isolated and reported on its own row.

### Degraded and failure semantics

- `ok: true` — a clean, non-blank capture. **Charged.**
- `ok: true, degraded: true` — captured, but something was imperfect (network never idled, navigation timed out after paint). `degradedReason` says which. **Charged**, because the image is usable.
- `blank: true` — the file exists but is effectively empty. **Not charged.** `error` gives the likely cause.
- `ok: false` — the capture failed. **Not charged.** `error` carries the real reason.

### Limitations — stated plainly

- Cannot capture pages behind a login, a paywall, or a bot wall. Those come back as blank or failed, with a reason.
- Consent-banner dismissal is best-effort. Unusual or custom banners may survive.
- No JavaScript execution or interaction beyond scrolling and one consent click.
- Very long pages (>40,000 px of scroll) stop auto-scrolling at that limit.
- No proxy rotation, and no attempt to defeat anti-bot systems.
- Report generation is capped by run memory; on very large batches, capture the batch in parts.

### Privacy and data retention

ShotPro visits only the URLs you supply and stores only what it captured. Images, `REPORT.pdf` and `SUMMARY.json` live in **your own** key-value store under **your** Apify account, subject to your account's retention settings. Nothing is sent anywhere else, and the developer has no access to your runs or their contents.

### Troubleshooting

| Symptom | Try |
|---|---|
| Blank capture | Raise `extraWaitMs`, or set `waitForSelector` to something you know renders. |
| Cookie banner still visible | Set `waitForSelector` to the banner, or capture with `selector` scoped to your content. |
| Timeouts | Raise `timeoutSecs`, or set `waitUntil` to `load` / `domcontentloaded`. |
| Slow or expensive runs | `blockResources: ["font","media"]` and raise `maxConcurrency`. |
| Report missing | Check `mergedReportError` in `SUMMARY.json` — it names the cause. |

### Support

Open an issue on the Actor's Issues tab. Include the run ID; every run carries `SUMMARY.json` with everything needed to diagnose it.

### Changelog

- **0.1.5** — Merged PDF report, `SUMMARY.json` manifest, blank/failed captures no longer charged, all text sanitised at source (fixes a report-generation crash on ANSI escape codes in Playwright errors), cover page sized to content.
- **0.1.2** — Bounded network settling, capture-after-timeout with `degraded` reporting, area-scaled blank detection, consent-platform selectors.
- **0.1.0** — Initial release.

# Actor input Schema

## `urls` (type: `array`):

Pages to capture. Bare domains work — https:// is added automatically.

## `viewports` (type: `array`):

Capture every URL at every viewport in a single run. Each item is {"name", "width", "height"}.

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

Capture the entire scrollable page. The page is auto-scrolled first so lazy-loaded images actually appear, then returned to the top.

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

Output image format. PNG is lossless and best for text; JPEG and WebP are smaller.

## `quality` (type: `integer`):

Compression quality for JPEG and WebP. Ignored for PNG.

## `deviceScaleFactor` (type: `integer`):

Pixel density. 2 gives a retina/2x image at double the resolution.

## `colorScheme` (type: `string`):

Renders the page with a real prefers-color-scheme preference, so sites with a dark theme serve it properly.

## `selector` (type: `string`):

Capture only the element matching this CSS selector instead of the whole page. Leave empty to capture the page.

## `pdf` (type: `boolean`):

Produce an A4 PDF with backgrounds printed, instead of an image.

## `dismissConsent` (type: `boolean`):

Try to close cookie and consent banners before capturing, by known consent-platform handles (OneTrust, Cookiebot, HubSpot, Usercentrics) and by button text.

## `blockResources` (type: `array`):

Resource types to block, for faster and cheaper runs. Useful values: font, media, image.

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

How long to let the page settle. 'networkidle' waits for quiet network, but is bounded — if the network never quiets (analytics, websockets) the capture still happens and is flagged as degraded rather than failing.

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

Wait for this CSS selector to appear before capturing. Useful for content rendered late by JavaScript.

## `extraWaitMs` (type: `integer`):

Additional fixed wait before capture, in milliseconds. Use for animations that need to finish.

## `timeoutSecs` (type: `integer`):

Maximum time to spend loading a single page before giving up on navigation.

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

How many pages to capture in parallel.

## `mergedReport` (type: `boolean`):

Also produce REPORT.pdf — one PDF containing a status cover page (every URL with its result and failure reason) followed by every successful capture, one per page. This is the deliverable you hand to a client or attach to a QA ticket.

## `reportTitle` (type: `string`):

Heading printed on the merged report's cover page.

## Actor input object example

```json
{
  "urls": [
    "https://example.com"
  ],
  "viewports": [
    {
      "name": "desktop",
      "width": 1920,
      "height": 1080
    },
    {
      "name": "mobile",
      "width": 390,
      "height": 844
    }
  ],
  "fullPage": true,
  "format": "png",
  "quality": 85,
  "deviceScaleFactor": 1,
  "colorScheme": "light",
  "pdf": false,
  "dismissConsent": true,
  "waitUntil": "networkidle",
  "extraWaitMs": 0,
  "timeoutSecs": 60,
  "maxConcurrency": 4,
  "mergedReport": false,
  "reportTitle": "Website Capture Report"
}
```

# Actor output Schema

## `results` (type: `string`):

One row per URL x viewport: ok, blank, degraded, bytes, duration, and the exact failure reason when a capture did not succeed.

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

Every captured image, the merged REPORT.pdf, and SUMMARY.json.

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

One PDF containing a status cover page for every URL followed by each successful capture. Present only when mergedReport is enabled.

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

Counts, success rate, median duration and the file manifest for the run.

# 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 = {
    "urls": [
        "https://example.com"
    ],
    "viewports": [
        {
            "name": "desktop",
            "width": 1920,
            "height": 1080
        },
        {
            "name": "mobile",
            "width": 390,
            "height": 844
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("ashutoshdadu/shotpro-screenshot-pdf").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 = {
    "urls": ["https://example.com"],
    "viewports": [
        {
            "name": "desktop",
            "width": 1920,
            "height": 1080,
        },
        {
            "name": "mobile",
            "width": 390,
            "height": 844,
        },
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("ashutoshdadu/shotpro-screenshot-pdf").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 '{
  "urls": [
    "https://example.com"
  ],
  "viewports": [
    {
      "name": "desktop",
      "width": 1920,
      "height": 1080
    },
    {
      "name": "mobile",
      "width": 390,
      "height": 844
    }
  ]
}' |
apify call ashutoshdadu/shotpro-screenshot-pdf --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,ashutoshdadu/shotpro-screenshot-pdf"
        }
    }
}
```

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/YbZvM1e87OqMWO4Sg/builds/caZeXMpb2IeLzsgDJ/openapi.json
