Claude AI

Meta Data Audit Claude Skill

This skill audits a website's title tags and meta descriptions and produces an actionable SEO report. It crawls pages to find missing, duplicate, too-long, too-short, or multiple titles and descriptions, generic boilerplate titles, and keyword-stuffed metadata, then recommends ready-to-paste replacements.

What The Skill Checks

  • Missing or empty title tags and meta descriptions, plus titles and descriptions duplicated across indexable pages, each reported with the exact page and current value.
  • Length problems on both sides: titles outside the 30 to 60 character band and descriptions outside 70 to 160 characters, where snippets get truncated or waste space, plus pages carrying multiple title or description tags from CMS and plugin conflicts.
  • Generic boilerplate titles ("Home", "Untitled", brand-only), descriptions that just repeat the title, keyword stuffing, and informational signals like title/H1 mismatch. A brand suffix option strips your brand pattern before duplicate detection.

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 metadata from pages and audit the resulting inventory. 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 meta-data-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 (including an optional brand suffix), the five-step audit workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
meta-data-audit
description
Audit a website's title tags and meta descriptions for SEO. Crawls pages to find missing, duplicate, too-long, too-short, or multiple title tags and meta descriptions, generic or boilerplate titles, and metadata that wastes the snippet. Use when the user asks to audit, analyze, check, or improve page titles, title tags, meta descriptions, metadata, or SERP snippets of a website.

Meta Data Audit

Audit the title tags and meta descriptions 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 title tags and meta descriptions on a website
  • Find pages with missing, duplicate, or multiple titles or meta descriptions
  • Find titles or descriptions that are too long (truncated in SERPs) or too short (wasted snippet space)
  • Identify generic, boilerplate, or keyword-stuffed metadata
  • Improve click-through rates from search by tightening SERP snippets
Inputs to collect

Before starting, confirm with the user:

  1. Site URL or local files — a live site root URL (e.g., https://example.com), a sitemap URL, a list of URLs, or a local folder of HTML files.
  2. Scope — full site, a specific section (e.g., /blog/), or a list of URLs.
  3. Crawl limits — max pages (default 500) for live crawls.
  4. Brand suffix — the brand pattern appended to titles (e.g., | Acme Co), if any, so brand-only and duplicate-after-brand titles can be detected accurately.
Workflow
Step 1: Gather the page set and extract metadata
  • 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 .html files.

Use scripts/extract_metadata.py to fetch pages and collect every title tag and meta description into a JSON inventory:

python3 scripts/extract_metadata.py https://example.com --max-pages 500 --output metadata_inventory.json

For every page, the inventory records: every <title> and <meta name="description"> value (including duplicates), the first <h1>, canonical URL, meta robots, and og:title/og:description for comparison.

If you've already crawled the site once into a shared page cache (e.g. as part of a full SEO audit), skip the crawl and extract from it instead — same inventory, no extra network:

python3 scripts/fetch_pages.py https://example.com --output page_cache.json   # crawl once
python3 scripts/extract_metadata.py --from-cache page_cache.json --output metadata_inventory.json
Step 2: Run the audit checks

Use scripts/audit_metadata.py to run all audit checks against the inventory:

python3 scripts/audit_metadata.py metadata_inventory.json --output audit_report.json

Add --brand-suffix "| Acme Co" to strip the brand pattern before duplicate detection and to flag brand-only titles. Noindexed pages are excluded from duplicate checks by default.

Step 3: Evaluate the audit checks

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

CheckSeverity
Missing or empty title tagHigh
Missing or empty meta descriptionHigh
Duplicate titles across pagesHigh
Duplicate meta descriptions across pagesHigh
Multiple title tags or meta descriptions on one pageMedium
Title too long (> 60 characters, likely truncated)Medium
Title too short (< 30 characters, wasted space)Medium
Description too long (> 160 characters, likely truncated)Medium
Description too short (< 70 characters, wasted space)Medium
Generic or boilerplate titles ("Home", "Untitled", brand-only)Medium
Description duplicates the titleLow
Keyword stuffing in the titleLow
Title/H1 mismatchInformational
Step 4: Produce the report

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

  1. Summary — pages crawled, titles and descriptions found, issue counts by severity
  2. Length distribution — how titles and descriptions spread across short/good/long bands
  3. Issues — one section per failing check, listing the page, the current value, and a specific fix
  4. Prioritized action list — ordered by severity and estimated impact
Step 5: Recommend fixes

For each issue, give a concrete, copy-pasteable recommendation. When drafting replacement titles and descriptions:

  • Keep titles near 60 characters and descriptions near 155 characters
  • Lead with the page's primary topic; put the brand suffix last
  • Base every value on the visible page content; never invent facts, prices, or claims
  • Write descriptions that summarize the page and give searchers a reason to click, without keyword stuffing

Example recommendations:

  • Add a title to /services/seo-audit: SEO Audit Services For B2B SaaS | Acme Co
  • Shorten the 87-character title on /blog/post-1 to lead with the primary topic
  • Differentiate the duplicate description shared by /locations/boston and /locations/cambridge with location-specific copy
Optional: export the report
As a Word document (.docx)

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

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

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

As a CSV of findings (.csv)

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

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

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

Resources
  • scripts/md_to_docx.py — convert the Markdown report into a Word (.docx) document (standard library only)
  • scripts/findings_to_csv.py — flatten the audit findings JSON into a CSV, one row per finding (standard library only)
  • references/audit-checks.md — full definitions, thresholds, and rationale for every audit check
  • references/report-template.md — report output structure
  • scripts/extract_metadata.py — crawl a site and build the title/description inventory
  • scripts/audit_metadata.py — run audit checks against a metadata inventory JSON file

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

Full definitions, thresholds, and rationale for every audit check, from missing and duplicate metadata to length bands and keyword stuffing.

references/audit-checks.mdView on GitHub

Meta Data Audit Checks

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

High severity
1. Missing or empty title tag
  • Definition: A page with no <title> element, or one whose text is empty after trimming whitespace.
  • Why it matters: The title is the strongest on-page relevance signal and the headline of the SERP snippet. Without one, search engines generate their own, usually badly.
  • Fix: Add a unique, descriptive title near 60 characters that leads with the page's primary topic.
2. Missing or empty meta description
  • Definition: A page with no <meta name="description">, or one whose content is empty after trimming whitespace.
  • Why it matters: Without a description, search engines pull arbitrary page text into the snippet, which usually hurts click-through rate.
  • Fix: Add a description near 155 characters that summarizes the page and gives searchers a reason to click.
3. Duplicate titles across pages
  • Definition: Two or more indexable pages sharing an identical title (compared after stripping a known brand suffix and normalizing whitespace).
  • Why it matters: Duplicate titles make pages compete with each other, hide what differentiates each page, and often indicate templated metadata that was never filled in.
  • Fix: Give each page a unique title reflecting its specific topic. For templated pages, interpolate the differentiating value (product name, location, category).
4. Duplicate meta descriptions across pages
  • Definition: Two or more indexable pages sharing an identical meta description.
  • Why it matters: Same problem as duplicate titles: snippets stop differentiating pages, and search engines are more likely to ignore the description entirely.
  • Fix: Write page-specific descriptions, or template them with page-specific values.
Medium severity
5. Multiple title tags or meta descriptions on one page
  • Definition: More than one <title> element, or more than one <meta name="description">, in a single document.
  • Why it matters: Search engines pick one unpredictably; the duplicates usually come from a CMS theme plus an SEO plugin both injecting metadata.
  • Fix: Remove all but one authoritative tag; fix the template or plugin conflict that produced the duplicates.
6. Title too long
  • Definition: Title text over 60 characters.
  • Threshold: Flag over 60; treat over 70 as certain truncation. Google truncates at roughly 600 pixels, and 60 characters is the practical proxy.
  • Why it matters: Truncated titles lose their ending (often the call to action or differentiator) behind an ellipsis.
  • Fix: Rewrite to lead with the primary topic and fit within roughly 60 characters; cut filler words and move the brand to the end or drop it.
7. Title too short
  • Definition: Title text under 30 characters.
  • Why it matters: Very short titles waste the strongest snippet real estate and are usually generic ("Blog", "About").
  • Fix: Expand with the page's specific topic and a differentiator; aim for 50–60 characters.
8. Description too long
  • Definition: Meta description over 160 characters.
  • Threshold: Flag over 160; treat over 200 as certain truncation. Google rewrites or truncates beyond roughly 155–160 characters on desktop, fewer on mobile.
  • Fix: Front-load the key message in the first 120 characters and trim to roughly 155.
9. Description too short
  • Definition: Meta description under 70 characters.
  • Why it matters: Wastes snippet space and competes poorly against fuller competitor snippets.
  • Fix: Expand toward 120–155 characters with specifics: what the page covers, for whom, and why to click.
10. Generic or boilerplate titles
  • Definition: Titles that are non-descriptive on their own.
  • Detection list: "home", "homepage", "index", "untitled", "new page", "default", "page", "welcome", "blog", "about", "contact", "products", "services" (bare, with no qualifier), and titles that equal the brand suffix alone.
  • Why it matters: Generic titles carry no relevance signal and no reason to click.
  • Fix: Replace with a specific topic plus differentiator, keeping the brand at the end.
Low severity
11. Description duplicates the title
  • Definition: A meta description identical to (or wholly contained in) the page title.
  • Why it matters: The snippet repeats itself instead of adding a second message; search engines will usually substitute their own text.
  • Fix: Write a description that complements the title with detail the title could not fit.
12. Keyword stuffing in the title
  • Definition: The same significant word appearing three or more times in one title, or comma-separated keyword lists as a title.
  • Why it matters: Looks spammy in the SERP, depresses click-through, and invites title rewrites by Google.
  • Fix: Use the keyword once, naturally, and spend the remaining characters on a differentiator.
Informational (note, do not count as issues)
  • Title/H1 mismatch — the title and the first <h1> describe different topics. Often deliberate (titles optimized for SERPs, H1s for the page), but large divergence is worth a look.
  • og:title / og:description divergence — social metadata that differs wildly from the SERP metadata; confirm it is intentional.
  • Noindexed pages with metadata issues — excluded from duplicate checks by default; list separately if the user wants full coverage.
  • Identical title and description across pagination — paginated series (?page=2) often inherit page 1 metadata; recommend appending page numbers.

Report Template (references/report-template.md)

The output structure the skill follows when writing the final audit report, including the length distribution table.

references/report-template.mdView on GitHub

Meta Data Audit Report Template

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

# Meta Data Audit — {site}

**Date:** {date}
**Scope:** {scope description}
**Pages crawled:** {n} | **Titles found:** {n} | **Meta descriptions found:** {n}

## Summary

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

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

## Length distribution

| Band | Titles | Descriptions |
|---|---|---|
| Missing | {n} | {n} |
| Too short (< 30 / < 70 chars) | {n} | {n} |
| Good (30–60 / 70–160 chars) | {n} | {n} |
| Too long (> 60 / > 160 chars) | {n} | {n} |

## High-severity issues

### Missing titles ({n})

| Page | Suggested title |
|---|---|
| ... | ... |

### Missing meta descriptions ({n})

| Page | Suggested description |
|---|---|
| ... | ... |

### Duplicate titles ({n} groups)

| Shared title | Pages | Suggested differentiation |
|---|---|---|
| ... | {url1}, {url2}, ... | ... |

### Duplicate meta descriptions ({n} groups)

| Shared description | Pages | Suggested differentiation |
|---|---|---|
| ... | ... | ... |

## Medium-severity issues

{One subsection per failing check, same table style: page, current value, character count, suggested rewrite.}

## Low-severity issues

{Brief list or table.}

## Prioritized action list

1. {Highest-impact fix, with exact pages and ready-to-paste replacement values}
2. ...
Guidance
  • Every issue row must name the exact page URL, the current value, and a concrete replacement — never generic advice.
  • Suggested titles must be near 60 characters and suggested descriptions near 155; state the character count when proposing values.
  • Base every suggested value on the page's actual content (use the H1 and visible copy from the inventory); never invent facts, prices, or claims. If a page lacks enough information to write accurate metadata, say so instead of guessing.
  • Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
  • Group templated issues (e.g., every product page sharing one description) as a single template-level fix with a few example URLs.
  • Order the action list by severity first, then by estimated traffic impact (high-traffic and high-intent pages first).

Metadata Extractor Script (scripts/extract_metadata.py)

Crawls a live site, URL list, or local HTML folder within the configured page limits and collects every title tag and meta description into a JSON inventory, along with the first H1, canonical, robots, and Open Graph tags.

scripts/extract_metadata.pyView on GitHub
#!/usr/bin/env python3
"""Build a title/meta description inventory as JSON.

Preferred: extract from a shared page cache produced by the shared fetch stage
(`fetch_pages.py`), so the site is crawled once and every audit reuses it:

    python3 extract_metadata.py --from-cache page_cache.json [--output metadata_inventory.json]

Standalone (fetches on its own — fine for a single-area run):
    python3 extract_metadata.py https://example.com [--max-pages 500] [--output metadata_inventory.json]
    python3 extract_metadata.py ./site-folder --local [--output metadata_inventory.json]
    python3 extract_metadata.py urls.txt --url-list [--output metadata_inventory.json]

Output JSON schema:
{
  "site": "https://example.com",
  "pages": {
    "<url>": {
      "status": 200,
      "titles": ["..."],            # every <title> found, in order
      "descriptions": ["..."],      # every <meta name="description"> found, in order
      "h1": "<first h1 text or null>",
      "canonical": "<url or null>",
      "meta_robots": "<content or null>",
      "og_title": "<content or null>",
      "og_description": "<content or null>"
    }
  }
}

Uses only the Python standard library.
"""

import argparse
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 = "MetaDataAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15


class MetadataParser(HTMLParser):
    """Extract titles, meta descriptions, first H1, canonical, robots, OG tags, and links."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.titles = []
        self.descriptions = []
        self.h1 = None
        self.canonical = None
        self.meta_robots = None
        self.og_title = None
        self.og_description = None
        self.links = []
        self._in_title = False
        self._title_parts = []
        self._in_h1 = False
        self._h1_parts = []
        self._h1_done = False

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "title":
            self._in_title = True
            self._title_parts = []
        elif tag == "h1" and not self._h1_done:
            self._in_h1 = True
            self._h1_parts = []
        elif tag == "meta":
            name = (a.get("name") or "").lower()
            prop = (a.get("property") or "").lower()
            content = a.get("content")
            if name == "description":
                self.descriptions.append((content or "").strip())
            elif name == "robots":
                self.meta_robots = content
            elif prop == "og:title" and self.og_title is None:
                self.og_title = (content or "").strip() or None
            elif prop == "og:description" and self.og_description is None:
                self.og_description = (content or "").strip() or None
        elif tag == "link" and (a.get("rel") or "").lower() == "canonical" and a.get("href"):
            self.canonical = urllib.parse.urljoin(self.base_url, a["href"])
        elif tag == "a" and a.get("href"):
            href = a["href"].strip()
            if not href.startswith(("mailto:", "tel:", "javascript:", "#")):
                target = urllib.parse.urljoin(self.base_url, href)
                self.links.append(urllib.parse.urldefrag(target)[0])

    def handle_endtag(self, tag):
        if tag == "title" and self._in_title:
            self._in_title = False
            self.titles.append(" ".join("".join(self._title_parts).split()))
        elif tag == "h1" and self._in_h1:
            self._in_h1 = False
            self._h1_done = True
            self.h1 = " ".join("".join(self._h1_parts).split()) or None

    def handle_data(self, data):
        if self._in_title:
            self._title_parts.append(data)
        if self._in_h1:
            self._h1_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 "html" in ctype or "xml" 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 pages_from_sitemap(site):
    status, _final, body = fetch(urllib.parse.urljoin(site, "/sitemap.xml"))
    if status != 200 or 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 parse_page(url, html):
    parser = MetadataParser(url)
    try:
        parser.feed(html)
    except Exception:
        pass
    info = {
        "titles": parser.titles,
        "descriptions": parser.descriptions,
        "h1": parser.h1,
        "canonical": parser.canonical,
        "meta_robots": parser.meta_robots,
        "og_title": parser.og_title,
        "og_description": parser.og_description,
    }
    return info, parser.links


def empty_record(status):
    return {"status": status, "titles": [], "descriptions": [], "h1": None,
            "canonical": None, "meta_robots": None,
            "og_title": None, "og_description": None}


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

    Makes no network calls — parses the cached HTML for exactly the fields this
    audit needs. This is the half of the old collector that stays after fetching
    is factored out into the shared fetch stage.
    """
    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)
        if not html:
            pages[url] = empty_record(status)
            continue
        info, _links = parse_page(page.get("final_url") or url, html)
        info["status"] = status
        pages[url] = info
    return cache.get("site", cache_path), pages


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, html = fetch(url)
        print(f"[{status}] {url}", file=sys.stderr)
        if html is None:
            pages[url] = empty_record(status)
            continue
        info, links = parse_page(final_url, html)
        info["status"] = status
        pages[url] = info
        for link in links:
            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()
                info, _links = parse_page("file://" + os.path.abspath(path), html)
                info["status"] = 200
                pages[os.path.relpath(path, folder)] = info
    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, html = fetch(url)
        print(f"[{status}] {url}", file=sys.stderr)
        if html is None:
            pages[url] = empty_record(status)
            continue
        info, _links = parse_page(final_url, html)
        info["status"] = status
        pages[url] = info
    return pages


def main():
    ap = argparse.ArgumentParser(description="Build a title/meta description inventory for a site.")
    ap.add_argument("source", nargs="?",
                    help="Site root URL, local folder (--local), or URL list file (--url-list)")
    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="metadata_inventory.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.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.local:
        site, pages = args.source, scan_local(args.source)
    elif args.url_list:
        site, pages = args.source, scan_url_list(args.source, args.max_pages)
    else:
        site, pages = args.source, crawl(args.source, args.max_pages)

    with open(args.output, "w", encoding="utf-8") as f:
        json.dump({"site": site, "pages": pages}, f, indent=2, ensure_ascii=False)
    titles = sum(len(p["titles"]) for p in pages.values())
    descs = sum(len(p["descriptions"]) for p in pages.values())
    print(f"Wrote {len(pages)} pages ({titles} titles, {descs} descriptions) to {args.output}",
          file=sys.stderr)


if __name__ == "__main__":
    main()

Metadata Auditor Script (scripts/audit_metadata.py)

Runs the audit checks against the metadata inventory, with options for brand suffix stripping and noindex handling, and outputs the structured audit report data with length-band distributions.

scripts/audit_metadata.pyView on GitHub
#!/usr/bin/env python3
"""Audit a metadata inventory JSON (from extract_metadata.py) and emit audit findings.

Usage:
    python3 audit_metadata.py metadata_inventory.json [--output audit_report.json]
        [--brand-suffix "| Acme Co"] [--include-noindex]

Checks implemented (see references/audit-checks.md):
- missing_title: no or empty <title> (high)
- missing_description: no or empty meta description (high)
- duplicate_titles: identical title on 2+ indexable pages (high)
- duplicate_descriptions: identical description on 2+ indexable pages (high)
- multiple_titles / multiple_descriptions: more than one tag on a page (medium)
- title_too_long (> 60 chars) / title_too_short (< 30 chars) (medium)
- description_too_long (> 160 chars) / description_too_short (< 70 chars) (medium)
- generic_title: boilerplate or brand-only titles (medium)
- description_duplicates_title: description equals or contains only the title (low)
- keyword_stuffing: same significant word 3+ times in a title (low)
- title_h1_mismatch: title and H1 share no significant words (informational)

Uses only the Python standard library.
"""

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

TITLE_MAX = 60
TITLE_MIN = 30
DESC_MAX = 160
DESC_MIN = 70

GENERIC_TITLES = {
    "home", "homepage", "index", "untitled", "untitled page", "new page",
    "default", "page", "welcome", "blog", "about", "about us", "contact",
    "contact us", "products", "services",
}
STOPWORDS = {
    "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in",
    "is", "it", "of", "on", "or", "the", "to", "with", "your", "you", "our",
}


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


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


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


def main():
    ap = argparse.ArgumentParser(description="Run title/meta description audit checks.")
    ap.add_argument("inventory", help="metadata_inventory.json from extract_metadata.py")
    ap.add_argument("--output", default="audit_report.json")
    ap.add_argument("--brand-suffix", default="",
                    help='brand pattern appended to titles, e.g. "| Acme Co"')
    ap.add_argument("--include-noindex", action="store_true",
                    help="include noindexed pages in duplicate checks")
    args = ap.parse_args()

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

    findings = {
        "missing_title": [], "missing_description": [],
        "duplicate_titles": [], "duplicate_descriptions": [],
        "multiple_titles": [], "multiple_descriptions": [],
        "title_too_long": [], "title_too_short": [],
        "description_too_long": [], "description_too_short": [],
        "generic_title": [],
        "description_duplicates_title": [], "keyword_stuffing": [],
        "title_h1_mismatch": [],
    }

    title_groups = defaultdict(list)
    desc_groups = defaultdict(list)
    bands = {"title": Counter(), "description": Counter()}

    for url, page in pages.items():
        if page.get("status", 200) != 200:
            continue
        titles = [t for t in page.get("titles", [])]
        descs = [d for d in page.get("descriptions", [])]
        title = next((t for t in titles if t.strip()), "")
        desc = next((d for d in descs if d.strip()), "")
        noindex = is_noindex(page)

        # Missing / multiple
        if not title:
            findings["missing_title"].append({"page": url, "h1": page.get("h1")})
            bands["title"]["missing"] += 1
        if not desc:
            findings["missing_description"].append({"page": url, "h1": page.get("h1")})
            bands["description"]["missing"] += 1
        if len([t for t in titles if t.strip()]) > 1:
            findings["multiple_titles"].append({"page": url, "count": len(titles), "values": titles})
        if len([d for d in descs if d.strip()]) > 1:
            findings["multiple_descriptions"].append({"page": url, "count": len(descs), "values": descs})

        # Title checks
        if title:
            n = len(title)
            entry = {"page": url, "title": title, "length": n}
            if n > TITLE_MAX:
                findings["title_too_long"].append(entry)
                bands["title"]["too_long"] += 1
            elif n < TITLE_MIN:
                findings["title_too_short"].append(entry)
                bands["title"]["too_short"] += 1
            else:
                bands["title"]["good"] += 1

            core = strip_brand(title, args.brand_suffix)
            if core.lower() in GENERIC_TITLES or not core:
                findings["generic_title"].append({"page": url, "title": title})

            counts = Counter(words(core))
            stuffed = [w for w, c in counts.items() if c >= 3 and len(w) > 2]
            if stuffed:
                findings["keyword_stuffing"].append(
                    {"page": url, "title": title, "repeated": stuffed})

            if not noindex or args.include_noindex:
                key = " ".join(core.lower().split())
                if key:
                    title_groups[key].append(url)

            h1 = page.get("h1")
            if h1:
                t_words, h_words = set(words(core)), set(words(h1))
                if t_words and h_words and not (t_words & h_words):
                    findings["title_h1_mismatch"].append(
                        {"page": url, "title": title, "h1": h1})

        # Description checks
        if desc:
            n = len(desc)
            entry = {"page": url, "description": desc, "length": n}
            if n > DESC_MAX:
                findings["description_too_long"].append(entry)
                bands["description"]["too_long"] += 1
            elif n < DESC_MIN:
                findings["description_too_short"].append(entry)
                bands["description"]["too_short"] += 1
            else:
                bands["description"]["good"] += 1

            if title and desc.lower().strip(" .") == title.lower().strip(" ."):
                findings["description_duplicates_title"].append(
                    {"page": url, "value": desc})

            if not noindex or args.include_noindex:
                key = " ".join(desc.lower().split())
                desc_groups[key].append(url)

    for value, urls in title_groups.items():
        if len(urls) > 1:
            findings["duplicate_titles"].append({"title": value, "pages": sorted(urls)})
    for value, urls in desc_groups.items():
        if len(urls) > 1:
            findings["duplicate_descriptions"].append({"description": value, "pages": sorted(urls)})

    severity = {
        "missing_title": "high", "missing_description": "high",
        "duplicate_titles": "high", "duplicate_descriptions": "high",
        "multiple_titles": "medium", "multiple_descriptions": "medium",
        "title_too_long": "medium", "title_too_short": "medium",
        "description_too_long": "medium", "description_too_short": "medium",
        "generic_title": "medium",
        "description_duplicates_title": "low", "keyword_stuffing": "low",
        "title_h1_mismatch": "info",
    }
    summary = {
        "pages_analyzed": sum(1 for p in pages.values() if p.get("status", 200) == 200),
        "titles_found": sum(len(p.get("titles", [])) for p in pages.values()),
        "descriptions_found": sum(len(p.get("descriptions", [])) for p in pages.values()),
        "length_bands": {k: dict(v) for k, v in bands.items()},
        "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

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 Keyword Cannibalization Audit Audits a site for keyword cannibalization: pages with duplicate title targets, clusters of pages whose title, H1, and keyword signatures overlap, and pages sharing the same primary keyword phrase, with a recommended hierarchy. keyword-cannibalization-audit Open Mixed Content Audit Audits an HTTPS site for mixed content: active mixed content (http scripts, styles, iframes browsers block), insecure form actions, passive mixed content (http images and media), protocol-relative URLs, and pages still served over http. mixed-content-audit Open Open Graph Audit Audits a site's Open Graph and Twitter Card metadata: missing og:title, og:description, og:image, or og:url, broken or relative og:image URLs, og:url that mismatches the canonical, missing twitter:card, over-length social titles, and reused default images. open-graph-audit Open Pagination Audit Audits a site's pagination: paginated pages canonicalizing to page 1 and hiding deep content, noindexed component pages, paginated pages missing a self-canonical, broken or inconsistent rel=next/prev, and redundant ?page=1 first pages. pagination-audit Open Soft 404 Audit Audits a site for soft 404s: error pages served with a 200 status, servers that return 200 for every unknown URL, thin pages matching the site's error template, and empty or near-empty pages. soft-404-audit Open Full SEO Audit The orchestrator skill that runs every individual audit below and consolidates the findings into one prioritized, cross-area report ranked by impact, for a complete technical-SEO review in a single pass. full-seo-audit Open Internal Link Audit Audits a site's internal linking structure for broken links, orphan pages, redirect chains, click depth, and anchor text quality. internal-link-audit Open External Link Audit Audits a site's external links for broken targets, redirects, insecure HTTP links, missing rel attributes on affiliate links, and unsafe target="_blank" usage. external-link-audit Open