Open Graph Audit Claude Skill
This skill audits a website's Open Graph and Twitter Card (social sharing) metadata and produces an actionable report on how the site's links render when shared. It crawls pages and probes each share image to find missing og:title, og:description, og:image, or og:url; broken, non-image, or relative og:image URLs; og:url that mismatches the canonical; missing twitter:card; over-length social titles and descriptions; and brand-default images reused across many pages.
What The Skill Checks
- Required tags: missing og:title, og:description, og:image, or og:url, and missing twitter:card.
- Image health: broken, non-image, or relative og:image and twitter:image URLs, and brand-default images reused across many pages.
- Consistency and length: og:url that mismatches the canonical, and over-length social titles and descriptions.
How The Skill Is Packaged
The skill follows the standard Claude Agent Skill structure: a SKILL.md file with YAML frontmatter and workflow instructions, a references/ folder with the full audit check definitions and report template, and a scripts/ folder with Python scripts that crawl the site and extract every Open Graph and Twitter Card tag, then probe each share image 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 open-graph-audit, or copy the skill folder into your Claude skills directory; Claude invokes it automatically when a request matches its description.
Skill Files
Every file in the skill is shown below, copied from the Claude-SEO-Skills repository, so you can review exactly what the skill instructs Claude to do before installing it.
SKILL.md
The skill definition: frontmatter, inputs to collect, the workflow, and the resources the skill relies on.
Open Graph / Social Metadata Audit
Audit a website's Open Graph and Twitter Card tags and produce an actionable report on how the site's links render when shared.
When to use this skill
Use this skill when the user asks to:
- Audit, validate, or check Open Graph (
og:*) or Twitter Card (twitter:*) tags - Diagnose why links look wrong / have no image when shared on social or chat
- Find missing or broken share images, or
og:urlthat points at the wrong URL - Improve social click-through and link previews
Inputs to collect
- Site URL or URL list — a live site root to crawl (auto-seeds from
/sitemap.xml), or a text file with--url-list. - Scope — pages to crawl (default 500,
--max-pages). - Image probing — whether to probe each
og:image/twitter:imagefor status and content type (default on;--no-probeto skip).
Workflow
Step 1: Crawl and probe social tags
Use scripts/extract_social.py:
python3 scripts/extract_social.py https://example.com --max-pages 500 --output social_inventory.json
# already crawled once (e.g. in a full SEO audit)? skip the crawl and reuse the shared cache:
# python3 scripts/fetch_pages.py https://example.com --output page_cache.json
# python3 scripts/extract_social.py --from-cache page_cache.json --output social_inventory.json
It records each page's OG and Twitter Card tags, canonical, and meta robots, then probes each unique share image for HTTP status and content type.
Step 2: Run the audit checks
python3 scripts/audit_social.py social_inventory.json --output audit_report.json
Step 3: Evaluate the audit checks
Evaluate each check in references/audit-checks.md. Core checks:
| Check | Severity |
|---|---|
| Missing og:title | High |
| Missing og:image | High |
| Broken og:image (non-200) | High |
| Missing og:description | Medium |
| Missing og:url | Medium |
| og:url mismatches canonical | Medium |
| og:image not an image / relative | Medium |
| Missing twitter:card (no OG fallback) | Low |
| og:title / og:description too long | Low |
| Duplicate/default share image across many pages | Info |
Step 4: Produce the report
Write a report following references/report-template.md: summary by severity,
broken/missing images first, then completeness gaps, then a prioritized action
list.
Step 5: Recommend fixes
- Missing/broken image: add or repair an absolute
https://og:image(1200×630 recommended) that returns200. - Missing og:title/description: add them (page title and meta description are good sources).
- og:url missing/mismatched: set it to the canonical absolute URL.
- Relative/non-image og:image: use an absolute URL to a real image file.
- twitter:card: add
summary_large_imagefor most content. - Base every recommendation on observed data; never assume an image loads.
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 rationalereferences/report-template.md— report output structurescripts/extract_social.py— crawl pages and probe share imagesscripts/audit_social.py— run audit checks against the inventory
Audit Checks Reference (references/audit-checks.md)
Full definitions, severities, and rationale for every check across the high, medium, and low tiers.
Open Graph / Twitter Card Audit Checks
Definitions, thresholds, and rationale. Open Graph (og:*) and Twitter Card
(twitter:*) tags control how a page looks when shared on social platforms and in
messaging apps. They don't directly affect rankings, but they drive click-through
and referral traffic, and broken share images make links look untrustworthy.
High severity
1. Missing og:title
- Definition: Indexable page with no
og:title. - Why it matters: Platforms fall back to the
<title>or a guess; the share card often shows truncated or wrong text. - Fix: Add
og:title(can mirror or improve on the page title; up to ~90 chars).
2. Missing og:image
- Definition: Indexable page with no
og:image(orog:image:secure_url). - Why it matters: Shares render as a bare text link with no thumbnail, sharply reducing engagement.
- Fix: Add an absolute
https://og:image(recommended 1200×630).
3. Broken og:image
- Definition: The
og:imageURL returns a non-200 status. - Why it matters: The share preview shows no image at all — worse than a missing tag because it looks broken.
- Fix: Point at a live image URL; verify it returns
200.
Medium severity
4. Missing og:description
- Definition: No
og:description. - Fix: Add a concise description (the meta description is a fine source).
5. Missing og:url
- Definition: No
og:url. - Why it matters:
og:urlis the canonical URL for sharing; without it, engagement can split across tracking-parameter variants of the URL. - Fix: Set
og:urlto the page's canonical absolute URL.
6. og:url mismatch
- Definition:
og:urldiffers from the page's canonical (or final) URL. - Why it matters: Splits social signals or points shares at the wrong URL.
- Fix: Make
og:urlequal the canonical URL.
7. og:image not an image / relative
- Definition: The
og:imagecontent-type isn'timage/*, or the URL is relative rather than absolute. - Why it matters: Most platforms require an absolute URL and a real image; relative or non-image values are ignored.
- Fix: Use an absolute
https://URL to a real image file.
Low severity
8. Missing twitter:card
- Definition: No
twitter:cardand no OG tags to fall back on. - Why it matters: Twitter/X can use OG as a fallback, but with neither, links render plain. (If OG is present, this is informational.)
- Fix: Add
twitter:card(summary_large_imagefor most content).
9. og:title / og:description too long
- Definition:
og:titleover ~90 chars orog:descriptionover ~300 chars. - Why it matters: Platforms truncate; the cut-off point may drop the key message.
- Fix: Trim to fit.
Informational (note, not counted as an issue)
- Duplicate og:image across many pages — one share image reused on 5+ pages. Fine for a brand default, but section- or page-specific images earn more engagement. Listed so you can decide.
Report Template (references/report-template.md)
The output structure the skill follows, including the sections issues are grouped into.
Open Graph / Twitter Card Audit Report Template
Use this structure for the final report delivered to the user.
# Open Graph / Social Metadata Audit — {site}
**Date:** {date}
**Scope:** {pages crawled, share images probed}
**Pages analyzed:** {n} | **Share images probed:** {n}
## Summary
| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |
{2–3 sentences: do pages have complete, valid OG tags with working images? Name
the dominant gap.}
## High-severity issues
### Missing og:image / broken og:image ({n})
| Page | Problem | Fix |
|---|---|---|
| ... | no og:image / 404 image | Add/repair an absolute 1200×630 https image |
### Missing og:title ({n})
| Page | Suggested og:title |
|---|---|
| ... | ... |
## Medium-severity issues
{Subsections for missing og:description, missing/mismatched og:url, non-image or
relative og:image. Table with page, current value, corrected value.}
## Low-severity issues
{twitter:card gaps, over-length og:title/og:description, as a brief list.}
## Shared / default images to review ({n})
| Share image | Pages using it | Note |
|---|---|---|
| ... | {n} | brand default vs. should be page-specific |
## Prioritized action list
1. {Highest-impact fix with exact pages and concrete values}
2. ...
Guidance
- Lead with broken and missing images — those have the most visible impact on shares.
- Every row names the exact page, the current value, and a concrete replacement.
Suggested
og:imageURLs must be absolutehttps://. - Group template-level gaps (e.g. the whole blog missing
og:image) as one fix. - Treat duplicate/default images as a judgement call to surface, not a defect.
- Base every status on the probed image data; never assume an image works.
Social Tag Extractor Script (scripts/extract_social.py)
Crawls pages and extracts every Open Graph (og:*) and Twitter Card (twitter:*) tag.
#!/usr/bin/env python3
"""Crawl a site and build an Open Graph / Twitter Card inventory, probing og:image.
Usage:
python3 extract_social.py https://example.com [--max-pages 500] [--output social_inventory.json]
python3 extract_social.py urls.txt --url-list [--output social_inventory.json]
Records per page: status, final_url, canonical, meta robots, and all OG and
Twitter Card tags (title, description, image, url, type, card). Each unique
og:image / twitter:image is probed once for HTTP status and content type so the
auditor can flag broken or non-image share images.
Uses only the Python standard library.
"""
import argparse
import json
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 = "OpenGraphAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
OG_KEYS = {"og:title", "og:description", "og:image", "og:image:secure_url",
"og:url", "og:type", "og:site_name", "og:image:width", "og:image:height"}
TW_KEYS = {"twitter:card", "twitter:title", "twitter:description", "twitter:image",
"twitter:site"}
class SocialParser(HTMLParser):
def __init__(self, base_url):
super().__init__(convert_charrefs=True)
self.base_url = base_url
self.og = {}
self.tw = {}
self.canonical = None
self.meta_robots = None
self.links = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "meta":
prop = (a.get("property") or "").lower()
name = (a.get("name") or "").lower()
content = (a.get("content") or "").strip()
if prop in OG_KEYS and prop not in self.og:
self.og[prop] = content
elif name in TW_KEYS and name not in self.tw:
self.tw[name] = content
elif name == "robots":
self.meta_robots = a.get("content")
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:", "#")):
self.links.append(
urllib.parse.urldefrag(urllib.parse.urljoin(self.base_url, href))[0])
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
ctype = resp.headers.get("Content-Type", "")
body = 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, ctype
except urllib.error.HTTPError as e:
return e.code, url, None, ""
except Exception:
return 0, url, None, ""
def norm_host(url):
h = urllib.parse.urlparse(url).netloc.lower()
return h[4:] if h.startswith("www.") else h
def pages_from_sitemap(site):
status, _f, body, _c = fetch(urllib.parse.urljoin(site, "/sitemap.xml"))
if status != 200 or not body:
return []
try:
root = ET.fromstring(body)
return [loc.text.strip() for loc in
root.iter("{http://www.sitemaps.org/schemas/sitemap/0.9}loc") if loc.text]
except ET.ParseError:
return []
def parse_page(url, html):
p = SocialParser(url)
try:
p.feed(html)
except Exception:
pass
return {"og": p.og, "twitter": p.tw, "canonical": p.canonical,
"meta_robots": p.meta_robots}, p.links
def empty(status, final_url):
return {"status": status, "final_url": final_url, "og": {}, "twitter": {},
"canonical": None, "meta_robots": None}
def extract_from_cache(cache_path):
"""Pure: turn a shared page cache (from fetch_pages.py) into a social inventory.
No network — parses the cached HTML for exactly the fields this audit needs.
(The optional share-image probe still hits the network unless --no-probe.)
"""
with open(cache_path, encoding="utf-8") as f:
cache = json.load(f)
pages = {}
for url, page in cache.get("pages", {}).items():
html = page.get("html")
status = page.get("status", 0)
final_url = page.get("final_url") or url
if not html:
pages[url] = empty(status, final_url)
continue
info, _links = parse_page(final_url, html)
info["status"] = status
info["final_url"] = final_url
pages[url] = info
return cache.get("site", cache_path), pages
def collect(urls, max_pages, crawl_host=None):
pages, queue, seen = {}, deque(urls), set(urls)
while queue and len(pages) < max_pages:
url = queue.popleft()
status, final_url, html, _c = fetch(url)
print(f"[{status}] {url}", file=sys.stderr)
if html is None:
pages[url] = empty(status, final_url)
continue
info, links = parse_page(final_url, html)
info["status"] = status
info["final_url"] = final_url
pages[url] = info
if crawl_host:
for link in links:
if norm_host(link) == crawl_host and link not in seen:
seen.add(link)
queue.append(link)
return pages
def probe_images(pages):
wanted = set()
for p in pages.values():
for key in ("og:image", "og:image:secure_url"):
if p.get("og", {}).get(key):
wanted.add(p["og"][key])
if p.get("twitter", {}).get("twitter:image"):
wanted.add(p["twitter"]["twitter:image"])
images = {}
for url in sorted(wanted):
if not urllib.parse.urlparse(url).scheme:
images[url] = {"status": 0, "content_type": "", "absolute": False}
continue
status, _f, _b, ctype = fetch(url)
print(f" og:image [{status}] {url}", file=sys.stderr)
images[url] = {"status": status, "content_type": ctype, "absolute": True}
return images
def main():
ap = argparse.ArgumentParser(description="Build an Open Graph / Twitter Card inventory.")
ap.add_argument("source", nargs="?")
ap.add_argument("--from-cache",
help="Extract from a shared page cache produced by fetch_pages.py (no network)")
ap.add_argument("--max-pages", type=int, default=500)
ap.add_argument("--output", default="social_inventory.json")
ap.add_argument("--url-list", action="store_true")
ap.add_argument("--no-probe", action="store_true", help="skip probing share images")
args = ap.parse_args()
if args.from_cache:
site, pages = extract_from_cache(args.from_cache)
elif not args.source:
ap.error("provide a source, or --from-cache page_cache.json")
elif args.url_list:
with open(args.source, encoding="utf-8") as f:
urls = [l.strip() for l in f if l.strip() and not l.startswith("#")]
site, pages = args.source, collect(urls[: args.max_pages], args.max_pages)
else:
start = args.source.rstrip("/")
seeds = pages_from_sitemap(start) or [start]
site, pages = args.source, collect(seeds[: args.max_pages], args.max_pages,
crawl_host=norm_host(start))
images = {} if args.no_probe else probe_images(pages)
with open(args.output, "w", encoding="utf-8") as f:
json.dump({"site": site, "pages": pages, "images": images},
f, indent=2, ensure_ascii=False)
print(f"Wrote {len(pages)} pages, {len(images)} share images to {args.output}",
file=sys.stderr)
if __name__ == "__main__":
main()
Social Tag Auditor Script (scripts/audit_social.py)
Probes each share image for status and content type, then runs the audit checks and outputs the structured report data.
#!/usr/bin/env python3
"""Audit an Open Graph / Twitter Card inventory and emit findings.
Usage:
python3 audit_social.py social_inventory.json [--output audit_report.json]
Checks (see references/audit-checks.md):
- missing_og_title / missing_og_description / missing_og_image (high/medium)
- missing_og_url (medium); og_url_mismatch (medium) vs. canonical/final URL
- og_image_broken (high): og:image returns non-200
- og_image_not_image (medium): og:image content-type is not image/*
- og_image_relative (medium): og:image is not an absolute URL
- missing_twitter_card (low): no twitter:card and no OG to fall back on
- og_description_too_long (low > 300 chars) / og_title_too_long (low > 90)
- duplicate_og_image (info): same share image across many pages
Uses only the Python standard library.
"""
import argparse
import json
import sys
import urllib.parse
from collections import defaultdict
OG_TITLE_MAX = 90
OG_DESC_MAX = 300
def norm(url):
if not url:
return ""
u = urllib.parse.urldefrag(url)[0].rstrip("/")
return u
def main():
ap = argparse.ArgumentParser(description="Run Open Graph / Twitter Card audit checks.")
ap.add_argument("inventory")
ap.add_argument("--output", default="audit_report.json")
args = ap.parse_args()
with open(args.inventory, encoding="utf-8") as f:
data = json.load(f)
pages = data.get("pages", {})
images = data.get("images", {})
findings = {k: [] for k in (
"missing_og_title", "missing_og_description", "missing_og_image",
"missing_og_url", "og_url_mismatch", "og_image_broken",
"og_image_not_image", "og_image_relative", "missing_twitter_card",
"og_title_too_long", "og_description_too_long", "duplicate_og_image")}
image_use = defaultdict(list)
for url, page in pages.items():
if page.get("status", 0) != 200:
continue
if "noindex" in (page.get("meta_robots") or "").lower():
continue
og = page.get("og", {})
tw = page.get("twitter", {})
final_url = page.get("final_url", url)
canonical = page.get("canonical")
if not og.get("og:title"):
findings["missing_og_title"].append({"page": url})
elif len(og["og:title"]) > OG_TITLE_MAX:
findings["og_title_too_long"].append(
{"page": url, "length": len(og["og:title"]), "value": og["og:title"]})
if not og.get("og:description"):
findings["missing_og_description"].append({"page": url})
elif len(og["og:description"]) > OG_DESC_MAX:
findings["og_description_too_long"].append(
{"page": url, "length": len(og["og:description"])})
image = og.get("og:image") or og.get("og:image:secure_url")
if not image:
findings["missing_og_image"].append({"page": url})
else:
image_use[image].append(url)
info = images.get(image, {})
if info:
if not info.get("absolute", True):
findings["og_image_relative"].append({"page": url, "og_image": image})
elif info.get("status", 0) == 0 or info.get("status", 0) >= 400:
findings["og_image_broken"].append(
{"page": url, "og_image": image, "status": info.get("status")})
elif "image" not in (info.get("content_type") or ""):
findings["og_image_not_image"].append(
{"page": url, "og_image": image, "content_type": info.get("content_type")})
og_url = og.get("og:url")
if not og_url:
findings["missing_og_url"].append({"page": url})
else:
expected = norm(canonical) or norm(final_url)
if norm(og_url) != expected:
findings["og_url_mismatch"].append(
{"page": url, "og_url": og_url, "expected": expected})
if not tw.get("twitter:card") and not og:
findings["missing_twitter_card"].append({"page": url})
for image, urls in image_use.items():
if len(urls) > 5:
findings["duplicate_og_image"].append(
{"og_image": image, "page_count": len(urls), "examples": sorted(urls)[:5]})
severity = {
"missing_og_title": "high", "missing_og_image": "high", "og_image_broken": "high",
"missing_og_description": "medium", "missing_og_url": "medium",
"og_url_mismatch": "medium", "og_image_not_image": "medium",
"og_image_relative": "medium",
"missing_twitter_card": "low", "og_title_too_long": "low",
"og_description_too_long": "low", "duplicate_og_image": "info",
}
summary = {
"pages_analyzed": sum(1 for p in pages.values() if p.get("status", 0) == 200),
"share_images_probed": len(images),
"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()