External Link Audit Claude Skill
This skill audits a website's external (outbound) links and produces an actionable SEO report. It crawls pages to find broken external links, redirected targets, insecure HTTP links, missing rel attributes on affiliate or sponsored links, unsafe target="_blank" usage, and risky link destinations, then recommends concrete fixes.
What The Skill Checks
- Broken external links (link rot) and redirected targets, with the exact source page, target URL, and anchor text for each issue.
- Affiliate or paid links missing
rel="sponsored", user-generated content links missingrel="ugc", and insecurehttp://outbound links where the destination supports HTTPS. - Unsafe
target="_blank"usage withoutrel="noopener", generic anchor text on external links, and pages or templates with excessive or sitewide external 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 extract external links from the site and check every target. 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 external-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.
External Link Audit
Audit the external (outbound) links 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 external/outbound links on a website
- Find broken external links (link rot) or redirected destinations
- Check affiliate, sponsored, or paid links for correct
relattribution - Find insecure
http://outbound links or unsafetarget="_blank"usage - Review which external domains a site links to and how often
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.
- Known paid/affiliate domains — domains that must carry
rel="sponsored"(e.g.,amzn.to, affiliate networks), if any.
Workflow
Step 1: Gather the page set and extract external links
- 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/extract_external_links.py to crawl the site and collect every external link into a JSON inventory:
python3 scripts/extract_external_links.py https://example.com --max-pages 500 --max-depth 5 --output external_links.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_external_links.py --from-cache page_cache.json --output external_links.json
For every external link, the inventory records: source page, target URL, target domain, anchor text, rel attributes, target attribute, and whether the link is in <nav>/<footer> or in main content.
Step 2: Check link targets and run the audit
Use scripts/check_external_links.py to resolve the HTTP status of each external target (deduplicated per URL) and run all audit checks:
python3 scripts/check_external_links.py external_links.json --output audit_report.json
Add --sponsored-domains amzn.to,partner.example to flag affiliate domains missing rel="sponsored". Add --skip-status-checks to run only the static checks without network requests.
Step 3: Evaluate the audit checks
Evaluate each check defined in references/audit-checks.md. The core checks are:
| Check | Severity |
|---|---|
| Broken external links (4xx/5xx or unresolvable targets) | High |
Affiliate/paid links missing rel="sponsored" | High |
Insecure http:// external links | Medium |
| Redirected external links (3xx targets) | Medium |
target="_blank" without rel="noopener"/noreferrer | Medium |
User-generated content links missing rel="ugc"/nofollow | Medium |
| Generic anchor text on external links | Low |
| Excessive external links on a page (> 100) | Low |
| Sitewide (nav/footer) external links | Low |
Step 4: Produce the report
Write a report following the structure in references/report-template.md:
- Summary — pages crawled, external links found, unique domains, issue counts by severity
- Domain overview — most-linked external domains with link counts and follow/nofollow split
- Issues — one section per failing check, listing source page, target URL, anchor text, and a specific fix
- Prioritized action list — ordered by severity and estimated impact
Step 5: Recommend fixes
For each issue, give a concrete, copy-pasteable recommendation, e.g.:
- Remove or replace the dead link
https://gone.example/pageon/blog/post-1; link to an archived copy or an equivalent live resource - Update
http://partner.exampleon/resourcestohttps://partner.example - Add
rel="sponsored"to the affiliate linkhttps://amzn.to/xyzon/reviews/widget - Add
rel="noopener"to thetarget="_blank"link on/about
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/extract_external_links.py— crawl a site and build the external link inventoryscripts/check_external_links.py— resolve target statuses and run audit checks
Audit Checks Reference (references/audit-checks.md)
Full definitions, severities, and rationale for all nine audit checks the skill evaluates, from broken links to affiliate link compliance.
External Link Audit Checks
Full definitions, thresholds, and rationale for each audit check.
High severity
1. Broken external links
- Definition: An external
<a href>whose target returns HTTP 4xx/5xx, or whose host cannot be resolved (link rot). - Why it matters: Dead outbound links harm user experience and signal an unmaintained page; pages full of rot tend to lose trust and rankings.
- Fix: Replace with a live equivalent resource or an archived copy (e.g., Wayback Machine), or remove the link and keep the plain text.
2. Affiliate/paid links missing rel="sponsored"
- Definition: Links to known affiliate or paid-placement domains (or links with affiliate URL parameters such as
tag=,ref=,aff_id=) that lackrel="sponsored"(or legacynofollow). - Why it matters: Google requires paid/affiliate links to be qualified; unflagged paid links risk manual link-scheme penalties.
- Fix: Add
rel="sponsored"(optionallysponsored nofollow) to every paid or affiliate link.
Medium severity
3. Insecure http:// external links
- Definition: Outbound links using
http://where the destination supports HTTPS. - Why it matters: Sends users through an insecure hop, leaks referrer data, and often produces an extra redirect.
- Fix: Update the link to the
https://URL. If the destination has no HTTPS, consider whether to keep the link at all.
4. Redirected external links
- Definition: An external link whose target returns 3xx.
- Threshold: Flag all 3xx targets; treat chains of 2+ hops and redirects to a different domain as higher priority (the destination content may have changed).
- Fix: Update links to point directly to the final destination URL after verifying it still matches the intended content.
5. target="_blank" without rel="noopener"/noreferrer
- Definition: External links opening a new tab without
rel="noopener"orrel="noreferrer". - Why it matters: Legacy browsers expose
window.openerto the destination page (reverse tabnabbing); it can also hurt performance. - Fix: Add
rel="noopener"(modern browsers imply it, but explicit is safest for older browsers).
6. User-generated content links missing rel="ugc"/nofollow
- Definition: External links inside comments, forums, or other user-submitted areas without
rel="ugc"orrel="nofollow". - Detection: Ask the user which page sections accept user-generated content if unclear.
- Why it matters: Unqualified UGC links pass equity to spam targets and invite comment spam.
- Fix: Add
rel="ugc"(ornofollow) to all links in user-submitted content, ideally via the platform template.
Low severity
7. Generic anchor text on external links
- Definition: External links whose anchor text is non-descriptive.
- Detection list: "click here", "here", "read more", "learn more", "more", "this page", "link", "source", bare URLs, empty anchors (image links without
alttext). - Fix: Use descriptive anchor text that tells users and crawlers what the destination is.
8. Excessive external links
- Definition: Pages with more than ~100 outgoing external links.
- Why it matters: Can look like a link farm or directory, dilutes the value of each citation, and overwhelms readers.
- Fix: Trim to the most valuable references; split genuine resource lists into curated, focused pages.
9. Sitewide external links
- Definition: The same external link appearing in
<nav>or<footer>across most pages (e.g., "designed by", widget credits, badge links). - Why it matters: Sitewide followed outbound links are a classic spam pattern and leak equity from every page.
- Fix: Keep only deliberate sitewide links; add
rel="nofollow"/sponsoredto credit or reciprocal links, or move them to a single about/credits page.
Additional signals to note (informational)
- Domain concentration — if a large share of external links point to one domain, confirm it's intentional (not injected spam).
- Suspicious destinations — links to parked domains, expired-domain redirects, gambling/pharma/adult sites the user didn't intend to cite; flag for manual review as possible hacked-content injection.
- Tracking-parameter bloat — outbound URLs carrying long tracking query strings (
utm_*,fbclid); link the clean canonical URL where practical. - Linking to competitors with keyword-rich anchors — note for the user to confirm it's intentional.
Report Template (references/report-template.md)
The output structure the skill follows when writing the final audit report.
External Link Audit Report Template
Use this structure for the final report delivered to the user.
# External Link Audit — {site}
**Date:** {date}
**Scope:** {scope description}
**Pages crawled:** {n} | **External links analyzed:** {n} | **Unique domains:** {n}
## Summary
| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |
{2–3 sentence overview of the site's outbound link health and the biggest wins.}
## Domain overview
| Domain | Links | Followed | Nofollow/Sponsored/UGC | Notes |
|---|---|---|---|---|
| ... | ... | ... | ... | ... |
## High-severity issues
### Broken external links ({n})
| Source page | Broken target | Anchor text | Status | Suggested fix |
|---|---|---|---|---|
| ... | ... | ... | 404 | Replace with {live or archived URL} / remove |
### Affiliate/paid links missing rel="sponsored" ({n})
| Source page | Target | Anchor text | Current rel | Suggested fix |
|---|---|---|---|---|
| ... | ... | ... | (none) | Add rel="sponsored" |
## Medium-severity issues
{One subsection per failing check, same table style: affected pages, details, specific fix.}
## Low-severity issues
{Brief list or table.}
## 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 user/equity impact (sitewide and high-traffic pages first).
- In the domain overview, sort by link count descending and flag any domain the user may not have intended to link.
Link Extractor Script (scripts/extract_external_links.py)
Crawls a live site or local HTML folder within the configured page and depth limits and extracts every external link into a JSON inventory, including anchor text, rel attributes, and placement context.
#!/usr/bin/env python3
"""Crawl a website and build an external (outbound) link inventory as JSON.
Usage:
python3 extract_external_links.py https://example.com [--max-pages 500] [--max-depth 5] [--output external_links.json]
Output JSON schema:
{
"site": "https://example.com",
"pages_crawled": 42,
"links": [
{
"source": "<page url>",
"target": "<external url>",
"domain": "<target host>",
"anchor": "...",
"rel": "nofollow sponsored|null",
"target_attr": "_blank|null",
"location": "nav|footer|content"
}
]
}
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 = "ExternalLinkAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
class LinkParser(HTMLParser):
def __init__(self, base_url):
super().__init__()
self.base_url = base_url
self.links = []
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 == "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,
"target_attr": a.get("target") 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 == "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._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 norm_host(url):
host = urllib.parse.urlparse(url).netloc.lower()
return host[4:] if host.startswith("www.") else host
def external_in(source_url, final_url, html, host):
"""Return (external_link_records, internal_targets) parsed from one page."""
parser = LinkParser(final_url)
try:
parser.feed(html)
except Exception:
pass
external, internal = [], []
for link in parser.links:
target = link["target"]
if not target.startswith(("http://", "https://")):
continue
if norm_host(target) == host:
internal.append(target)
continue
external.append({"source": source_url, "domain": norm_host(target), **link})
return external, internal
def extract_from_cache(cache_path):
"""Pure: collect external links from a shared page cache (from fetch_pages.py).
No network — the link checking lives in check_external_links.py, which still
probes the external URLs.
"""
with open(cache_path, encoding="utf-8") as f:
cache = json.load(f)
pages = cache.get("pages", {})
site = cache.get("site", cache_path)
host = norm_host(site) if site.startswith(("http://", "https://")) else ""
if not host:
for page in pages.values():
if page.get("final_url", "").startswith(("http://", "https://")):
host = norm_host(page["final_url"])
break
external_links, crawled = [], 0
for url, page in pages.items():
html = page.get("html")
if not html:
continue
crawled += 1
out, _internal = external_in(url, page.get("final_url") or url, html, host)
external_links.extend(out)
return site, crawled, external_links
def main():
ap = argparse.ArgumentParser(description="Crawl a site and collect external links.")
ap.add_argument("start_url", 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("--max-depth", type=int, default=5)
ap.add_argument("--output", default="external_links.json")
args = ap.parse_args()
if args.from_cache:
start, pages_crawled, external_links = extract_from_cache(args.from_cache)
elif not args.start_url:
ap.error("provide a start_url, or --from-cache page_cache.json")
else:
start = args.start_url.rstrip("/")
host = norm_host(start)
external_links = []
crawled = set()
queue = deque([(start, 0)])
seen = {start}
while queue and len(crawled) < args.max_pages:
url, depth = queue.popleft()
status, final_url, html = fetch(url)
crawled.add(url)
print(f"[{status}] depth={depth} {url}", file=sys.stderr)
if not html:
continue
out, internal = external_in(url, final_url, html, host)
external_links.extend(out)
for target in internal:
if target not in seen and depth + 1 <= args.max_depth:
seen.add(target)
queue.append((target, depth + 1))
pages_crawled = len(crawled)
with open(args.output, "w") as f:
json.dump({"site": start, "pages_crawled": pages_crawled,
"links": external_links}, f, indent=2)
print(f"Wrote {len(external_links)} external links from {pages_crawled} pages to {args.output}",
file=sys.stderr)
if __name__ == "__main__":
main()
Link Checker Script (scripts/check_external_links.py)
Resolves the HTTP status of each external target, runs the audit checks against the link inventory, and outputs the structured audit report data.
#!/usr/bin/env python3
"""Check external link targets and run audit checks on an inventory from extract_external_links.py.
Usage:
python3 check_external_links.py external_links.json [--output audit_report.json]
[--sponsored-domains amzn.to,partner.example] [--skip-status-checks]
Checks implemented (see references/audit-checks.md):
- broken_links: external links to 4xx/5xx or unresolvable targets (high)
- unflagged_sponsored: links to known affiliate/paid domains missing rel sponsored/nofollow (high)
- insecure_links: http:// external links (medium)
- redirected_links: external links to 3xx targets (medium)
- unsafe_blank: target="_blank" without rel noopener/noreferrer (medium)
- generic_anchors: weak anchor text (low)
- excessive_external: pages with > 100 external links (low)
- sitewide_links: same nav/footer external link on 5+ pages (low)
Uses only the Python standard library.
"""
import argparse
import json
import sys
import urllib.error
import urllib.request
from collections import Counter, defaultdict
USER_AGENT = "ExternalLinkAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
GENERIC_ANCHORS = {
"click here", "here", "read more", "learn more", "more",
"this page", "link", "this", "source", "website", "see more", "",
}
MAX_EXTERNAL_LINKS = 100
SITEWIDE_MIN_PAGES = 5
AFFILIATE_PARAMS = ("tag=", "ref=", "aff_id=", "affid=", "affiliate=", "utm_medium=affiliate")
def check_status(url):
"""Return final HTTP status without following redirects (HEAD, GET fallback)."""
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
opener = urllib.request.build_opener(NoRedirect)
for method in ("HEAD", "GET"):
req = urllib.request.Request(url, method=method,
headers={"User-Agent": USER_AGENT})
try:
with opener.open(req, timeout=TIMEOUT) as resp:
return resp.status
except urllib.error.HTTPError as e:
if e.code in (405, 501) and method == "HEAD":
continue # retry with GET
return e.code
except Exception:
return 0 # 0 = DNS/network/connection error
return 0
def main():
ap = argparse.ArgumentParser(description="Run external link audit checks.")
ap.add_argument("inventory", help="external_links.json from extract_external_links.py")
ap.add_argument("--output", default="audit_report.json")
ap.add_argument("--sponsored-domains", default="",
help="comma-separated affiliate/paid domains requiring rel=sponsored")
ap.add_argument("--skip-status-checks", action="store_true",
help="skip network requests; run only static checks")
args = ap.parse_args()
with open(args.inventory) as f:
data = json.load(f)
links = data["links"]
sponsored_domains = {d.strip().lower() for d in args.sponsored_domains.split(",") if d.strip()}
statuses = {}
if not args.skip_status_checks:
for target in sorted({l["target"] for l in links}):
statuses[target] = check_status(target)
print(f"[{statuses[target]}] {target}", file=sys.stderr)
findings = {
"broken_links": [], "unflagged_sponsored": [], "insecure_links": [],
"redirected_links": [], "unsafe_blank": [], "generic_anchors": [],
"excessive_external": [], "sitewide_links": [],
}
per_page = Counter()
sitewide = defaultdict(set) # nav/footer target -> source pages
domains = Counter()
domain_followed = Counter()
for link in links:
src, target, anchor = link["source"], link["target"], link["anchor"]
rel = (link.get("rel") or "").lower()
entry = {"source": src, "target": target, "anchor": anchor}
per_page[src] += 1
domains[link["domain"]] += 1
qualified = any(r in rel for r in ("nofollow", "sponsored", "ugc"))
if not qualified:
domain_followed[link["domain"]] += 1
if link["location"] in ("nav", "footer"):
sitewide[target].add(src)
status = statuses.get(target)
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 target.startswith("http://"):
findings["insecure_links"].append(entry)
is_affiliate = (link["domain"] in sponsored_domains
or any(p in target for p in AFFILIATE_PARAMS))
if is_affiliate and "sponsored" not in rel and "nofollow" not in rel:
findings["unflagged_sponsored"].append({**entry, "rel": link.get("rel")})
if (link.get("target_attr") == "_blank"
and "noopener" not in rel and "noreferrer" not in rel):
findings["unsafe_blank"].append({**entry, "rel": link.get("rel")})
if anchor.lower().strip() in GENERIC_ANCHORS:
findings["generic_anchors"].append(entry)
for page, count in per_page.items():
if count > MAX_EXTERNAL_LINKS:
findings["excessive_external"].append({"page": page, "external_links": count})
for target, sources in sitewide.items():
if len(sources) >= SITEWIDE_MIN_PAGES:
findings["sitewide_links"].append({"target": target, "pages": len(sources)})
severity = {
"broken_links": "high", "unflagged_sponsored": "high",
"insecure_links": "medium", "redirected_links": "medium",
"unsafe_blank": "medium",
"generic_anchors": "low", "excessive_external": "low", "sitewide_links": "low",
}
summary = {
"pages_crawled": data.get("pages_crawled"),
"external_links": len(links),
"unique_domains": len(domains),
"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")
},
}
domain_overview = [
{"domain": d, "links": n, "followed": domain_followed[d],
"qualified": n - domain_followed[d]}
for d, n in domains.most_common()
]
report = {"summary": summary, "severity": severity, "findings": findings,
"domain_overview": domain_overview, "statuses": statuses}
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()