# On-Page SEO Audit Tool: SEO Checker & Crawler (`cyber1nj3ct0r/on-page-seo-audit`) Actor

SEO audit tool: crawls a website, or checks a list of pages, and audits the on-page SEO of each page: title, meta description, H1 and headings, canonical, noindex, image alt text, structured data, status codes and redirects. A 0-100 SEO score per page and a site report of duplicate titles.

- **URL**: https://apify.com/cyber1nj3ct0r/on-page-seo-audit.md
- **Developed by:** [Sanira Botheju](https://apify.com/cyber1nj3ct0r) (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 $7.00 / 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

### What does On-Page SEO Audit do?

On-Page SEO Audit **checks the SEO basics of every page of a website** and gives each page a score from 0 to 100. You get one result per page in a table you can download as JSON, CSV or Excel or read through the API.

Enter a website. The tool starts at its address, follows the links to the other pages of the same site, and for each page reports the title, meta description, headings, canonical address, noindex, images without alt text, structured data, links and more. Every problem it finds is listed on the page with a code and a sentence that says what is wrong.

- **Crawls the site for you**: follows links on the same site, up to the number of pages you set. Or turn crawling off and give it a list of pages.
- **One result per page**: about 50 facts, a list of issues and a score. Sort by score to see the worst pages first.
- **A report for the whole site**: duplicate titles, duplicate descriptions, pages with noindex, pages that returned errors, redirects, and pages the site's `robots.txt` does not allow.
- **Follows the rules of the site**: reads `robots.txt` and does not fetch what it forbids, waits for a `Crawl-delay`, and sends no more than three requests at a time to one host.
- **Reads the HTML the server sends.** It does not run JavaScript, so a page that builds its content in the browser shows little text here. See the FAQ.

### What data do you get?

| Field | Example | Meaning |
|---|---|---|
| `url`, `finalUrl` | `https://crawlee.dev/js` | The address checked, and where it ended up after redirects |
| `httpStatus` | `200` | HTTP status of the final address |
| `redirectCount`, `redirectChain` | `1` | How many redirects were followed, and the addresses that redirected |
| `responseTimeMs`, `sizeBytes` | `51`, `62516` | Time to download the page (from the Apify server) and the size of its HTML |
| `title`, `titleLength` | `Crawlee for JavaScript` | The page title and its length in characters |
| `metaDescription`, `metaDescriptionLength` | `Crawlee helps you...` | The meta description and its length |
| `h1Count`, `h1Texts`, `h2Count` to `h6Count` | `1` | Headings that have text |
| `canonicalUrl`, `canonicalStatus` | `self` | `missing`, `self`, `elsewhere` or `invalid` |
| `metaRobots`, `xRobotsTag`, `noindex`, `nofollow` | `false` | What the robots meta tag and the X-Robots-Tag header say |
| `indexable` | `true` | Loaded, no noindex, canonical not pointing to another page |
| `lang`, `hreflangCount`, `hasViewport`, `charset` | `en`, `2`, `true`, `utf-8` | Language and device settings |
| `hasOgTitle`, `hasOgDescription`, `hasOgImage`, `hasTwitterCard` | `true` | Social sharing tags |
| `structuredDataTypes` | `["Organization"]` | The `@type` values of the JSON-LD on the page |
| `wordCount` | `554` | Words of visible text in the main content |
| `imageCount`, `imagesMissingAlt` | `30`, `0` | Images, and images with no alt attribute |
| `internalLinkCount`, `externalLinkCount`, `nofollowLinkCount` | `28`, `11`, `0` | Links by kind |
| `mixedContentCount` | `0` | Files loaded over http on an https page |
| `score`, `errorCount`, `warningCount`, `noticeCount` | `100`, `0`, `0`, `0` | The score and the number of issues of each severity |
| `issues` | `[{ "code": "TITLE_TOO_LONG", ... }]` | Each issue: `code`, `severity` (`error`, `warning` or `notice`) and `message` |
| `depth`, `foundOn` | `1`, `https://crawlee.dev/` | How many clicks from your start address, and the page that linked here |
| `error` | `null` | Empty when the page was fetched, otherwise a code and a message |

### How to audit a website

1. Open the tool and enter the website under **Websites or pages**, for example `https://crawlee.dev`.
2. Set **Maximum pages per site**. The default is 100. The run stops there, so this is also the most you can be charged for that site.
3. Press **Start**.
4. Open the **Output** tab. The **Pages** view shows the score and the main facts of each page, the **Issues** view shows what was found. The site report is in the key-value store of the run, under `SITE_ISSUES`.

To check only certain pages, turn off **Crawl the site** and list the pages. To run an audit every week, use the **Schedule** option in Apify Console.

### How much does it cost to audit a website?

You pay for results, not for time: **$10 per 1,000 pages** checked ($0.01 a page). Apify subscribers on the Silver plan pay $8.50 and on the Gold plan $7.00 per 1,000 pages. Each run also has a start fee of $0.00005.

| Pages checked | Cost |
|---|---|
| 10 | $0.10 |
| 100 | $1.00 |
| 1,000 | $10.00 |
| 10,000 | $100.00 |

Apify's free plan includes $5 of credit each month, which covers about 500 pages.

Every result in the table is charged, also a page that answered 404 or could not be fetched, because finding those is part of the audit. So is the result for an entry that is not a web address, and for an address you entered that `robots.txt` forbids. A page that is reached under two addresses (a redirect and its target) is one result. Links to files that are not web pages, and links the site's `robots.txt` forbids, are not saved and cost nothing. Set **Maximum pages per site**, or a maximum cost for the run in Apify Console, and the tool stops there and keeps what it has.

### How the score works

Each page starts at 100 points. For every issue found, points come off: **20 for an error, 8 for a warning, 3 for a notice**. The score does not go below 0. A page that could not be loaded (a fetch failure, or a final HTTP status that is not 2xx) scores 0. No score (`null`) is given where no page was read: an entry that is not a web address, a file that is not HTML, or an address `robots.txt` forbids.

| Issue | Severity | When |
|---|---|---|
| `HTTP_ERROR` | error | The final HTTP status is not 2xx |
| `FETCH_FAILED` | error | No answer: DNS error, refused connection, timeout, certificate error, more than 10 redirects |
| `TITLE_MISSING` | error | No title |
| `TITLE_TOO_LONG` / `TITLE_TOO_SHORT` | warning / notice | More than 60 / fewer than 30 characters |
| `DESCRIPTION_MISSING` | warning | No meta description |
| `DESCRIPTION_TOO_LONG` / `DESCRIPTION_TOO_SHORT` | warning / notice | More than 160 / fewer than 70 characters |
| `H1_MISSING` / `H1_MULTIPLE` | warning | No H1 with text / more than one |
| `NOINDEX` | warning | Meta robots or `X-Robots-Tag` says noindex |
| `NOFOLLOW` | notice | Meta robots or `X-Robots-Tag` says nofollow |
| `CANONICAL_MISSING` | notice | No canonical address |
| `CANONICAL_ELSEWHERE` / `CANONICAL_INVALID` | warning | The canonical names another page / cannot be used as an address |
| `LANG_MISSING` | notice | The html tag has no `lang` |
| `NO_VIEWPORT` | warning | No viewport meta tag |
| `IMAGES_MISSING_ALT` | warning | An `img` has no alt attribute (an empty alt is fine) |
| `THIN_CONTENT` | warning | Fewer than 200 words in the main content |
| `MIXED_CONTENT` | warning | An https page loads images, scripts, styles, media or frames over http |
| `JSONLD_INVALID` | warning | A JSON-LD block is not valid JSON |
| `OG_MISSING` / `OG_INCOMPLETE` | notice | No Open Graph tags at all / some of title, description and image missing |
| `REDIRECTED` | notice | The address redirects |
| `REDIRECT_CHAIN` | warning | Two or more redirects in a row |
| `REDIRECTED_OFFSITE` | warning | The address redirects to a different site (only that page is checked) |
| `SLOW_RESPONSE` | notice | The download took more than 3,000 ms |
| `PAGE_TRUNCATED` | notice | The page is over 3 MB; only the start was checked |
| `NOT_HTML` | warning | An address you entered returns something that is not an HTML page |
| `ROBOTS_BLOCKED` / `ROBOTS_UNREACHABLE` | warning | `robots.txt` forbids the page / could not be read, so the page was not fetched |

The limits (60, 160, 200 words and so on) are common rules of thumb, not rules of any search engine. Use the score to sort pages, not as a verdict.

### Input

The only required field is **Websites or pages**.

```json
{
    "startUrls": [{ "url": "https://crawlee.dev" }],
    "crawlSite": true,
    "maxPagesPerSite": 100,
    "includePatterns": ["/docs/"],
    "excludePatterns": ["*/api/*"],
    "requestTimeoutSecs": 30
}
```

| Field | What it does |
|---|---|
| `startUrls` | The websites to crawl, or the pages to check when `crawlSite` is off. Several sites in one run are fine. |
| `crawlSite` | On (default): follow links to other pages of the same site. Off: check only the addresses you listed. |
| `maxPagesPerSite` | The most pages checked on each site (default 100, up to 100,000). Your start addresses count. |
| `includePatterns` | Check a page found by the crawl only if its address matches one of these. |
| `excludePatterns` | Skip a page if its address matches one of these. |
| `requestTimeoutSecs` | How long to wait for one page (default 30). |
| `userAgent` | The name sent with each request. Leave empty to use the tool's own name. |

Patterns are plain text that may appear anywhere in the address, with `*` for any characters. For a regular expression, wrap it in slashes, for example `/\/blog\/\d{4}\//`. Patterns filter the pages the crawl finds. The addresses you enter yourself are always checked.

**What counts as the same site:** the same host, with or without a leading `www.`, over http or https. Links to other hosts, such as a `blog.` subdomain, are counted as external links and not followed. The crawl does not follow links marked `rel="nofollow"`, or the links on a page whose robots meta tag or header says nofollow, and it skips addresses that end in a file type such as `.pdf`, `.jpg` or `.zip`.

### Output example

One page of crawlee.dev, checked with **Crawl the site** off:

```json
{
    "url": "https://crawlee.dev/blog",
    "finalUrl": "https://crawlee.dev/blog",
    "site": "https://crawlee.dev",
    "depth": 0,
    "foundOn": null,
    "httpStatus": 200,
    "redirectCount": 0,
    "redirectChain": [],
    "responseTimeMs": 957,
    "sizeBytes": 66715,
    "contentType": "text/html",
    "title": "Crawlee Blog - learn how to build better scrapers | Crawlee for JavaScript · Build reliable crawlers. Fast.",
    "titleLength": 107,
    "metaDescription": "Guides and tutorials on using Crawlee, the most reliable open-source web scraping and browser automation library for JavaScript and Node.js developers.",
    "metaDescriptionLength": 151,
    "h1Count": 0,
    "h1Texts": [],
    "h2Count": 12,
    "h3Count": 3,
    "h4Count": 0,
    "h5Count": 0,
    "h6Count": 0,
    "canonicalUrl": "https://crawlee.dev/blog",
    "canonicalStatus": "self",
    "metaRobots": null,
    "xRobotsTag": null,
    "noindex": false,
    "nofollow": false,
    "indexable": true,
    "lang": "en",
    "hreflangCount": 2,
    "hasViewport": true,
    "charset": "utf-8",
    "hasOgTitle": true,
    "hasOgDescription": true,
    "hasOgImage": true,
    "hasTwitterCard": true,
    "structuredDataTypes": [
        "Blog"
    ],
    "structuredDataErrors": 0,
    "wordCount": 1340,
    "imageCount": 28,
    "imagesMissingAlt": 0,
    "internalLinkCount": 88,
    "externalLinkCount": 49,
    "nofollowLinkCount": 0,
    "mixedContentCount": 0,
    "score": 84,
    "errorCount": 0,
    "warningCount": 2,
    "noticeCount": 0,
    "issues": [
        {
            "code": "TITLE_TOO_LONG",
            "severity": "warning",
            "message": "The title is 107 characters long. Search results usually cut titles after about 60."
        },
        {
            "code": "H1_MISSING",
            "severity": "warning",
            "message": "The page has no H1 heading."
        }
    ],
    "error": null
}
```

### The site report

Findings that need all pages are saved in the key-value store of the run under `SITE_ISSUES`, as JSON. For each site it holds:

- `duplicateTitles` and `duplicateDescriptions`: groups of pages with the same text (ignoring case), with the count and up to 25 addresses
- `noindexPages`: the pages that say noindex, and whether the meta tag or the header says it
- `errorPages`: pages that answered with an error status or could not be fetched, and the page that linked to them
- `redirects`: addresses that redirect, with the address they lead to
- `blockedByRobots`: addresses the site's `robots.txt` does not allow, which the tool did not fetch
- `lowestScoringPages`, `issueCounts`, `averageScore` and `counts` with the true totals

Each list holds at most 1,000 entries. The `SUMMARY` record of the run has the average score, the number of pages and how often each issue was found.

### What can you use it for?

- **SEO audits for clients or your own site**: a first pass over every page in minutes, sorted by score.
- **Before and after a redesign or migration**: run it on the old and the new site and compare titles, canonicals, noindex and redirects.
- **Finding the pages that need work**: missing titles, duplicate descriptions, thin pages, images without alt text.
- **Regular checks**: put the tool on a schedule and watch for a noindex that slipped into production or a page that started to return errors.
- **Feeding a report or an AI pipeline**: the results are flat JSON with stable field names.

### Use it from the API

```bash
curl -X POST "https://api.apify.com/v2/acts/cyber1nj3ct0r~on-page-seo-audit/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "startUrls": [{ "url": "https://crawlee.dev" }], "maxPagesPerSite": 50 }'
```

To read the site report, start the run with the normal run endpoint, then fetch `https://api.apify.com/v2/key-value-stores/<defaultKeyValueStoreId>/records/SITE_ISSUES` with the store id from the run. The tool also works as a tool for AI agents through the [Apify MCP server](https://docs.apify.com/mcp).

### FAQ

#### Does it run JavaScript?

No. It reads the HTML the server sends, as a search engine's first fetch does. A page that builds its title, headings or text in the browser with JavaScript shows little or nothing here, and a site made only of such pages will give you a thin result. Links that appear only after JavaScript runs are not found either, so use the list mode for those pages.

#### How fast is it, and how many pages can it check?

About two pages a second on one site, because the tool spaces out its requests (three at a time at most, 300 ms between starts). A site of 1,000 pages takes five to ten minutes, more if the site answers slowly. Several sites in one run are checked at the same time. If the site's `robots.txt` asks for a `Crawl-delay`, the tool waits for it, up to 30 seconds between requests. The run has a time limit of one hour by default; when it is reached, the tool stops and keeps what it has.

#### Why do I not see duplicate titles in the page results?

Duplicates can only be found once all pages are known, so they are in the site report (`SITE_ISSUES`), not in the single results.

#### A page appears once although I entered two addresses for it

A page that is reached under two addresses, for example `/old` that redirects to `/new` and `/new` itself, is checked and charged once. The redirect is listed in the `redirects` of the site report.

#### A site blocks the tool. What can I do?

The tool identifies itself honestly in its User-Agent and does not try to get round blocks, so it will not pass a firewall, a captcha or a login. If it is your own site, allow the User-Agent in your firewall, or set your own under **More options**. If the site's `robots.txt` forbids the tool, those pages are not fetched; they are listed under `blockedByRobots`. When ten pages in a row fail or are refused (HTTP 403, 429, a server error, or no answer), the tool gives that site up, keeps the results so far and says why under `stopped` in the summary and the site report. You are charged only for the results that were saved.

#### Is it allowed?

The tool fetches public pages the way a search engine's crawler does, obeys `robots.txt`, limits its speed and never logs in. You are responsible for how you use the results, and for having the right to audit a site at the speed you choose. Use it on your own sites and on sites you have permission to test.

#### Something does not work

Open an issue on the **Issues** tab with the website you tried and what you expected. Every report is read and answered.

# Actor input Schema

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

Add the address of a website (for example <code>https://crawlee.dev</code>) or of the pages you want checked. With <b>Crawl the site</b> on, the tool starts at each address and follows links to other pages on the same site. With it off, only the pages you list are checked.

## `crawlSite` (type: `boolean`):

On: follow the links on each page to find more pages of the same site, up to the maximum below. Off: check only the addresses you listed above and follow no links.

## `maxPagesPerSite` (type: `integer`):

Stop after this many pages on each site. You pay per page checked, so this also caps the cost of the run. The start addresses count too.

## `includePatterns` (type: `array`):

Check a page that the crawl finds only if its address matches at least one of these. Plain text matches anywhere in the address and <code>*</code> stands for any characters, for example <code>/blog/</code> or <code>*/products/\*</code>. For a regular expression, wrap it in slashes: <code>//p/\d+$/</code>. The addresses you list above are always checked.

## `excludePatterns` (type: `array`):

Leave out a page if its address matches any of these. Same pattern rules as above, for example <code>/tag/</code> or <code>*?sort=*</code>.

## `requestTimeoutSecs` (type: `integer`):

How long to wait for one page before giving up and reporting it as failed.

## `userAgent` (type: `string`):

The name the tool sends with each request. Leave empty to use the tool's own name, which tells site owners what is visiting. Set your own only for sites you run or have permission to test. The site's <code>robots.txt</code> is checked for the tool's name and for yours.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://crawlee.dev"
    }
  ],
  "crawlSite": true,
  "maxPagesPerSite": 10,
  "requestTimeoutSecs": 30
}
```

# Actor output Schema

## `pages` (type: `string`):

One record per page checked, with its SEO facts, its issues and its score.

## `siteIssues` (type: `string`):

Findings that need all pages: duplicate titles and descriptions, pages with noindex, pages with errors, redirects, and pages the site's robots.txt does not allow.

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

Counts for the run: pages checked, average score per site, how often each issue was found.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "startUrls": [
        {
            "url": "https://crawlee.dev"
        }
    ],
    "maxPagesPerSite": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("cyber1nj3ct0r/on-page-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": [{ "url": "https://crawlee.dev" }],
    "maxPagesPerSite": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("cyber1nj3ct0r/on-page-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": [
    {
      "url": "https://crawlee.dev"
    }
  ],
  "maxPagesPerSite": 10
}' |
apify call cyber1nj3ct0r/on-page-seo-audit --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,cyber1nj3ct0r/on-page-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/1SjGvPEZsxwChh4TN/builds/eUC06uAFdPqf0ugNH/openapi.json
