Internal Link Audit Claude Skill
This skill audits a website's internal linking structure and produces an actionable SEO report. It crawls pages to find broken internal links, orphan pages, redirect chains, deep pages, weak anchor text, and link equity distribution issues, then recommends concrete fixes.
What The Skill Checks
- Broken internal links and redirect chains, with the exact source page, target page, and anchor text for each issue.
- Orphan pages that appear in the sitemap but receive zero inbound internal links, plus pages buried deeper than three clicks from the homepage.
- Generic anchor text, nofollow on internal links, links to non-canonical or noindexed URLs, and pages with excessive outgoing links.
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 analyze the link graph. 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 internal-link-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.
Internal Link Audit
Audit the internal linking structure of a website and produce an actionable SEO report.
When to use this skill
Use this skill when the user asks to:
- Audit or analyze internal links on a website
- Find broken internal links or redirect chains
- Identify orphan pages (pages with no internal links pointing to them)
- Review anchor text quality for internal links
- Improve crawl depth, site architecture, or internal link equity distribution
Inputs to collect
Before starting, confirm with the user:
- Site URL or local files — a live site root URL (e.g.,
https://example.com), a sitemap URL, or a local folder of HTML files. - Scope — full site, a specific section (e.g.,
/blog/), or a list of URLs. - Crawl limits — max pages (default 500) and max depth (default 5) for live crawls.
Workflow
Step 1: Gather the page set
- If a sitemap is available (
/sitemap.xml), fetch it to get the canonical page list. - Otherwise, crawl from the homepage following only same-host links.
- For local HTML folders, enumerate all
.htmlfiles.
Use scripts/crawl_links.py to crawl a live site and extract all internal links into a JSON link graph:
python3 scripts/crawl_links.py https://example.com --max-pages 500 --max-depth 5 --output link_graph.json
Step 2: Build the link graph
For every page, record:
- Outgoing internal links: target URL, anchor text,
relattributes (nofollow), whether the link is in<nav>/<footer>or in main content - HTTP status of each target (200, 3xx, 4xx, 5xx)
- Page metadata: title, canonical URL, meta robots (
noindex,nofollow)
Step 3: Run the audit checks
Use scripts/analyze_graph.py to analyze the link graph:
python3 scripts/analyze_graph.py link_graph.json --output audit_report.json
Evaluate each check defined in references/audit-checks.md. The core checks are:
| Check | Severity |
|---|---|
| Broken internal links (4xx/5xx targets) | High |
| Orphan pages (in sitemap but zero inbound internal links) | High |
| Redirect chains and redirected internal links (3xx targets) | Medium |
| Deep pages (click depth > 3 from homepage) | Medium |
| Generic anchor text ("click here", "read more", bare URLs) | Medium |
| Pages with excessive outgoing links (> 150) | Low |
| Nofollow on internal links | Low |
| Links to non-canonical or noindexed URLs | Medium |
| Low inbound link count on important pages | Medium |
Step 4: Produce the report
Write a report following the structure in references/report-template.md:
- Summary — pages crawled, total internal links, issue counts by severity
- Issues — one section per failing check, listing affected source page, target page, anchor text, and a specific fix
- Opportunities — pages that deserve more internal links, suggested anchor text using target page keywords
- Prioritized action list — ordered by severity and estimated impact
Step 5: Recommend fixes
For each issue, give a concrete, copy-pasteable recommendation, e.g.:
- Replace the broken link
/old-pageon/blog/post-1with/new-page - Add 2–3 contextual links to the orphan page
/services/seo-auditfrom topically related pages - Change anchor text "click here" on
/aboutto descriptive text containing the target page's primary keyword
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 checkreferences/report-template.md— report output structurescripts/crawl_links.py— crawl a site and build the internal link graphscripts/analyze_graph.py— run audit checks against a link graph JSON file
Audit Checks Reference (references/audit-checks.md)
Full definitions, thresholds, and rationale for every audit check the skill evaluates.
Internal Link Audit Checks
Full definitions, thresholds, and rationale for each audit check.
High severity
1. Broken internal links
- Definition: An internal
<a href>whose target returns HTTP 4xx or 5xx. - Why it matters: Wastes crawl budget, loses link equity, and harms user experience.
- Fix: Update the link to the correct live URL, or remove it. If the target was intentionally removed, 301-redirect it to the closest relevant page.
2. Orphan pages
- Definition: A page present in the sitemap (or known page set) that receives zero inbound internal links from crawlable pages.
- Why it matters: Search engines may never discover the page through crawling; it receives no internal link equity.
- Fix: Add at least 2–3 contextual internal links from topically related pages. Important pages should also be reachable from navigation or hub pages.
Medium severity
3. Redirected internal links and redirect chains
- Definition: An internal link whose target returns 3xx. A chain is two or more consecutive redirects.
- Threshold: Flag all 3xx targets; treat chains of 2+ hops as higher priority.
- Fix: Update links to point directly to the final destination URL.
4. Deep pages (click depth)
- Definition: Pages requiring more than 3 clicks from the homepage to reach.
- Threshold: Depth > 3 flagged; depth > 5 is critical.
- Fix: Link to deep pages from hub/category pages, the homepage, or popular high-depth-0/1 pages.
5. Generic or weak anchor text
- Definition: Internal links whose anchor text is non-descriptive.
- Detection list: "click here", "here", "read more", "learn more", "more", "this page", "link", bare URLs, empty anchors (image links without
alttext). - Fix: Use descriptive anchor text that includes the target page's primary topic or keyword. Avoid exact-match repetition across many links; vary naturally.
6. Links to non-canonical or noindexed URLs
- Definition: Internal links pointing to a URL whose canonical tag points elsewhere, or whose meta robots is
noindex. - Why it matters: Sends crawlers and equity to pages that won't be indexed.
- Fix: Point links at the canonical, indexable URL.
7. Under-linked important pages
- Definition: High-value pages (conversion pages, key content) with fewer inbound internal links than the site median.
- Detection: Compare inbound link counts; ask the user which pages are priorities if unclear.
- Fix: Add contextual links from related, well-linked pages with descriptive anchors.
Low severity
8. Excessive outgoing links
- Definition: Pages with more than ~150 outgoing internal links.
- Why it matters: Dilutes equity per link and can indicate poor information architecture.
- Fix: Trim navigation bloat; split mega-pages into focused hub pages.
9. Nofollow on internal links
- Definition: Internal links carrying
rel="nofollow"(orsponsored/ugc). - Why it matters: Internal nofollow blocks equity flow and discovery with no benefit.
- Fix: Remove
nofollowfrom internal links unless there is a deliberate reason (e.g., faceted URLs you want left uncrawled — prefer robots.txt for that).
Additional signals to note (informational)
- Self-referencing links (page linking to itself) — usually harmless, note only.
- Duplicate links (same source → same target multiple times) — only the first anchor typically counts; consolidate where practical.
- Links using URL parameters or fragments that resolve to the same page — normalize to clean URLs.
- HTTP links on an HTTPS site — update to HTTPS.
Report Template (references/report-template.md)
The output structure the skill follows when writing the final audit report.
Internal Link Audit Report Template
Use this structure for the final report delivered to the user.
# Internal Link Audit — {site}
**Date:** {date}
**Scope:** {scope description}
**Pages crawled:** {n} | **Internal links analyzed:** {n}
## Summary
| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |
{2–3 sentence overview of the site's internal linking health and the biggest wins.}
## High-severity issues
### Broken internal links ({n})
| Source page | Broken target | Anchor text | Status | Suggested fix |
|---|---|---|---|---|
| ... | ... | ... | 404 | ... |
### Orphan pages ({n})
| Orphan page | Suggested linking pages | Suggested anchor text |
|---|---|---|
| ... | ... | ... |
## Medium-severity issues
{One subsection per failing check, same table style: affected pages, details, specific fix.}
## Low-severity issues
{Brief list or table.}
## Opportunities
{Pages that would benefit from more internal links, with suggested source pages and anchor text.}
## Prioritized action list
1. {Highest-impact fix, with exact pages/links}
2. ...
Guidance
- Every issue row must name the exact source URL, target URL, and a concrete fix — never generic advice.
- Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
- Order the action list by severity first, then by estimated traffic/equity impact.
Link Crawler Script (scripts/crawl_links.py)
Crawls a live site within the configured page and depth limits and extracts all internal links into a JSON link graph.
#!/usr/bin/env python3
"""Crawl a website and build an internal link graph as JSON.
Usage:
python3 crawl_links.py https://example.com [--max-pages 500] [--max-depth 5] [--output link_graph.json]
Output JSON schema:
{
"site": "https://example.com",
"pages": {
"<url>": {
"status": 200,
"depth": 1,
"title": "...",
"canonical": "<url or null>",
"meta_robots": "<content or null>",
"links": [
{"target": "<url>", "anchor": "...", "rel": "nofollow|null", "location": "nav|footer|content"}
]
}
},
"statuses": {"<url>": 200} # status of every linked URL, including non-crawled targets
}
Uses only the Python standard library.
"""
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections import deque
from html.parser import HTMLParser
USER_AGENT = "InternalLinkAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
class LinkParser(HTMLParser):
def __init__(self, base_url):
super().__init__()
self.base_url = base_url
self.links = []
self.title = None
self.canonical = None
self.meta_robots = None
self._in_title = False
self._current = None # link being captured
self._anchor_parts = []
self._context = [] # nav/footer nesting
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag in ("nav", "footer"):
self._context.append(tag)
elif tag == "title":
self._in_title = True
elif tag == "link" and a.get("rel", "").lower() == "canonical" and a.get("href"):
self.canonical = urllib.parse.urljoin(self.base_url, a["href"])
elif tag == "meta" and a.get("name", "").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)
target = urllib.parse.urldefrag(target)[0]
location = self._context[-1] if self._context else "content"
self._current = {
"target": target,
"anchor": "",
"rel": a.get("rel") or None,
"location": location,
}
self._anchor_parts = []
elif tag == "img" and self._current is not None and a.get("alt"):
self._anchor_parts.append(a["alt"])
def handle_endtag(self, tag):
if tag in ("nav", "footer") and self._context and self._context[-1] == tag:
self._context.pop()
elif tag == "title":
self._in_title = False
elif tag == "a" and self._current is not None:
self._current["anchor"] = " ".join(" ".join(self._anchor_parts).split())
self.links.append(self._current)
self._current = None
def handle_data(self, data):
if self._in_title:
self.title = (self.title or "") + data.strip()
if self._current is not None:
self._anchor_parts.append(data)
def fetch(url):
"""Return (status, final_url, 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 "text/html" in ctype:
body = resp.read(2_000_000).decode("utf-8", errors="replace")
return resp.status, resp.geturl(), body
except urllib.error.HTTPError as e:
return e.code, url, None
except Exception:
return 0, url, None # 0 = network/connection error
def same_host(url, host):
return urllib.parse.urlparse(url).netloc.lower().lstrip("www.") == host
def main():
ap = argparse.ArgumentParser(description="Crawl a site and build an internal link graph.")
ap.add_argument("start_url")
ap.add_argument("--max-pages", type=int, default=500)
ap.add_argument("--max-depth", type=int, default=5)
ap.add_argument("--output", default="link_graph.json")
args = ap.parse_args()
start = args.start_url.rstrip("/")
host = urllib.parse.urlparse(start).netloc.lower().lstrip("www.")
pages, statuses = {}, {}
queue = deque([(start, 0)])
seen = {start}
while queue and len(pages) < args.max_pages:
url, depth = queue.popleft()
status, final_url, html = fetch(url)
statuses[url] = status
print(f"[{status}] depth={depth} {url}", file=sys.stderr)
page = {"status": status, "depth": depth, "title": None,
"canonical": None, "meta_robots": None, "links": []}
if html:
parser = LinkParser(final_url)
try:
parser.feed(html)
except Exception:
pass
page.update(title=parser.title, canonical=parser.canonical,
meta_robots=parser.meta_robots)
for link in parser.links:
if not same_host(link["target"], host):
continue
page["links"].append(link)
t = link["target"]
if t not in seen and depth + 1 <= args.max_depth:
seen.add(t)
queue.append((t, depth + 1))
pages[url] = page
# Resolve statuses for linked-but-uncrawled targets
linked = {l["target"] for p in pages.values() for l in p["links"]}
for t in sorted(linked - set(statuses)):
status, _, _ = fetch(t)
statuses[t] = status
print(f"[{status}] (status-check) {t}", file=sys.stderr)
with open(args.output, "w") as f:
json.dump({"site": start, "pages": pages, "statuses": statuses}, f, indent=2)
print(f"Wrote {len(pages)} pages, {len(statuses)} URL statuses to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()
Graph Analyzer Script (scripts/analyze_graph.py)
Runs the audit checks against the link graph JSON and outputs the structured audit report data.
#!/usr/bin/env python3
"""Analyze an internal link graph JSON (from crawl_links.py) and emit audit findings.
Usage:
python3 analyze_graph.py link_graph.json [--output audit_report.json]
Checks implemented (see references/audit-checks.md):
- broken_links: internal links to 4xx/5xx targets (high)
- orphan_pages: crawled pages with zero inbound internal links (high)
- redirected_links: internal links to 3xx targets (medium)
- deep_pages: pages with click depth > 3 (medium)
- generic_anchors: weak anchor text (medium)
- noncanonical_targets: links to pages whose canonical differs or are noindexed (medium)
- excessive_outlinks: pages with > 150 outgoing internal links (low)
- nofollow_internal: internal links with rel nofollow/sponsored/ugc (low)
Uses only the Python standard library.
"""
import argparse
import json
import sys
from collections import Counter
GENERIC_ANCHORS = {
"click here", "here", "read more", "learn more", "more",
"this page", "link", "this", "continue", "view", "see more", "",
}
MAX_DEPTH = 3
MAX_OUTLINKS = 150
def main():
ap = argparse.ArgumentParser(description="Run internal link audit checks on a link graph.")
ap.add_argument("graph", help="link_graph.json from crawl_links.py")
ap.add_argument("--output", default="audit_report.json")
args = ap.parse_args()
with open(args.graph) as f:
data = json.load(f)
pages = data["pages"]
statuses = data.get("statuses", {})
inbound = Counter()
for src, page in pages.items():
for link in page["links"]:
if link["target"] != src:
inbound[link["target"]] += 1
findings = {
"broken_links": [], "orphan_pages": [], "redirected_links": [],
"deep_pages": [], "generic_anchors": [], "noncanonical_targets": [],
"excessive_outlinks": [], "nofollow_internal": [],
}
home_depth0 = min((p["depth"] for p in pages.values()), default=0)
for src, page in pages.items():
if len(page["links"]) > MAX_OUTLINKS:
findings["excessive_outlinks"].append(
{"page": src, "outgoing_links": len(page["links"])})
if page["depth"] - home_depth0 > MAX_DEPTH:
findings["deep_pages"].append({"page": src, "depth": page["depth"]})
for link in page["links"]:
target, anchor = link["target"], link["anchor"]
status = statuses.get(target)
entry = {"source": src, "target": target, "anchor": anchor}
if status is not None:
if status >= 400 or status == 0:
findings["broken_links"].append({**entry, "status": status})
elif 300 <= status < 400:
findings["redirected_links"].append({**entry, "status": status})
if anchor.lower().strip() in GENERIC_ANCHORS:
findings["generic_anchors"].append(entry)
rel = (link.get("rel") or "").lower()
if any(r in rel for r in ("nofollow", "sponsored", "ugc")):
findings["nofollow_internal"].append({**entry, "rel": link["rel"]})
tpage = pages.get(target)
if tpage:
robots = (tpage.get("meta_robots") or "").lower()
canonical = tpage.get("canonical")
if "noindex" in robots:
findings["noncanonical_targets"].append({**entry, "reason": "noindex"})
elif canonical and canonical.rstrip("/") != target.rstrip("/"):
findings["noncanonical_targets"].append(
{**entry, "reason": "canonical points elsewhere",
"canonical": canonical})
for url, page in pages.items():
if inbound[url] == 0 and page["depth"] > home_depth0:
findings["orphan_pages"].append({"page": url})
severity = {
"broken_links": "high", "orphan_pages": "high",
"redirected_links": "medium", "deep_pages": "medium",
"generic_anchors": "medium", "noncanonical_targets": "medium",
"excessive_outlinks": "low", "nofollow_internal": "low",
}
summary = {
"pages_analyzed": len(pages),
"total_internal_links": sum(len(p["links"]) for p in pages.values()),
"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,
"inbound_link_counts": dict(inbound)}
with open(args.output, "w") as f:
json.dump(report, f, indent=2)
print(json.dumps(summary, indent=2))
print(f"Full report written to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()