# Technical SEO Page Audit (`ouroborus/technical-seo-page-audit`) Actor

Audit public pages for technical SEO with 44 deterministic checks: indexability, robots.txt, canonical, titles, headings, links, images, JSON-LD, hreflang, HTTPS and headers. Every check carries its evidence. No JavaScript rendering, no AI.

- **URL**: https://apify.com/ouroborus/technical-seo-page-audit.md
- **Developed by:** [Alijah Konikowski](https://apify.com/ouroborus) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

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

## Technical SEO Page Audit

Audits public web pages for technical SEO and returns one JSON report per page: 44 fixed checks, each with a
status, the evidence it was decided on, and a fixed recommendation when it warns or fails. The same HTML and response
headers always give the same `checksSha256`.

It reads what a crawler reads: the server HTML, response headers, `robots.txt` and, if you ask, the status of the
page's links. It does not run JavaScript and uses no AI model.

### Input

| Field | Type | Default | |
|---|---|---|---|
| `urls` | array of http(s) URLs | | Pages to audit, up to 100 |
| `sitemapUrl` | URL | | Optional sitemap or sitemap index; same-host pages are added in sitemap order |
| `maxPages` | 1-100 | 10 | Pages taken from the sitemap |
| `checkLinks` | boolean | false | HEAD-request the page's links and report 4xx/5xx |
| `maxLinksToCheck` | 1-50 | 20 | Links checked per page |
| `timeoutSecsPerUnit` | 10-300 | 60 | Time allowed per page |

Give `urls`, `sitemapUrl`, or both. Example:

```json
{
  "urls": [
    "https://example.com/"
  ]
}
```

### Output

One dataset item per audited page:

- `score.overall` (0-100) and `score.categories`, computed from check weights: pass counts fully, warn half, fail zero
- `counts` of pass / warn / fail / info / not_applicable / not_checked
- `checks[]`: `id`, `category`, `title`, `weight`, `status`, `evidence`, `recommendation` (null unless the check warns
  or fails), `measured` (true for the two time-dependent checks)
- `page`: title, meta description, canonical, robots directives, H1s, word count, link and image counts, JSON-LD types,
  Open Graph, hreflang
- `http`: status, redirect chain, response time, HTML bytes, transfer bytes, content encoding
- `completeness`: whether `robots.txt` and the link check were fully read
- `htmlSha256` (the HTML that was judged) and `checksSha256` (the checks, excluding the two time-dependent ones)

A page that cannot be audited (DNS failure, timeout, 4xx/5xx, non-HTML, blocked address) produces an item with
`ok: false` and an `error.code` instead. The key-value store record `OUTPUT` lists every page with whether it was
charged and why.

Real output for the example input above, shortened to 3 of the 44 checks and 6 of the page fields:

```json
{
  "url": "https://example.com/",
  "finalUrl": "https://example.com/",
  "ok": true,
  "score": {
    "categories": {
      "content": 84,
      "indexability": 93,
      "links": 75,
      "performance": 100,
      "security": 92,
      "structured_data": 50
    },
    "overall": 86
  },
  "counts": {
    "fail": 0,
    "info": 5,
    "not_applicable": 10,
    "not_checked": 1,
    "pass": 20,
    "warn": 8
  },
  "completeness": {
    "linkCheck": "skipped",
    "robotsTxt": "absent"
  },
  "htmlSha256": "25ddf2c883e0d1958ea971d279a7e4f0fd446724ee3db7db19dadabd4a62e484",
  "checksSha256": "80404755eac37451a50a6046a764bdb094592c32ead1074bc4bf41a6a4680935",
  "fetchedAt": "2026-10-08T02:51:10Z",
  "checks": [
    {
      "category": "indexability",
      "evidence": {
        "canonical": []
      },
      "id": "IDX004",
      "measured": false,
      "recommendation": "Add one <link rel=\"canonical\"> with an absolute URL in <head>; remove duplicates.",
      "status": "warn",
      "title": "Exactly one absolute canonical URL",
      "weight": 2
    },
    {
      "category": "indexability",
      "evidence": {
        "http_status": 404,
        "note": "no robots.txt: everything is allowed",
        "robots_url": "https://example.com/robots.txt"
      },
      "id": "IDX006",
      "measured": false,
      "recommendation": null,
      "status": "pass",
      "title": "robots.txt allows crawling this URL",
      "weight": 5
    },
    {
      "category": "content",
      "evidence": {
        "h1": [],
        "h1_count": 0
      },
      "id": "CNT005",
      "measured": false,
      "recommendation": "Use exactly one <h1> that states the page topic.",
      "status": "warn",
      "title": "Exactly one H1",
      "weight": 2
    }
  ],
  "page": {
    "title": "Example Domain",
    "metaDescription": null,
    "canonical": null,
    "lang": "en",
    "h1": [],
    "wordCount": 25
  },
  "http": {
    "status": 200,
    "redirects": [],
    "responseMs": 50,
    "htmlBytes": 577,
    "transferBytes": 400,
    "contentEncoding": "gzip"
  }
}
```

### Pricing

Pay per event:

- `page-audited`: $0.02 per page that produced a report. A page is charged only after its report is
  validated against the output schema and saved to the dataset.
- Apify's run-start event, `apify-actor-start`: $0.00005 per run (one event: this Actor's memory is capped at 1 GB).
- Platform usage (compute, storage, data transfer) is not charged to you on top of these events.

Never charged: pages that fail to load (DNS, timeout, connection reset, 4xx/5xx, rate limiting), non-HTML responses,
invalid or private-network URLs, and any page the run's maximum charge cannot cover (the run stops before fetching
it). A report whose `robots.txt` or link check could not be completed is still a report and is charged; `completeness`
says which part is missing.

### Checks

| Id | Category | Rule | Weight |
|---|---|---|---|
| `IDX001` | indexability | Final response is HTTP 200 | 5 |
| `IDX002` | indexability | At most one redirect before the page | 1 |
| `IDX003` | indexability | Page is not marked noindex | 5 |
| `IDX004` | indexability | Exactly one absolute canonical URL | 2 |
| `IDX005` | indexability | Canonical points to this page | 1 |
| `IDX006` | indexability | robots.txt allows crawling this URL | 5 |
| `IDX007` | indexability | robots.txt declares a sitemap | 1 |
| `IDX008` | indexability | Response is HTML | 3 |
| `CNT001` | content | Page has a title | 4 |
| `CNT002` | content | Title length 10-60 characters | 2 |
| `CNT003` | content | Page has a meta description | 2 |
| `CNT004` | content | Meta description length 50-160 characters | 1 |
| `CNT005` | content | Exactly one H1 | 2 |
| `CNT006` | content | Heading levels are not skipped | 1 |
| `CNT007` | content | At least 200 words of visible text | 1 |
| `CNT008` | content | <html> declares lang | 1 |
| `CNT009` | content | Mobile viewport meta tag | 2 |
| `CNT010` | content | Character encoding declared | 1 |
| `LNK001` | links | Page links to other pages on the site | 2 |
| `LNK002` | links | Every link has an accessible name | 1 |
| `LNK003` | links | No broken links among those checked | 3 |
| `LNK004` | links | No placeholder links (# or javascript:) | 1 |
| `IMG001` | media | Every image has an alt attribute | 2 |
| `IMG002` | media | Images declare width and height | 1 |
| `SD001` | structured data | JSON-LD structured data present | 1 |
| `SD002` | structured data | All JSON-LD blocks parse | 3 |
| `SD003` | structured data | JSON-LD nodes have schema.org @context and @type | 2 |
| `SD004` | structured data | Open Graph title, description and image | 1 |
| `SD005` | structured data | Twitter card declared | 1 |
| `INT001` | international | hreflang values and URLs are valid | 2 |
| `INT002` | international | hreflang set includes this page | 2 |
| `INT003` | international | hreflang set has x-default | 1 |
| `SEC001` | security | Final URL uses HTTPS | 5 |
| `SEC002` | security | HTTP redirects to HTTPS | 1 |
| `SEC003` | security | Strict-Transport-Security header | 2 |
| `SEC004` | security | No mixed content | 3 |
| `SEC005` | security | X-Content-Type-Options: nosniff | 1 |
| `SEC006` | security | Content-Security-Policy header | 1 |
| `SEC007` | security | TLS certificate valid for 21+ more days | 3 |
| `PRF001` | performance | HTML document under 500 KB | 2 |
| `PRF002` | performance | HTML is compressed in transit | 2 |
| `PRF003` | performance | At most 2 render-blocking scripts in <head> | 2 |
| `PRF004` | performance | Server answered within 800 ms | 1 |
| `PRF005` | performance | HTML response has Cache-Control | 1 |

`PRF004` (response time) and `SEC007` (certificate days left) depend on when the page is fetched and are excluded
from `checksSha256`.

### What it does not do

- It does not crawl a site. It audits the URLs you give and, if you give a sitemap, up to `maxPages` same-host pages
  from it.
- It does not render JavaScript, measure Core Web Vitals (LCP, CLS, INP) or take screenshots.
- It does not log in, send cookies or fill forms; pages behind authentication are audited as an anonymous visitor
  sees them.
- It does not report rankings, keywords, backlinks or traffic.
- It does not change your site: every request is read-only (GET for the page and `robots.txt`; HEAD for links, or a
  one-byte ranged GET when a server refuses HEAD).

### Limits

- Server HTML only. Content added by JavaScript is not seen; neither are lab metrics such as LCP or CLS.
- `robots.txt` is evaluated per RFC 9309 for `Googlebot` and `*` (wildcards, `$`, longest match, allow wins ties).
- At most 100 pages per run, 5 MB of HTML per page, 5 redirects.
- Requests to private, loopback, link-local and reserved addresses are refused, including after redirects.

### Schemas and permissions

The input and every output item are described by JSON schemas that ship with the Actor (`.actor/input_schema.json`,
`.actor/dataset_schema.json`). The Actor runs with Apify's limited permissions: it accesses only its own run's
storage and the input you give it.

# Actor input Schema

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

Public http(s) pages to audit, up to 100. Each page is fetched once as a crawler sees it (no JavaScript rendering).

## `sitemapUrl` (type: `string`):

Optional sitemap.xml (or sitemap index). Pages on the same host are added after the URLs above, in sitemap order.

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

Upper bound on pages taken from the sitemap. The whole run is capped at 100 pages.

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

Send a HEAD request to each page's links (up to the limit below) and report links that return 4xx/5xx.

## `maxLinksToCheck` (type: `integer`):

Upper bound on links checked per page when link checking is on.

## `timeoutSecsPerUnit` (type: `integer`):

Time allowed for one page, including robots.txt and link checks. A page that runs out of time is reported and not charged.

## Actor input object example

```json
{
  "urls": [
    "https://example.com/"
  ],
  "maxPages": 10,
  "checkLinks": false,
  "maxLinksToCheck": 20,
  "timeoutSecsPerUnit": 60
}
```

# Actor output Schema

## `audits` (type: `string`):

One item per audited page: score, category scores, 44 checks with status and evidence, page facts.

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

Per-page settlement (charged or not, and why), errors, usage counters.

# 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://example.com/"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("ouroborus/technical-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://example.com/"] }

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

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,ouroborus/technical-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/5SMIEr69zmjPmqMHf/builds/ceEYd6N3DS4imgOya/openapi.json
