Full SEO Audit Claude Skill
This skill runs a complete technical-SEO audit of a website by orchestrating every individual audit skill, sitemap, robots.txt, redirects, internal and external links, metadata, headings, images, schema markup, canonical tags, Open Graph, mixed content, pagination, soft 404s, content quality, keyword cannibalization, site architecture, Core Web Vitals, and llms.txt, then consolidates the findings into one prioritized report ranked by impact, not by which audit found the issue.
What The Skill Checks
- Crawlability and indexation: sitemap, robots.txt, redirects, canonical tags, pagination, and soft 404s.
- On-page and content: metadata, headings, images, schema markup, Open Graph, content quality, and keyword cannibalization.
- Site health and AI readiness: internal and external links, site architecture, mixed content, Core Web Vitals, and llms.txt, all consolidated into one impact-ranked report.
How The Skill Is Packaged
The skill follows the standard Claude Agent Skill structure: a SKILL.md orchestrator with YAML frontmatter and workflow instructions, a references/ folder with a coverage map of every audit it runs and the consolidated report template, and a scripts/ folder with the shared fetch_pages.py crawler that fetches the site once into a page_cache.json every page-based sub-audit reads from, plus a Python script that merges the individual reports into one prioritized output. 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 full-seo-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 orchestrator definition: frontmatter, inputs to collect, and how it runs and sequences the per-area skills.
Full SEO Audit
Run the individual SEO audit skills, then merge their findings into one consolidated, prioritized report. This skill is the orchestrator; the real work is done by the per-area skills it coordinates.
When to use this skill
Use this when the user wants the whole picture rather than one area:
- "Run a full / complete / comprehensive SEO audit of example.com"
- "Do a technical SEO review of my site"
- "Give me one report covering everything that's wrong with my SEO"
For a single area ("check my sitemap", "audit my titles"), use that area's skill directly instead.
Inputs to collect
- Site URL — the live site root (e.g.,
https://example.com). - Scope — how many pages to crawl (default ~200) and how many sitemap URLs to probe. Larger sites should be sampled.
- Focus — run everything (default), or a chosen subset (e.g., "just crawlability and indexation"). Map the user's intent to the skills below.
- API key (optional) — Core Web Vitals field data needs a Google PageSpeed Insights key; without it, that audit uses lab proxies only.
Coverage map
Each row is a separate skill. Run the ones in scope.
| Area | Skill | Crawls / fetches |
|---|---|---|
| Indexation: sitemaps | sitemap-audit | XML sitemaps |
| Crawlability: robots.txt | robots-txt-audit | robots.txt |
| Crawlability: architecture | site-architecture-audit | site + robots + sitemap |
| Redirects | redirect-audit | URL list / crawl |
| Internal links | internal-link-audit | crawl |
| External links | external-link-audit | crawl |
| Canonical tags | canonical-tag-audit | crawl |
| Pagination | pagination-audit | crawl |
| Soft 404s | soft-404-audit | crawl |
| Titles & descriptions | meta-data-audit | crawl |
| Headings | heading-structure-audit | crawl |
| Images | image-seo-audit | crawl |
| Structured data | schema-markup-audit | crawl |
| Social metadata | open-graph-audit | crawl |
| Content quality | content-quality-audit | crawl |
| Keyword cannibalization | keyword-cannibalization-audit | crawl |
| HTTPS / mixed content | mixed-content-audit | crawl |
| Page experience | core-web-vitals-audit | per-URL |
| AI discoverability | llms-txt-audit | /llms.txt |
Workflow
Step 1: Crawl once into a shared page cache
The page-based audits all read the same HTML, so crawl the site a single time with the shared fetch stage and let every extractor read from that cache:
mkdir -p audits
python3 scripts/fetch_pages.py https://example.com --max-pages 200 --output audits/page_cache.json
Step 2: Extract + audit each in-scope area
For each page-based area in scope, run its extractor with --from-cache (pure,
no extra crawl) then its audit script. Save each audit output to the shared
folder using a stable, skill-named filename so they can be merged:
# repeat per in-scope page-based skill — all read the ONE page_cache.json
python3 ../meta-data-audit/scripts/extract_metadata.py --from-cache audits/page_cache.json --output audits/meta.inventory.json
python3 ../meta-data-audit/scripts/audit_metadata.py audits/meta.inventory.json --output audits/meta-data-audit.audit_report.json
python3 ../heading-structure-audit/scripts/extract_headings.py --from-cache audits/page_cache.json --output audits/head.inventory.json
python3 ../heading-structure-audit/scripts/audit_headings.py audits/head.inventory.json --output audits/heading-structure-audit.audit_report.json
The skills that read --from-cache are: meta-data, heading-structure,
image-seo, schema-markup, open-graph, content-quality, keyword-cannibalization,
mixed-content, canonical-tag, pagination, soft-404, and external-link. (A few
also run a small secondary probe — canonical targets, share images, the soft-404
missing-page probe — on top of the cached crawl.)
The non-page audits fetch their own thing and don't use the page cache: run them
per their own SKILL.md — sitemap-audit, robots-txt-audit, llms-txt-audit,
redirect-audit, core-web-vitals-audit, plus internal-link-audit and
site-architecture-audit (which crawl with depth tracking of their own).
Step 3: Consolidate
Merge every *.audit_report.json into one cross-skill view:
python3 scripts/consolidate_reports.py --dir audits --output consolidated_report.json
The consolidator emits total issues by severity, a per-skill breakdown, and a flat list of every failing check ranked by severity then count.
Step 4: Produce the consolidated report
Write the report following references/report-template.md:
- Executive summary — overall health, issue counts by severity, the 3–5 highest-impact fixes across all areas.
- Scorecard — one row per audited area with its issue counts and a status.
- Findings by area — a short section per skill summarizing its top issues and linking to (or inlining) that skill's own detailed report.
- Prioritized roadmap — a single cross-area action list ordered by impact, not by area.
Step 5: Prioritize across areas
Rank fixes by impact, not by which skill found them. Apply this order:
- Indexation blockers first — anything stopping pages from being crawled or
indexed (robots
Disallow: /, sitewide noindex, sitemap full of dead URLs, broken canonicals) outranks on-page polish. - Sitewide/template issues over one-off issues — a bug in one template affecting 500 pages beats a single page's long title.
- High-traffic, high-intent pages first within a given fix.
- Group related findings across skills (e.g., the same
http→httpsmigration surfaces in sitemap, redirect, internal-link, canonical, and mixed-content audits — present it once as a single root-cause fix).
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/report-template.md— consolidated report structurereferences/coverage-map.md— what each sub-skill covers and what it needsscripts/consolidate_reports.py— merge per-skill audit reports into one ranked report
Coverage Map (references/coverage-map.md)
Maps every SEO area to the individual skill that covers it, so the orchestrator knows what to run and how findings map back.
Coverage map
What each sub-skill audits, the scripts it runs, and what it needs as input. Use this to decide which skills are in scope for a given request and to map a user's words ("indexation", "page speed", "international") to the right skills.
Crawlability & indexation
| Skill | Finds | Needs |
|---|---|---|
robots-txt-audit | Sitewide Disallow: /, syntax errors, blocked CSS/JS, missing/unreachable Sitemap:, unsupported directives | robots.txt |
sitemap-audit | Dead/redirecting/noindexed/blocked URLs in sitemaps, size limits, lastmod hygiene | sitemap(s) |
site-architecture-audit | Host/protocol canonicalization, sitemap parity, crawl/index conflicts, click depth, URL quality | site + robots + sitemap |
redirect-audit | Redirect chains/loops, 302-for-permanent, HTTPS→HTTP, meta/JS redirects, dropped query strings | URL list or crawl |
soft-404-audit | Pages that return 200 but are really "not found" / empty | crawl |
Links
| Skill | Finds | Needs |
|---|---|---|
internal-link-audit | Broken links, orphans, redirect chains, click depth, anchor quality, equity distribution | crawl |
external-link-audit | Broken/redirected outbound links, insecure HTTP, rel="sponsored" compliance, unsafe target="_blank" | crawl |
pagination-audit | rel=next/prev, canonical-on-paginated mistakes, orphaned/duplicated paginated pages | crawl |
On-page & indexation signals
| Skill | Finds | Needs |
|---|---|---|
meta-data-audit | Missing/duplicate/long/short/generic titles & descriptions, stuffing | crawl |
heading-structure-audit | Missing/multiple H1s, skipped levels, empty/long/duplicate headings | crawl |
canonical-tag-audit | Missing/duplicate/relative canonicals, canonical to non-200/redirect/noindex, cross-host, self vs. cluster | crawl |
schema-markup-audit | Missing/invalid/incomplete JSON-LD, Microdata, RDFa vs. Google requirements | crawl |
open-graph-audit | Missing/duplicate OG & Twitter Card tags, bad image dimensions, og:url mismatches | crawl |
Content
| Skill | Finds | Needs |
|---|---|---|
content-quality-audit | Thin content, low text-to-HTML, duplicate/near-duplicate bodies, missing main content | crawl |
keyword-cannibalization-audit | Multiple pages targeting the same query via title/H1/overlap | crawl |
Security & delivery
| Skill | Finds | Needs |
|---|---|---|
mixed-content-audit | HTTP subresources on HTTPS pages, insecure form actions, upgradeable links | crawl |
core-web-vitals-audit | LCP/CLS/INP/TTFB field data (with API key) + lab proxies | per-URL; optional PSI key |
AI discoverability
| Skill | Finds | Needs |
|---|---|---|
llms-txt-audit | Missing/malformed /llms.txt, broken links, missing descriptions | /llms.txt |
Sharing one crawl
Most on-page skills crawl HTML. To avoid hammering the site, prefer running the
broadest crawler first (e.g. site-architecture-audit or internal-link-audit)
and feeding the discovered URL set to the others via their --url-list mode
where supported, or cap --max-pages consistently across skills.
Consolidated Report Template (references/report-template.md)
The structure of the single, cross-area report, with findings grouped and ranked by impact.
Full SEO Audit Report Template
Use this structure for the consolidated report delivered to the user.
# Full SEO Audit — {site}
**Date:** {date}
**Scope:** {pages crawled, sitemap URLs probed, areas audited}
**Areas audited:** {n} of {total}
## Executive summary
| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |
{3–5 sentences: overall health, the single biggest theme (e.g. an incomplete
http→https migration), and the highest-impact fixes.}
**Top 5 fixes (across all areas):**
1. {Highest-impact fix, the area(s) it touches, and the pages affected}
2. ...
## Scorecard
| Area | High | Medium | Low | Status |
|---|---|---|---|---|
| Sitemaps | {n} | {n} | {n} | 🔴 / 🟡 / 🟢 |
| robots.txt | … | … | … | … |
| Internal links | … | … | … | … |
| Metadata | … | … | … | … |
| … one row per audited area … | | | | |
## Findings by area
### {Area name}
{2–4 sentence summary of this area's top issues, with counts. Link to or inline
the area's detailed report. Do not repeat every row here — point to the per-skill
report for the full list.}
{Repeat per audited area, ordered by severity of findings.}
## Prioritized roadmap
Ordered by impact across all areas, not by which audit found the issue.
1. **{Fix}** — {severity}, {pages/areas affected}. {One line on why it's first.}
2. ...
### Quick wins (low effort, real impact)
- {Bulleted shortlist of cheap fixes}
### Root-cause groupings
- **{e.g. http→https migration}** surfaces across {sitemap, redirects, internal
links, canonical, mixed content}. Fix once at the source: {the fix}.
Guidance
- Prioritize across areas, not within them. A single template bug affecting 500 pages outranks a worse-sounding issue on one page. Indexation blockers (robots, noindex, broken canonicals, dead sitemap URLs) come before on-page polish.
- Collapse duplicated root causes. The same migration or template defect often trips several audits; present it once with all the symptoms it explains.
- Don't drown the reader. The consolidated report summarizes; the per-skill reports hold the exhaustive tables. Link to them.
- Every claim traces to data. Pull counts from
consolidated_report.jsonand specifics from each skill'saudit_report.json. Never invent URLs or numbers. - State coverage honestly. List which areas were audited and which were skipped (and why — e.g. "Core Web Vitals field data skipped: no PSI key").
Shared Page Crawler (scripts/fetch_pages.py)
Crawls the site a single time and stores the raw HTML and response facts in one page_cache.json, so every page-based sub-audit extracts from the same snapshot instead of re-crawling per area.
#!/usr/bin/env python3
"""Crawl a website once and write a raw page cache that every audit can extract from.
This is the *fetch* stage of the pipeline, split out from per-skill collectors so a
site is crawled a single time and all audits read from the same snapshot:
fetch (this script) -> page_cache.json -> extract_*.py (pure) -> *_inventory.json
The cache stores raw HTML and the response facts every collector needs, and nothing
audit-specific — each skill's extractor pulls only the bits it cares about. Because
fetching is the only network stage, everything downstream is pure and re-runnable
offline.
Usage:
python3 fetch_pages.py https://example.com [--max-pages 500] [--output page_cache.json]
python3 fetch_pages.py ./site-folder --local [--output page_cache.json]
python3 fetch_pages.py urls.txt --url-list [--output page_cache.json]
Output JSON schema:
{
"site": "https://example.com",
"mode": "crawl" | "local" | "url-list",
"fetched_at": "2026-06-19T12:00:00Z",
"pages": {
"<requested url or local relpath>": {
"status": 200, # HTTP status; 0 = network/connection error
"final_url": "<url after redirects>",
"content_type": "text/html; charset=utf-8",
"headers": {"Link": "...", "X-Robots-Tag": "..."}, # response headers
"html": "<!doctype html>..." # null for non-HTML or failed fetches
}
}
}
Uses only the Python standard library.
"""
import argparse
import datetime
import json
import os
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 = "SEOAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
MAX_BYTES = 2_000_000
class LinkParser(HTMLParser):
"""Pull same-document <a href> links so the crawler can do a same-host BFS.
The fetch stage only needs links to know where to crawl next — every other
tag is left for the per-skill extractors to parse from the cached HTML.
"""
def __init__(self, base_url):
super().__init__(convert_charrefs=True)
self.base_url = base_url
self.links = []
def handle_starttag(self, tag, attrs):
if tag != "a":
return
a = dict(attrs)
href = (a.get("href") or "").strip()
if not href or href.startswith(("mailto:", "tel:", "javascript:", "#")):
return
target = urllib.parse.urljoin(self.base_url, href)
self.links.append(urllib.parse.urldefrag(target)[0])
def fetch(url):
"""Return (status, final_url, content_type, headers, html_or_none)."""
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
ctype = resp.headers.get("Content-Type", "")
body = None
if "html" in ctype or "xml" in ctype:
body = resp.read(MAX_BYTES).decode("utf-8", errors="replace")
return resp.status, resp.geturl(), ctype, dict(resp.headers), body
except urllib.error.HTTPError as e:
hdrs = dict(e.headers or {})
return e.code, url, hdrs.get("Content-Type", ""), hdrs, None
except Exception:
return 0, url, "", {}, None # 0 = network/connection error
def norm_host(url):
host = urllib.parse.urlparse(url).netloc.lower()
return host[4:] if host.startswith("www.") else host
def links_in(final_url, html):
parser = LinkParser(final_url)
try:
parser.feed(html)
except Exception:
pass
return parser.links
def pages_from_sitemap(site):
_status, _final, _ctype, _headers, body = fetch(urllib.parse.urljoin(site, "/sitemap.xml"))
if not body:
return []
urls = []
try:
root = ET.fromstring(body)
for loc in root.iter("{http://www.sitemaps.org/schemas/sitemap/0.9}loc"):
if loc.text:
urls.append(loc.text.strip())
except ET.ParseError:
return []
return urls
def record(status, final_url, content_type, headers, html):
return {"status": status, "final_url": final_url,
"content_type": content_type, "headers": headers, "html": html}
def crawl(site, max_pages):
start = site.rstrip("/")
host = norm_host(start)
seeds = pages_from_sitemap(start) or [start]
queue = deque(seeds[:max_pages])
seen = set(queue)
pages = {}
while queue and len(pages) < max_pages:
url = queue.popleft()
status, final_url, ctype, headers, html = fetch(url)
print(f"[{status}] {url}", file=sys.stderr)
pages[url] = record(status, final_url, ctype, headers, html)
if not html:
continue
for link in links_in(final_url, html):
if norm_host(link) == host and link not in seen:
seen.add(link)
queue.append(link)
return pages
def scan_local(folder):
pages = {}
for root, _dirs, files in os.walk(folder):
for name in files:
if name.lower().endswith((".html", ".htm")):
path = os.path.join(root, name)
with open(path, encoding="utf-8", errors="replace") as f:
html = f.read()
rel = os.path.relpath(path, folder)
pages[rel] = record(200, "file://" + os.path.abspath(path),
"text/html", {}, html)
return pages
def scan_url_list(path, max_pages):
pages = {}
with open(path, encoding="utf-8") as f:
urls = [line.strip() for line in f if line.strip() and not line.startswith("#")]
for url in urls[:max_pages]:
status, final_url, ctype, headers, html = fetch(url)
print(f"[{status}] {url}", file=sys.stderr)
pages[url] = record(status, final_url, ctype, headers, html)
return pages
def main():
ap = argparse.ArgumentParser(description="Crawl once into a raw page cache for SEO audits.")
ap.add_argument("source", help="Site root URL, local folder (--local), or URL list file (--url-list)")
ap.add_argument("--max-pages", type=int, default=500)
ap.add_argument("--output", default="page_cache.json")
ap.add_argument("--local", action="store_true", help="Treat source as a local folder of HTML files")
ap.add_argument("--url-list", action="store_true", help="Treat source as a text file of URLs")
args = ap.parse_args()
if args.local:
mode, pages = "local", scan_local(args.source)
elif args.url_list:
mode, pages = "url-list", scan_url_list(args.source, args.max_pages)
else:
mode, pages = "crawl", crawl(args.source, args.max_pages)
cache = {
"site": args.source,
"mode": mode,
"fetched_at": datetime.datetime.now(datetime.timezone.utc)
.strftime("%Y-%m-%dT%H:%M:%SZ"),
"pages": pages,
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=2, ensure_ascii=False)
html_pages = sum(1 for p in pages.values() if p["html"])
print(f"Cached {len(pages)} pages ({html_pages} with HTML) to {args.output}",
file=sys.stderr)
if __name__ == "__main__":
main()
Report Consolidator Script (scripts/consolidate_reports.py)
Merges the output of every individual audit into one prioritized report ranked by impact across areas.
#!/usr/bin/env python3
"""Merge multiple per-skill audit_report.json files into one consolidated report.
Each individual audit skill writes an audit report shaped like:
{ "summary": {...}, "severity": {"<check>": "high"|"medium"|"low"|"info", ...},
"findings": {"<check>": [ ... ], ...} }
This script reads several of those (by file, glob, or directory) and produces a
single cross-skill view: total issues by severity, a per-skill breakdown, and a
flat, severity-ranked list of every failing check.
Usage:
python3 consolidate_reports.py report1.json report2.json ... [--output consolidated.json]
python3 consolidate_reports.py --dir ./audits [--output consolidated.json]
The skill name for each report is inferred from the filename (e.g.
`sitemap-audit.audit_report.json` or `sitemap_audit_report.json` -> "sitemap").
Override per file with `name=path`:
python3 consolidate_reports.py sitemap=sm.json meta=md.json
Uses only the Python standard library.
"""
import argparse
import glob
import json
import os
import sys
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
def infer_name(path):
base = os.path.basename(path)
for suffix in (".audit_report.json", "_audit_report.json", ".report.json",
"_report.json", ".json"):
if base.endswith(suffix):
base = base[: -len(suffix)]
break
return base.replace("_", "-").replace(".", "-") or "report"
def load_report(name, path):
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f" skipped {path}: {e}", file=sys.stderr)
return None
severity = data.get("severity", {})
findings = data.get("findings", {})
checks = []
for check, items in findings.items():
count = len(items) if isinstance(items, list) else int(bool(items))
if count == 0:
continue
checks.append({
"skill": name,
"check": check,
"severity": severity.get(check, "info"),
"count": count,
})
return checks
def main():
ap = argparse.ArgumentParser(description="Consolidate per-skill audit reports.")
ap.add_argument("reports", nargs="*",
help="Report JSON files, optionally as name=path. Or use --dir.")
ap.add_argument("--dir", help="Directory to scan for *report*.json files")
ap.add_argument("--output", default="consolidated_report.json")
args = ap.parse_args()
inputs = [] # (name, path)
for token in args.reports:
if "=" in token and not os.path.exists(token):
name, path = token.split("=", 1)
inputs.append((name, path))
else:
for path in glob.glob(token) or [token]:
inputs.append((infer_name(path), path))
if args.dir:
for path in sorted(glob.glob(os.path.join(args.dir, "*report*.json"))):
inputs.append((infer_name(path), path))
if not inputs:
ap.error("no report files given (pass files or --dir)")
all_checks = []
per_skill = {}
for name, path in inputs:
checks = load_report(name, path)
if checks is None:
continue
all_checks.extend(checks)
bucket = per_skill.setdefault(name, {"high": 0, "medium": 0, "low": 0, "info": 0})
for c in checks:
bucket[c["severity"]] = bucket.get(c["severity"], 0) + c["count"]
all_checks.sort(key=lambda c: (SEVERITY_ORDER.get(c["severity"], 9),
-c["count"], c["skill"], c["check"]))
totals = {"high": 0, "medium": 0, "low": 0, "info": 0}
for c in all_checks:
totals[c["severity"]] = totals.get(c["severity"], 0) + c["count"]
consolidated = {
"summary": {
"skills_audited": sorted(per_skill),
"issues_by_severity": totals,
"total_issues": sum(totals.values()),
"per_skill": per_skill,
},
"checks_ranked": all_checks,
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(consolidated, f, indent=2, ensure_ascii=False)
print(json.dumps(consolidated["summary"], indent=2))
print(f"\nConsolidated {len(per_skill)} report(s), "
f"{consolidated['summary']['total_issues']} total issues, "
f"written to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()