# Website SEO Audit & Broken Link Checker (`icyanstudio/seo-audit-crawler`) Actor

Crawl a website and audit every page's on-page SEO (title, meta description, headings, canonical, robots, hreflang, Open Graph, JSON-LD, alt text, word count) and check every link for broken pages and redirects. Obeys robots.txt.

- **URL**: https://apify.com/icyanstudio/seo-audit-crawler.md
- **Developed by:** [iCyan Studio](https://apify.com/icyanstudio) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

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

## Website SEO Audit & Broken Link Checker

**Crawl your website, audit every page's on-page SEO and check every link for breakage in one run.** Enter a domain and the actor crawls it breadth-first inside the site, obeying `robots.txt`. It also reads your sitemap, so pages that nothing links to are audited too. For each HTML page you get the title, meta description, headings, canonical, robots directives, hreflang, Open Graph, JSON-LD types, image alt text, word count and response time, plus a list of SEO issues with plain-English messages. Every link on those pages is checked once per run, and each page lists its broken links with their anchor text.

One page or link failing never fails the run. A page that returns 500 is reported as an `http-error`, and a link that times out is reported as broken with the reason. Results come as JSON, CSV or Excel, over the API, or from AI agents through the Apify MCP server.

### What it does

- **Crawls your site politely:** breadth-first from the start URL, same origin by default (or the whole registrable domain with `includeSubdomains`), up to `maxPages` pages and `maxDepth` clicks deep. A start URL that redirects (`example.com` to `www.example.com/en`) brings the target host into scope automatically. It obeys `robots.txt` and its `Crawl-delay`, and never sends more than 2 requests at once to one host.
- **Audits on-page SEO:** title and meta description (with lengths), `h1` texts and `h2` count, canonical (and whether it points at the page itself), robots meta and `X-Robots-Tag`, an `isIndexable` verdict, `html lang`, hreflang alternates, Open Graph, Twitter card, JSON-LD `@type`s (including `@graph`), visible word count, and images without `alt`.
- **Flags issues with stable codes:** `title-missing`, `title-too-long` (> 60), `meta-description-missing`, `meta-description-too-long` (> 160), `h1-missing`, `h1-multiple`, `canonical-invalid`, `canonical-mismatch`, `noindex`, `images-missing-alt`, `broken-links`, `thin-content` (< 200 words), `slow-response` (> 3 s), `http-error`, `html-truncated`.
- **Checks every link once:** HEAD first, GET when a server rejects HEAD. It follows up to 10 redirects and retries 429 and 5xx with backoff. Each unique link is checked once per run and the result is reused on every page that links to it. Links to pages the crawler fetches anyway are not checked a second time, and they cost nothing extra. `mailto:`, `tel:` and `javascript:` links are skipped.
- **Handles real-world HTML:** broken markup, `<base href>`, Shift_JIS and other declared charsets, and Chinese and Japanese text (each character counts as a word, so CJK pages aren't all flagged as thin).

### Use cases

- **Technical SEO audits:** find missing or too-long titles and descriptions, missing `h1`s, `noindex` pages and wrong canonicals across a whole site, then sort the **Issues** view.
- **Broken link monitoring:** schedule a weekly run and send pages with `broken-links` to Slack, email or a webhook through Apify integrations.
- **Site migrations:** after a relaunch, see which internal links now redirect or return 404, and on which pages they sit.
- **International SEO:** check `html lang` and hreflang alternates on every language version.
- **Content quality:** find thin pages and images without alt text.
- **AI agents (Apify MCP, LangChain, OpenAI Agents):** a small input and a flat, documented output. An agent can ask "which pages on example.com have SEO problems?" and get structured answers.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `startUrls` | string\[] | `["https://crawlee.dev"]` | Where the crawl starts: a domain (`example.com`) or a page URL. Up to 100. Start URLs are always audited. |
| `maxPages` | integer 1–10,000 | `10` | Stop after auditing this many HTML pages. This also caps the cost. Set it higher for a full-site audit. |
| `maxDepth` | integer 0–50 | `5` | Clicks from the start URL. `0` audits only the start URLs. Sitemap pages count as depth 1. |
| `checkLinks` | boolean | `true` | Check every link on the audited pages. |
| `checkExternalLinks` | boolean | `true` | Also check links to other websites. |
| `includeSubdomains` | boolean | `false` | Crawl every host of the same registrable domain (`www.`, `blog.`, the bare domain), on http and https. |
| `urlPattern` | regex | none | Crawl only pages whose URL matches (case-insensitive). Links to other pages are still checked. |
| `excludeUrlPattern` | regex | none | Don't crawl pages whose URL matches. Links to them are still checked. |
| `respectRobotsTxt` | boolean | `true` | Obey `robots.txt` rules and `Crawl-delay` (capped at 10 s). Turn it off only for a site you own. |
| `useSitemap` | boolean | `true` | Also queue pages from the sitemap (found through `robots.txt`, or `/sitemap.xml`). |
| `concurrency` | integer 1–20 | `5` | Requests at the same time across all hosts. One host never gets more than 2 at once. |

Example: audit the blog only, without tag and pagination pages:

```json
{
    "startUrls": ["https://example.com/blog/"],
    "urlPattern": "/blog/",
    "excludeUrlPattern": "/tag/|\\?page=",
    "maxPages": 500
}
```

Example: a quick on-page audit with no link checks:

```json
{
    "startUrls": ["https://example.com"],
    "checkLinks": false,
    "maxPages": 1000
}
```

### Output

One dataset item per audited page. This one is from a real run on www.icyanstudio.net, 2026-10-25 (shortened). That site renders its content in the browser, so the HTML the server sends has no `h1` and almost no text:

```json
{
    "url": "https://www.icyanstudio.net/",
    "finalUrl": "https://www.icyanstudio.net/en",
    "statusCode": 200,
    "depth": 0,
    "responseTimeMs": 670,
    "title": "iCyan Studio",
    "titleLength": 12,
    "metaDescription": "Welcome to the iCyan studio. This is a personal website and portfolio to showcase my work, skills, and experience. ...",
    "metaDescriptionLength": 290,
    "h1": [],
    "h2Count": 0,
    "canonical": "https://www.icyanstudio.net/en",
    "canonicalRaw": "https://www.icyanstudio.net/en",
    "canonicalMatches": true,
    "robotsMeta": null,
    "xRobotsTag": null,
    "isIndexable": true,
    "lang": "en",
    "hreflang": [
        { "lang": "en", "href": "https://www.icyanstudio.net/en" },
        { "lang": "ja", "href": "https://www.icyanstudio.net/ja" },
        { "lang": "zh-TW", "href": "https://www.icyanstudio.net/zh_tw" },
        { "lang": "x-default", "href": "https://www.icyanstudio.net/en" }
    ],
    "og": { "title": "iCyan Studio", "description": "Welcome to the iCyan studio. ...", "image": "https://www.icyanstudio.net/images/icyanstudio.jpg" },
    "twitterCard": "summary_large_image",
    "jsonLdTypes": [],
    "wordCount": 2,
    "imagesTotal": 0,
    "imagesMissingAlt": 0,
    "internalLinks": 0,
    "externalLinks": 0,
    "brokenLinks": [],
    "redirectedLinks": 0,
    "linksNotChecked": 0,
    "issues": [
        { "code": "meta-description-too-long", "message": "The meta description is 290 characters; search results show about 160." },
        { "code": "h1-missing", "message": "The page has no <h1> heading." },
        { "code": "thin-content", "message": "Only 2 words of visible text (under 200)." }
    ],
    "crawledAt": "2026-10-25T10:31:16.194Z"
}
```

Broken links on a page look like this (from a run on crawlee.dev; Stack Overflow answers automated requests with 403):

```json
"brokenLinks": [
    { "url": "https://stackoverflow.com/questions/tagged/crawlee", "statusCode": 403, "error": null, "anchorText": "Stack Overflow" }
]
```

- `brokenLinks` lists links that returned 400 or higher, or failed (DNS, timeout, TLS, too many redirects, refused address). `error` says why. It is `null` when `checkLinks` is off.
- URL fields (`canonical`, `hreflang[].href`, `og.image`) only ever hold absolute `http(s)` URLs. A value such as `javascript:...` becomes `null`; the canonical as written is kept in the plain-text `canonicalRaw`, and an unusable one is reported as `canonical-invalid`.
- `canonicalMatches` is `null` when the page has no usable canonical. A trailing-slash difference (`/python` vs `/python/`) counts as a mismatch, as it does for search engines.
- `isIndexable` is `true` when the status is 2xx, neither the robots meta nor `X-Robots-Tag` says `noindex`, and the canonical doesn't point elsewhere.
- `linksNotChecked` counts links that weren't checked: external links with `checkExternalLinks` off, links `robots.txt` disallows, and links beyond the run's maximum cost.

The dataset has three views: **Overview**, **Issues** and **Broken links**.

#### Run summary (key-value store `OUTPUT`)

Every run saves a summary: pages audited and why others weren't (not HTML, failed, redirected off the site, duplicate), crawl candidates skipped (robots.txt, patterns, depth), issue counts by code, link totals (unique, internal, external, checked, answered by the crawl itself), every broken link once with the pages it was found on (up to 1,000, with a total count), robots.txt and sitemap details, and warnings. Example (shortened, from crawlee.dev with `maxPages: 3`):

```json
{
    "pagesAudited": 3,
    "issuesByCode": { "broken-links": 3, "canonical-mismatch": 1 },
    "links": { "unique": 43, "internal": 29, "external": 14, "checkedByRequest": 40, "resolvedFromCrawl": 3 },
    "linkChecksCharged": 40,
    "brokenLinksTotal": 2,
    "redirectedLinksTotal": 2,
    "brokenLinks": [
        { "url": "https://stackoverflow.com/questions/tagged/crawlee", "statusCode": 403, "error": null, "foundOn": ["https://crawlee.dev/", "https://crawlee.dev/js"], "foundOnCount": 2 }
    ],
    "stoppedAtMaxPages": true,
    "warnings": ["Reached maxPages (3); 26 more queued page(s) were not crawled."]
}
```

### Pricing

Pay per event: you pay for audited pages and for link checks, nothing else.

| Event | Free plan | Bronze | Silver | Gold |
|---|---|---|---|---|
| Page audited (one HTML page with all its SEO fields and issues) | $0.003 | $0.002 | $0.0015 | $0.001 |
| Link checked (one unique link actually requested, once per run) | $0.0005 | $0.0005 | $0.0005 | $0.0005 |
| Actor start | Apify's default | | | |

For example, a 100-page site with 300 unique links that aren't pages of the site costs $0.30 for the pages plus $0.15 for the links: $0.45 on the free plan. That's much cheaper per page than typical SEO audit actors, and it includes a link check for every link. Links to pages the crawler audits anyway are free. `mailto:`, `tel:` and `javascript:` links, links `robots.txt` disallows and addresses refused by the safety check are never charged.

Set a maximum cost per run in Apify Console. Every page and every link check is paid for out of that limit before it happens, so the run stops cleanly at the limit and is never cut off halfway. Link checks count toward the limit too, so a tight limit on a link-heavy site audits fewer pages. Turn off `checkExternalLinks` or `checkLinks` to spend it all on pages.

### FAQ

**Does it render JavaScript?** No. It audits the HTML your server sends, like most SEO crawlers in their default mode. If your site builds its content in the browser (as the example above does), the audit shows what a non-rendering crawler sees: few words, no `h1` and few links. Search engines that render JavaScript may see more.

**Why is a link to LinkedIn, Stack Overflow or a Cloudflare-protected site "broken"?** Some sites answer every automated request with 403, 429 or 999. Those links show up as broken with the real status code. The actor doesn't try to get around bot protection.

**Does it obey robots.txt?** Yes, by default. It reads `robots.txt` for each site it crawls, uses the rules for its own User-Agent (`SeoAuditCrawler`) or else the `*` group, and honours `Crawl-delay` up to 10 seconds. If `robots.txt` can't be read because of a server error, the site is treated as fully disallowed (RFC 9309), and the run says so. Links to other websites get a single status check and their `robots.txt` isn't read, as in other link checkers.

**What counts as one page?** One HTML page that was audited, i.e. one dataset item. A URL that redirects to a page already audited, or that turns out to be a PDF or an image, is not a page. It costs one link check if it was requested.

**Why does a run fail?** Only when the input is invalid, or when no start URL could be fetched as an HTML page (it's down, it isn't HTML, or `robots.txt` blocks it). The status message and `OUTPUT` say which.

**Can I audit a site I don't own?** Only crawl sites you own or have permission to audit. Requests go out at a polite rate, but the target site's terms are your responsibility.

### Limits and notes

- Up to 10,000 pages per run, 10,000 links per page, 5 MB of HTML per page (larger pages are audited up to 5 MB and flagged `html-truncated`), 50 sitemap files per run.
- One host gets at most 2 requests at a time, so a single site is crawled 2 pages at a time. In a test run, 10 pages of a small site were audited in about 2 seconds.
- For safety, the actor only requests public `http` and `https` addresses. Links and redirects that point at private, loopback or cloud-metadata addresses are refused, reported as broken with the reason, and not charged when no request was sent.
- Links are taken from `<a href>`. Images, scripts and stylesheets are counted or ignored, not checked.

### Feedback

Found a page it audits wrongly, or need a field or an issue rule? Open an issue on the actor's **Issues** tab with the URL.

# Actor input Schema

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

Where the crawl starts: a domain (example.com) or a page URL (https://example.com/blog/). Pages on the same site (same origin) are crawled from there. Up to 100 entries.

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

Stop after auditing this many HTML pages. Each audited page is one result, and the limit also caps the cost.

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

How many clicks away from the start URL the crawl goes. 0 audits only the start URLs, 1 adds the pages they link to, and so on. Pages found in the sitemap count as depth 1.

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

Check the status of every link on the audited pages and report broken and redirected links. Each unique link is checked once per run. Links to pages that are crawled anyway are not checked again and cost nothing extra.

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

Also check links that point to other sites. Turn off to check only links inside your site.

## `includeSubdomains` (type: `boolean`):

Crawl every host of the same registrable domain (www.example.com, blog.example.com, example.com) and both http and https. When off, only the start URL's exact origin is crawled.

## `urlPattern` (type: `string`):

Crawl only pages whose URL matches this regular expression (case-insensitive). The start URLs are always crawled. Links to other pages are still checked. Example: /blog/

## `excludeUrlPattern` (type: `string`):

Skip pages whose URL matches this regular expression (case-insensitive). Links to them are still checked. Example: /tag/|?page=

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

Skip pages and links that the site's robots.txt disallows, and honour its Crawl-delay (up to 10 s). Turn off only for a site you own.

## `useSitemap` (type: `boolean`):

Also queue pages listed in the site's sitemap (from robots.txt, or /sitemap.xml), so pages that nothing links to are audited too.

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

Requests running at the same time across all hosts. To be polite, at most 2 requests run at once against any single host.

## Actor input object example

```json
{
  "startUrls": [
    "https://crawlee.dev"
  ],
  "maxPages": 5,
  "maxDepth": 5,
  "checkLinks": true,
  "checkExternalLinks": true,
  "includeSubdomains": false,
  "respectRobotsTxt": true,
  "useSitemap": true,
  "concurrency": 5
}
```

# Actor output Schema

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

One item per audited HTML page: SEO fields, issues and broken links.

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

Totals, issues by code, every broken link with the pages it was found on, robots.txt and sitemap details, and warnings.

# 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"
    ],
    "maxPages": 5
};

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

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

```

## MCP server setup

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

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/nm5OUY44zAXYQcbfh/builds/Aj15qjY8EZeFUt1QI/openapi.json
