# On-Page SEO Audit API: Titles, Meta, Headings, Schema (`itayozer9/seo-page-audit`) Actor

On-page SEO audit for a list of URLs or a capped crawl of a site: title and meta lengths, H1 to H3 outline, canonical, robots and noindex, hreflang, Open Graph, JSON-LD types, image alt text, links, redirects, word count, a 0 to 100 score and a fix list. Plain HTTP, obeys robots.txt.

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

## Pricing

from $3.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

## On-Page SEO Audit API: Titles, Meta, Headings, Schema

Send a list of page URLs, or one homepage plus a crawl limit, and get an on-page SEO audit for every page: title and meta description lengths, the H1 to H3 outline, canonical and noindex checks, hreflang, Open Graph, schema.org types from JSON-LD, image alt text, link counts, redirects, response time and word count. Each page gets a **0 to 100 score** and a **fix list** written in plain language.

It reads pages over plain HTTP, the way a search engine crawler does on its first pass. No headless browser and no proxy, so it is fast (a 100-page crawl of a docs site took under 4 seconds in our tests) and cheap: **$3 per 1,000 pages**. Error pages, pages blocked by robots.txt and duplicates cost nothing.

It obeys each site's `robots.txt` (RFC 9309) and never reads pages behind a login.

### What you get

One result per page:

- `score` (0 to 100) and `issues`: every problem found, each with a `code`, a `severity` (`error`, `warning` or `notice`), the `points` it takes off the score, and a `message` that says how to fix it
- `title`, `titleLength`, `metaDescription`, `metaDescriptionLength`
- `h1` (every H1 text), `h1Count`, and `headings`: the H1 to H3 outline in page order
- `canonical`, `canonicalIsSelf`, `metaRobots`, `xRobotsTag` (the response header) and `indexable` (false when noindex is set or the canonical points to another page)
- `language` (from `<html lang>`), `viewport`, `hreflang` alternates
- `openGraph` (all `og:` tags), `twitterCard`, `jsonLdTypes` (such as `Organization`, `Product`, `BreadcrumbList`, `FAQPage`, including types nested in `@graph`)
- `imagesTotal` and `imagesMissingAlt`, `linksInternal`, `linksExternal`, `linksNofollow`, `wordCount` of visible text
- `statusCode`, `redirectChain` (every hop), `finalUrl`, `responseMs` (time to first byte), `htmlBytes`, `contentType`
- `url`, `foundOn` (for crawled pages: the page that links to it), `status`, `error`, `fetchedAt`

### What the score checks

The score starts at 100 and loses the points of each issue found.

| Code | Severity | Points | Fires when |
|---|---|---|---|
| `title-missing` | error | 15 | No `<title>` |
| `title-too-long` / `title-too-short` | warning | 5 / 3 | Title over 60 or under 30 characters |
| `meta-description-missing` | error | 10 | No meta description |
| `meta-description-too-long` / `-too-short` | warning | 3 | Over 160 or under 70 characters |
| `h1-missing` | error | 10 | No H1 |
| `h1-multiple` | warning | 3 | More than one H1 |
| `heading-levels-skipped` | warning | 2 | For example an H3 straight after an H1 |
| `noindex` | error | 15 | noindex in the robots meta tag or X-Robots-Tag header |
| `canonical-missing` | warning | 3 | No canonical link |
| `canonical-elsewhere` | warning | 5 | The canonical points to another address |
| `viewport-missing` | error | 8 | No viewport meta tag (not mobile friendly) |
| `lang-missing` | warning | 3 | No `lang` on `<html>` |
| `not-https` | error | 10 | The page is served over plain HTTP |
| `images-missing-alt` | warning | 1 to 10 | Images without an alt attribute, scaled by their share |
| `needs-javascript` | error | 15 | Almost no content without JavaScript |
| `thin-content` | warning | 5 | Under 200 words of visible text |
| `redirect-chain` / `redirected` | warning / notice | 3 / 1 | Two or more redirects / one redirect before the page |
| `slow-response` | warning | 3 | Time to first byte over 1,800 ms |
| `json-ld-invalid` | warning | 5 | A JSON-LD block that is not valid JSON |
| `structured-data-missing` | notice | 2 | No JSON-LD at all |
| `open-graph-incomplete` | notice | 2 | og:title, og:description or og:image missing |

### Input

| Field | Required | Example | Notes |
|---|---|---|---|
| `urls` | yes | `["https://www.python.org/"]` | Full addresses, up to 10,000 per run. An address typed without `https://` is read as https |
| `maxCrawlPages` | no | `100` | 0 (default) audits only your URLs. A number makes it follow links to other pages on the same site and audit up to that many more pages per site, up to 5,000 |

Audit a list of pages:

```json
{
    "urls": ["https://www.python.org/", "https://www.gov.uk/"]
}
```

Audit a whole site, up to 500 pages:

```json
{
    "urls": ["https://www.example-shop.com/"],
    "maxCrawlPages": 499
}
```

While crawling it stays on the same site (`www.` and the bare domain count as one site), skips links marked `nofollow`, pages that are `nofollow` as a whole, files such as PDFs, images and archives, and anything robots.txt disallows. Links that lead to a page already audited in the run come back as free `duplicate` rows, and broken links come back as free `http-error` rows with `foundOn` telling you which page links to them.

### Output example

A real result for python.org, shortened:

```json
{
    "url": "https://www.python.org/",
    "finalUrl": "https://www.python.org/",
    "status": "ok",
    "statusCode": 200,
    "redirectChain": [],
    "responseMs": 11,
    "score": 88,
    "issues": [
        { "code": "title-too-short", "severity": "warning", "points": 3, "message": "The title is 21 characters. Use 30 to 60 characters to describe the page." },
        { "code": "meta-description-too-short", "severity": "warning", "points": 3, "message": "The meta description is 52 characters. Use 70 to 160 to describe the page." },
        { "code": "h1-multiple", "severity": "warning", "points": 3, "message": "The page has 5 H1 headings. Keep one H1 and use H2 for sections." },
        { "code": "canonical-missing", "severity": "warning", "points": 3, "message": "No canonical link. Add <link rel=\"canonical\" href=\"...\"> with the preferred address of this page." }
    ],
    "title": "Welcome to Python.org",
    "titleLength": 21,
    "metaDescription": "The official home of the Python Programming Language",
    "metaDescriptionLength": 52,
    "h1": ["Functions Defined", "All the Flow You’d Expect", "Compound Data Types", "Intuitive Interpretation", "Quick & Easy to Learn"],
    "h1Count": 5,
    "headings": [{ "level": 1, "text": "Functions Defined" }, { "level": 1, "text": "All the Flow You’d Expect" }],
    "canonical": null,
    "canonicalIsSelf": null,
    "metaRobots": null,
    "indexable": true,
    "language": "en",
    "viewport": "width=device-width, initial-scale=1.0",
    "hreflang": [],
    "openGraph": { "type": "website", "title": "Welcome to Python.org", "image": "https://www.python.org/static/opengraph-icon-200x200.png", "url": "https://www.python.org/" },
    "jsonLdTypes": ["WebSite", "SearchAction"],
    "imagesTotal": 1,
    "imagesMissingAlt": 0,
    "linksInternal": 126,
    "linksExternal": 79,
    "linksNofollow": 0,
    "wordCount": 1066,
    "fetchedAt": "2026-10-31T17:00:25.605Z"
}
```

Every URL gets a row, so you always know what happened:

| `status` | Meaning | Charged |
|---|---|---|
| `ok` | HTML page audited | yes |
| `http-error` | The page answered 4xx or 5xx (for example 404 or 403) | no |
| `not-html` | A file (PDF, image, archive), not a web page | no |
| `blocked-by-robots` | The site's robots.txt disallows it, or robots.txt had a server error | no |
| `too-many-redirects` | More than 5 redirects | no |
| `duplicate` | Crawl only: leads to a page already audited in this run | no |
| `fetch-failed` | No answer (timeout, unknown host) | no |
| `invalid-url` | Not a public http or https address | no |

### Pricing

Pay per event:

| Event | Price | When |
|---|---|---|
| Page audited | $0.003 | Every result with status `ok` |
| Actor start | $0.00005 | Once per run |

Example: a 500-page site crawl where 470 pages are audited and 30 are broken links costs 470 x $0.003 + $0.00005 = $1.41005.

The actor checks your run's maximum charge before it fetches anything and stops at what that covers, so a run never goes over the limit you set.

### Limits

- Pages are read without running JavaScript, like a crawler's first pass. Pages that only show content after JavaScript runs get the `needs-javascript` issue. There are no Core Web Vitals or Lighthouse scores.
- Sites that block bots (a 403 page or a challenge wall) return `http-error`. There is no proxy rotation or unblocking.
- It does not check whether outgoing links work, except the same-site links it visits while crawling.
- HTML is read up to 4 MB per page. Up to 5 pages are fetched at a time. When a site answers 429 or 503 (too busy), it waits up to 10 seconds (following `Retry-After`) and tries once more.
- The score is a quick on-page health check, not a ranking prediction. Thresholds follow common SEO practice (titles up to about 60 characters, descriptions up to about 160).

### FAQ

**Can it audit a whole website?**
Yes. Put the homepage in `urls` and set `maxCrawlPages`. It follows links between pages of the same site, breadth first, and stops at your limit or your maximum charge, whichever comes first.

**Does it respect robots.txt?**
Always, and it cannot be turned off. It identifies itself as `SeoPageAuditBot` with a link to this page, reads each site's robots.txt once per run and follows RFC 9309. Pages it may not read come back as free `blocked-by-robots` rows.

**Why did my own site come back with `http-error` 403?**
Your firewall or CDN blocks automated requests. Allow the `SeoPageAuditBot` user agent, or audit a staging copy. Nothing is charged for blocked pages.

**Can I call it from an AI agent, MCP client, Make, Zapier or the API?**
Yes. The input is a list of URLs, every output field has a fixed type, and the fix list is plain text an agent can act on. It runs with limited permissions, so it cannot touch your other Apify data.

**Is there personal data in the results?**
The results describe page structure (titles, headings, tags, counts). The actor targets no site by default and adds nothing of its own. You need the right to audit the pages you send.

**Something looks wrong in an audit. What do I do?**
Open an issue on the Issues tab with the run link and the URL.

# Actor input Schema

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

Pages to audit, one full address per line (https://...). Up to 10,000 per run. You pay only for pages that answer with HTML and get audited; error pages, redirect loops and pages blocked by robots.txt are free.

## `maxCrawlPages` (type: `integer`):

0 audits only the URLs above. Set a number to also follow links from those pages to other pages on the same site and audit up to that many more pages per site (robots.txt and nofollow are respected). Up to 5,000.

## Actor input object example

```json
{
  "urls": [
    "https://www.python.org/",
    "https://www.gov.uk/"
  ],
  "maxCrawlPages": 0
}
```

# Actor output Schema

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

One item per page: SEO score, fix list, title, meta description, headings, canonical, indexability, hreflang, Open Graph, JSON-LD types, images, links, status.

# 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://www.python.org/",
        "https://www.gov.uk/"
    ],
    "maxCrawlPages": 0
};

// Run the Actor and wait for it to finish
const run = await client.actor("itayozer9/seo-page-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 = {
    "urls": [
        "https://www.python.org/",
        "https://www.gov.uk/",
    ],
    "maxCrawlPages": 0,
}

# Run the Actor and wait for it to finish
run = client.actor("itayozer9/seo-page-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 '{
  "urls": [
    "https://www.python.org/",
    "https://www.gov.uk/"
  ],
  "maxCrawlPages": 0
}' |
apify call itayozer9/seo-page-audit --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,itayozer9/seo-page-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/z5GSpEi0sJZbat7W9/builds/vFPuup1S683Ukc3w5/openapi.json
