# SEO Audit Crawler with Change Report (`zerolag/seo-audit-crawler`) Actor

Crawl a website and get an SEO audit of every page: score, issues with fixes, broken links, redirects, duplicates, robots.txt and sitemap checks. Run it again and it reports what changed since the last audit: new 404s, lost titles, added noindex.

- **URL**: https://apify.com/zerolag/seo-audit-crawler.md
- **Developed by:** [ZeroLag Data](https://apify.com/zerolag) (community)
- **Categories:** SEO tools, Developer tools, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.50 / 1,000 pages

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

SEO audit crawler for whole websites. It reads a site page by page and gives every page a score from 0 to 100, the issues behind that score and a fix for each one. Run it again and it reports what changed since the last SEO audit: new 404s, lost titles, added noindex, robots.txt edits.

- **Price:** $5.00 per 1,000 pages on the Free plan, $3.50 on Gold. Platform usage is included. Redirects, 404s and failed addresses are free.
- **Input:** a domain, a section of a site, a sitemap or a list of addresses.
- **Output:** one row per address with status, score, issues, fixes, title, description, headings, canonical, indexability, structured data, links and changes. Plus an HTML report you can send to a client.
- **Measured:** 1,000 pages and 1,219 links checked in 568 seconds on Apify. A repeat run of 200 pages reported no changes.

Found a bug, need a field, or want a new Actor? Open an issue on the **Issues** tab. If it works well for you, a short review helps others find it.

### How to use it

1. Put your site in **Websites**: `example.com`, or `example.com/blog/` for one section.
2. Set **Max pages per site**. This limit also caps the cost.
3. Click **Start**. The first run of a site is the baseline.
4. Open **SEO audit** in the Output tab, or **HTML report** for everything on one page.
5. Run it again later. **Change report** shows what changed.

```json
{
    "startUrls": ["https://example.com"],
    "maxPages": 500,
    "excludeUrlPatterns": ["/tag/", "?sort="]
}
```

| What to audit | What it does |
| --- | --- |
| Crawl the site | Follows links from the start page and adds what the sitemap lists |
| Sitemap only | Audits the addresses in the sitemap. Paste a sitemap address, or let the Actor find it |
| Only the listed addresses | Audits exactly what is in **Websites**, on as many sites as you like |

The crawl stays under the path you give, so `example.com/blog/` never leaves the blog.

### What data you get

The first row of each site has `rowType` set to `site` and holds the site score, totals, site-wide issues and site-wide changes. Pages follow, worst first, then redirects and errors.

| Field | What it holds |
| --- | --- |
| `rowType`, `url`, `statusCode` | `site`, `page`, `redirect`, `error`, `failed` or `blocked`, the address and its HTTP status |
| `score`, `issues` | Score from 0 to 100. Each issue has `id`, `severity`, `message` and `fix` |
| `changeType`, `changeSeverity`, `changeSummary`, `changes` | What changed since the last audit, with `before` and `after` |
| `indexable`, `indexabilityReason`, `metaRobots`, `xRobotsTag` | Whether Google may index the page, and why not |
| `title`, `titlePixels`, `metaDescription`, `h1`, `wordCount` | Text search engines read, and the title width in search results |
| `canonicalUrl`, `hreflang`, `structuredDataTypes`, `ogImage` | Canonical tag, language versions, schema.org types, social tags |
| `internalLinks`, `externalLinks`, `inlinks`, `crawlDepth`, `inSitemap` | Links out, links in, clicks from the start page, sitemap presence |
| `brokenLinks`, `imagesMissingAlt`, `redirectTarget`, `linkedFrom` | Broken links with anchor text, images without alt, where a redirect ends, which pages link to a dead address |
| `responseTimeMs`, `pageBytes` | Time to answer and HTML size |

Part of a real page row:

```json
{
    "rowType": "page",
    "url": "https://docs.apify.com/search",
    "statusCode": 200,
    "score": 78,
    "indexable": false,
    "indexabilityReason": "noindex",
    "title": "Search the documentation | Apify Documentation",
    "titlePixels": 434,
    "metaDescription": null,
    "h1": "Search the documentation",
    "wordCount": 6,
    "canonicalUrl": "https://docs.apify.com/search",
    "metaRobots": "noindex, follow",
    "inSitemap": true,
    "internalLinks": 27,
    "externalLinks": 19,
    "responseTimeMs": 15,
    "issues": [
        {
            "id": "noindex",
            "severity": "critical",
            "message": "The page is in the sitemap but says noindex.",
            "fix": "Remove noindex, or remove the page from the sitemap."
        },
        {
            "id": "description-missing",
            "severity": "warning",
            "message": "The page has no meta description.",
            "fix": "Write one or two sentences that say what the page offers."
        },
        {
            "id": "structured-data-missing",
            "severity": "notice",
            "message": "The page has no structured data.",
            "fix": "Add schema.org JSON-LD that fits the page, such as Article or Product."
        },
        {
            "id": "links-to-redirects",
            "severity": "notice",
            "message": "Internal links that go through a redirect: 1.",
            "fix": "Point them straight at the final address."
        }
    ]
}
```

#### What the audit checks

| Area | Checks |
| --- | --- |
| Titles and descriptions | Missing, duplicated, repeated tags, too short, too long, with the title width measured in pixels |
| Headings | Missing H1, several H1, skipped levels |
| Indexing | noindex in tags and headers, canonical tags (missing, repeated, pointing elsewhere or at a dead page), rules that block Googlebot |
| Links | Broken internal and external links, broken images, links through redirects, nofollow on internal links, orphan pages, deep pages |
| Redirects | Chains, loops, temporary redirects |
| Content | Thin pages, duplicated text, pages that show nothing without JavaScript, images without alt |
| Structured data and languages | Types found, JSON-LD that cannot be parsed, hreflang codes, self links and return links |
| Technical | HTTPS, mixed content, viewport, lang, charset, meta refresh, slow answers, heavy HTML, long or uppercase addresses |
| Whole site | robots.txt, sitemaps, http to https redirect, www and non-www, soft 404, llms.txt, AI crawlers blocked in robots.txt |

Each issue counts once per page and takes points off 100: 15 for critical, 5 for a warning, 1 for a notice. The site score is the mean of its pages, with error pages as 0, minus the same points for site-wide issues.

#### What counts as a change

| Severity | Change |
| --- | --- |
| Critical | An address now returns an error or stops answering, a page can no longer be indexed, a title is gone, robots.txt blocks a page or the whole site, the sitemap is gone, http no longer redirects to https, a broken link on your own site |
| Warning | Title or canonical changed, description or H1 removed, a page now redirects, a redirect moved, structured data removed, hreflang changed, text fell by 30% or more, a page left the sitemap, no page links here any more, a broken link to another site, robots.txt edited, the site score fell by 5 or more |
| Notice | Meta description or H1 changed, a new page, a page works again, broken links fixed |

A real row from a small test site that we edited between two runs:

```json
{
    "rowType": "page",
    "title": "Contact the Acme Tools workshop team",
    "changeType": "changed",
    "changeSeverity": "warning",
    "changeSummary": "The meta description was removed. The H1 was removed. The text went from 325 to 125 words. And 2 more.",
    "previousScore": 94,
    "score": 68,
    "issuesAdded": [
        "description-missing",
        "h1-missing",
        "structured-data-missing"
    ],
    "changes": [
        {
            "field": "metaDescription",
            "severity": "warning",
            "before": "How to reach the Acme Tools workshop by phone or by post, the opening hours, and how to return a tool you do not need.",
            "after": null,
            "message": "The meta description was removed."
        },
        {
            "field": "h1",
            "severity": "warning",
            "before": "Contact",
            "after": null,
            "message": "The H1 was removed."
        },
        {
            "field": "wordCount",
            "severity": "warning",
            "before": 325,
            "after": 125,
            "message": "The text went from 325 to 125 words."
        },
        {
            "field": "structuredDataTypes",
            "severity": "warning",
            "before": [
                "ContactPage"
            ],
            "after": [],
            "message": "Structured data removed: ContactPage."
        },
        {
            "field": "issues",
            "severity": "warning",
            "before": null,
            "after": [
                "description-missing",
                "h1-missing",
                "structured-data-missing"
            ],
            "message": "New issues: description-missing, h1-missing, structured-data-missing."
        }
    ]
}
```

### How much does it cost?

| Event | Free | Bronze | Silver | Gold |
| --- | --- | --- | --- | --- |
| Page, per 1,000 | $5.00 | $4.50 | $4.00 | $3.50 |
| Unchanged page, per 1,000 | $1.00 | $0.90 | $0.80 | $0.70 |

You pay for HTML pages that load. Redirects, 404s, server errors, blocked addresses and failed requests appear in the results for free. **Unchanged page** applies only with **Only return changed pages** on, and it replaces the page price for pages that did not change. The usual run start fee of $0.00005 applies.

On the Free plan a first audit of 500 pages costs $2.50. A weekly run of that site with **Only return changed pages** on, where 20 pages changed, costs 20 x $0.005 + 480 x $0.001 = $0.58. The run stops at your maximum cost per run.

### How to monitor a site on a schedule

1. Turn on **Only return changed pages** and set a **Monitor name**, such as `client-acme`.
2. Run it once. This first run returns every page and sets the baseline.
3. Save the input as a task, add a weekly schedule and a Slack, email or webhook integration.

Each later run saves only new and changed pages, plus one site row. The status message works as the alert text. This one is from the test site:

`api.apify.com: 6 pages, score 53. Since 2026-11-06: 2 critical, 8 warnings, 1 notice. 4 pages changed, 2 new.`

If nothing changed, the dataset holds the site row alone. Pages that stayed the same are listed in **Summary as JSON** under `unchangedUrls`.

### How to check a release or a site migration

Audit the site before the change, audit it again right after, and open **Change report**. Pages from the last audit are read again even when no link points to them any more, so a moved page shows up as a redirect or a 404 with the number of pages that still link to it.

### How false alarms are kept out

- A page that went bad is read again at the end of the run before it is reported.
- When a host refuses many pages at once (401, 403, 429, 503), they are marked `unverified` and the memory of them stays as it was.
- A server that refuses a link checker is counted apart, not reported as a broken link, and a link that refused this time is not reported as fixed. A broken link the last audit could not check is listed, but not called a change.
- A change of the text alone sets `contentChanged`, not `changeType`, so dates and counters do not mark a page as changed.
- A page the run did not reach is never reported as removed.

### Limits

- It reads the HTML the server sends and runs no JavaScript. A page that shows nothing without it gets the `needs-javascript` issue. There are no Core Web Vitals and no colour contrast checks.
- robots.txt is always respected, and this cannot be turned off. Crawl delays are honoured up to 60 seconds.
- Sites that block cloud servers may refuse the crawl. The site row then says what the server answered.
- Pages behind a login cannot be audited.
- `responseTimeMs` is one measurement from an Apify server, not what your visitors see.
- The limit is 10,000 pages per site in one run. Link checks stop at 3,000 per site, and after twice as long as the crawl took, with a minimum of 30 seconds. The summary says how many links were not checked.
- At most 50 sitemap files are read, those under the audited path first. When files stay unread, `inSitemap` is empty for pages not found in the rest, and **Summary as JSON** says how many under `sitemapFilesNotRead`.

### API and integrations

Start a run through the [Apify API](https://docs.apify.com/api/v2), then read the dataset, or the `REPORT` and `SUMMARY` records of the run's key-value store. Schedules and integrations work as for any Actor.

### FAQ

**Do I pay for 404 pages and redirects?**
No. Only HTML pages that load are charged.

**Where is the history kept?**
In a key-value store in your own Apify account, named `seo-audit-crawler-default` or after your **Monitor name**. Delete the store to start over.

**How fast is it?**
A run of 1,000 pages took 568 seconds with its link checks, at the default 1 GB of memory. Requests to one host are at least 200 ms apart. Lower **Parallel requests** for a slow server.

**Why is the score of a good page not 100?**
Small things count too. The `issues` field says what took points off, and a notice is often fine to leave.

**Is page text stored?**
No. Rows hold the title, the description, the first H1, counts and a fingerprint of the text.

**Can I audit a site that is not mine?**
It reads public pages and obeys robots.txt. Whether you may audit a site depends on that site's terms, so check them first.

*This Actor is an independent tool with no connection to Google or any audited website. All trademarks belong to their owners.*

# Actor input Schema

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

One website per line: a domain, a home page or a section such as example.com/blog/. The crawl stays under the path you give. Several lines on one domain count as one site.

## `crawlMode` (type: `string`):

Crawl: follow links from the start page and read the sitemap. Sitemap: audit only the addresses in the sitemap. List: audit only the addresses in Websites.

## `maxPages` (type: `integer`):

The audit stops after this many HTML pages on each site. This limit also caps the cost.

## `compareWithPrevious` (type: `boolean`):

Remember each audit and report what changed since the last one: new 404s, lost titles, added noindex, robots.txt edits and more. The first run of a site is the baseline.

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

Save only new and changed pages to the dataset. Unchanged pages are still rechecked, at the lower Unchanged page price. The first run of a site always returns every page. Made for schedules.

## `monitorName` (type: `string`):

Keeps a separate history, for example one per client or per schedule. Leave it empty to share one history per site. Letters, digits and dashes.

## `checkExternalLinks` (type: `boolean`):

Verify outgoing links as well as internal ones. Servers that refuse automated checks are counted apart and never reported as broken.

## `checkImages` (type: `boolean`):

Verify that the images on audited pages load.

## `includeSubdomains` (type: `boolean`):

Treat subdomains such as blog.example.com as part of the site.

## `maxDepth` (type: `integer`):

How many clicks from the start page the crawl may go. 0 means no limit.

## `includeUrlPatterns` (type: `array`):

Audit only addresses that contain one of these texts. Start a line with regex: for a regular expression, for example regex:/blog/\[0-9]+. Leave empty for all.

## `excludeUrlPatterns` (type: `array`):

Skip addresses that contain one of these texts, for example /tag/ or ?sort=. Start a line with regex: for a regular expression.

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

Requests in flight at the same time. Requests to one host are also spaced by at least 200 ms, and robots.txt can ask for more. Lower it for a slow server.

## Actor input object example

```json
{
  "startUrls": [
    "https://crawlee.dev"
  ],
  "crawlMode": "crawl",
  "maxPages": 20,
  "compareWithPrevious": true,
  "onlyChanges": false,
  "monitorName": "",
  "checkExternalLinks": true,
  "checkImages": true,
  "includeSubdomains": false,
  "maxDepth": 0,
  "includeUrlPatterns": [],
  "excludeUrlPatterns": [],
  "maxConcurrency": 5
}
```

# Actor output Schema

## `audit` (type: `string`):

No description

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

No description

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

No description

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

No description

# 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": [
        "https://crawlee.dev"
    ],
    "maxPages": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("zerolag/seo-audit-crawler").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": ["https://crawlee.dev"],
    "maxPages": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("zerolag/seo-audit-crawler").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": [
    "https://crawlee.dev"
  ],
  "maxPages": 20
}' |
apify call zerolag/seo-audit-crawler --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zerolag/seo-audit-crawler"
        }
    }
}
```

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/mY8gKf8KRgqq531NT/builds/bNJ2jeKs9LXezGvuu/openapi.json
