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.
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:
- 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. - Scope — full site, a specific section (e.g.,
/blog/), or a list of URLs. - Crawl limits — max pages (default 500) for live crawls.
- 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
.htmlfiles.
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:
| Check | Severity |
|---|---|
| Missing or empty title tag | High |
| Missing or empty meta description | High |
| Duplicate titles across pages | High |
| Duplicate meta descriptions across pages | High |
| Multiple title tags or meta descriptions on one page | Medium |
| 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 title | Low |
| Keyword stuffing in the title | Low |
| Title/H1 mismatch | Informational |
Step 4: Produce the report
Write a report following the structure in references/report-template.md:
- Summary — pages crawled, titles and descriptions found, issue counts by severity
- Length distribution — how titles and descriptions spread across short/good/long bands
- Issues — one section per failing check, listing the page, the current value, 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. 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-1to lead with the primary topic - Differentiate the duplicate description shared by
/locations/bostonand/locations/cambridgewith 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 checkreferences/report-template.md— report output structurescripts/extract_metadata.py— crawl a site and build the title/description inventoryscripts/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.
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 whosecontentis 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.
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.
#!/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.
#!/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()