Claude AI

Keyword Cannibalization Audit Claude Skill

This skill audits a website for keyword cannibalization, multiple pages competing for the same search query, and produces an actionable report. It crawls pages and compares titles, H1s, and body keywords to find pages with duplicate title targets, clusters of pages whose keyword signatures overlap, and pages sharing the same primary keyword phrase. Findings are candidates to confirm: some overlap is legitimate, so the skill surfaces the clusters and recommends a hierarchy.

What The Skill Checks

  • Duplicate targeting: pages with duplicate title targets and pages sharing the same primary keyword phrase.
  • Overlap clusters: groups of pages whose title, H1, and body-keyword signatures overlap above the Jaccard similarity threshold.
  • Resolution guidance: a recommended page hierarchy for each cluster, with brand-suffix handling so brand words don't inflate similarity.

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 extract each page's keyword targets (title, H1, body), then compare signatures and audit for cannibalization clusters. 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 keyword-cannibalization-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 workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
keyword-cannibalization-audit
description
Audit a website for keyword cannibalization — multiple pages competing for the same search query. Crawls pages and compares titles, H1s, and body keywords to find pages with duplicate title targets, clusters of pages whose keyword signatures overlap, and pages sharing the same primary keyword phrase. Use when the user asks to audit, check, or fix keyword cannibalization, pages competing for the same keyword, overlapping/competing content, keyword overlap, or which page should rank for a query on a website.

Keyword Cannibalization Audit

Audit a website for pages that compete with each other for the same query, and produce an actionable report. Findings are candidates to confirm — some overlap is legitimate; this skill surfaces the clusters and recommends a hierarchy.

When to use this skill

Use this skill when the user asks to:

  • Audit or fix keyword cannibalization
  • Find pages competing for the same keyword / query
  • Decide which page should rank for a topic and what to do with the rest
  • Review overlapping or competing content
Inputs to collect
  1. Site URL or URL list — a live site root to crawl (auto-seeds from /sitemap.xml), or a text file with --url-list.
  2. Scope — pages to crawl (default 500, --max-pages).
  3. Brand suffix — e.g. "| Acme Co" so brand words don't inflate title similarity (--brand-suffix).
  4. Overlap threshold — Jaccard similarity to flag competing pages (default 0.6, --overlap).
Workflow
Step 1: Crawl and capture target signals

Use scripts/extract_targets.py:

python3 scripts/extract_targets.py https://example.com --max-pages 500 --output target_inventory.json
# already crawled once (e.g. in a full SEO audit)? skip the crawl and reuse the shared cache:
# python3 scripts/fetch_pages.py https://example.com --output page_cache.json
# python3 scripts/extract_targets.py --from-cache page_cache.json --output target_inventory.json

For each page it records title, H1, meta description, noindex, word count, and the top body keywords by frequency.

Step 2: Run the audit checks
python3 scripts/audit_cannibalization.py target_inventory.json --brand-suffix "| Acme Co" --overlap 0.6 --output audit_report.json
Step 3: Evaluate the audit checks

Evaluate each check in references/audit-checks.md. Core checks:

CheckSeverity
Duplicate title targetHigh
Overlapping target cluster (keyword signatures)Medium
Shared primary keyword (title bigram)Low
Step 4: Produce the report

Write a report following references/report-template.md: summary, duplicate title targets first, then overlapping clusters (largest first) each with a recommended hierarchy, and a prioritized action list.

Step 5: Recommend fixes
  • For each cluster, name one primary page and a role for every other page: differentiate to a distinct sub-query, merge, or 301/canonicalize into the primary.
  • Frame as hierarchy, not deletion — supporting pages that target distinct intents and link up to the primary are healthy.
  • Always recommend confirming with Search Console query data before consolidating: this audit infers competition from on-page signals.
  • Related angles: meta-data-audit (duplicate titles) and content-quality-audit (duplicate bodies).
  • Base every grouping on observed signatures; never invent rankings.
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
  • references/report-template.md — report output structure
  • scripts/extract_targets.py — crawl and capture keyword-target signals
  • scripts/audit_cannibalization.py — run cannibalization audit checks

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

Full definitions, severities, and rationale for every check across the high, medium, and low tiers.

references/audit-checks.mdView on GitHub

Keyword Cannibalization Audit Checks

Definitions, thresholds, and rationale. Cannibalization is when several pages on the same site target the same query, so they compete with each other in the results — splitting links, clicks, and authority, and letting the engine pick the "wrong" page. This audit finds groups of pages that look like they chase the same keyword, based on their titles, H1s, and body keywords.

These are candidates for review: some overlap is legitimate (a hub page plus its children). The audit surfaces the clusters; a human decides which to consolidate.

High severity
1. Duplicate title target
  • Definition: Two or more indexable pages whose title (after removing a known brand suffix and stopwords) normalizes to the same phrase.
  • Why it matters: Identical title targets are the clearest cannibalization signal — the pages explicitly chase the same query.
  • Fix: Decide the one canonical page for that query; differentiate, merge, or redirect the others; and 301 or canonicalize as appropriate.
Medium severity
2. Overlapping targets (keyword cluster)
  • Definition: A cluster of pages whose keyword signatures (title + H1 + top body keywords) overlap above the threshold (default Jaccard ≥ 0.6).
  • Why it matters: Even with different titles, pages that share most of their topic vocabulary compete for the same searches and dilute each other.
  • Fix: Establish a clear hierarchy — one primary page per intent, supporting pages that target distinct sub-queries and link up to the primary. Consolidate true overlaps; differentiate genuine variants.
Low severity
3. Shared primary keyword (title bigram)
  • Definition: Multiple pages share the same significant two-word phrase in their title.
  • Why it matters: A weaker hint of overlapping intent; often fine (a product line), sometimes the start of cannibalization.
  • Fix: Review the set; ensure each page targets a distinct angle of the shared phrase.
Reading the results
  • Confirm intent before acting. A category page and its articles should share vocabulary. The fix is usually a clearer hierarchy, not deletion.
  • Tune --overlap. Lower it to surface looser themes, raise it for only the tightest competitors. Pass --brand-suffix "| Brand" so brand words don't inflate title similarity.
  • Cross-check Search Console. True cannibalization shows as multiple URLs ranking and swapping for the same query; use this audit to find candidates, then confirm with real query data before consolidating.
  • Related: meta-data-audit (duplicate titles) and content-quality-audit (duplicate/near-duplicate bodies) overlap with this audit from different angles.

Report Template (references/report-template.md)

The output structure the skill follows, including the sections issues are grouped into.

references/report-template.mdView on GitHub

Keyword Cannibalization Audit Report Template

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

# Keyword Cannibalization Audit — {site}

**Date:** {date}
**Scope:** {pages crawled}
**Pages analyzed:** {n} | **Overlap threshold:** {jaccard}

## Summary

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

{2–3 sentences: how much overlap exists, and the most significant competing
cluster. Remind the reader these are candidates to confirm, not automatic merges.}

## High-severity: duplicate title targets ({n})

| Shared target | Competing pages | Suggested resolution |
|---|---|---|
| "customer onboarding" | /a/, /b/, /c/ | Pick the primary; merge/redirect/differentiate the rest |

## Medium-severity: overlapping target clusters ({n})

### Cluster {i} — {shared keywords}
| Page | Title | Role |
|---|---|---|
| ... | ... | primary / supporting / consolidate |

**Recommended hierarchy:** {which page is primary, which support it, which to merge.}

{Repeat per cluster, largest first.}

## Low-severity: shared title phrases ({n})

{Brief table of pages sharing a significant title bigram, for review.}

## Prioritized action list

1. {The highest-value cluster to resolve, naming the primary page and what to do
   with each competitor. Confirm against Search Console query data first.}
2. ...
Guidance
  • Frame as hierarchy, not deletion. For each cluster, recommend one primary page and a role for every other page (support / differentiate / merge / redirect) — don't blanket-recommend removing pages.
  • Always say "confirm with query data." This audit infers competition from on-page signals; Search Console (or analytics) confirms whether URLs actually swap for the same query.
  • Order clusters by size and by the value of the query they target.
  • Pass --brand-suffix so brand words don't create false overlap.
  • Base every grouping on the observed signatures; never invent rankings.

Target Extractor Script (scripts/extract_targets.py)

Crawls pages and extracts each page's keyword targets: title, H1, and body keyword signature.

scripts/extract_targets.pyView on GitHub
#!/usr/bin/env python3
"""Crawl a site and capture each page's keyword-target signals.

Usage:
    python3 extract_targets.py https://example.com [--max-pages 500] [--output target_inventory.json]
    python3 extract_targets.py urls.txt --url-list [--output target_inventory.json]

For each page it records status, title, first H1, meta description, noindex, and
the page's top body keywords by frequency (stopword-filtered). The auditor uses
the title + H1 + top keywords to find multiple pages targeting the same query
(keyword cannibalization).

Uses only the Python standard library.
"""

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

USER_AGENT = "CannibalizationAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
TOP_KEYWORDS = 15
SKIP_TEXT_TAGS = {"script", "style", "noscript", "template", "svg"}

STOPWORDS = {
    "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from",
    "has", "have", "how", "in", "is", "it", "its", "of", "on", "or", "our",
    "that", "the", "this", "to", "was", "we", "what", "when", "where", "which",
    "who", "will", "with", "you", "your", "can", "do", "does", "get", "more",
    "all", "about", "into", "than", "then", "they", "their", "if", "so", "no",
}


class TargetParser(HTMLParser):
    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.title = None
        self.h1 = None
        self.description = None
        self.meta_robots = None
        self.tokens = []
        self.links = []
        self._in_title = False
        self._title = []
        self._in_h1 = False
        self._h1 = []
        self._h1_done = False
        self._skip = 0

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag in SKIP_TEXT_TAGS:
            self._skip += 1
        if tag == "title":
            self._in_title, self._title = True, []
        elif tag == "h1" and not self._h1_done:
            self._in_h1, self._h1 = True, []
        elif tag == "meta":
            name = (a.get("name") or "").lower()
            if name == "description":
                self.description = (a.get("content") or "").strip() or None
            elif name == "robots":
                self.meta_robots = a.get("content")
        elif tag == "a" and a.get("href"):
            href = a["href"].strip()
            if not href.startswith(("mailto:", "tel:", "javascript:", "#")):
                self.links.append(
                    urllib.parse.urldefrag(urllib.parse.urljoin(self.base_url, href))[0])

    def handle_endtag(self, tag):
        if tag in SKIP_TEXT_TAGS and self._skip > 0:
            self._skip -= 1
        if tag == "title":
            self._in_title = False
            self.title = " ".join("".join(self._title).split()) or None
        elif tag == "h1" and self._in_h1:
            self._in_h1, self._h1_done = False, True
            self.h1 = " ".join("".join(self._h1).split()) or None

    def handle_data(self, data):
        if self._in_title:
            self._title.append(data)
        if self._in_h1:
            self._h1.append(data)
        if self._skip == 0:
            self.tokens.extend(
                w for w in re.findall(r"[a-z][a-z0-9']+", data.lower())
                if w not in STOPWORDS and len(w) > 2)


def fetch(url):
    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 = resp.read(2_000_000).decode("utf-8", errors="replace") \
                if "html" in ctype else None
            return resp.status, resp.geturl(), body
    except urllib.error.HTTPError as e:
        return e.code, url, None
    except Exception:
        return 0, url, None


def norm_host(url):
    h = urllib.parse.urlparse(url).netloc.lower()
    return h[4:] if h.startswith("www.") else h


def pages_from_sitemap(site):
    status, _f, body = fetch(urllib.parse.urljoin(site, "/sitemap.xml"))
    if status != 200 or not body:
        return []
    try:
        root = ET.fromstring(body)
        return [loc.text.strip() for loc in
                root.iter("{http://www.sitemaps.org/schemas/sitemap/0.9}loc") if loc.text]
    except ET.ParseError:
        return []


def analyze(url, html):
    p = TargetParser(url)
    try:
        p.feed(html)
    except Exception:
        pass
    top = [w for w, _c in Counter(p.tokens).most_common(TOP_KEYWORDS)]
    info = {"title": p.title, "h1": p.h1, "description": p.description,
            "noindex": "noindex" in (p.meta_robots or "").lower(),
            "word_count": len(p.tokens), "top_keywords": top}
    return info, p.links


def extract_from_cache(cache_path):
    """Pure: turn a shared page cache (from fetch_pages.py) into a target inventory.

    No network — analyzes the cached HTML for exactly the signals this audit needs.
    """
    with open(cache_path, encoding="utf-8") as f:
        cache = json.load(f)
    pages = {}
    for url, page in cache.get("pages", {}).items():
        html = page.get("html")
        status = page.get("status", 0)
        final_url = page.get("final_url") or url
        if not html:
            pages[url] = {"status": status, "final_url": final_url, "title": None,
                          "h1": None, "description": None, "noindex": False,
                          "word_count": 0, "top_keywords": []}
            continue
        info, _links = analyze(final_url, html)
        info["status"] = status
        info["final_url"] = final_url
        pages[url] = info
    return cache.get("site", cache_path), pages


def collect(urls, max_pages, crawl_host=None):
    pages, queue, seen = {}, deque(urls), set(urls)
    while queue and len(pages) < max_pages:
        url = queue.popleft()
        status, final_url, html = fetch(url)
        print(f"[{status}] {url}", file=sys.stderr)
        if html is None:
            pages[url] = {"status": status, "final_url": final_url, "title": None,
                          "h1": None, "description": None, "noindex": False,
                          "word_count": 0, "top_keywords": []}
            continue
        info, links = analyze(final_url, html)
        info["status"] = status
        info["final_url"] = final_url
        pages[url] = info
        if crawl_host:
            for link in links:
                if norm_host(link) == crawl_host and link not in seen:
                    seen.add(link)
                    queue.append(link)
    return pages


def main():
    ap = argparse.ArgumentParser(description="Capture keyword-target signals for a site.")
    ap.add_argument("source", nargs="?")
    ap.add_argument("--from-cache",
                    help="Extract from a shared page cache produced by fetch_pages.py (no network)")
    ap.add_argument("--max-pages", type=int, default=500)
    ap.add_argument("--output", default="target_inventory.json")
    ap.add_argument("--url-list", action="store_true")
    args = ap.parse_args()

    if args.from_cache:
        site, pages = extract_from_cache(args.from_cache)
    elif not args.source:
        ap.error("provide a source, or --from-cache page_cache.json")
    elif args.url_list:
        with open(args.source, encoding="utf-8") as f:
            urls = [l.strip() for l in f if l.strip() and not l.startswith("#")]
        site, pages = args.source, collect(urls[: args.max_pages], args.max_pages)
    else:
        start = args.source.rstrip("/")
        seeds = pages_from_sitemap(start) or [start]
        site, pages = args.source, collect(seeds[: args.max_pages], args.max_pages,
                                           crawl_host=norm_host(start))

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


if __name__ == "__main__":
    main()

Cannibalization Auditor Script (scripts/audit_cannibalization.py)

Compares keyword signatures across the crawl to cluster competing pages and outputs the structured report data with a recommended hierarchy.

scripts/audit_cannibalization.pyView on GitHub
#!/usr/bin/env python3
"""Audit a target inventory (from extract_targets.py) for keyword cannibalization.

Usage:
    python3 audit_cannibalization.py target_inventory.json [--output audit_report.json]
        [--brand-suffix "| Acme Co"] [--overlap 0.6]

Checks (see references/audit-checks.md):
- duplicate_title_target (high): 2+ indexable pages with the same normalized title
  core — they target the same query head-on
- overlapping_targets (medium): clusters of pages whose title+H1 keyword sets
  overlap above the threshold (Jaccard) — competing for the same topic
- shared_primary_keyword (low): pages sharing the same significant title bigram

Clustering uses union-find over pairwise Jaccard of each page's keyword signature
(title + H1 + top body keywords). Dependency-free.

Uses only the Python standard library.
"""

import argparse
import json
import re
import sys
from collections import Counter, defaultdict

STOPWORDS = {
    "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "is",
    "it", "of", "on", "or", "the", "to", "with", "your", "you", "our", "how",
    "what", "best", "guide", "top",
}


def words(text):
    if not text:
        return []
    return [w for w in re.findall(r"[a-z0-9']+", text.lower())
            if w not in STOPWORDS and len(w) > 2]


def strip_brand(text, brand):
    if not text:
        return ""
    t = text.strip()
    if brand and t.lower().endswith(brand.lower()):
        t = t[: len(t) - len(brand)].rstrip(" |-–:·")
    return t


def jaccard(a, b):
    if not a or not b:
        return 0.0
    return len(a & b) / len(a | b)


class UnionFind:
    def __init__(self):
        self.parent = {}

    def find(self, x):
        self.parent.setdefault(x, x)
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]
            x = self.parent[x]
        return x

    def union(self, a, b):
        self.parent[self.find(a)] = self.find(b)


def main():
    ap = argparse.ArgumentParser(description="Run keyword-cannibalization audit checks.")
    ap.add_argument("inventory")
    ap.add_argument("--output", default="audit_report.json")
    ap.add_argument("--brand-suffix", default="")
    ap.add_argument("--overlap", type=float, default=0.6,
                    help="Jaccard overlap of keyword signatures to flag as competing")
    args = ap.parse_args()

    with open(args.inventory, encoding="utf-8") as f:
        data = json.load(f)
    pages = data.get("pages", {})

    findings = {k: [] for k in (
        "duplicate_title_target", "overlapping_targets", "shared_primary_keyword")}

    indexable = {u: p for u, p in pages.items()
                 if p.get("status", 0) == 200 and not p.get("noindex")}

    # 1. Exact duplicate title core.
    title_groups = defaultdict(list)
    for url, p in indexable.items():
        core = strip_brand(p.get("title"), args.brand_suffix)
        key = " ".join(words(core))
        if key:
            title_groups[key].append(url)
    for key, urls in title_groups.items():
        if len(urls) > 1:
            findings["duplicate_title_target"].append(
                {"target": key, "pages": sorted(urls)})

    # 2. Overlapping keyword signatures -> clusters.
    sigs = {}
    for url, p in indexable.items():
        sig = set(words(strip_brand(p.get("title"), args.brand_suffix)))
        sig |= set(words(p.get("h1")))
        sig |= set(p.get("top_keywords", [])[:10])
        if sig:
            sigs[url] = sig

    uf = UnionFind()
    urls = list(sigs)
    edges = []
    for i in range(len(urls)):
        for j in range(i + 1, len(urls)):
            sim = jaccard(sigs[urls[i]], sigs[urls[j]])
            if sim >= args.overlap:
                uf.union(urls[i], urls[j])
                edges.append((urls[i], urls[j], round(sim, 2)))

    clusters = defaultdict(list)
    for url in urls:
        if any(url in (e[0], e[1]) for e in edges):
            clusters[uf.find(url)].append(url)
    for members in clusters.values():
        if len(members) > 1:
            shared = set.intersection(*(sigs[m] for m in members))
            findings["overlapping_targets"].append(
                {"pages": sorted(members),
                 "shared_keywords": sorted(shared)[:10],
                 "size": len(members)})

    # 3. Shared significant title bigram.
    bigram_groups = defaultdict(list)
    for url, p in indexable.items():
        tw = words(strip_brand(p.get("title"), args.brand_suffix))
        for i in range(len(tw) - 1):
            bigram_groups[(tw[i], tw[i + 1])].append(url)
    for bigram, urls_b in bigram_groups.items():
        uniq = sorted(set(urls_b))
        if len(uniq) > 1:
            findings["shared_primary_keyword"].append(
                {"keyword": " ".join(bigram), "pages": uniq})

    severity = {
        "duplicate_title_target": "high",
        "overlapping_targets": "medium",
        "shared_primary_keyword": "low",
    }
    summary = {
        "pages_analyzed": len(indexable),
        "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", "info")},
    }
    report = {"summary": summary, "severity": severity, "findings": findings}
    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

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 Site Architecture Audit Audits a site's architecture: hostname and protocol canonicalization, robots.txt, XML sitemap parity, crawl and index directive conflicts, URL structure, click depth, and directory taxonomy. site-architecture-audit Open 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