# SEO Page Audit: On-Page Checker, Meta, Headings, Schema (`charlesmarkette/seo-page-audit-on-page-checker-meta-headings-schema`) Actor

Audit pages for on-page SEO: title and meta description length and duplicates, canonical, noindex, headings, alt text, broken images, Open Graph, structured data and mixed content, with an issue list and a 0-100 score. Crawl a site, a sitemap or a URL list.

- **URL**: https://apify.com/charlesmarkette/seo-page-audit-on-page-checker-meta-headings-schema.md
- **Developed by:** [Charles Markette](https://apify.com/charlesmarkette) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $7.00 / 1,000 audited 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 Page Audit** checks the on-page SEO of any list of URLs, the pages in a sitemap, or a small crawl of one site. Each page becomes one row with its **title, meta description, canonical, robots / noindex, hreflang, headings, word count, image alt text, broken images and links, Open Graph and Twitter cards, JSON-LD and microdata types, mixed content**, a list of **issues** (each an error, warning or notice with a plain-English message) and a **score from 0 to 100**. Duplicate titles and meta descriptions across the run are flagged on every page that shares them.

### Why this Actor

- **One row per page, ready for a spreadsheet.** Every check is a column (`title`, `titleLength`, `canonicalStatus`, `h1Count`, `wordCount`…), and the `issues` list says what to fix, so you can sort by `score` and start at the worst pages.
- **Indexability in one field.** `indexable` and `indexabilityReason` combine the HTTP status, `noindex` in meta robots or the `X-Robots-Tag` header, a canonical pointing elsewhere, and whether robots.txt allows the page for Googlebot.
- **Duplicates across the run.** Pages with the same title or meta description (ignoring case and spacing) are all flagged, not only the later ones, and the run summary lists each group with its URLs.
- **Images and links checked.** Up to 30 images per page are checked for errors and for files over 500 KB; with *Check internal links* on, up to 50 same-site links per page are checked for 4xx/5xx. Each image or link is requested once per run, however many pages use it.
- **You pay only for real pages.** A row is charged only when the page answered 2xx with HTML. 404s, redirects to errors, PDFs, timeouts, blocked addresses, pages blocked by robots.txt and invalid URLs get a free row that says why.
- **Polite and safe.** robots.txt is respected by default (for every URL and every redirect hop), at most 2 requests per site at a time, and a User-Agent that names the tool (`SEOPageAudit/1.0; +https://apify.com/charlesmarkette`). Requests to private, loopback, link-local and cloud-metadata addresses are refused, on the first request and on every redirect.

### Use cases

- **Pre-launch checks:** audit a staging or new site's pages for missing titles, descriptions, H1s, canonicals and `noindex` left on by mistake.
- **Regular site health:** run weekly on your sitemap and compare scores and issue counts over time.
- **Migrations:** check that old URLs redirect in one hop and that new pages are indexable with self-canonicals.
- **Client and competitor snapshots:** audit a few dozen key pages of any site in minutes.
- **Content teams:** find thin pages (`wordCount`), duplicate titles and descriptions, and images without alt text.

### How to use it

1. Paste page URLs under **Start URLs**, or add a sitemap under **Sitemap URLs**, or pick a dataset of URLs under **Dataset to audit**.
2. Optional: turn on **Crawl the site** to follow links on the same site, up to **Max pages** (default 50, at most 5,000) and **Max crawl depth** (default 2).
3. Run it, then open the **Overview** or **Issues** view of the dataset, or download it as CSV, Excel or JSON.

Input example:

```json
{
  "startUrls": ["https://www.python.org/", "https://crawlee.dev/"],
  "crawl": true,
  "maxPages": 100,
  "maxDepth": 2,
  "checkImages": true,
  "checkLinks": false
}
```

Output example (one row, shortened):

```json
{
  "url": "https://example.com/",
  "finalUrl": "https://example.com/",
  "statusCode": 200,
  "result": "audited",
  "score": 88,
  "issueCount": 4,
  "indexable": true,
  "indexabilityReason": null,
  "title": "Example Store: Handmade Ceramics and Tableware",
  "titleLength": 46,
  "metaDescription": "Handmade mugs, plates and bowls from our studio in Lisbon. Free shipping in the EU on orders over 50 euros.",
  "metaDescriptionLength": 107,
  "canonical": "https://example.com/",
  "canonicalStatus": "self",
  "metaRobots": null,
  "xRobotsTag": null,
  "robotsTxtAllowed": true,
  "h1": "Handmade ceramics",
  "h1Count": 1,
  "headings": {"h1": 1, "h2": 5, "h3": 8, "h4": 0, "h5": 0, "h6": 0, "h1Texts": ["Handmade ceramics"], "skippedLevels": []},
  "wordCount": 642,
  "lang": "en",
  "hreflang": [{"hreflang": "en", "url": "https://example.com/"}, {"hreflang": "pt", "url": "https://example.com/pt/"}],
  "images": {"count": 24, "missingAlt": 3, "emptyAlt": 2, "missingAltImages": ["https://example.com/img/hero.jpg"], "checked": 24, "broken": [], "large": [{"url": "https://example.com/img/hero.jpg", "bytes": 812345}]},
  "links": {"internal": 38, "external": 6, "nofollow": 1, "checked": 0, "broken": []},
  "openGraph": {"title": "Example Store", "description": "Handmade ceramics", "image": "https://example.com/og.jpg", "type": "website"},
  "twitterCard": {"card": "summary_large_image"},
  "structuredDataTypes": ["Organization", "WebSite"],
  "mixedContent": [],
  "redirectChain": [],
  "responseTimeMs": 412,
  "pageBytes": 58210,
  "issues": [
    {"code": "images-missing-alt", "severity": "warning", "message": "3 image(s) have no alt attribute."},
    {"code": "images-large", "severity": "warning", "message": "1 image(s) are over 500 KB."},
    {"code": "images-empty-alt", "severity": "notice", "message": "2 image(s) have an empty alt (fine for decorative images only)."},
    {"code": "favicon-missing", "severity": "notice", "message": "No favicon is linked (<link rel=\"icon\">); browsers will try /favicon.ico."}
  ],
  "error": null,
  "auditedAt": "2026-10-21T12:00:00Z"
}
```

Other columns: `viewport`, `charset`, `favicon`, `jsonLdErrors`, `microdataItemCount`, `iframeCount`, `deprecatedTags`, `duplicateTitle`, `duplicateMetaDescription`, `contentType`, `depth`, `source` (`startUrls`, `sitemap`, `crawl` or `dataset`). `result` is `audited`, `http-error`, `not-html`, `robots-blocked`, `failed` or `invalid-url`. The run summary (key-value store record `OUTPUT`) has counts per result, the average score, how many pages have each issue, and the duplicate title and description groups.

### What is checked

All checks read the page's static HTML and response headers (no JavaScript is run). Each finding is an issue with a `code`, a `severity` and a `message`:

| Severity | Issue codes |
|---|---|
| **error** | `robots-txt-disallowed`, `canonical-multiple`, `canonical-invalid`, `title-missing`, `h1-missing`, `images-broken`, `links-broken` |
| **warning** | `redirect-chain`, `insecure-http`, `meta-refresh`, `noindex`, `title-too-long`, `title-too-short`, `title-multiple`, `title-duplicate`, `meta-description-missing`, `meta-description-multiple`, `meta-description-duplicate`, `h1-empty`, `lang-missing`, `hreflang-invalid`, `hreflang-relative`, `hreflang-conflict`, `viewport-missing`, `mixed-content`, `images-missing-alt`, `images-large`, `structured-data-invalid` |
| **notice** | `redirect`, `slow-response`, `large-html`, `nofollow`, `canonical-missing`, `canonical-other`, `canonical-duplicate`, `canonical-relative`, `meta-description-too-long`, `meta-description-too-short`, `h1-multiple`, `heading-levels-skipped`, `low-word-count`, `lang-invalid`, `hreflang-no-self`, `charset-missing`, `favicon-missing`, `deprecated-tags`, `images-empty-alt`, `no-internal-links`, `open-graph-missing`, `twitter-card-missing`, `structured-data-missing` |

Thresholds: title 30–60 characters, meta description 70–160 characters, at least 200 words of visible text, response over 3,000 ms, HTML over 1 MB, images over 500 KB. hreflang codes must look like `en`, `en-GB`, `zh-Hant`, `es-419` or `x-default`.

**Score:** every page starts at 100 and loses **10 points per error, 5 per warning and 1 per notice**. Each issue code counts once per page (20 images without alt text is one warning, not 20), and the score never goes below 0. Rows that weren't audited (HTTP errors, non-HTML, blocked, failed) have `score: null` and one issue saying why.

**Duplicates:** rows are kept in memory and written in blocks of up to 5,000 pages, so in runs of up to 5,000 pages every page of a duplicate group is flagged. In bigger runs (a large dataset or URL list), a page is compared with every page audited before it and in its own block, but rows already written in an earlier block aren't updated; the run summary still lists every group. Pages are told apart by their final URL, so two URLs that redirect to the same page aren't duplicates of each other.

### How much does it cost?

Pay per event: **$0.01 per page audited**, plus Apify's **$0.00005 run start**. Apify platform usage is included.

- A **page audited** is one output row with `result: "audited"`: a URL that answered 2xx (after following redirects) with an HTML page.
- **Free:** rows for HTTP errors (404, 403, 500…), non-HTML files (PDFs, images, JSON…), pages blocked by robots.txt, timeouts, DNS errors, blocked private addresses and invalid URLs. Image and link checks cost nothing extra. The $0.00005 run start applies to every run, even one where no page is audited.

**Apify Store discounts:** the price above is for Apify's Free plan. Paid plans pay less per page audited: $0.009 on Starter, $0.008 on Scale, $0.007 on Business. The "from" price at the top of the Store page is the Business price.

**Worked examples:**

*Examples use the Free-plan rate. Where an example doesn't add it, each run also has its run-start charge, counted per GB of Actor memory (minimum one; one at the default 512 MB).*

- The default input (2 home pages): 2 × $0.01 + $0.00005 = **$0.02005**.
- A crawl with *Max pages* 50 where 46 pages are HTML and 4 links return 404: 46 × $0.01 + $0.00005 = **$0.46005**. The 4 error rows are free.
- A sitemap of 1,000 pages, all live: 1,000 × $0.01 = **$10.00** (+ run start).
- A weekly audit of the same 200 pages: 200 × $0.01 + $0.00005 = $2.00005 per run, about **$8.00 per month** (4 runs).

If you set a maximum cost per run, the Actor stops starting new pages when the next paid page wouldn't fit, and the summary says `"stoppedBy": "max cost"`. No audited row is stored that wasn't paid for, and nothing is charged that wasn't stored; if a block of buffered rows doesn't fully fit at write time, the rest isn't written and the summary's `notOutput` says how many. If a run is restarted (for example after a migration or a timeout), URLs already in its dataset are skipped, so nothing is charged twice.

### FAQ and limits

#### Does it run JavaScript?

No. It reads the HTML the server sends, as a search engine's first pass does. Titles, meta tags, links or structured data that only appear after JavaScript runs (many single-page apps) aren't seen, and those pages may show as missing a title or H1.

#### Does it measure page speed or Core Web Vitals?

No. `responseTimeMs` is only the time to download the HTML from Apify's servers. For LCP, CLS, INP and Lighthouse scores, use our [PageSpeed & Lighthouse Audit](https://apify.com/charlesmarkette/pagespeed-lighthouse-audit-core-web-vitals).

#### Does it give backlinks, rankings or keyword data?

No. It audits what is on the page. It has no backlink, search volume or ranking data.

#### Does it use proxies or get past bot protection?

No. Requests come from Apify's servers without proxies. Sites that block automated access (Cloudflare challenges, HTTP 403 or 429) get a free `http-error` row.

#### How does the crawl work?

With *Crawl the site* on, links on each audited page are followed if they stay on the same site as a start URL or sitemap page (`www.` and the bare domain count as one site), skipping links to files such as PDFs and images. It stops adding pages at *Max pages* (default 50, at most 5,000) and *Max crawl depth*. Links on pages with `nofollow` aren't followed. Start URLs you list and dataset URLs are always audited, even beyond *Max pages*.

#### How does the robots.txt check work?

With *Respect robots.txt* on (the default), each site's robots.txt is read once per run, and pages it disallows for bots aren't fetched (free `robots-blocked` row). Separately, every row's `robotsTxtAllowed` says whether the site's rules allow the page for Googlebot, and a disallowed page gets the `robots-txt-disallowed` error. With it on, internal links that robots.txt disallows for bots aren't checked either (images are, like a browser loading the page would).

#### What do the image and link checks do?

For each page, up to 30 image URLs and (if turned on) up to 50 internal link URLs get a HEAD request (a short GET if the server refuses HEAD or doesn't send a size). An image or link is broken if it answers 4xx/5xx or can't be reached. External links aren't checked; for a full broken-link crawl, use our [Broken Link Checker](https://apify.com/charlesmarkette/broken-link-checker-crawl-website-404).

#### Are there size limits?

Pages are capped at 10 MB while downloading. Sitemaps are capped at 50 MB each, up to 50 sitemap files and 3 levels of sitemap indexes per run. Very large runs (thousands of pages with many images) may need more than the default 512 MB of memory.

### Related Actors

| Actor | Use it to |
|---|---|
| [PageSpeed & Lighthouse Audit](https://apify.com/charlesmarkette/pagespeed-lighthouse-audit-core-web-vitals) | Measure Core Web Vitals and Lighthouse scores for the same pages. |
| [Sitemap URL Extractor & New Page Monitor](https://apify.com/charlesmarkette/sitemap-url-extractor-new-page-monitor) | Get every URL of a site's sitemaps (or only new ones), then audit them here with *Dataset to audit*. |
| [Broken Link Checker](https://apify.com/charlesmarkette/broken-link-checker-crawl-website-404) | Crawl a whole site and check every internal and external link for 404s. |
| [Bulk URL Status Checker](https://apify.com/charlesmarkette/bulk-url-status-checker-redirects-ssl) | Check status codes, redirect chains and SSL certificates for large URL lists. |
| [Website Tech Stack Detector](https://apify.com/charlesmarkette/website-tech-stack-detector-shopify-cms-analytics) | See which CMS, shop platform and analytics tools the audited sites use. |
| [Google Sheets Sync](https://apify.com/charlesmarkette/google-sheets-sync) | Send the audit rows to a Google Sheet after each run. |

Built with open-source software under permissive licences: [lxml](https://lxml.de/) (BSD) for HTML and XML parsing, [httpx](https://www.python-httpx.org/) (BSD) for requests, and charset-normalizer (MIT).

# Actor input Schema

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

Pages to audit, one per line. Each URL becomes one output row. With 'Crawl the site' on, these are also where the crawl starts.

## `crawl` (type: `boolean`):

Off: only the URLs you list (and sitemap pages) are audited. On: links found on audited pages are followed too, staying on the same sites as the start URLs (www. and non-www count as one site), up to 'Max pages' and 'Max crawl depth'.

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

When crawling or reading sitemaps, no new pages are added once this many URLs are queued (start URLs and sitemap pages count). Start URLs and dataset URLs you give yourself are always audited.

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

How many clicks away from a start URL the crawl goes. 0 = only the start URLs, 1 = pages they link to, and so on.

## `sitemapUrls` (type: `array`):

Optional. XML sitemaps, sitemap indexes or plain-text sitemaps (gzipped is fine). Their page URLs are audited, up to 'Max pages'.

## `datasetId` (type: `string`):

Optional. A dataset from another run that has page URLs (a sitemap run, a crawler's results…). Each item's URL is audited and the audit columns are added next to the item's fields. Use {{resource.defaultDatasetId}} in an integration.

## `urlField` (type: `string`):

The item field that holds the page URL. Dotted paths work (page.url). Default: url.

## `keepItemFields` (type: `boolean`):

On: each output row is the original item plus the audit columns (audit columns win if a name clashes). Off: only the audit columns.

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

Sends a HEAD request to up to 30 images per page to find broken images (HTTP 4xx/5xx or unreachable) and images over 500 KB. Each image is checked once per run.

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

Sends a HEAD request to up to 50 same-site links per page to find broken internal links. Each link is checked once per run. Off by default because it adds requests to the site.

## `respectRobotsTxt` (type: `boolean`):

On (recommended): pages robots.txt disallows for bots get a free 'robots-blocked' row instead of being fetched. If robots.txt can't be read because the site is down or overloaded (HTTP 429 or 5xx), the site is treated as off-limits, as RFC 9309 says. Either way, each row reports whether robots.txt allows the page for Googlebot.

## `concurrency` (type: `integer`):

How many pages to audit at the same time (1–20).

## `perHostConcurrency` (type: `integer`):

At most this many requests to the same host at a time (1–10), image and link checks included, to stay polite to each site.

## `timeoutSecs` (type: `integer`):

How long one request may take (5–120). A page, with its redirects and retries, gets at most twice this long.

## `maxResults` (type: `integer`):

Optional cap on output rows (each row is one URL, audited or not).

## Actor input object example

```json
{
  "startUrls": [
    "https://www.python.org/",
    "https://crawlee.dev/"
  ],
  "crawl": false,
  "maxPages": 50,
  "maxDepth": 2,
  "keepItemFields": true,
  "checkImages": true,
  "checkLinks": false,
  "respectRobotsTxt": true,
  "concurrency": 5,
  "perHostConcurrency": 2,
  "timeoutSecs": 30
}
```

# Actor output Schema

## `items` (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://www.python.org/",
        "https://crawlee.dev/"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("charlesmarkette/seo-page-audit-on-page-checker-meta-headings-schema").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://www.python.org/",
        "https://crawlee.dev/",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("charlesmarkette/seo-page-audit-on-page-checker-meta-headings-schema").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://www.python.org/",
    "https://crawlee.dev/"
  ]
}' |
apify call charlesmarkette/seo-page-audit-on-page-checker-meta-headings-schema --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,charlesmarkette/seo-page-audit-on-page-checker-meta-headings-schema"
        }
    }
}
```

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/bdg5O0IeeguPAiJoM/builds/fiZ0yAj3elf1z1Bbg/openapi.json
