Extract and Check Open Graph and Twitter Card Meta Tags with Python (Standard Library Only)
When someone pastes your URL into Slack, LinkedIn, iMessage or X, the preview is not reading your <title> tag and hoping for the best. It looks for Open Graph tags (og:*) and, for X, Twitter Card tags (twitter:*). A page
When someone pastes your URL into Slack, LinkedIn, iMessage or X, the preview is not reading your <title> tag and hoping for the best. It looks for Open Graph tags (og:*) and, for X, Twitter Card tags (twitter:*). A page can be fine in the browser and still look broken in a preview: missing image, wrong title, relative og:image that only works on your domain, or no twitter:card at all.
This post builds a small checker in Python using only the standard library (html.parser, urllib). It fetches a URL (or reads a local HTML file), extracts every og:* and twitter:* meta tag, and flags the ones this script treats as required for a usable link preview: og:title, og:description, og:image, og:url and twitter:card.
Open Graph rules below come from ogp.me (opened 6 Nov 2026). Twitter Card tag names and Open Graph fallback behavior come from the archived Cards Markup reference on developer.twitter.com via the Wayback Machine (snapshot retrieved 6 Nov 2026); X's current docs.x.com index no longer surfaces that page as a first-class doc. Appie free-tool descriptions are as of 6 Nov 2026. The script was tested on Python 3.13 against local fixtures shown below.
Disclosure: Appie.run publishes this article. One free tool mentioned near the end is ours. The script does not depend on it.
Where this fits
Earlier posts in this series covered checking which AI crawlers your robots.txt blocks and extracting JSON-LD structured data with the standard library. Those look at machine-readable signals search and AI systems use. Open Graph and Twitter Cards are the parallel signal for human link previews. Same idea: parse the page yourself, print what is there, fail loudly when the important tags are missing.
What "required" means here
The Open Graph protocol lists four required properties for every page: og:title, og:type, og:image and og:url. og:description is optional in the protocol but nearly always needed for a readable preview, so this script treats it as required for the checklist.
For X/Twitter Cards, the important tag is twitter:card (common values: summary, summary_large_image, app, player). The older Cards Markup reference notes that if og:type, og:title and og:description exist but twitter:card is absent, a summary card may still render via Open Graph fallback. Relying on that fallback is fragile, so the script still flags a missing twitter:card.
This is a preview hygiene checklist, not a claim that every platform uses the same rules.
The script
Save this as og_meta_check.py. It should run on Python 3.8 or later; we tested it on 3.13.
#!/usr/bin/env python3
"""Extract Open Graph and Twitter Card meta tags from a URL or local HTML file.
Standard library only. Usage:
python3 og_meta_check.py https://example.com/page
python3 og_meta_check.py /path/to/page.html
python3 og_meta_check.py https://example.com/page --json
"""
import argparse
import json
import sys
import urllib.error
import urllib.request
from html.parser import HTMLParser
from urllib.parse import urljoin
UA = "og-meta-check/1.0 (+https://example.com/contact)"
# Preview checklist used by this script (not identical to every platform's rules).
# Open Graph protocol (ogp.me) requires og:title, og:type, og:image, og:url.
# This checker also treats og:description as required for useful link previews,
# and twitter:card as required for X/Twitter Cards (OG tags may fill in the rest).
REQUIRED = (
"og:title",
"og:description",
"og:image",
"og:url",
"twitter:card",
)
class MetaParser(HTMLParser):
"""Collect og:* and twitter:* meta tags from <head>."""
def __init__(self):
super().__init__()
self.tags = {} # property/name -> list of content values (order preserved)
self._in_head = False
self._done = False
def handle_starttag(self, tag, attrs):
if self._done:
return
name = tag.lower()
attr = {k.lower(): (v or "") for k, v in attrs}
if name == "head":
self._in_head = True
return
if name == "body":
self._done = True
return
if not self._in_head or name != "meta":
return
prop = attr.get("property") or attr.get("name") or ""
prop = prop.strip().lower()
if not (prop.startswith("og:") or prop.startswith("twitter:")):
return
content = attr.get("content", "").strip()
self.tags.setdefault(prop, []).append(content)
def handle_endtag(self, tag):
if tag.lower() == "head":
self._in_head = False
self._done = True
def fetch_html(src, timeout):
if src.startswith(("http://", "https://")):
req = urllib.request.Request(src, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
return resp.read().decode(charset, errors="replace"), resp.geturl()
with open(src, "r", encoding="utf-8", errors="replace") as fh:
return fh.read(), src
def first(tags, key):
vals = tags.get(key) or []
return vals[0] if vals else None
def analyze(tags, page_url):
missing = [k for k in REQUIRED if not first(tags, k)]
warnings = []
og_image = first(tags, "og:image")
if og_image and page_url.startswith(("http://", "https://")):
abs_img = urljoin(page_url, og_image)
if abs_img != og_image and not og_image.startswith(("http://", "https://")):
warnings.append(
f"og:image is relative ({og_image}); resolved as {abs_img}"
)
if first(tags, "og:title") and not first(tags, "og:type"):
warnings.append(
"og:type is missing; Open Graph protocol lists it as required "
"(this script still treats the page as checkable without it)."
)
card = first(tags, "twitter:card")
if card and card not in {
"summary",
"summary_large_image",
"app",
"player",
}:
warnings.append(
f"twitter:card value {card!r} is unusual; common values are "
"summary, summary_large_image, app, player"
)
return {
"url": page_url,
"tags": {k: v for k, v in sorted(tags.items())},
"missing_required": missing,
"warnings": warnings,
"ok": not missing,
}
def print_report(result):
print(f"URL: {result['url']}")
print()
if result["tags"]:
print("Found tags:")
for key, values in result["tags"].items():
for val in values:
print(f" {key}: {val}")
else:
print("Found tags: (none)")
print()
if result["missing_required"]:
print("Missing required:")
for key in result["missing_required"]:
print(f" - {key}")
else:
print("Missing required: (none)")
if result["warnings"]:
print()
print("Warnings:")
for w in result["warnings"]:
print(f" - {w}")
print()
print("Status:", "OK" if result["ok"] else "INCOMPLETE")
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("source", help="URL or path to an HTML file")
p.add_argument("--timeout", type=float, default=15.0)
p.add_argument("--json", action="store_true", help="Print JSON instead of text")
args = p.parse_args(argv)
try:
html, final_url = fetch_html(args.source, args.timeout)
except urllib.error.HTTPError as e:
print(f"HTTP error: {e.code} {e.reason}", file=sys.stderr)
return 2
except urllib.error.URLError as e:
print(f"URL error: {e.reason}", file=sys.stderr)
return 2
except OSError as e:
print(f"File error: {e}", file=sys.stderr)
return 2
parser = MetaParser()
parser.feed(html)
result = analyze(parser.tags, final_url)
if args.json:
print(json.dumps(result, indent=2))
else:
print_report(result)
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
Local test
Create a complete fixture:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Fallback Title</title>
<meta property="og:title" content="Sample Product Launch" />
<meta property="og:description" content="A short description for link previews." />
<meta property="og:image" content="https://example.com/og.png" />
<meta property="og:url" content="https://example.com/launch" />
<meta property="og:type" content="article" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Sample Product Launch" />
</head>
<body><h1>Hello</h1></body>
</html>
Run:
python3 og_meta_check.py sample.html
Expected output (status OK, exit code 0):
URL: sample.html
Found tags:
og:description: A short description for link previews.
og:image: https://example.com/og.png
og:title: Sample Product Launch
og:type: article
og:url: https://example.com/launch
twitter:card: summary_large_image
twitter:title: Sample Product Launch
Missing required: (none)
Status: OK
Now an incomplete page:
<!DOCTYPE html>
<html><head>
<title>Only a title</title>
<meta property="og:title" content="Incomplete page" />
</head><body></body></html>
python3 og_meta_check.py incomplete.html; echo "exit: $?"
Expected (status INCOMPLETE, exit code 1):
URL: incomplete.html
Found tags:
og:title: Incomplete page
Missing required:
- og:description
- og:image
- og:url
- twitter:card
Warnings:
- og:type is missing; Open Graph protocol lists it as required (this script still treats the page as checkable without it).
Status: INCOMPLETE
exit: 1
We ran both fixtures on Python 3.13 on 6 Nov 2026 and got the outputs above.
Against a live site:
python3 og_meta_check.py https://example.com/
python3 og_meta_check.py https://example.com/ --json
Exit code 0 means the checklist passed; 1 means required tags are missing; 2 means fetch failed.
How the parser works
html.parser.HTMLParser is intentionally picky about where it looks. The collector only records meta tags while it is inside <head>, and it stops when it sees </head> or <body>. That keeps footer widgets and body markup from polluting the report.
Open Graph tags usually use property="og:...". Twitter Card tags usually use name="twitter:...". The script accepts either attribute for both prefixes, because real pages mix them. Multiple values for the same key are kept in document order; the checklist uses the first value, which matches how most consumers prefer the first og:image.
urllib.request follows redirects by default. The report prints the final URL after redirects, which matters when og:url still points at an old path.
Common failures the script catches
-
No
og:image. Previews fall back to a blank or a random scraped image. -
Relative
og:image. Some consumers resolve it; many do not. The script warns and shows the absolute form viaurljoin. -
Missing
og:description. Title-only cards look unfinished in Slack and iMessage. -
og:urlmissing or pointing at a tracking redirect. Prefer the canonical public URL. -
No
twitter:card. You may get an OG fallback on X, or nothing useful. Set the card type explicitly. -
og:typemissing. The protocol requires it. This script warns but does not fail the checklist solely for that, so you can prioritize preview text and image first.
What it does not do
- It does not download
og:imageto check dimensions, file size or format. - It does not call Facebook's Sharing Debugger or X's card validator APIs.
- It does not execute JavaScript. Tags injected only by client-side code will not appear. If your site is a pure CSR app, render meta tags on the server or in the prerendered HTML.
- It does not crawl a whole site. Point it at one URL at a time, or wrap it in a small loop over a URL list.
If you already have an XML sitemap and want the URL list first, Appie's free Sitemap URL Extractor (checked 6 Nov 2026) turns a sitemap or sitemap index into one URL per line. Feed those URLs to this script in a shell loop. The extractor lists child sitemap locations when you give it an index; open a child and run it again for page URLs.
Minimal tags to aim for
<meta property="og:title" content="Your page title" />
<meta property="og:description" content="One or two sentences for the preview." />
<meta property="og:image" content="https://example.com/images/og.png" />
<meta property="og:url" content="https://example.com/your-page" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
Use absolute HTTPS image URLs. Keep descriptions short. Match og:url to the canonical page people should share.
FAQ
Does this replace Facebook or X's official debuggers?
No. Use those when you need to see the cached preview a platform will show. This script is for local and CI checks before you ship.
Why flag og:description if ogp.me marks it optional?
Because link previews without a description look incomplete in the places people actually paste URLs. The protocol's required set still includes og:type, which this script warns about separately.
Can I use Beautiful Soup instead?
Yes, if you already depend on it. This series stays on the standard library so the script runs anywhere Python runs, with no pip install.
Will it see meta tags added by React or Vue after load?
Only if they are present in the HTML response. View-source what the server returns; that is what most crawlers and preview bots see on the first request.
If you are already auditing sitemaps and structured data with small Python scripts, add this checker to the same folder and run it on every template that can be shared. For pulling URLs out of a sitemap before you loop, the free Sitemap URL Extractor is enough to get started.
RG, Founder, Appie.run
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.