# SEO Audit Crawler (`humble-echidna/seo-audit`) Actor

Audit your own website's on-page SEO: crawl it from a start URL or sitemap and get a 0-100 score per page and site from a published formula, plus title and meta description length, H1s, canonical, robots, hreflang, Open Graph, JSON-LD, broken links and images, redirects and issues.

- **URL**: https://apify.com/humble-echidna/seo-audit.md
- **Developed by:** [Michael Costa](https://apify.com/humble-echidna) (community)
- **Categories:** SEO tools, Marketing, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 page auditeds

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 SEO Audit Crawler do?

**SEO Audit Crawler** is a **website SEO audit tool**. Give it your site; it crawls it like a search engine and
reports every page's **on-page SEO**, **broken links and images**, **issues by severity** and a **0-100 score** from
a published formula, plus a **site summary** with the site's score, issue totals and duplicate titles.

It is **not** a rank tracker: it reads your own pages as your server sends them, with no third-party SEO data (no
rankings, backlinks or traffic estimates), no login and no proxy.

**Try it in one click:** the input comes pre-filled with crawlee.dev and a 10-page limit. That's 10 pages, about
$0.10 (10 × $0.01, plus $0.00005 for the run start). **Then replace it with your own site and raise Max pages per
run.**

### Monitor your site: what changed since the last audit, in Slack, email or a webhook

With **Compare with the previous audit** on, each run says what changed since the last run of the same audit: every
page is `new`, `changed` or `unchanged`, with the fields and issues that changed and its score before and after, and
the site summary counts issues added and fixed by type, new and fixed broken links, pages added and removed, and the
site score's change. Turn on **Only return changed
pages** as well and each run returns only the new and changed pages, charged as pages ($0.01 each). The unchanged
ones are still crawled and rechecked, at a tenth of the price ($0.001 each): a 100-page site where nothing changed
costs $0.10 a week.

1. Put your site in **Start URLs**, set **Max pages per run** above the number of pages on the site, turn on **Only
   return changed pages** (`"onlyChangedPages": true`), and click **Start**. This first run returns every page and
   is the baseline the next runs compare with.
2. Click **Save as a new task** (top right of the actor page). The comparison belongs to that exact audit (the same
   start URLs, mode and settings), so the task keeps comparing with its own last run. Changing the start URLs, mode,
   max pages, crawl depth or link-check settings starts a new baseline.
3. In Apify Console, open **Schedules**, click **Create new**, set how often in **Schedule setup** (for example
   weekly, Monday at 06:00), then **Add** your task.
4. On the task, open the **Integrations** tab and pick where the changes go:
   - **Slack**: click **Configure**, sign in, pick the workspace and channel, and the "run succeeded" event. A
     useful message: `{{resource.statusMessage}}` and a link to the results,
     `<https://console.apify.com/storage/datasets/{{resource.defaultDatasetId}}|changed pages>`.
   - **Gmail**: click **Connect with Google**, set the subject and body, and attach the dataset (for example as
     CSV). It sends after each successful run.
   - **HTTP webhook**: event `ACTOR.RUN.SUCCEEDED`, your URL. Apify POSTs `{"eventType": ..., "resource": {...}}`;
     `resource.defaultDatasetId` is the run's dataset (the changed pages), and
     `GET https://api.apify.com/v2/key-value-stores/<defaultKeyValueStoreId>/records/SUMMARY` (with your API token)
     returns the site summary with its `comparison`.

Apify's integrations fire after every successful run, including quiet ones: a quiet run's dataset is empty, and its
status message says how many pages were unchanged. The pre-filled example, run twice with **Only return changed
pages** on (local runs, 2026-09-26): the first run returned all 10 pages ($0.10); the second returned 0 and said
"10 pages audited, site score 89/100: 0 errors, 10 warnings, 13 notices; 0 broken links; since the audit of
2026-09-26 00:06 UTC: 0 new, 0 changed, removed pages not reported (the crawl was incomplete); 0 issues added, 0
fixed; 0 new broken links, 0 fixed; site score 89 -> 89; 10 unchanged pages rechecked, not returned; ..."
(shortened). "Incomplete" because crawlee.dev has more
than the example's 10 pages: pages are only reported as **removed** when the crawl was complete. See
[Changes since the last audit](#changes-since-the-last-audit) for what's compared and when.

### What data does SEO Audit Crawler return?

| Field | Example | Notes |
|---|---|---|
| `url`, `statusCode`, `indexable` | `https://crawlee.dev/blog`, `200`, `true` | Indexable: a 200, no `noindex`, no canonical pointing elsewhere. |
| `score` | `74` | 0-100: 100 minus points per issue found; see [How the score is calculated](#how-the-score-is-calculated). |
| `title`, `titleLength` | `Crawlee Blog - learn how to build better scrapers ...`, `107` | |
| `metaDescription`, `metaDescriptionLength` | `Who we are and what we make.`, `28` | `null` when missing. |
| `h1Count`, `h1` | `0`, `[]` | |
| `canonicalUrl` | `https://example.com/about` | `null` when missing. |
| `robotsMeta`, `xRobotsTag` | `null` | The robots meta tag and header. |
| `hreflang`, `openGraphTags`, `twitterTags` | `[{"hreflang": "de", ...}]`, `["og:title", ...]` | |
| `structuredDataTypes` | `["Organization", "BreadcrumbList"]` | JSON-LD `@type` values. |
| `imageAltCoverage`, `imagesEmptyAlt`, `imagesMissingSize` | `0.75`, `1`, `20` | Alt text and width/height attributes of `<img>`. |
| `brokenImages`, `oversizedImages` | `[]`, `[{"url": ..., "sizeBytes": 205098}]` | Each image checked once per run; over 200 KB by its Content-Length. |
| `wordCount` | `1596` | Visible text in `<body>`. |
| `internalLinkCount`, `externalLinkCount` | `42`, `29` | |
| `brokenLinkCount`, `brokenLinks` | `1`, `[{"url": ..., "statusCode": 404, "linkText": ...}]` | Every link checked once per run. |
| `redirectChain`, `responseTimeMs` | `[...]`, `182.4` | Hop by hop. |
| `issues` | `[{"code": "title-too-long", "severity": "warning", "message": ...}]` | Most severe first; see [Issues](#issues). |
| `changeType`, `changedFields`, `changedIssues`, `previousScore` | `"changed"`, `["title", "score"]`, `{"added": ["title-too-long"], ...}`, `82` | With the comparison on; `null` otherwise and on the first (baseline) run. |

One row per page, plus a free `SUMMARY` record for the whole site. The full list is under [Output](#output).

### How much does it cost to run an SEO audit?

You pay per page audited: **$10.00 per 1,000 pages** ($0.01 a page), plus $0.00005 each time a run starts. With
**Only return changed pages** on, pages that come back unchanged are rechecked but not returned, at **$1.00 per 1,000
unchanged pages** ($0.001 a page).

- **The example below:** 10 pages × $0.01 = $0.10, plus the start fee.
- **A month, for example:** an agency auditing 5 client sites of 200 pages each, weekly: 5 × 200 × 4 = 4,000 pages
  × $0.01 = **$40.00**, plus 4 run starts ($0.0002) if the 5 sites are one run.
- **Caps:** **Max pages per run** in the input, and **Maximum cost per run** in the run options. The run stops
  cleanly at whichever comes first; no page is requested past the limit, so a capped run is also a fast one.

**Free:** link and image checks, the page and site scores, the site summary, robots.txt and sitemap files, and pages that were never requested
(robots.txt disallows them, or the address is private or invalid; they're listed in the summary). A link that turns
out to be a file (a PDF, an image) is checked as a link, not audited as a page, and isn't charged.

**Charged:** a page that answers with an error (404, 500, a timeout) *is* audited: its status is the finding.

**Only return changed pages:** unchanged pages are still crawled and audited (they count in the site summary; the
crawl costs the same whether a page changed or not), but they aren't returned: each is charged $0.001, a tenth of a
returned page. New and changed pages are returned and charged $0.01, as usual. The first run is the baseline and
returns every page. For example, weekly monitoring of a 100-page site:

- **Week 1 (baseline):** 100 × $0.01 = $1.00.
- **A quiet week** (nothing changed): 100 × $0.001 = $0.10.
- **A week where 5 pages changed:** 5 × $0.01 + 95 × $0.001 = $0.145.

Plus the start fee each run. Pages that refused the crawler or failed again (unchanged, but no real answer) aren't
charged. In this mode the **Maximum cost per run** limits the pages *read*: a page is only read when the remaining
budget covers at least a rechecked page, and a changed page is only returned when it covers the full page price (one
that isn't is reported again next run).

### How to run an SEO audit of your website

1. Open SEO Audit Crawler and click **Try for free** (or **Start** if you're signed in).
2. Put your site in **Start URLs** (`https://example.com/`).
3. Pick **Find pages by**: `crawl` (follow links) or `sitemap` (the pages your sitemaps list).
4. Raise **Max pages per run** to cover the site, and set **Crawl depth**.
5. Click **Start**, then open the **Output** tab: sort the pages by score, filter them by issue, or open the "Site
   summary" link for the site score. Export as JSON, CSV or Excel.

<img src="https://api.apify.com/v2/key-value-stores/s6WDkGbLM6XWiFVHQ/records/seo-audit-input-form.png" alt="SEO Audit Crawler input form: start URL, how pages are found, max pages, crawl depth, link and image checks" width="700">

### Example: a 10-page audit of crawlee.dev

The pre-filled input:

```json
{"startUrls": ["https://crawlee.dev/"], "mode": "crawl", "maxPages": 10, "maxDepth": 3,
 "checkLinks": true, "checkExternalLinks": true, "maxLinkChecks": 500}
```

One of the 10 pages it returned (real output from a local run on 2026-09-26, other fields left out):

```json
{
  "url": "https://crawlee.dev/blog",
  "statusCode": 200,
  "indexable": true,
  "depth": 1,
  "foundOn": "https://crawlee.dev/",
  "title": "Crawlee Blog - learn how to build better scrapers | Crawlee for JavaScript · Build reliable crawlers. Fast.",
  "titleLength": 107,
  "h1Count": 0,
  "wordCount": 1596,
  "imageCount": 28,
  "imagesMissingAlt": 0,
  "imagesMissingSize": 20,
  "oversizedImages": [{"url": "https://crawlee.dev/assets/images/gradcracker-scraper-caefb62d1c150c4209a6e564c052fa41.webp", "sizeBytes": 205098}],
  "brokenImageCount": 0,
  "internalLinkCount": 42,
  "externalLinkCount": 29,
  "brokenLinkCount": 0,
  "score": 74,
  "issues": [
    {"code": "title-too-long", "severity": "warning", "message": "The title is 107 characters; search results usually cut it after about 60."},
    {"code": "missing-h1", "severity": "warning", "message": "The page has no <h1> heading."},
    {"code": "oversized-images", "severity": "warning", "message": "1 image is over 200 KB (see oversizedImages)."},
    {"code": "images-missing-size", "severity": "notice", "message": "20 of 28 images have no width and height attributes, so the page can shift while they load."}
  ]
}
```

Its score: 100 − 3 warnings × 8 − 1 notice × 2 = **74**. The same run's `SUMMARY` record: 10 pages audited, **site
score 89** (6 pages scored 90-100, 4 scored 50-89), 9 indexable, 10 warnings and 13 notices, `title-too-long` on 7
pages, one meta description shared by 6 pages, 63 distinct images checked with 3 over 200 KB (the largest a 2.0 MB
GIF), no broken links or images, average response 60.5 ms.

**What that cost:** 10 pages × $0.01 = $0.10, plus the $0.00005 start fee.

### Input

| Field | What it does |
|---|---|
| **Start URLs** | Your site, one address per line: a page to crawl from (`https://example.com/`), or for the sitemap mode a site or a sitemap URL (`example.com`, `https://example.com/sitemap.xml`). |
| Find pages by | `crawl` (default): follow same-site links from the start URLs. `sitemap`: the pages your sitemaps list, then their links up to the crawl depth. |
| Max pages per run | Default 10, up to 10,000. |
| Crawl depth | Clicks away from the start (or sitemap) pages. Default 3; 0 audits only those pages. |
| Check links for broken ones | On by default. Off: only links to pages the crawl read get a status. |
| Check links to other sites | On by default. Other sites' pages are checked, never crawled. |
| Check images | On by default. Each distinct `<img src>` gets one HEAD request, wherever it's hosted: broken images, and files over 200 KB by their Content-Length. Off: no image requests (alt text and width/height are still read from the page). |
| Max link checks per run | Default 500, up to 50,000; links and images together. Links past it are counted in `linksNotChecked`, images in `imagesNotChecked`. |
| Compare with the previous audit | Off by default. On: each page says what changed since the last run of the same audit, and the summary has a `comparison`. See [Changes since the last audit](#changes-since-the-last-audit). |
| Only return changed pages | Off by default. On: turns the comparison on and returns only new and changed pages ($0.01 each); unchanged pages are rechecked at $0.001 each. |

"Same site" means the same host, with or without `www.`. If your start URL redirects to another host (say
`example.com` to `www.example.co.uk`), that host counts as your site too.

```json
{
  "startUrls": ["https://example.com/"],
  "mode": "crawl",
  "maxPages": 500,
  "maxDepth": 5
}
```

### Output

One row per page, in the order the crawl reached them. Fields that don't apply are `null` (or empty lists).

<img src="https://api.apify.com/v2/key-value-stores/s6WDkGbLM6XWiFVHQ/records/seo-audit-output-table.png" alt="SEO Audit Crawler output table: page, status, indexable, score, issue count, title" width="700">

```json
{
  "url": "https://example.com/about",
  "finalUrl": "https://example.com/about",
  "statusCode": 200,
  "indexable": true,
  "depth": 1,
  "foundOn": "https://example.com/",
  "source": "https://example.com/",
  "responseTimeMs": 182.4,
  "redirectCount": 0,
  "redirectChain": [{"url": "https://example.com/about", "statusCode": 200, "location": null, "timeMs": 182.4}],
  "contentType": "text/html",
  "sizeBytes": 48211,
  "title": "About us | Example",
  "titleLength": 18,
  "metaDescription": "Who we are and what we make.",
  "metaDescriptionLength": 28,
  "h1Count": 1,
  "h1": ["About us"],
  "canonicalUrl": "https://example.com/about",
  "robotsMeta": null,
  "xRobotsTag": null,
  "lang": "en",
  "hreflang": [{"hreflang": "de", "url": "https://example.com/de/about"}],
  "openGraphTags": ["og:description", "og:image", "og:title"],
  "twitterTags": ["twitter:card"],
  "structuredDataTypes": ["Organization", "BreadcrumbList"],
  "imageCount": 4,
  "imagesMissingAlt": 1,
  "imageAltCoverage": 0.75,
  "imagesEmptyAlt": 1,
  "imagesMissingSize": 0,
  "imageLinksWithoutText": 0,
  "wordCount": 612,
  "internalLinkCount": 24,
  "externalLinkCount": 3,
  "brokenLinkCount": 1,
  "brokenLinks": [{"url": "https://example.com/old-post", "statusCode": 404, "outcome": "broken", "error": null,
                   "linkText": "Our old post"}],
  "linksNotChecked": 0,
  "brokenImageCount": 0,
  "brokenImages": [],
  "oversizedImages": [{"url": "https://example.com/img/team.jpg", "sizeBytes": 412000}],
  "imagesNotChecked": 0,
  "score": 62,
  "issueCount": 4,
  "issueCounts": {"error": 1, "warning": 2, "notice": 1},
  "issues": [
    {"code": "broken-links", "severity": "error", "message": "1 link on the page is broken (see brokenLinks)."},
    {"code": "images-missing-alt", "severity": "warning", "message": "1 of 4 images have no alt attribute."},
    {"code": "oversized-images", "severity": "warning", "message": "1 image is over 200 KB (see oversizedImages)."},
    {"code": "title-too-short", "severity": "notice", "message": "The title is 18 characters; 30-60 is the usual target."}
  ],
  "error": null,
  "changeType": "changed",
  "changedFields": ["title", "brokenLinks", "score"],
  "changedIssues": {"added": ["broken-links", "title-too-short"], "fixed": [],
                    "brokenLinksAdded": ["https://example.com/old-post"], "brokenLinksFixed": 0},
  "previousStatusCode": 200,
  "previousScore": 84,
  "auditedAt": "2026-01-01T00:00:00Z"
}
```

- `indexable`: a 200 answer, no `noindex` (robots meta or `X-Robots-Tag`), and no canonical URL pointing to a
  different page.
- `imageAltCoverage`: the share of `<img>` elements with an `alt` attribute. An empty `alt=""` counts: it's the
  right markup for decorative images. `imagesEmptyAlt` counts those, for you to check they really are decorative;
  an image that is a link's only content and has no alt text is reported as `image-links-without-text`.
- `score`: see [How the score is calculated](#how-the-score-is-calculated). `previousScore`: its value in the
  previous audit, with the comparison on.
- `brokenImages`, `oversizedImages`, `imagesNotChecked`: see [Images](#images-what-is-checked-and-what-isnt).
- `wordCount`: words of visible text in `<body>` (scripts, styles and inline SVG aside).
- `structuredDataTypes`: the `@type` values of the page's JSON-LD blocks (nested ones and `@graph` included). Only
  the types are reported, not the rest of the data.
- `brokenLinks`: links whose check ended in a 4xx or 5xx answer (except 401, 403, 407, 429 and 451, which usually
  mean a site refused an automated check and may work in a browser), a domain that doesn't exist, or an error
  (timeout, too many redirects). The first 100 per page; `brokenLinkCount` has the full number. Links robots.txt
  disallows aren't requested, so they're never reported as broken.
- `foundOn`: the page whose link led the crawl here (`null` for start and sitemap pages).
- `changeType`, `changedFields`, `changedIssues`, `previousStatusCode`: see
  [Changes since the last audit](#changes-since-the-last-audit). `null` (or empty) when the comparison is off.

#### Issues

| Code | Severity | Points | When |
|---|---|---:|---|
| `http-error` / `request-failed` | error | score 0 | The page answers 4xx/5xx, or couldn't be loaded at all |
| `broken-links` | error | −20 | One or more links on the page are broken |
| `broken-images` | error | −20 | One or more images on the page are broken |
| `missing-title` | error | −20 | No `<title>` |
| `redirect-chain` / `redirect` | warning / notice | −8 / −2 | The address redirects twice or more / once |
| `redirects-off-site` | warning | −8 | The address ends up on another site |
| `not-https` | warning | −8 | Served over plain http |
| `slow-response` | warning | not scored | Over 1,000 ms to the response headers |
| `title-too-long` / `title-too-short` | warning / notice | −8 / −2 | Over 60 / under 30 characters |
| `multiple-titles` | warning | −8 | More than one `<title>` |
| `missing-meta-description` | warning | −8 | No meta description |
| `meta-description-too-long` / `-too-short` | notice | −2 | Over 160 / under 70 characters |
| `missing-h1` / `multiple-h1` | warning / notice | −8 / −2 | No `<h1>` / more than one |
| `noindex` / `nofollow` | warning / notice | −8 / −2 | The robots meta tag or `X-Robots-Tag` says so |
| `missing-canonical` / `canonicalised` | notice | −2 | No canonical URL / it points to another page |
| `canonical-other-site` | warning | −8 | The canonical URL is on another site |
| `images-missing-alt` | warning | −8 | Images without an `alt` attribute |
| `image-links-without-text` | warning | −8 | A link whose only content is images without alt text (and no `aria-label` or `title`) |
| `oversized-images` | warning | −8 | Images over 200 KB by their Content-Length |
| `images-missing-size` | notice | −2 | Images without both `width` and `height` attributes (the page can shift while they load) |
| `invalid-structured-data` | warning | −8 | A JSON-LD block that doesn't parse |
| `missing-open-graph` / `missing-twitter-card` | notice | −2 | No `og:title`, `og:description` or `og:image` / no `twitter:card` |
| `missing-lang` | notice | −2 | No `lang` on `<html>` |
| `thin-content` | notice | −2 | Under 200 words on a 200 page |

The length, size and speed thresholds are common SEO conventions, not search engine rules.

#### How the score is calculated

Every page gets a **score from 0 to 100**, worked out from its issues and nothing else, so you can check it by hand:

- Start at **100**.
- Subtract **20 for each error**, **8 for each warning** and **2 for each notice** found on the page (the Points
  column above). Each issue code counts once per page: 5 broken links cost 20, like 1.
- A page that doesn't load (`http-error`, `request-failed`) scores **0**.
- `slow-response` isn't counted: response time is measured from Apify's servers and changes from run to run, so it
  would make the score move without anything on the page changing. It's still listed as an issue.
- The score never goes below 0.

The **site score** (`siteScore` in the summary) is the average of the scores of every page audited, rounded to the
nearest whole number (halves round up). An example: a page with a broken link (error), an image without alt text
and an oversized image (2 warnings), and images without width and height (a notice) scores 100 − 20 − 16 − 2 = 62.

The points are ours, not a search engine's: a guide to where to start, not a ranking prediction. What counts as an
issue is the table above; a change to the formula will be in the changelog.

#### Images: what is checked and what isn't

Checked: every `<img>` on an audited page for an `alt` attribute (missing, or empty), `width` and `height`
attributes, and whether it's a link's only content without alt text. With **Check images** on, each distinct image
address (`src`) gets one HEAD request per run, wherever it's hosted (a CDN too), counted against **Max link checks
per run**: an error answer (4xx other than 401, 403, 407, 429 and 451; 5xx; a dead domain; a timeout) makes it
broken, and a Content-Length over 200 KB makes it oversized.

Not measured:

- **The real file size when the server doesn't send a Content-Length** (the image isn't downloaded to measure it; if
  HEAD fails and the GET fallback runs, the size is read only when the header is there or the whole body arrives
  within 2 MB).
- **Pixel dimensions, format and compression** (whether a JPEG could be a smaller WebP, or a 4000 px image shown at
  400 px): the file isn't opened.
- **`srcset`, `<picture><source>`, lazy-loading attributes (`data-src`) and CSS background images**: only `src` is
  read, so an image that only has a placeholder there is checked as that placeholder.
- **Whether an alt text is good**, or an empty `alt=""` is right: only that it's there.
- Images added by JavaScript after the page loads.

#### Site summary (`SUMMARY`)

The run's key-value store has a `SUMMARY` record (the "Site summary" link on the run's Output tab), covering every
page in the results: `pagesAudited`, `siteScore`, `scoreBands` (pages scoring 90-100, 50-89 and 0-49),
`lowestScorePages`, `statusCodes`, `indexablePages`, `issueTotals` per severity, `issues` (pages per issue code),
`worstPages`, `duplicateTitles` and `duplicateMetaDescriptions` (with the pages sharing each),
`brokenLinks` (each broken link once, with the pages it's on), `averageResponseTimeMs`, `slowestPages`,
`averageWordCount`, `imagesMissingAlt`, `brokenImages` and `oversizedImages` (each image once), `structuredDataTypes`
and `notRequested` (pages that were never requested,
with the reason). Lists stop at 1,000 entries; the counts are always complete. With the comparison on, it also has
`comparison` (below); in the only-changed mode it still covers every page audited, returned or not.

#### Changes since the last audit

With **Compare with the previous audit** (or **Only return changed pages**) on, the run remembers each page in a
key-value store in your own account (`seo-audit-memory`, one record per audit) and compares with it next time:

- `changeType`: `new` (not in the previous audit), `changed` or `unchanged`. `null` on the first run, which is the
  baseline: nothing is reported as new.
- `changedFields`: which of `statusCode`, `finalUrl` (the redirect target), `title`, `metaDescription`, `h1`,
  `canonicalUrl`, `noindex` (robots meta or `X-Robots-Tag`), `brokenLinks` and `score` changed. The score only
  changes when an issue was added or fixed, so `score` always comes with `changedIssues` saying which;
  `previousScore` has the score last time.
- `changedIssues`: issue codes `added` and `fixed` on the page, the page's broken links that are new
  (`brokenLinksAdded`) and how many of its broken links were fixed (`brokenLinksFixed`). `previousStatusCode`: the
  status last time.

The summary's `comparison` has `previousAudit` (its date, run and dataset IDs, and whether its crawl was complete),
`pagesNew`, `pagesChanged`, `pagesUnchanged`, `pagesRemoved` and the lists of each, `issuesAdded` and `issuesFixed`
per issue code (pages audited both times), `newBrokenLinks` (with the pages they're on) and `fixedBrokenLinks` (each
marked `works` or `no longer linked`), `siteScore`, `previousSiteScore` and `siteScoreChange`, and
`pagesReturned` / `unchangedNotReturned`.

What it deliberately doesn't report:

- **Removed pages when the crawl may have missed some.** A page is reported removed only when this crawl was
  complete. If it stopped at Max pages per run or the maximum cost per run, a start page or sitemap failed, or pages
  refused the crawler, failed or answered 5xx (a block or an outage can hide the rest of the site), `pagesRemoved`
  is `null` and `incompleteReasons` says why; those pages are remembered and compared again next time.
- **A broken link as fixed when it had no clear answer.** A link that was broken and this time wasn't checked (the
  link-check limit), was refused or was rate-limited stays broken in the memory. The same goes for a page's broken
  or oversized images when one of its images wasn't checked or its host refused the check.
- **Image issues on the first comparison after upgrading to 1.2.** An audit remembered by 1.1 never looked at image
  sizes or broken images, so what the new checks find on a page the first time it's compared again is recorded,
  not reported as added (and its `previousScore` includes it). From then on, image issues are compared like any
  other.
- **Response-time noise.** `slow-response` isn't compared: response times vary on every run.

The memory holds the status code and short fingerprints (hashes) of each page's fields, its issue codes and its
broken links, not the pages themselves, so it tells you *that* a title changed, not what it was: the previous
values are in the previous run's dataset (`previousAudit.datasetId`, while Apify keeps that run's storage). A page
cut by your maximum cost per run isn't remembered, so it's reported again next run.

### Run it on a schedule, or from your own code

1. Save your input as a **task** (**Save as a new task**, top right of the actor page) and add it to a
   **schedule** (Console → Schedules → **Create new**): for example weekly, early Monday, so the report is ready at
   the start of the week.
2. Collect results: download the dataset as JSON, CSV or Excel; fetch the latest run's results from the API
   (`GET https://api.apify.com/v2/actor-tasks/<task id>/runs/last/dataset/items?status=SUCCEEDED&format=csv`, with
   your API token); fetch the summary with
   `GET https://api.apify.com/v2/actor-tasks/<task id>/runs/last/key-value-store/records/SUMMARY`; let a webhook
   tell your system when a run succeeds; or connect it to Make, Zapier or n8n through Apify's integrations. The
   task's **Integrations** tab can also post to Slack or email you (Gmail) after each run.

To hear only about what changed, turn on **Only return changed pages**; the steps are in
[Monitor your site](#monitor-your-site-what-changed-since-the-last-audit-in-slack-email-or-a-webhook). Between weekly
audits, a daily [Bulk URL & Broken Link Checker](https://apify.com/humble-echidna/url-checker) run with **Only report
URLs that changed since the last run** catches newly broken URLs.

#### Can I use SEO Audit Crawler from an AI agent (MCP)?

Yes, through Apify's MCP server: add `https://mcp.apify.com?tools=humble-echidna/seo-audit` to your MCP client (or
let the agent find it with the server's actor search). The agent passes the site and a small page limit, e.g.
`{"startUrls": ["https://example.com/"], "maxPages": 20}`, and reads each page's `issues`.

### Who it's for

SEO agencies, freelancers and in-house web teams who look after the same sites every week or month. Point it at a
client's site, and each run gives you one row per page with its issues, ready to filter in a spreadsheet and hand to
whoever fixes the pages, plus a site summary for the report.

### Why this one?

- **Broken links per page, included in the price.** Every link on your pages is checked once per run (HEAD first,
  GET when a server answers HEAD wrongly), and each page lists its own broken links with their status code and link
  text. Links to pages the crawl reads reuse that answer instead of a second request.
- **Issues you can act on**, each with a code, a severity and a plain-English message, most severe first. Filter or
  sort by them in the dataset, or read the totals in the summary.
- **The whole redirect chain**, hop by hop, for every page.
- **A score you can check by hand.** Every page and the site get a 0-100 score from a published formula (100 minus
  20, 8 or 2 points per error, warning or notice), not a black box, and the score's change shows in each
  comparison.
- **Images checked in the same run**: broken images, files over 200 KB, missing alt text and width/height, image
  links without text, with one HEAD request per image.
- **A free site summary**: it's a key-value record, not a dataset row, so it isn't charged.
- **Changes since the last audit**, per page and for the site: new, changed and removed pages, issues added and
  fixed by type, new and fixed broken links. With only-changed pages on, unchanged pages cost a tenth of a returned
  page, and a crawl that was cut short never reports the pages it didn't reach as removed.
- **Polite and safe.** It identifies itself honestly (User-Agent `HumbleEchidnaApify`), follows your robots.txt
  (read once per site per run) and Crawl-delay, sends at most 2 requests at a time to your site, and only requests
  public web addresses on the standard ports (80 and 443). Pages robots.txt disallows are never requested and never
  charged.
- **Reliable.** A page that fails is reported as a page with an error; it never affects the rest of the run. The
  run log and the `RUN_STATS` record say what happened to each start URL.

### Limits

- Reads HTML as your server sends it: content, links and tags that JavaScript adds after the page loads aren't seen.
- Only public web addresses on the standard ports (80 for http, 443 for https) are requested, for every redirect hop
  too. robots.txt is respected on every hop; a site whose robots.txt can't be read (server error) is treated as
  disallowing everything for the run, as the robots.txt standard says. A site that asks for more than 30 seconds
  between requests isn't crawled.
- No proxies and no login: pages behind a login or bot protection come back as they answer an automated request
  (usually 403).
- Pages are read up to 2 MB; a failed request is retried once; each page gets up to 60 seconds in total.
- A site that answers a link check with HTTP 429 (too many requests) is left alone for the rest of the run: its
  other links stay unchecked (`linksNotChecked`, and `linkHostsThrottled` in `RUN_STATS`). Large sites such as
  GitHub often do this when a page links to hundreds of their pages.
- `<a href>` links are followed and checked, and `<img src>` images are checked; scripts, stylesheets, `srcset` and
  CSS images aren't. See [Images](#images-what-is-checked-and-what-isnt) for what isn't measured.
- A page reached through a redirect whose target the crawl also reads at the same depth may be requested twice
  (once as each address); both rows are reported, since both addresses are linked.

### FAQ

#### Why is a page I can open in my browser reported with a 403?

Some sites or firewalls block automated requests. This crawler doesn't get around that; allow the
`HumbleEchidnaApify` User-Agent if you want it audited.

#### Why wasn't a page audited?

It may be deeper than the crawl depth, past the max pages, only linked by JavaScript, or disallowed by robots.txt
(then it's in the summary's `notRequested`, not charged).

#### Does it compare an audit with the previous one?

Yes, when you turn on **Compare with the previous audit**: each page says whether it's new, changed or unchanged,
with the fields and issues that changed and its score before and after, and the site summary counts issues added and
fixed, new and fixed broken links, pages added and removed, and the site score's change since the previous run of
the same audit. **Only return changed pages** returns
just the new and changed pages ($10.00 per 1,000) and charges each unchanged page it rechecked $1.00 per 1,000 (a
100-page site with nothing changed: $0.10 a run). The first run with it on is the baseline. See
[Changes since the last audit](#changes-since-the-last-audit).

#### How is the SEO score calculated?

100, minus 20 for each error, 8 for each warning and 2 for each notice found on the page (each issue code once), 0
for a page that doesn't load, never below 0; `slow-response` isn't counted. The site score is the average over every
page audited. The [full rules and the points per issue](#how-the-score-is-calculated) are above. It's our weighting
of common SEO checks, meant to show where to start, not a prediction of rankings.

#### Does it check images?

Yes: alt text (missing or empty), width and height attributes, image links without text, and, with one HEAD request
per distinct image, broken images and files over 200 KB by their Content-Length. It doesn't open the files, so pixel
size, format and compression aren't measured, and it reads `src` only, not `srcset` or CSS backgrounds. See
[Images](#images-what-is-checked-and-what-isnt).

#### Why are unchanged pages charged at all?

Because they're still crawled: finding out that a page didn't change takes the same requests and link checks as
auditing it, and that's what a monitoring run costs to run. They're charged a tenth of a returned page, and never
when the page refused the crawler or failed.

#### Why doesn't it list removed pages?

It only does when the crawl was complete. Raise **Max pages per run** above the number of pages on your site (and
check the maximum cost per run covers the pages that change); the summary's `incompleteReasons` says what cut the
crawl short.

#### Does it check rankings, backlinks or Core Web Vitals?

No. It reads your pages' HTML and headers only. `responseTimeMs` is the time to the response headers from Apify's
servers, not a browser speed score.

#### Something that used to work now fails. Why?

The run log and the `RUN_STATS` record name the start URL and what went wrong; every other page in the run is
unaffected. Please open an issue with the input you used.

#### Is it legal to run an SEO audit on a website?

It's built for auditing a site you own or manage. It requests only your start URLs, your sitemaps and the pages and
links they lead to, the way a search engine crawler would: no login, honouring robots.txt and Crawl-delay,
identified by its User-Agent. It reports what the pages say about SEO (titles, tags, links, status codes), not
personal data, and it uses no third-party SEO data. You're responsible for auditing only sites you're allowed to.

### Related actors

| Actor | Use it when |
|---|---|
| [Bulk URL & Broken Link Checker](https://apify.com/humble-echidna/url-checker) | Between full audits: check the sitemap daily and get only the URLs whose status, redirect or canonical changed ($1.00 per 1,000 URLs; unchanged URLs are free). |
| [Sitemap URL Extractor](https://apify.com/humble-echidna/sitemap-urls) | You want the full URL list of a site from its sitemaps, with `lastmod`, for planning a crawl or finding pages missing from the sitemap. |
| [Website & Page to Markdown for RAG and LLMs](https://apify.com/humble-echidna/page-to-markdown) | You want the pages' main content as Markdown, for content reviews. |

### Feedback and support

Found a bug, or need a check that isn't here? Open an issue on the **Issues** tab with the input you used.

### Versions

Current version: **1.2**. See the Changelog tab for what changed in each version.

# Changelog

This Actor's version history is a separate document: https://apify.com/humble-echidna/seo-audit/changelog.md

# Actor input Schema

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

Your site: one address per line, e.g. https://example.com/ (crawl) or example.com or https://example.com/sitemap.xml (sitemap). Pages on the same site (the same host, with or without www.) are audited; other sites' pages are only checked as links. Only public web addresses on the standard ports (80 and 443) are requested.

## `mode` (type: `string`):

`crawl` (default): start at your start URLs and follow same-site links, up to the crawl depth. `sitemap`: audit the pages your site's sitemaps list (robots.txt Sitemap: lines, else /sitemap.xml), then follow their links up to the crawl depth (0 = sitemap pages only). Both stop at `maxPages`.

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

Audit at most this many pages in total, 1 to 10,000 (default 10; you pay per page audited). Raise it to cover your whole site. The run also stops cleanly at the maximum cost per run you set in the run options, whichever comes first.

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

How many clicks away from the start pages (or sitemap pages) to follow same-site links, 0 to 20 (default 3). 0 audits only the start pages.

## `checkLinks` (type: `boolean`):

Check every link on the audited pages (each distinct URL once per run, HEAD first) and list the broken ones per page. Links to pages the crawl reads reuse that answer. Off: links aren't checked; only links to pages the crawl read get a status (images are a separate switch).

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

Also check links that point to other websites (checked, never crawled). Off: only links within your site.

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

Check every image on the audited pages (<img src>, each distinct address once per run, with a HEAD request) for broken images and files over 200 KB (from Content-Length), wherever they're hosted. Counts against Max link checks per run. Alt text and width/height attributes are read from the page either way. Off: no image requests.

## `maxLinkChecks` (type: `integer`):

At most this many links and images are checked per run, 0 to 50,000 (default 500; distinct URLs, not counting the pages audited). Links past it are counted in each page's linksNotChecked, images in imagesNotChecked. Link and image checks are free; this bounds how long a run takes.

## `compareWithPreviousAudit` (type: `boolean`):

Say what changed since the last run of this same audit (same start URLs, mode and settings; kept in your own account's key-value store "seo-audit-memory"): each page gets changeType new, changed or unchanged, with the fields and issues that changed, and the site summary counts issues added and fixed, new and fixed broken links, and pages added and removed. The first run with it on is the baseline. Changing the start URLs, mode or any setting above starts a new baseline.

## `onlyChangedPages` (type: `boolean`):

Turns on the comparison and returns only new and changed pages ($0.01 each, as usual). Unchanged pages are still crawled and audited (they count in the site summary) but not returned, and cost a tenth of a page ($0.001 each; never for a page that refused or failed). The first run returns every page (the baseline). A 100-page site where nothing changed costs $0.10 a run. The maximum cost per run limits the pages read.

## Actor input object example

```json
{
  "startUrls": [
    "https://crawlee.dev/"
  ],
  "mode": "crawl",
  "maxPages": 10,
  "maxDepth": 3,
  "checkLinks": true,
  "checkExternalLinks": true,
  "checkImages": true,
  "maxLinkChecks": 500,
  "compareWithPreviousAudit": false,
  "onlyChangedPages": false
}
```

# Actor output Schema

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

No description

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

No description

## `runStats` (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/"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("humble-echidna/seo-audit").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/"] }

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

```

## MCP server setup

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

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/g3OhVUAphR7hEQ251/builds/7yc7mdtSAYkeKhwUH/openapi.json
