# Pagewright: Verified Page Screenshots (`canopy_ne3/pagewright-verified-screenshots`) Actor

Full-page and viewport PNG screenshots, checked before they're returned. Blank renders, soft 404s served under HTTP 200, and error pages are refused with a reason instead of being passed off as successes. You aren't billed for a refused page.

- **URL**: https://apify.com/canopy\_ne3/pagewright-verified-screenshots.md
- **Developed by:** [Brenden Bushman](https://apify.com/canopy_ne3) (community)
- **Categories:** Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 verified pages

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?

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

## Pagewright: Verified Page Screenshots

Screenshots that are **checked before they are returned**, and **refusals are never billed**.

A screenshot Actor that hands back a blank image, a soft 404, or an error page has succeeded by every
metric the platform records: the run exits `SUCCEEDED`, the dataset has a row, the image is there. The
caller finds out later, usually by looking. Pagewright measures the render and refuses the ones that
are not worth having.

### What it does

- Full-page or viewport **PNG** screenshots, configurable viewport width, height and device scale.
- **Batch**: 1–500 URLs per run, one dataset item per URL whatever the outcome.
- Wait strategy: **network-idle** (bounded, so it captures anyway if the network never settles, since
  plenty of ordinary pages hold a connection open indefinitely) or a **fixed delay**.
- **Four checks on every render**, each reported separately.
- **A refused page emits no billable event at all.** You are not charged for a screenshot the Actor
  itself would not stand behind.

### The checks

| Check | What it measures | Refuses? |
|---|---|---|
| `httpOk` | the response status was below 400 | yes → `HTTP_ERROR` |
| `nonBlank` | the image carries detail rather than being near-uniform | yes → `BLANK_RENDER` |
| `notErrorPage` | the title and body lead carry no generic error-page wording | yes → `ERROR_PAGE` |
| `consentDetected` | a known consent platform was present | **no, flag only** |

Two more refusals come from the browser itself: `TIMEOUT` and `BLOCKED_BY_SITE`. Those five are the
whole vocabulary, and nothing outside it is ever returned.

**Consent walls are flagged, not refused.** They are common enough that refusing them would make the
Actor useless, and you are better placed than we are to decide whether a cookie banner matters for
your screenshot.

**A check that could not run reports `null`, never `true`.** If the response never arrived, or the image
could not be decoded, or the page text could not be read, the check says so. In `strict` mode a `null`
refuses, for the same reason a `false` does: neither one is evidence that the render is good.

### Input

| Field | Type | Default | Notes |
|---|---|---|---|
| `urls` | string\[] | none | **required**, 1 to 500 |
| `fullPage` | boolean | `false` | whole scrollable page instead of the viewport |
| `viewport` | object | `1280×800 @1×` | `width`, `height`, `deviceScaleFactor` |
| `waitStrategy` | `networkIdle` | `delay` | `networkIdle` | |
| `waitMs` | integer | `1000` | used by `delay` |
| `timeoutMs` | integer | `30000` | per page |
| `verification` | `strict` | `off` | `strict` | see below |

A value outside its declared bound is **refused, not clamped**. If your input gets quietly corrected,
what ran is no longer what you asked for.

#### `verification: "off"`

Every check still runs and every result is still reported. `off` only stops them *refusing*. Opting out
of enforcement is not opting out of information, so you can see exactly what a lenient run let through.
A browser-level failure (timeout, blocked fetch) is still not a success.

### Output

One dataset item per URL:

```json
{
  "url": "https://example.com",
  "status": "ok",
  "refusalReason": null,
  "errorText": null,
  "imageUrl": "https://api.apify.com/v2/key-value-stores/.../records/screenshot-0000.png",
  "httpStatus": 200,
  "renderedAt": "2026-07-30T18:20:11+00:00",
  "bytes": 48213,
  "dimensions": { "width": 1280, "height": 800 },
  "checks": {
    "httpOk": true,
    "nonBlank": true,
    "notErrorPage": true,
    "consentDetected": false
  }
}
```

`status` is `ok`, `refused`, or `error`. **The `checks` object is present and complete on all three**,
so you can always see what was actually measured rather than just the verdict.

A refusal looks like this, and costs nothing:

```json
{
  "url": "https://example.com/missing",
  "status": "refused",
  "refusalReason": "ERROR_PAGE",
  "errorText": null,
  "imageUrl": null,
  "httpStatus": 200,
  "checks": { "httpOk": true, "nonBlank": true, "notErrorPage": false, "consentDetected": false }
}
```

Note the `httpStatus: 200`. That case is the reason this Actor exists.

A render that failed outright says why, and also costs nothing:

```json
{
  "url": "https://example.com/unreachable",
  "status": "error",
  "refusalReason": null,
  "errorText": "net::ERR_NAME_NOT_RESOLVED; unmeasured: body_text, png, title",
  "imageUrl": null,
  "httpStatus": null,
  "checks": { "httpOk": null, "nonBlank": null, "notErrorPage": null, "consentDetected": null }
}
```

On an `error` every measurement is absent, which is what makes it an error rather than a refusal, so
`errorText` is the whole of what you have to go on. Read it, don't parse it: the wording can change
between runs and carries no compatibility promise. Branch on `status` and `refusalReason`.

### Pricing

Pay-per-event. `page-rendered` is charged **once per verified success**. Refusals and errors don't
emit a billable event at all, so there's nothing to charge for.

### Limits

- PNG only. No JPEG, WebP or PDF.
- Consent walls are **detected, not dismissed**.
- No authenticated pages, no proxy rotation, no anti-bot evasion. You supply the URL; if a site blocks
  automated fetches, you get `BLOCKED_BY_SITE` and no charge.
- `notErrorPage` reads the title plus the first 600 characters of body text. An error page leads with
  its message. Scanning a whole long article for the same phrases would start refusing pages that
  simply discuss outages.
- The blankness check is a near-uniformity measure. A page whose entire content amounts to a few dozen
  pixels is treated as blank, because at that point you can't tell the two apart.

### Development

```
py -3.14 -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\python -m playwright install chromium

.venv\Scripts\python tools\run_tests.py          # the whole guard suite
.venv\Scripts\python tools\ast_guard.py          # structural guards over the AST
.venv\Scripts\python tools\build_input_schema.py # regenerate .actor/input_schema.json
.venv\Scripts\python tools\calibrate_entropy.py  # re-measure the blankness band
.venv\Scripts\python tools\live_sample.py        # measure the live refusal rate
.venv\Scripts\python tools\publication_gate.py   # grade the retained sample
```

[`docs/ACCEPTANCE.md`](docs/ACCEPTANCE.md) records which guard holds which clause of the governing
contract, and what is deliberately **not** proven.

# Actor input Schema

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

Pages to render, 1 to 500 per run. You get one dataset item per URL, including the ones that fail.

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

Capture the full scrollable page instead of just the viewport.

## `viewport` (type: `object`):

width 240 to 3840, height 240 to 4320, deviceScaleFactor 0.5 to 3.0. Values outside these bounds are rejected rather than clamped.

## `waitStrategy` (type: `string`):

networkIdle waits for network activity to settle, then captures anyway if it never does. Plenty of ordinary pages hold a connection open indefinitely. delay just waits for waitMs.

## `waitMs` (type: `integer`):

How long the delay strategy waits, 0 to 30000 ms.

## `timeoutMs` (type: `integer`):

Per-page navigation and capture timeout, 1000 to 120000 ms. A page that times out is refused as TIMEOUT and isn't billed.

## `verification` (type: `string`):

strict refuses a render that fails a check or can't complete one, and refused pages aren't billed. off still runs every check and still reports the results, it just doesn't refuse anything.

## Actor input object example

```json
{
  "urls": [
    "https://example.com"
  ],
  "fullPage": false,
  "viewport": {
    "width": 1280,
    "height": 800,
    "deviceScaleFactor": 1
  },
  "waitStrategy": "networkIdle",
  "waitMs": 1000,
  "timeoutMs": 30000,
  "verification": "strict"
}
```

# Actor output Schema

## `overview` (type: `string`):

Every URL with its screenshot, status, refusal reason and HTTP status.

## `verification` (type: `string`):

How each of the four checks scored on each page, whether the page came back, was refused, or failed, and the cause of anything that failed outright.

## `screenshots` (type: `string`):

The key-value store holding the PNGs. Only verified pages are stored here.

# 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"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("canopy_ne3/pagewright-verified-screenshots").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"] }

# Run the Actor and wait for it to finish
run = client.actor("canopy_ne3/pagewright-verified-screenshots").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"
  ]
}' |
apify call canopy_ne3/pagewright-verified-screenshots --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,canopy_ne3/pagewright-verified-screenshots"
        }
    }
}

```

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/SXc8OKOXfjP1EgTDd/builds/xZazZ2hEJLul9M7Yv/openapi.json
