Claude AI

Site Architecture Audit Claude Skill

This skill audits a website's architecture and crawlability and produces an actionable SEO report. It crawls the site and inspects robots.txt and XML sitemaps to find hostname and protocol duplication, URL structure problems, excessive crawl depth, weak directory taxonomy, blocked or conflicting crawl and index directives, and sitemap-to-crawl parity gaps, then recommends concrete fixes.

What The Skill Checks

  • High-severity structural problems: multiple indexable hostname and protocol variants (http and https, www and non-www) with no canonical host enforcement, important pages blocked by robots.txt, and conflicting crawl and index directives such as a noindex page that is also disallowed or canonicalized elsewhere.
  • XML sitemap health, checked in both directions: sitemap URLs that are non-200, redirected, noindexed, or non-canonical, plus indexable pages missing from the sitemap entirely, along with robots.txt sitemap declaration and size-limit handling.
  • URL and depth structure: uppercase, underscore, encoded, over-long, over-deep, or ID-only URLs, inconsistent trailing-slash, protocol, and www forms across internal links, crawlable tracking and session parameter spaces, click-depth distribution, and thin or inconsistent directory taxonomy.

How The Skill Is Packaged

The skill follows the standard Claude Agent Skill structure: a SKILL.md file with YAML frontmatter and workflow instructions, a references/ folder with the full audit check definitions and report template, and a scripts/ folder with Python scripts that crawl the site and gather architecture signals (host variants, robots.txt, and XML sitemaps) and audit the resulting inventory. The architecture skill runs against a live site, since robots.txt, sitemaps, redirects, and host-variant behavior only exist on a live server. Every report can be exported to a shareable Word .docx or a flat .csv of findings via the bundled md_to_docx.py and findings_to_csv.py scripts. Install it with npx claude-seo-skills install site-architecture-audit, or copy the skill folder into your Claude skills directory; Claude invokes it automatically when a request matches its description.

Skill Files

Every file in the skill is shown below, copied from the Claude-SEO-Skills repository, so you can review exactly what the skill instructs Claude to do before installing it.

SKILL.md

The skill definition: frontmatter, inputs to collect, the five-step audit workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
site-architecture-audit
description
Audit a website's architecture and crawlability for SEO. Crawls the site and inspects robots.txt and XML sitemaps to find hostname/protocol duplication, URL structure problems, excessive crawl depth, weak directory taxonomy, blocked or conflicting crawl and index directives, and sitemap-to-crawl parity gaps. Use when the user asks to audit, analyze, or improve site architecture, site structure, URL structure, crawlability, indexability, robots.txt, XML sitemaps, canonicalization, or how a site is organized.

Site Architecture Audit

Audit the structural layer of a website (URL design, hostname and protocol canonicalization, robots.txt, XML sitemaps, crawl and index directives, depth distribution, and directory taxonomy) and produce an actionable SEO report.

When to use this skill

Use this skill when the user asks to:

  • Audit or analyze a site's architecture, structure, or how it is organized
  • Review URL structure quality (depth, readability, parameters, trailing slashes, case)
  • Check crawlability and indexability: robots.txt, meta robots, canonical signals, conflicts between them
  • Validate XML sitemaps and how well they match what is actually crawlable
  • Confirm hostname and protocol canonicalization (http vs https, www vs non-www)
  • Understand click-depth distribution and directory grouping across the site

This skill covers the structural layer. For the link-by-link view of broken links, orphan pages, anchor text, and redirect chains, use internal-link-audit. For outbound links use external-link-audit. The two are complementary: architecture explains the shape of the site, internal-link explains the flow of equity within it.

Inputs to collect

Before starting, confirm with the user:

  1. Site URL : a live site root URL (e.g., https://example.com). A live crawl is required because robots.txt, sitemaps, and redirect behavior cannot be assessed from local files alone.
  2. Scope : full site or a specific section (e.g., /blog/).
  3. Crawl limits : max pages (default 500) and max depth (default 10) for the crawl.
  4. Business context : site type (e-commerce, blog, local business, SaaS) and the few page templates that matter most, so depth and taxonomy expectations can be set.
Workflow
Step 1: Crawl the site and gather architecture signals

Use scripts/crawl_architecture.py to crawl from the homepage (same registrable host only), probe the four hostname/protocol variants, fetch and parse robots.txt, and collect every XML sitemap URL (following sitemap index files):

python3 scripts/crawl_architecture.py https://example.com --max-pages 500 --max-depth 10 --output architecture_inventory.json

For every crawled page the inventory records: requested URL, final URL, HTTP status, redirect chain, click depth, scheme, host, path, query, path-segment count, canonical, meta robots, title, and the raw resolved internal link targets (used to detect URL-form inconsistency). It also records the host-variant probe, the parsed robots.txt rules and Sitemap: directives, and the full sitemap URL set.

Step 2: Run the audit checks

Use scripts/audit_architecture.py to evaluate every check against the inventory:

python3 scripts/audit_architecture.py architecture_inventory.json --output audit_report.json

Useful flags: --max-url-depth 4 (path-segment threshold), --max-click-depth 3 (click-depth threshold), and --tracking-params utm_source,utm_medium,gclid,fbclid to extend the tracking-parameter list.

Step 3: Evaluate the audit checks

Evaluate each check defined in references/audit-checks.md. The core checks are:

CheckSeverity
Multiple indexable hostname/protocol variants (no canonical host enforcement)High
Important pages blocked by robots.txtHigh
Conflicting crawl/index directives (disallow + noindex, noindex + canonical elsewhere, canonical to non-200/redirect/noindex)High
Sitemap lists non-indexable URLs (non-200, redirected, noindexed, or non-canonical)High
Indexable pages missing from the XML sitemap (sitemap/crawl parity gap)Medium
Excessive click depth or top-heavy depth distributionMedium
URL structure problems (uppercase, underscores, spaces/encoding, over-long, over-deep, ID-only slugs)Medium
Inconsistent URL forms in internal links (mixed trailing slash, mixed http/https, mixed www)Medium
Crawlable parameter URLs (tracking, session, sort/filter) creating duplicate spacesMedium
Missing or non-200 robots.txtMedium
No XML sitemap foundMedium
Pages missing a self-referencing canonicalLow
Sitemap not referenced in robots.txtLow
Thin or inconsistent directory taxonomyLow
Sitemap over size limits (50,000 URLs / 50 MB) without a sitemap indexLow
Step 4: Produce the report

Write a report following the structure in references/report-template.md:

  1. Summary : pages crawled, canonical host, sitemap/robots status, issue counts by severity
  2. Canonicalization and crawl controls : host-variant table, robots.txt findings, index-signal conflicts
  3. URL and depth structure : depth histogram, URL-quality issues, directory map
  4. Sitemap health : parity gaps and non-indexable entries
  5. Prioritized action list : ordered by severity and estimated impact
Step 5: Recommend fixes

For each issue, give a concrete, copy-pasteable recommendation, e.g.:

  • Redirect http://example.com and https://www.example.com to https://example.com with a single 301 so only one host variant resolves
  • Remove Disallow: /blog from robots.txt; it is blocking 42 linked, indexable articles
  • Drop noindex from /services/seo or remove it from the sitemap; the two signals contradict each other
  • Lowercase and de-underscore /Services/SEO_Audit to /services/seo-audit and 301 the old URL
  • Add the 60 indexable pages listed below to sitemap.xml and reference the sitemap from robots.txt
Optional: export the report
As a Word document (.docx)

If the user wants the report as a .docx (for example, to share with stakeholders or attach to a ticket), save the Markdown report to a file and convert it:

python3 scripts/md_to_docx.py report.md --output report.docx

scripts/md_to_docx.py uses only the Python standard library (no pip install) and renders headings, tables, lists, links, bold/italic, and code blocks. Offer this whenever a user asks for a Word doc, a .docx, or a shareable/downloadable report.

As a CSV of findings (.csv)

If the user wants the raw findings as a spreadsheet (for filtering, sorting, or triage in Sheets or Excel), convert the audit's audit_report.json directly:

python3 scripts/findings_to_csv.py audit_report.json --output findings.csv

scripts/findings_to_csv.py is also standard-library only. It writes one row per finding, with check and severity columns prepended and list fields (e.g. the pages sharing a duplicate value) joined with ; . Unlike the .docx, which reformats the written report, the CSV is a direct dump of the structured findings — offer it whenever a user wants the data itself, a spreadsheet, or to slice findings by check or severity.

Resources
  • scripts/md_to_docx.py — convert the Markdown report into a Word (.docx) document (standard library only)
  • scripts/findings_to_csv.py — flatten the audit findings JSON into a CSV, one row per finding (standard library only)
  • references/audit-checks.md : full definitions, thresholds, and rationale for every audit check
  • references/report-template.md : report output structure
  • scripts/crawl_architecture.py : crawl the site, probe host variants, parse robots.txt and sitemaps, build the inventory
  • scripts/audit_architecture.py : run audit checks against an architecture inventory JSON file

Audit Checks Reference (references/audit-checks.md)

Full definitions, thresholds, and rationale for all fifteen audit checks the skill evaluates, from hostname canonicalization and directive conflicts to sitemap parity and URL structure.

references/audit-checks.mdView on GitHub

Site Architecture Audit Checks

Full definitions, thresholds, and rationale for each audit check.

High severity
1. Multiple indexable hostname/protocol variants
  • Definition: The site answers with HTTP 200 on more than one of the four host/protocol variants (http://, https://, with and without www) instead of redirecting every variant to one canonical version with a 301.
  • Why it matters: Each resolvable variant is a separate, fully duplicated copy of the site. It splits link equity, wastes crawl budget, and forces search engines to guess the canonical host.
  • Fix: Pick one canonical host (almost always https:// and a single www/non-www choice) and 301-redirect the other three variants to it. Confirm the canonical tag and internal links use the chosen host.
2. Important pages blocked by robots.txt
  • Definition: A Disallow rule in robots.txt matches URLs that are linked from the site and otherwise indexable (200, not noindex).
  • Why it matters: Disallowed URLs are not crawled, so their content and links are never seen. If they are also linked elsewhere they can still appear in results as bare URLs with no snippet.
  • Fix: Narrow or remove the Disallow. Reserve robots.txt for genuinely crawl-wasteful spaces (internal search, faceted parameter URLs); use noindex (not Disallow) for pages that should be crawlable but kept out of the index.
3. Conflicting crawl/index directives
  • Definition: Two signals on the same URL contradict each other. Common cases:
    • A URL is Disallowed in robots.txt and carries a noindex meta tag. The crawler cannot fetch the page, so it never sees the noindex, and the URL can linger in the index.
    • A page is noindex and has a canonical pointing to a different URL. Canonical asks to consolidate; noindex asks to drop. Search engines may ignore both.
    • A page's canonical points to a URL that is non-200, redirects, or is itself noindex.
  • Why it matters: Contradictory directives produce unpredictable indexing and waste the signals entirely.
  • Fix: Choose one outcome per URL. To deindex but keep crawlable, use noindex and allow crawling. To consolidate duplicates, use a self-consistent canonical that points to a live, indexable, self-canonical target.
4. Sitemap lists non-indexable URLs
  • Definition: The XML sitemap contains URLs that are non-200, redirect (3xx), carry noindex, or canonicalize to a different URL.
  • Why it matters: XML sitemaps are a statement of the canonical, indexable URL set. Polluting them with redirects, errors, or non-canonical URLs erodes trust in the sitemap and wastes crawl budget on dead ends.
  • Fix: Regenerate the sitemap to include only final, 200, self-canonical, indexable URLs. Automate generation from the canonical URL set rather than maintaining it by hand.
Medium severity
5. Indexable pages missing from the XML sitemap
  • Definition: Pages that are 200, not noindex, and self-canonical, but absent from every sitemap.
  • Why it matters: Sitemaps speed discovery and give search engines a complete picture of the canonical URL set. Gaps slow indexing of new or deep content.
  • Fix: Add the missing indexable URLs to the sitemap. If the gap is large and systematic (a whole section missing), fix the sitemap generator rather than patching URLs individually.
6. Excessive click depth or top-heavy depth distribution
  • Definition: Pages requiring more than 3 clicks from the homepage to reach, or a distribution where a large share of pages sit beyond depth 3.
  • Threshold: Flag click depth greater than --max-click-depth (default 3); depth greater than 5 is critical. Also flag when more than ~40% of crawled pages sit beyond the threshold.
  • Why it matters: Depth is a proxy for importance and for crawl reachability. Deep pages are crawled less often and accrue less internal equity.
  • Fix: Flatten the architecture with hub/category pages, contextual links from shallow high-authority pages, and a navigation that exposes key sections within two or three clicks.
7. URL structure problems
  • Definition: URLs that are hard to read, crawl, or maintain. Detected sub-cases:
    • Uppercase letters in the path (case-sensitivity duplicate risk)
    • Underscores as word separators (hyphens are the convention)
    • Spaces or percent-encoded characters in the path
    • Over-long URLs (path + query beyond ~115 characters)
    • Over-deep paths (more than --max-url-depth path segments, default 4)
    • ID-only or opaque slugs (final segment is all digits or a long random token, carrying no keyword signal)
  • Why it matters: Clean, lowercase, hyphenated, shallow, descriptive URLs are easier to crawl, share, and understand, and they reduce duplicate-content risk from case or encoding variants.
  • Fix: Adopt a lowercase, hyphenated, descriptive URL convention; 301 the old URLs to the new ones. Apply at the template level so new URLs are correct by default.
8. Inconsistent URL forms in internal links
  • Definition: The same site is linked using inconsistent URL forms. Detected sub-cases across the internal links found:
    • Mixed trailing slash (both /path and /path/ linked)
    • Mixed protocol (some internal links use http:// on an https:// site)
    • Mixed host (some links use www, others non-www)
  • Why it matters: Each inconsistent form is a redirect hop at best and a duplicate URL at worst. It dilutes equity and muddies canonicalization.
  • Fix: Standardize internal links on one form (canonical host, one trailing-slash convention) and enforce it in templates. Make sure server redirects normalize the other forms.
9. Crawlable parameter URLs
  • Definition: Internal links or crawled URLs carrying query strings that create duplicate or near-duplicate crawl spaces. Categorized as:
    • Tracking parameters in internal links (utm_*, gclid, fbclid)
    • Session identifiers (sid, sessionid, phpsessid)
    • Sort/filter/pagination parameters that multiply URLs
  • Why it matters: Parameterized duplicates waste crawl budget and split signals. Tracking parameters never belong on internal links.
  • Fix: Link to clean canonical URLs internally (strip tracking params). For functional parameters, set self-referencing canonicals to the clean URL, and where appropriate disallow the parameter space in robots.txt.
10. Missing or non-200 robots.txt
  • Definition: /robots.txt is absent or returns a non-200 status.
  • Why it matters: A missing robots.txt is not fatal (everything is allowed by default), but a 5xx robots.txt can cause search engines to pause crawling entirely, and the absence means no place to declare sitemaps or manage crawl-wasteful spaces.
  • Fix: Serve a valid 200 robots.txt, even a permissive one, and reference the sitemap from it.
11. No XML sitemap found
  • Definition: No sitemap at /sitemap.xml and none declared in robots.txt.
  • Why it matters: Sitemaps are the most direct discovery channel, especially for large sites, new content, and deep pages.
  • Fix: Generate an XML sitemap of canonical indexable URLs and declare it in robots.txt and Search Console.
Low severity
12. Pages missing a self-referencing canonical
  • Definition: Indexable 200 pages with no rel="canonical" at all.
  • Why it matters: A self-referencing canonical is a low-cost defense against parameter and variant duplication. Its absence is not an error but leaves the page exposed.
  • Fix: Add a self-referencing canonical to every indexable page at the template level.
13. Sitemap not referenced in robots.txt
  • Definition: A sitemap exists but is not declared with a Sitemap: line in robots.txt.
  • Why it matters: The robots.txt Sitemap: directive is a standard discovery path for every crawler.
  • Fix: Add Sitemap: https://example.com/sitemap.xml to robots.txt.
14. Thin or inconsistent directory taxonomy
  • Definition: First-level directories (sections) that contain only a single page, or a taxonomy where naming conventions are inconsistent across sections.
  • Why it matters: Single-page sections suggest a hierarchy that does not reflect real content groupings; inconsistent naming makes the architecture harder to reason about and scale.
  • Fix: Consolidate one-off sections into a relevant parent, or expand them into genuine hubs. Standardize directory naming.
15. Sitemap over size limits without a sitemap index
  • Definition: A single sitemap file with more than 50,000 URLs or larger than 50 MB uncompressed, with no sitemap index splitting it.
  • Why it matters: Over-limit sitemaps may be rejected or partially processed.
  • Fix: Split into multiple sitemaps under the limits and reference them from a sitemap index file.
Additional signals to note (informational)
  • Subdomain sprawl : content split across many subdomains (blog., shop., help.). Confirm it is intentional; subfolders usually consolidate authority better than subdomains.
  • hreflang presence : for international sites, note whether hreflang annotations exist and appear reciprocal. Full hreflang validation is out of scope here.
  • Breadcrumb wayfinding : deep pages without breadcrumb navigation are harder to place in the hierarchy (overlaps schema-markup-audit for BreadcrumbList).
  • Crawl vs sitemap count delta : a large gap between crawlable pages and sitemap URL count, in either direction, is worth explaining.
  • Mixed content : https:// pages linking http:// internal resources.

Report Template (references/report-template.md)

The output structure the skill follows when writing the final audit report, including the host-variant table and the click-depth distribution.

references/report-template.mdView on GitHub

Site Architecture Audit Report Template

Use this structure for the final report delivered to the user.

# Site Architecture Audit : {site}

**Date:** {date}
**Scope:** {scope description}
**Pages crawled:** {n} | **Canonical host:** {host} | **robots.txt:** {found/missing} | **Sitemap URLs:** {n}

## Summary

| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |

{2-3 sentence overview of the site's architectural health and the biggest wins.}

## Canonicalization and crawl controls

### Hostname / protocol variants

| Variant | Status | Resolves to | Canonical? |
|---|---|---|---|
| http://example.com | 301 | https://example.com/ | redirects (good) |
| https://www.example.com | 200 | https://www.example.com/ | duplicate (fix) |
| ... | ... | ... | ... |

### robots.txt

{Status, the Disallow rules that block indexable content, and whether a sitemap is declared.}

### Index-signal conflicts ({n})

| Page | Conflict | Suggested fix |
|---|---|---|
| ... | noindex + canonical to other URL | ... |

## URL and depth structure

### Click-depth distribution

| Depth | Pages |
|---|---|
| 0 | 1 |
| 1 | {n} |
| 2 | {n} |
| 3 | {n} |
| 4+ | {n} |

### URL-quality issues ({n})

| Page | Problem | Suggested URL |
|---|---|---|
| /Services/SEO_Audit | uppercase, underscore | /services/seo-audit |

### Directory map

| Section (first path segment) | Pages | Note |
|---|---|---|
| /blog | {n} | |
| /services | {n} | |
| /x | 1 | single-page section, consider consolidating |

## Sitemap health

### Non-indexable URLs in sitemap ({n})

| Sitemap URL | Problem | Suggested fix |
|---|---|---|
| ... | 301 redirect | list the final URL instead |

### Indexable pages missing from sitemap ({n})

| Page | Note |
|---|---|
| ... | add to sitemap |

## Prioritized action list

1. {Highest-impact fix, with exact hosts/URLs/rules}
2. ...
Guidance
  • Every issue row must name the exact URL or rule and a concrete fix, never generic advice.
  • Lead the action list with canonicalization and robots.txt issues: a single host-redirect or one over-broad Disallow line usually outweighs dozens of cosmetic URL fixes.
  • Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
  • Group templated problems (every product URL using underscores, a whole section missing from the sitemap) as one template-level fix with a few example URLs rather than listing hundreds of rows.
  • When recommending URL changes, always pair them with a 301 from the old URL so existing equity and links are preserved.
  • State thresholds you used (click-depth and URL-depth limits) so the user can recalibrate for their site type.

Architecture Crawler Script (scripts/crawl_architecture.py)

Crawls a live site within the configured page and depth limits, probes the four hostname and protocol variants, parses robots.txt, and collects every XML sitemap URL (following sitemap index files) into a JSON inventory.

scripts/crawl_architecture.pyView on GitHub
#!/usr/bin/env python3
"""Crawl a website and gather architecture signals into a JSON inventory.

Usage:
    python3 crawl_architecture.py https://example.com [--max-pages 500] [--max-depth 10] [--output architecture_inventory.json]

Gathers:
- Host/protocol variant probe (http/https x www/non-www): status + redirect target of each
- robots.txt: status, parsed user-agent groups (Disallow/Allow), Sitemap: directives, raw text
- XML sitemaps: every <loc>, following sitemap index files; total URL count
- Per crawled page: requested URL, final URL, status, redirect chain, click depth,
  scheme/host/path/query, path-segment count, canonical, meta robots, title,
  and the raw resolved internal link targets (for URL-form consistency checks)

Output JSON schema:
{
  "site": "https://example.com",
  "canonical_host": "https://example.com",
  "host_variants": {"<variant>": {"status": 301, "final_url": "..."}},
  "robots_txt": {"found": true, "status": 200, "sitemaps": [...],
                 "rules": [{"agent": "*", "disallow": [...], "allow": [...]}], "raw": "..."},
  "sitemap": {"found": true, "sources": [...], "url_count": 120, "urls": [...], "errors": [...]},
  "pages_crawled": 100,
  "pages": {
    "<requested url>": {
      "status": 200, "final_url": "...", "redirected": false, "redirect_chain": [],
      "depth": 0, "scheme": "https", "host": "example.com", "path": "/x", "query": "",
      "segments": 1, "canonical": "...|null", "meta_robots": "...|null", "title": "...|null",
      "links": ["<raw resolved internal target>", ...]
    }
  }
}

Uses only the Python standard library.
"""

import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import deque
from html.parser import HTMLParser

USER_AGENT = "SiteArchitectureAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
MAX_SITEMAPS = 50


class PageParser(HTMLParser):
    """Extract title, canonical, meta robots, and same-document link hrefs."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.title = None
        self.canonical = None
        self.meta_robots = None
        self.links = []
        self._in_title = False
        self._title_parts = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "title":
            self._in_title = True
            self._title_parts = []
        elif tag == "link" and (a.get("rel") or "").lower() == "canonical" and a.get("href"):
            self.canonical = urllib.parse.urljoin(self.base_url, a["href"].strip())
        elif tag == "meta" and (a.get("name") or "").lower() == "robots":
            self.meta_robots = a.get("content")
        elif tag == "a" and a.get("href"):
            href = a["href"].strip()
            if href.startswith(("mailto:", "tel:", "javascript:", "#")):
                return
            target = urllib.parse.urljoin(self.base_url, href)
            self.links.append(urllib.parse.urldefrag(target)[0])

    def handle_endtag(self, tag):
        if tag == "title" and self._in_title:
            self._in_title = False
            self.title = " ".join("".join(self._title_parts).split()) or None

    def handle_data(self, data):
        if self._in_title:
            self._title_parts.append(data)


def norm_host(host):
    host = host.lower()
    return host[4:] if host.startswith("www.") else host


def fetch(url, follow=True):
    """Return (status, final_url, html_or_none, redirect_chain).

    redirect_chain is a list of {"from": url, "status": code, "to": location}.
    """
    chain = []

    class ChainRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            chain.append({"from": req.full_url, "status": code, "to": newurl})
            if not follow:
                return None
            return super().redirect_request(req, fp, code, msg, headers, newurl)

    opener = urllib.request.build_opener(ChainRedirect)
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with opener.open(req, timeout=TIMEOUT) as resp:
            ctype = resp.headers.get("Content-Type", "")
            body = None
            if "html" in ctype or "xml" in ctype:
                body = resp.read(2_000_000).decode("utf-8", errors="replace")
            return resp.status, resp.geturl(), body, chain
    except urllib.error.HTTPError as e:
        if not follow and e.code in range(300, 400):
            loc = e.headers.get("Location")
            final = urllib.parse.urljoin(url, loc) if loc else url
            return e.code, final, None, chain
        return e.code, url, None, chain
    except Exception:
        return 0, url, None, chain  # 0 = network/connection error


def probe_host_variants(start):
    """Probe http/https x www/non-www; return dict and the chosen canonical host."""
    parsed = urllib.parse.urlparse(start)
    bare = norm_host(parsed.netloc)
    variants = {}
    for scheme in ("https", "http"):
        for host in (bare, "www." + bare):
            root = f"{scheme}://{host}/"
            status, final_url, _body, _chain = fetch(root, follow=False)
            variants[f"{scheme}://{host}"] = {"status": status, "final_url": final_url}

    # Canonical host = the 200 variant that the others redirect toward, preferring https.
    live_200 = [v for v, info in variants.items()
                if info["status"] == 200]
    canonical_host = None
    for pref in (f"https://{bare}", f"https://www.{bare}",
                 f"http://{bare}", f"http://www.{bare}"):
        if pref in variants and variants[pref]["status"] == 200:
            canonical_host = pref
            break
    if canonical_host is None and live_200:
        canonical_host = live_200[0]
    if canonical_host is None:
        canonical_host = f"{parsed.scheme}://{parsed.netloc}"
    return variants, canonical_host


def parse_robots(text):
    """Minimal robots.txt parse: groups of agents with allow/disallow, plus sitemaps.

    A run of consecutive User-agent lines shares one group. The first allow/disallow
    after a User-agent line closes the run, so the next User-agent starts a new group.
    """
    groups = []
    sitemaps = []
    current = None
    last_was_directive = False
    for raw_line in text.splitlines():
        line = raw_line.split("#", 1)[0].strip()
        if not line or ":" not in line:
            continue
        field, _, value = line.partition(":")
        field = field.strip().lower()
        value = value.strip()
        if field == "sitemap":
            if value:
                sitemaps.append(value)
            continue
        if field == "user-agent":
            if current is None or last_was_directive:
                current = {"agents": [], "disallow": [], "allow": []}
                groups.append(current)
            current["agents"].append(value)
            last_was_directive = False
        elif field in ("disallow", "allow") and current is not None:
            current[field].append(value)
            last_was_directive = True
    flat = []
    for g in groups:
        for agent in g["agents"]:
            flat.append({"agent": agent, "disallow": list(g["disallow"]),
                         "allow": list(g["allow"])})
    return flat, sitemaps


def fetch_robots(canonical_host):
    url = canonical_host.rstrip("/") + "/robots.txt"
    status, _final, body, _chain = fetch(url, follow=True)
    if status != 200 or body is None:
        # Try a plain fetch in case content-type was not html/xml
        try:
            req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
            with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
                status = resp.status
                body = resp.read(1_000_000).decode("utf-8", errors="replace")
        except urllib.error.HTTPError as e:
            return {"found": False, "status": e.code, "sitemaps": [], "rules": [], "raw": ""}
        except Exception:
            return {"found": False, "status": status, "sitemaps": [], "rules": [], "raw": ""}
    rules, sitemaps = parse_robots(body or "")
    return {"found": status == 200, "status": status, "sitemaps": sitemaps,
            "rules": rules, "raw": (body or "")[:20000]}


def collect_sitemap_urls(seed_sitemaps, canonical_host):
    """Follow sitemap index files and gather all page <loc> URLs."""
    SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
    seeds = list(seed_sitemaps)
    if not seeds:
        seeds = [canonical_host.rstrip("/") + "/sitemap.xml"]
    seen = set()
    queue = deque(seeds)
    page_urls = []
    sources = []
    errors = []
    found_any = False
    while queue and len(seen) < MAX_SITEMAPS:
        sm_url = queue.popleft()
        if sm_url in seen:
            continue
        seen.add(sm_url)
        status, _final, body, _chain = fetch(sm_url, follow=True)
        if status != 200 or not body:
            # retry plain
            try:
                req = urllib.request.Request(sm_url, headers={"User-Agent": USER_AGENT})
                with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
                    status = resp.status
                    body = resp.read(5_000_000).decode("utf-8", errors="replace")
            except Exception:
                errors.append({"sitemap": sm_url, "error": f"status {status}"})
                continue
        try:
            root = ET.fromstring(body)
        except ET.ParseError as e:
            errors.append({"sitemap": sm_url, "error": f"parse error: {e}"})
            continue
        found_any = True
        sources.append(sm_url)
        tag = root.tag.lower()
        if tag.endswith("sitemapindex"):
            for loc in root.iter(SM_NS + "loc"):
                if loc.text:
                    queue.append(loc.text.strip())
        else:  # urlset
            for loc in root.iter(SM_NS + "loc"):
                if loc.text:
                    page_urls.append(loc.text.strip())
    # Deduplicate while preserving order
    seen_u = set()
    deduped = []
    for u in page_urls:
        if u not in seen_u:
            seen_u.add(u)
            deduped.append(u)
    return {"found": found_any, "sources": sources, "url_count": len(deduped),
            "urls": deduped, "errors": errors}


def url_parts(url):
    p = urllib.parse.urlparse(url)
    path = p.path or "/"
    segments = [s for s in path.split("/") if s]
    return {"scheme": p.scheme, "host": p.netloc.lower(), "path": path,
            "query": p.query, "segments": len(segments)}


def main():
    ap = argparse.ArgumentParser(description="Crawl a site and gather architecture signals.")
    ap.add_argument("start_url")
    ap.add_argument("--max-pages", type=int, default=500)
    ap.add_argument("--max-depth", type=int, default=10)
    ap.add_argument("--output", default="architecture_inventory.json")
    args = ap.parse_args()

    start = args.start_url.rstrip("/")
    host = norm_host(urllib.parse.urlparse(start).netloc)

    print("Probing host/protocol variants...", file=sys.stderr)
    host_variants, canonical_host = probe_host_variants(start)
    print(f"Canonical host: {canonical_host}", file=sys.stderr)

    print("Fetching robots.txt...", file=sys.stderr)
    robots = fetch_robots(canonical_host)

    print("Collecting sitemap URLs...", file=sys.stderr)
    sitemap = collect_sitemap_urls(robots.get("sitemaps", []), canonical_host)
    print(f"Sitemap URLs found: {sitemap['url_count']}", file=sys.stderr)

    print("Crawling pages...", file=sys.stderr)
    pages = {}
    queue = deque([(start, 0)])
    seen = {start}
    while queue and len(pages) < args.max_pages:
        url, depth = queue.popleft()
        status, final_url, html, chain = fetch(url, follow=True)
        parts = url_parts(final_url if status < 400 else url)
        page = {
            "status": status,
            "final_url": final_url,
            "redirected": bool(chain),
            "redirect_chain": chain,
            "depth": depth,
            "scheme": parts["scheme"],
            "host": parts["host"],
            "path": parts["path"],
            "query": parts["query"],
            "segments": parts["segments"],
            "canonical": None,
            "meta_robots": None,
            "title": None,
            "links": [],
        }
        print(f"[{status}] depth={depth} {url}", file=sys.stderr)
        if html:
            parser = PageParser(final_url)
            try:
                parser.feed(html)
            except Exception:
                pass
            page["canonical"] = parser.canonical
            page["meta_robots"] = parser.meta_robots
            page["title"] = parser.title
            internal = []
            for target in parser.links:
                if not target.startswith(("http://", "https://")):
                    continue
                if norm_host(urllib.parse.urlparse(target).netloc) == host:
                    internal.append(target)
                    if target not in seen and depth + 1 <= args.max_depth:
                        seen.add(target)
                        queue.append((target, depth + 1))
            page["links"] = internal
        pages[url] = page

    inventory = {
        "site": start,
        "canonical_host": canonical_host,
        "host_variants": host_variants,
        "robots_txt": robots,
        "sitemap": sitemap,
        "pages_crawled": len(pages),
        "pages": pages,
    }
    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(inventory, f, indent=2, ensure_ascii=False)
    print(f"Wrote {len(pages)} pages to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Architecture Auditor Script (scripts/audit_architecture.py)

Runs the audit checks against the architecture inventory JSON, with options for click-depth, URL-depth, and tracking-parameter thresholds, and outputs the structured audit report data.

scripts/audit_architecture.pyView on GitHub
#!/usr/bin/env python3
"""Analyze an architecture inventory JSON (from crawl_architecture.py) and emit audit findings.

Usage:
    python3 audit_architecture.py architecture_inventory.json [--output audit_report.json]
        [--max-url-depth 4] [--max-click-depth 3] [--tracking-params utm_source,gclid,fbclid]

Checks implemented (see references/audit-checks.md):
- multiple_host_variants: more than one 200 host/protocol variant (high)
- robots_blocks_content: Disallow rules matching linked/indexable pages (high)
- directive_conflicts: disallow+noindex, noindex+canonical-elsewhere, canonical to bad target (high)
- sitemap_non_indexable: sitemap URLs that are non-200/redirect/noindex/non-canonical (high)
- sitemap_missing_pages: indexable pages absent from the sitemap (medium)
- deep_pages / depth_distribution: click depth issues (medium)
- url_quality: uppercase/underscore/space/over-long/over-deep/id-only URLs (medium)
- inconsistent_url_forms: mixed trailing slash, http/https, www in internal links (medium)
- parameter_urls: tracking/session/sort params in internal links (medium)
- missing_robots / missing_sitemap (medium)
- missing_self_canonical (low)
- sitemap_not_in_robots (low)
- thin_directories (low)
- sitemap_over_limit (low)

Uses only the Python standard library.
"""

import argparse
import json
import re
import sys
import urllib.parse
from collections import Counter, defaultdict

SITEMAP_URL_LIMIT = 50000
TRACKING_DEFAULT = ["utm_source", "utm_medium", "utm_campaign", "utm_term",
                    "utm_content", "gclid", "fbclid", "mc_cid", "mc_eid"]
SESSION_PARAMS = ["sid", "sessionid", "phpsessid", "jsessionid", "aspsessionid"]
ID_SLUG = re.compile(r"^(\d+|[0-9a-f]{12,}|[0-9a-f-]{20,})$", re.I)


def norm_host(host):
    host = host.lower()
    return host[4:] if host.startswith("www.") else host


def normalize_url(url):
    """Canonical comparison key: lowercase host, strip default ports and fragment, keep path/query."""
    p = urllib.parse.urlparse(url)
    netloc = p.netloc.lower()
    netloc = re.sub(r":(80|443)$", "", netloc)
    path = p.path or "/"
    return urllib.parse.urlunparse((p.scheme, netloc, path, "", p.query, ""))


def strip_slash(url):
    p = urllib.parse.urlparse(url)
    path = p.path.rstrip("/") or "/"
    return urllib.parse.urlunparse((p.scheme, p.netloc.lower(), path, "", p.query, ""))


def robots_match(path, rules, agent="*"):
    """Return True if path is Disallowed for the given agent (longest-match, Allow wins on ties)."""
    applicable = [r for r in rules if r["agent"] == agent]
    if not applicable:
        applicable = [r for r in rules if r["agent"] == "*"]
    if not applicable:
        return False
    disallow, allow = [], []
    for r in applicable:
        disallow.extend(r["disallow"])
        allow.extend(r["allow"])

    def pattern_len(pattern):
        if pattern == "":
            return None  # empty Disallow means allow everything
        regex = re.escape(pattern).replace(r"\*", ".*")
        if regex.endswith(r"\$"):
            regex = regex[:-2] + "$"
        m = re.match(regex, path)
        return len(pattern) if m else -1

    best_dis = max([pattern_len(p) for p in disallow if pattern_len(p) is not None] or [-1])
    best_allow = max([pattern_len(p) for p in allow if pattern_len(p) is not None] or [-1])
    if best_dis < 0:
        return False
    return best_dis > best_allow


def is_noindex(meta_robots):
    return "noindex" in (meta_robots or "").lower()


def main():
    ap = argparse.ArgumentParser(description="Run architecture audit checks on an inventory.")
    ap.add_argument("inventory")
    ap.add_argument("--output", default="audit_report.json")
    ap.add_argument("--max-url-depth", type=int, default=4)
    ap.add_argument("--max-click-depth", type=int, default=3)
    ap.add_argument("--tracking-params", default="")
    args = ap.parse_args()

    with open(args.inventory, encoding="utf-8") as f:
        inv = json.load(f)

    pages = inv["pages"]
    host_variants = inv.get("host_variants", {})
    robots = inv.get("robots_txt", {})
    sitemap = inv.get("sitemap", {})
    rules = robots.get("rules", [])
    tracking = set(TRACKING_DEFAULT + [t.strip() for t in args.tracking_params.split(",") if t.strip()])

    findings = {
        "multiple_host_variants": [], "robots_blocks_content": [], "directive_conflicts": [],
        "sitemap_non_indexable": [], "sitemap_missing_pages": [],
        "deep_pages": [], "url_quality": [], "inconsistent_url_forms": [],
        "parameter_urls": [], "missing_robots": [], "missing_sitemap": [],
        "missing_self_canonical": [], "sitemap_not_in_robots": [],
        "thin_directories": [], "sitemap_over_limit": [],
    }

    # --- 1. Host/protocol variants -------------------------------------------------
    live_200 = [v for v, info in host_variants.items() if info.get("status") == 200]
    if len(live_200) > 1:
        findings["multiple_host_variants"].append({
            "variants_200": live_200,
            "canonical_host": inv.get("canonical_host"),
            "detail": "More than one host/protocol variant returns 200; redirect all but the canonical host.",
        })

    # --- robots.txt / sitemap presence --------------------------------------------
    if not robots.get("found"):
        findings["missing_robots"].append(
            {"status": robots.get("status"),
             "detail": "robots.txt missing or non-200; serve a valid robots.txt and declare the sitemap."})
    if not sitemap.get("found"):
        findings["missing_sitemap"].append(
            {"detail": "No XML sitemap found at /sitemap.xml or via robots.txt."})
    elif not robots.get("sitemaps"):
        findings["sitemap_not_in_robots"].append(
            {"detail": "A sitemap exists but is not referenced with a Sitemap: line in robots.txt."})

    if sitemap.get("url_count", 0) > SITEMAP_URL_LIMIT and len(sitemap.get("sources", [])) <= 1:
        findings["sitemap_over_limit"].append(
            {"url_count": sitemap["url_count"],
             "detail": f"Single sitemap has {sitemap['url_count']} URLs (limit {SITEMAP_URL_LIMIT}); split with a sitemap index."})

    # --- Build lookups for indexability -------------------------------------------
    # A crawled page is "indexable" if 200, not noindex, and self-canonical (or no canonical).
    indexable_keys = {}     # normalized self-URL -> requested url
    page_by_norm = {}       # normalized requested url -> page
    canonical_target = {}   # normalized requested url -> normalized canonical
    status_by_norm = {}
    noindex_by_norm = {}
    redirected_by_norm = {}

    for url, page in pages.items():
        nkey = normalize_url(url)
        page_by_norm[nkey] = page
        status_by_norm[nkey] = page["status"]
        noindex_by_norm[nkey] = is_noindex(page.get("meta_robots"))
        redirected_by_norm[nkey] = page.get("redirected", False)
        canon = page.get("canonical")
        if canon:
            canonical_target[nkey] = normalize_url(canon)
        self_canon = (not canon) or strip_slash(canon) == strip_slash(url)
        if page["status"] == 200 and not noindex_by_norm[nkey] and self_canon:
            indexable_keys[nkey] = url

    # --- 2. robots.txt blocks indexable/linked content ----------------------------
    for nkey, url in indexable_keys.items():
        path = urllib.parse.urlparse(url).path or "/"
        if robots_match(path, rules):
            findings["robots_blocks_content"].append(
                {"page": url, "detail": "Disallowed in robots.txt but linked and otherwise indexable."})

    # --- 3. Directive conflicts ----------------------------------------------------
    for url, page in pages.items():
        nkey = normalize_url(url)
        path = urllib.parse.urlparse(url).path or "/"
        noidx = noindex_by_norm[nkey]
        canon = page.get("canonical")

        if noidx and robots_match(path, rules):
            findings["directive_conflicts"].append(
                {"page": url, "conflict": "disallow + noindex",
                 "detail": "Page is Disallowed in robots.txt, so the noindex can never be seen."})

        if noidx and canon and strip_slash(canon) != strip_slash(url):
            findings["directive_conflicts"].append(
                {"page": url, "conflict": "noindex + canonical elsewhere",
                 "detail": f"noindex contradicts canonical -> {canon}."})

        if canon:
            ckey = normalize_url(canon)
            if ckey in status_by_norm:
                if status_by_norm[ckey] >= 400 or status_by_norm[ckey] == 0:
                    findings["directive_conflicts"].append(
                        {"page": url, "conflict": "canonical to broken URL",
                         "detail": f"canonical -> {canon} returns {status_by_norm[ckey]}."})
                elif redirected_by_norm.get(ckey):
                    findings["directive_conflicts"].append(
                        {"page": url, "conflict": "canonical to redirect",
                         "detail": f"canonical -> {canon} is itself a redirect."})
                elif noindex_by_norm.get(ckey) and ckey != nkey:
                    findings["directive_conflicts"].append(
                        {"page": url, "conflict": "canonical to noindex",
                         "detail": f"canonical -> {canon} is noindex."})

    # --- 4 & 5. Sitemap parity -----------------------------------------------------
    sitemap_urls = sitemap.get("urls", [])
    sitemap_norm = {normalize_url(u): u for u in sitemap_urls}
    for nkey, original in sitemap_norm.items():
        page = page_by_norm.get(nkey)
        if page is None:
            continue  # not crawled; cannot assess
        problem = None
        if page["status"] >= 400 or page["status"] == 0:
            problem = f"returns {page['status']}"
        elif page.get("redirected"):
            problem = f"redirects to {page.get('final_url')}"
        elif is_noindex(page.get("meta_robots")):
            problem = "is noindex"
        else:
            canon = page.get("canonical")
            if canon and strip_slash(canon) != strip_slash(original):
                problem = f"canonical points to {canon}"
        if problem:
            findings["sitemap_non_indexable"].append({"sitemap_url": original, "problem": problem})

    sitemap_slash_set = {strip_slash(u) for u in sitemap_urls}
    if sitemap.get("found"):
        for nkey, url in indexable_keys.items():
            if strip_slash(url) not in sitemap_slash_set:
                findings["sitemap_missing_pages"].append({"page": url})

    # --- 6. Click depth ------------------------------------------------------------
    depth_hist = Counter()
    beyond = 0
    total = 0
    for url, page in pages.items():
        if page["status"] != 200:
            continue
        total += 1
        d = page["depth"]
        depth_hist[d] += 1
        if d > args.max_click_depth:
            beyond += 1
            findings["deep_pages"].append({"page": url, "depth": d})
    depth_distribution = {
        "histogram": dict(sorted(depth_hist.items())),
        "beyond_threshold": beyond,
        "share_beyond_threshold": round(beyond / total, 3) if total else 0,
        "threshold": args.max_click_depth,
    }

    # --- 7. URL quality ------------------------------------------------------------
    for url, page in pages.items():
        if page["status"] >= 400 or page["status"] == 0:
            continue
        p = urllib.parse.urlparse(url)
        path = p.path or "/"
        problems = []
        if re.search(r"[A-Z]", path):
            problems.append("uppercase")
        if "_" in path:
            problems.append("underscore")
        if "%20" in url or " " in path or re.search(r"%[0-9A-Fa-f]{2}", path):
            problems.append("space/encoded char")
        if len(url) > 115:
            problems.append(f"long ({len(url)} chars)")
        if page["segments"] > args.max_url_depth:
            problems.append(f"deep path ({page['segments']} segments)")
        last_seg = [s for s in path.split("/") if s]
        if last_seg and ID_SLUG.match(last_seg[-1]):
            problems.append("ID-only slug")
        if problems:
            findings["url_quality"].append({"page": url, "problems": problems})

    # --- 8. Inconsistent URL forms (across internal links) ------------------------
    link_forms = []
    for page in pages.values():
        link_forms.extend(page.get("links", []))
    slash_paths = defaultdict(set)  # path without slash -> set of "slash"/"noslash"
    schemes = set()
    www_forms = set()
    for target in link_forms:
        tp = urllib.parse.urlparse(target)
        schemes.add(tp.scheme)
        www_forms.add(tp.netloc.lower().startswith("www."))
        base = tp.path.rstrip("/")
        if tp.path.endswith("/") and tp.path != "/":
            slash_paths[base].add("slash")
        elif tp.path and tp.path != "/":
            slash_paths[base].add("noslash")
    mixed_slash = [b for b, forms in slash_paths.items() if len(forms) > 1]
    if mixed_slash:
        findings["inconsistent_url_forms"].append(
            {"type": "mixed trailing slash", "examples": mixed_slash[:10],
             "count": len(mixed_slash)})
    if "http" in schemes and "https" in schemes:
        findings["inconsistent_url_forms"].append(
            {"type": "mixed http/https in internal links"})
    if len(www_forms) > 1:
        findings["inconsistent_url_forms"].append(
            {"type": "mixed www / non-www in internal links"})

    # --- 9. Parameter URLs ---------------------------------------------------------
    seen_param = set()
    for target in link_forms:
        tp = urllib.parse.urlparse(target)
        if not tp.query:
            continue
        keys = {k.lower() for k, _ in urllib.parse.parse_qsl(tp.query)}
        cat = None
        if keys & tracking:
            cat = "tracking parameter in internal link"
        elif keys & set(SESSION_PARAMS):
            cat = "session id in URL"
        else:
            cat = "functional parameter (sort/filter/pagination)"
        key = (cat, urllib.parse.urlunparse(tp._replace(query="")), tuple(sorted(keys)))
        if key in seen_param:
            continue
        seen_param.add(key)
        findings["parameter_urls"].append(
            {"url": target, "category": cat, "params": sorted(keys)})

    # --- 12. Missing self-canonical ------------------------------------------------
    for url, page in pages.items():
        if page["status"] == 200 and not is_noindex(page.get("meta_robots")) and not page.get("canonical"):
            findings["missing_self_canonical"].append({"page": url})

    # --- 14. Directory taxonomy ----------------------------------------------------
    section_counts = Counter()
    for url, page in pages.items():
        if page["status"] != 200:
            continue
        segs = [s for s in urllib.parse.urlparse(url).path.split("/") if s]
        section = "/" + segs[0] if segs else "/"
        section_counts[section] += 1
    thin = [{"section": s, "pages": c} for s, c in section_counts.items()
            if c == 1 and s != "/"]
    if thin:
        findings["thin_directories"] = thin

    severity = {
        "multiple_host_variants": "high", "robots_blocks_content": "high",
        "directive_conflicts": "high", "sitemap_non_indexable": "high",
        "sitemap_missing_pages": "medium", "deep_pages": "medium",
        "url_quality": "medium", "inconsistent_url_forms": "medium",
        "parameter_urls": "medium", "missing_robots": "medium", "missing_sitemap": "medium",
        "missing_self_canonical": "low", "sitemap_not_in_robots": "low",
        "thin_directories": "low", "sitemap_over_limit": "low",
    }

    summary = {
        "pages_crawled": inv.get("pages_crawled"),
        "canonical_host": inv.get("canonical_host"),
        "robots_found": robots.get("found"),
        "sitemap_found": sitemap.get("found"),
        "sitemap_url_count": sitemap.get("url_count"),
        "depth_distribution": depth_distribution,
        "section_counts": dict(section_counts.most_common()),
        "issues_by_check": {k: len(v) for k, v in findings.items()},
        "issues_by_severity": {
            level: sum(len(findings[k]) for k, s in severity.items() if s == level)
            for level in ("high", "medium", "low")
        },
    }
    report = {"summary": summary, "severity": severity, "findings": findings,
              "host_variants": host_variants}

    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(report, f, indent=2, ensure_ascii=False)
    print(json.dumps(summary, indent=2))
    print(f"Full report written to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Explore Other Claude SEO Skills

Heading Structure Audit Audits a site's heading hierarchy for missing or multiple H1s, skipped heading levels, empty headings, headings that don't start at H1, overly long headings, and duplicate heading text. heading-structure-audit Open Image SEO Audit Audits a site's images for SEO and Core Web Vitals: missing alt text, absent width/height, missing lazy-loading, oversized files, legacy formats, generic filenames, and broken images. image-seo-audit Open Core Web Vitals Audit Audits a site's Core Web Vitals and page-experience signals (LCP, CLS, INP, TTFB), pulling real CrUX field data from PageSpeed Insights and collecting lab proxies like render-blocking resources, page weight, and image hygiene. core-web-vitals-audit Open Redirect Audit Audits a site's redirects for redirect chains and loops, redirects ending in 404s or errors, 302s used for permanent moves, HTTPS-to-HTTP downgrades, client-side redirects, dropped query strings, and canonicals pointing at redirecting URLs. redirect-audit Open Robots.txt Audit Audits a site's robots.txt for a redirected or HTML-served file, a 5xx read as block-everything, a sitewide Disallow, syntax errors, blocked CSS/JS, missing Sitemap directives, unsupported directives, oversized files, and encoding issues. robots-txt-audit Open Sitemap Audit Audits a site's XML sitemaps: discovering them from robots.txt, following sitemap index files, and validating every URL for broken or redirecting targets, noindexed or canonicalized pages, wrong host or protocol, duplicates, oversized sitemaps, and invalid lastmod values. sitemap-audit Open Llms.txt Audit Audits a site's /llms.txt for AI and LLM discoverability: missing files, HTML served instead of markdown, a missing H1 or summary, broken or redirecting links, links missing descriptions, wrong-host or relative URLs, and a companion /llms-full.txt. llms-txt-audit Open Canonical Tag Audit Audits a site's rel=canonical tags for missing canonicals, multiple conflicting canonicals, canonicals pointing at non-200, redirecting, or noindexed URLs, relative or cross-host canonicals, https-to-http downgrades, and Link-header conflicts. canonical-tag-audit Open Content Quality Audit Audits a site's content quality for thin content, exact-duplicate body text across pages, near-duplicate (templated) pages, low text-to-HTML ratio, and pages missing an H1. content-quality-audit Open Keyword Cannibalization Audit Audits a site for keyword cannibalization: pages with duplicate title targets, clusters of pages whose title, H1, and keyword signatures overlap, and pages sharing the same primary keyword phrase, with a recommended hierarchy. keyword-cannibalization-audit Open Mixed Content Audit Audits an HTTPS site for mixed content: active mixed content (http scripts, styles, iframes browsers block), insecure form actions, passive mixed content (http images and media), protocol-relative URLs, and pages still served over http. mixed-content-audit Open Open Graph Audit Audits a site's Open Graph and Twitter Card metadata: missing og:title, og:description, og:image, or og:url, broken or relative og:image URLs, og:url that mismatches the canonical, missing twitter:card, over-length social titles, and reused default images. open-graph-audit Open Pagination Audit Audits a site's pagination: paginated pages canonicalizing to page 1 and hiding deep content, noindexed component pages, paginated pages missing a self-canonical, broken or inconsistent rel=next/prev, and redundant ?page=1 first pages. pagination-audit Open Soft 404 Audit Audits a site for soft 404s: error pages served with a 200 status, servers that return 200 for every unknown URL, thin pages matching the site's error template, and empty or near-empty pages. soft-404-audit Open Full SEO Audit The orchestrator skill that runs every individual audit below and consolidates the findings into one prioritized, cross-area report ranked by impact, for a complete technical-SEO review in a single pass. full-seo-audit Open Internal Link Audit Audits a site's internal linking structure for broken links, orphan pages, redirect chains, click depth, and anchor text quality. internal-link-audit Open External Link Audit Audits a site's external links for broken targets, redirects, insecure HTTP links, missing rel attributes on affiliate links, and unsafe target="_blank" usage. external-link-audit Open Meta Data Audit Audits a site's title tags and meta descriptions for missing, duplicate, too-long, too-short, or multiple tags, generic boilerplate titles, and keyword stuffing. meta-data-audit Open Schema Markup Audit Audits a site's structured data: extracting JSON-LD, Microdata, and RDFa, then validating it against schema.org and Google rich result requirements. schema-markup-audit Open