Portfolio

Case study

Rendered from the project README. Books to Scrape is a public practice site, not a client.

SiteAtlas SEO Audit: Broken Links, Hreflang & llms.txt

SiteAtlas crawls a public website and audits on-page SEO: title, meta description, headings, canonical, robots meta, image alt text, hreflang, broken links, duplicate titles and descriptions, and response time. It also checks AI readability once per start domain (robots rules for GPTBot, ClaudeBot, PerplexityBot, Google-Extended, and CCBot, plus /llms.txt, /llms-full.txt, and /sitemap.xml). Each page gets a 0–100 score. The run writes a site score and an HTML report and a Markdown report.

It does not run a browser and it does not collect personal data. Fix text is a fixed sentence from the deduction table. The crawler does not call a language model.

The same core/ package runs this portfolio demo and the Apify Actor siteatlas-seo-crawler. The Actor is ready to publish with limited permissions. It is not on the Store yet, and this page does not link to a Store listing.

Store title: SiteAtlas SEO Audit: Broken Links, Hreflang & llms.txt. The Store description is the description field in .actor/actor.json.

Books to Scrape (https://books.toscrape.com/) is a public crawler practice site, not a client.

Problem → Solution → Result

Problem

Solution

Result

These are capabilities of this project, not claims about a client engagement.

Features

PageSpeed, Core Web Vitals, and orphan-page detection are not in this version.

Input

FieldTypeDefaultDescription
startUrlsarray of URLs["https://books.toscrape.com/"]Pages to start from. The crawler stays on the same hostname.
maxPagesinteger20 (maximum 5000; the demo maximum is 50)How many pages to audit. One dataset row each.
maxDepthinteger2Link hops from a start URL. 0 audits only the start URLs.
checkExternalLinksbooleanfalseHEAD external links (up to 20 per page) and list failures in brokenLinks.
respectRobotsTxtbooleantrueSkip URLs disallowed by robots.txt. A missing file allows crawling.
concurrencyinteger5 (maximum 20)Parallel fetches. Each hostname is still limited to 5 requests per second.
checkAiReadinessbooleantrueCheck AI crawlers, /llms.txt, /llms-full.txt, and /sitemap.xml once per start domain.
{
        "startUrls": ["https://books.toscrape.com/"],
        "maxPages": 20,
        "maxDepth": 2,
        "checkExternalLinks": false,
        "respectRobotsTxt": true,
        "concurrency": 5,
        "checkAiReadiness": true
      }

Output

One dataset item per audited page:

url, finalUrl, statusCode, responseMs, contentType, title, titleLength, metaDescription, metaDescriptionLength, canonical, robotsMeta (noindex / nofollow), h1Count, h1First, wordCount, imagesWithoutAlt, internalLinks, externalLinks, brokenLinks, ogTitle, ogImage, hreflang (language codes), hreflangLinks (lang and href), issues (code strings), issueDetails (code, severity, fix), score, crawledAt.

The key-value record SUMMARY has pages, issueCounts, averageResponseMs, slowestPages (up to five), siteScore, scores (onPage, links, international, aiReadiness), siteIssues, duplicateTitles, duplicateDescriptions, topIssuePages (up to ten), aiReadiness, and charges.

siteScore is the average page score minus each site-level finding once, clamped to 0–100. scores.aiReadiness is null when the check did not run. International starts at 100 when the crawled pages have no international findings. A page with no hreflang is not, by itself, an international finding.

REPORT.html and REPORT.md are built from that summary. The HTML file uses a <style> block and does not load a script, a stylesheet link, or a remote image.

The dataset view Overview shows url, statusCode, title, issues, and responseMs. The Scores view shows url, score, statusCode, issues, and responseMs.

Sample output

Local prefill run on 2026-10-03 against Books to Scrape (maxPages 20, maxDepth 2, checkAiReadiness true). The run stored 20 pages in 5.3 seconds. Every page had MISSING_META_DESC and score 85. SUMMARY reported averageResponseMs 95.8, siteScore 62, and charges of 20 page-audited plus 1 ai-readiness-check. The homepage and /index.html share one title, so the run reported one DUPLICATE_TITLE. robots.txt returned 404, so all five AI crawlers are unspecified and that status is not deducted. /llms.txt, /llms-full.txt, and /sitemap.xml returned 404. responseMs is the HTTP time only.

First dataset row:

{
        "url": "https://books.toscrape.com/",
        "finalUrl": "https://books.toscrape.com/",
        "statusCode": 200,
        "responseMs": 233,
        "contentType": "text/html",
        "title": "All products | Books to Scrape - Sandbox",
        "titleLength": 40,
        "metaDescription": null,
        "metaDescriptionLength": 0,
        "canonical": null,
        "robotsMeta": {"noindex": false, "nofollow": false},
        "h1Count": 1,
        "h1First": "All products",
        "wordCount": 309,
        "imagesWithoutAlt": 0,
        "internalLinks": 73,
        "externalLinks": 0,
        "brokenLinks": [],
        "ogTitle": null,
        "ogImage": null,
        "hreflang": [],
        "hreflangLinks": [],
        "issues": ["MISSING_META_DESC", "DUPLICATE_TITLE", "LLMS_TXT_MISSING", "SITEMAP_UNREACHABLE", "SITEMAP_NOT_DECLARED", "LLMS_FULL_TXT_MISSING"],
        "issueDetails": [
          {"code": "MISSING_META_DESC", "severity": "error", "fix": "Add a meta description that summarizes this page."},
          {"code": "DUPLICATE_TITLE", "severity": "warning", "fix": "Give each URL its own title."},
          {"code": "LLMS_TXT_MISSING", "severity": "warning", "fix": "Publish /llms.txt with an H1 title and a markdown list of links."},
          {"code": "SITEMAP_UNREACHABLE", "severity": "warning", "fix": "Serve /sitemap.xml with HTTP 200."},
          {"code": "SITEMAP_NOT_DECLARED", "severity": "notice", "fix": "Declare the sitemap URL in robots.txt."},
          {"code": "LLMS_FULL_TXT_MISSING", "severity": "notice", "fix": "Publish /llms-full.txt if you want a full-text companion file."}
        ],
        "score": 85,
        "crawledAt": "2026-10-03T07:24:58Z"
      }

SUMMARY from the same run:

{
        "pages": 20,
        "averageResponseMs": 95.8,
        "siteScore": 62,
        "scores": {"onPage": 79, "links": 100, "international": 100, "aiReadiness": 83},
        "issueCounts": {"MISSING_META_DESC": 20},
        "duplicateTitles": [
          {
            "value": "All products | Books to Scrape - Sandbox",
            "urls": ["https://books.toscrape.com/", "https://books.toscrape.com/index.html"]
          }
        ],
        "duplicateDescriptions": [],
        "charges": {"page-audited": 20, "ai-readiness-check": 1},
        "aiReadiness": {
          "checked": true,
          "domains": [
            {
              "domain": "books.toscrape.com",
              "robotsFound": false,
              "robotsStatus": 404,
              "bots": {
                "GPTBot": "unspecified",
                "ClaudeBot": "unspecified",
                "PerplexityBot": "unspecified",
                "Google-Extended": "unspecified",
                "CCBot": "unspecified"
              },
              "llmsTxt": {"exists": false, "statusCode": 404, "contentType": "text/html", "startsWithH1": false, "hasMarkdownLinkList": false, "bytes": 153},
              "llmsFullTxt": {"exists": false, "statusCode": 404},
              "sitemap": {"declaredInRobots": false, "declaredUrls": [], "sitemapXml": {"accessible": false, "statusCode": 404}}
            }
          ]
        }
      }

The issueCounts object in the stored SUMMARY also includes the other page-level codes with a count of 0. The excerpt above shows the non-zero count.

Issue codes

Page-level codes are the only codes in issueCounts. Site-level codes are listed in siteIssues and copied onto the related pages. A page score does not subtract site-level codes.

CodeWhen it is set
MISSING_TITLEThe HTML page has no title text.
TITLE_TOO_LONGThe collapsed title is longer than 60 characters.
MISSING_META_DESCThe HTML page has no meta description text.
META_DESC_TOO_LONGThe collapsed meta description is longer than 160 characters.
MULTIPLE_H1The page has more than one h1.
MISSING_H1The HTML page has no h1.
NOINDEXThe robots meta tag or X-Robots-Tag contains noindex (or none).
BROKEN_LINKAt least one checked external link failed.
SLOW_PAGEThe fetch took longer than 2000 ms.
IMG_NO_ALTAt least one image has a missing or blank alt.
NON_200The final HTTP status is not 200. A failed request is status 0.

TITLE_TOO_LONG is not added when the title is missing. The same is true for the meta description and for MISSING_H1 versus MULTIPLE_H1. A length of 60 or 160 is allowed. A response of 2000 ms is allowed.

Deduction table

A page starts at 100. Each page-level code subtracts its own points once. Severity is a label. It does not add a second penalty. Points differ inside one severity because the effect differs: a missing title removes the snippet, and a long title only risks truncation.

The site score is clamp(average(page scores) − site findings). The average is an integer, half up. Each finding subtracts once, even when the code is written onto more than one page. Two different findings that share a code (two one-way hreflang edges, or two disallowed AI crawlers) each subtract once. The result is clamped to 0–100.

Category scores use the same points. On-page and links average the per-page scores in that category, then subtract site findings in that category. International and AI readiness start at 100 and subtract only their findings. The site score is not the average of the four category scores.

Page rules

CodeSeverityPointsCategoryWhyFix
NON_200error30on-pageThe URL did not return 200, so it cannot rank as a normal page.Return HTTP 200 for this URL or link to the working page.
MISSING_TITLEerror20on-pageThe snippet has no title.Add a unique title element that describes this page.
MISSING_META_DESCerror15on-pageThe snippet has no description.Add a meta description that summarizes this page.
NOINDEXerror15on-pageThe page asks crawlers not to index it.Remove noindex if this page should be indexed.
BROKEN_LINKerror12linksAt least one checked external link failed.Fix or remove the external link that did not resolve.
MISSING_H1warning8on-pageThe page has no primary heading.Add one h1 that states the page topic.
MULTIPLE_H1warning6on-pageMore than one h1 makes the outline ambiguous.Keep a single h1 on the page.
TITLE_TOO_LONGwarning5on-pageThe title is present but longer than 60 characters and may be cut off.Shorten the title to 60 characters or fewer.
SLOW_PAGEwarning5on-pageThe response took longer than 2000 ms.Reduce the server response time to 2000 ms or less.
META_DESC_TOO_LONGwarning4on-pageThe description is present but longer than 160 characters and may be cut off.Shorten the meta description to 160 characters or fewer.
IMG_NO_ALTnotice3on-pageAt least one image has a missing or blank alt.Give every image a descriptive alt attribute.

Site rules

CodeSeverityPointsCategoryWhyFix
HREFLANG_NOT_RECIPROCALerror8internationalThe other language page does not point back, so the cluster is one-way.Add a return hreflang link so the target page points back.
HREFLANG_TARGET_BADerror8internationalThe alternate URL is not a 200 indexable page.Point hreflang at a page that returns 200 and is indexable.
HREFLANG_INVALID_LANGerror6internationalThe language tag is not a simple BCP 47 tag or x-default.Use a valid BCP 47 language tag, or x-default.
HREFLANG_MISSING_SELFwarning5internationalThe page lists alternates but does not list itself.Add an hreflang link that points at this page.
HREFLANG_MISSING_XDEFAULTwarning4internationalThe page lists alternates but has no x-default.Add an x-default hreflang link for this page.
CANONICAL_TARGET_NOT_200error8on-pageThe canonical URL was crawled and did not return 200.Point canonical at a URL that returns HTTP 200.
CANONICAL_TO_NOINDEXerror8on-pageSeveral URLs canonicalize to a page marked noindex.Do not canonicalize several URLs to a noindex page.
DUPLICATE_TITLEwarning6on-pageThe same title is used by more than one URL.Give each URL its own title.
CANONICAL_CHAINwarning5on-pageThe canonical target has a different canonical of its own (A to B to C).Point canonical directly at the final URL instead of another canonical.
DUPLICATE_META_DESCwarning4on-pageThe same meta description is used by more than one URL.Give each URL its own meta description.
LLMS_TXT_MISSINGwarning8ai-readiness/llms.txt did not return 200.Publish /llms.txt with an H1 title and a markdown list of links.
LLMS_TXT_MALFORMEDwarning5ai-readiness/llms.txt returned 200 but is not a # title plus a markdown link list.Start /llms.txt with a # title and include a markdown link list.
SITEMAP_UNREACHABLEwarning5ai-readiness/sitemap.xml did not return 200.Serve /sitemap.xml with HTTP 200.
AI_BOT_DISALLOWEDwarning4ai-readinessrobots.txt disallows this AI crawler.Allow this AI crawler in robots.txt if the content should be readable.
SITEMAP_NOT_DECLAREDnotice2ai-readinessrobots.txt does not declare a sitemap.Declare the sitemap URL in robots.txt.
LLMS_FULL_TXT_MISSINGnotice2ai-readiness/llms-full.txt did not return 200.Publish /llms-full.txt if you want a full-text companion file.

An AI crawler that is not named in robots.txt is unspecified. That status is reported and does not subtract points. User-agent: * is not a named decision for these five crawlers. A missing /llms.txt is LLMS_TXT_MISSING. A file that returns 200 but has no # title or no markdown link list is LLMS_TXT_MALFORMED, not both. /llms-full.txt is only an existence check.

Hreflang findings are raised only on pages that list at least one alternate. The return-link and bad-target checks run only when the target URL was crawled. A canonical chain is three or more URLs (A to B to C), including a cycle. One hop is not a chain. CANONICAL_TO_NOINDEX requires two or more source URLs. Empty titles and empty descriptions are not grouped.

Pricing

Pay per event. The primary event is page-audited at a suggested $1.00 per 1,000 pages ($0.001 per page). At listing, price only page-audited. Keep the event names apify-actor-start and apify-default-dataset-item, but do not set a price on apify-default-dataset-item (the same page would be charged twice). apify-actor-start stays the platform default. ai-readiness-check is recorded once per start domain and has no price in this code. Charge each dataset row as it is written, and stop when eventChargeLimitReached is true. See PRICING.md. The account owner sets the final price after measuring a free-tier run. Publish with limited permissions.

Limits

Run the Actor locally

From this directory, with the demo virtualenv (Python 3.9 or newer):

.venv/bin/python -m actor

With no INPUT.json, the run uses the input schema prefill. It writes storage/datasets/default/*.json, storage/key_value_stores/default/SUMMARY.json, REPORT.html, and REPORT.md, then prints SUCCEEDED. On Apify the image is apify/actor-python:3.12 and APIFY_IS_AT_HOME=1 uses the SDK. Do not push this Actor until the account owner says to.

Run the demo

./run.sh

The process listens on 127.0.0.1:18806. Open http://127.0.0.1:18806/. The public URL is https://3.1.79.63/portfolio/siteatlas/. Links are relative, so the same page works at the process root and under that prefix.

curl -fsS http://127.0.0.1:18806/healthz

Open the live demo