opengraph-linter
A pluggable CLI meta-tag inspector for Open Graph and X card tags — a terminal take on opengraph.xyz.
Install
uv tool install opengraph-linter # core: reports without previews
uv tool install "opengraph-linter[preview]" # + inline og:image previews
The inline preview needs the optional preview extra (textual-image), and is
off by default. Turn it on with --preview or with preview = true in the
config. Without the extra, the report still runs every rule and prints a
one-line hint instead of the picture.
Usage
# straight from a checkout, no install
uv run src/opengraph_linter https://example.com/
# the installed console script; several URLs work in one run
uv run opengraph-linter https://example.com/ https://example.org/
# equivalents
uv run python -m opengraph_linter https://example.com/
uv run scripts/og_preview.py https://example.com/
Options:
| Flag | Effect |
|---|---|
--preview / --no-preview |
Force the inline og:image preview on or off; overrides the preview config (off by default, needs the preview extra) |
--width |
Image width in terminal cells (default: half the panel width) |
--skip-image-checks |
Do not download og:image |
--list-rules |
List every registered rule and exit |
--only X / --disable X |
Run or skip a rule by id or lint code, repeatable |
--no-plugins |
Ignore third-party plugin entry points |
--json |
Emit findings as JSON |
--strict |
Exit non-zero on warnings as well as criticals |
--timeout |
Request timeout in seconds (default 15) |
Exit codes: 0 clean, 1 critical findings (warnings too with --strict),
2 a page could not be fetched or an option was invalid. A run with several
URLs prints one panel per URL and a final overview table.
--json emits a list with one {url, findings} object per URL.
Configuration
Every threshold lives on the Settings model. Override any field from a
[tool.opengraph-linter] table in the pyproject.toml of the directory you
run from:
[tool.opengraph-linter]
og_title_max = 70
max_image_bytes = 8388608
# render og:image inline (needs the `preview` extra, off by default);
# --preview/--no-preview wins
preview = true
# rules to skip on every run, by lint code or rule id
ignore = ["OG004", "image-conversion-text"]
When pyproject.toml has no [tool.opengraph-linter] table, the linter
falls back on an opengraph-linter.toml file in the same directory. Its keys
sit at the top level:
# opengraph-linter.toml
og_title_max = 70
ignore = ["OG004", "image-conversion-text"]
The ignore list is merged with any --disable flags. An unknown code or id
in either place exits with code 2.
Rules
Every data point is a Rule — a lint code, an id, a tag, and a
check(context) -> Finding. Twenty-three ship built in. Each reports critical,
warning, or success; the "Fails as" column shows the severity when the check
does not pass. Ignore any of them by code or id with --disable or the
ignore list.
Open Graph (OG)
| Code | Rule id | Tag | Fails as | Checks |
|---|---|---|---|---|
OG001 |
og-title-present |
og:title |
critical | The tag is set; platforms fall back to the page title or the URL without it |
OG002 |
og-title-length |
og:title |
warning | The title fits the 60-character target (og_title_max) |
OG003 |
og-description-length |
og:description |
critical / warning | The tag is set (critical) and fits a preview card (og_description_max, warning) |
OG004 |
og-site-name |
og:site_name |
warning | The tag is set; Discord and others show the bare domain without it |
OG005 |
og-url |
og:url |
warning | The canonical URL is set and absolute, so shares consolidate on one URL |
OG006 |
og-type |
og:type |
warning | The object type is declared; platforms assume website without it |
OG007 |
og-image-alt |
og:image:alt |
warning | Alt text is set for the image; the spec asks for it and screen readers announce it |
OG008 |
og-image-dimensions |
og:image:width |
warning | Width and height are declared (and match the file), so the first share renders before download |
OG009 |
og-image-secure-url |
og:image:secure_url |
warning | The image has an HTTPS URL, directly or via og:image:secure_url |
OG010 |
og-type-valid |
og:type |
warning | The value is a global type from the spec, or a namespaced custom type with a colon |
OG011 |
og-locale-format |
og:locale |
warning | The locale, when set, matches language_TERRITORY (e.g. en_US) |
OG012 |
og-determiner |
og:determiner |
warning | The determiner, when set, is one of a, an, the, "", auto |
OG013 |
og-vertical-properties |
og:type |
warning | An article, book, profile, music.*, or video.* page carries its vertical's recommended properties |
X / Twitter cards (TW)
| Code | Rule id | Tag | Fails as | Checks |
|---|---|---|---|---|
TW001 |
twitter-card-type |
twitter:card |
warning | The card type is summary_large_image (preferred_card) for a full-width preview |
TW002 |
twitter-title |
twitter:title |
warning | A title is available (falls back to og:title) and fits twitter_title_max |
TW003 |
twitter-description-length |
twitter:description |
warning | A description is available (falls back to og:description) and fits twitter_description_max |
TW004 |
twitter-image |
twitter:image |
critical | An image is available for the card (falls back to og:image) |
The og:image asset (IMG)
| Code | Rule id | Tag | Fails as | Checks |
|---|---|---|---|---|
IMG001 |
image-loads |
og:image |
critical | The tag is set and the file downloads cleanly |
IMG002 |
image-aspect-ratio |
og:image |
critical | The image is close to target_ratio (1.91:1, 1200×630) within ratio_tolerance |
IMG003 |
image-file-size |
og:image |
warning | The file is under max_image_bytes (5 MB); platforms reject larger files |
IMG004 |
image-conversion-text |
og:image |
warning | The image reads as a designed card with a headline or CTA (see below) |
Plain SEO tags (SEO)
| Code | Rule id | Tag | Fails as | Checks |
|---|---|---|---|---|
SEO001 |
page-title-present |
title |
critical | The document has a <title> |
SEO002 |
meta-description-length |
description |
warning | A meta description is set and fits a Google snippet (meta_description_max) |
The IMG rules that need the downloaded file (IMG002–IMG004) report a
warning when the image was not fetched, for example with
--skip-image-checks. Run opengraph-linter --list-rules for the same table
in the terminal.
The conversion-text rule is a heuristic, not OCR: it measures how much of the
image is covered by its dominant flat colors, which separates designed cards
from photographs and auto-generated video thumbnails. Every threshold lives on
the Settings model in src/opengraph_linter/models.py.
Plugins
Rules are registered through pluggy, so a separate package can add or replace them:
from opengraph_linter import Rule, hookimpl
class FacebookAppId(Rule):
id = "fb-app-id"
code = "FB001" # pick an unused prefix for your plugin
tag = "fb:app_id"
description = "fb:app_id is set for Facebook insights"
order = 35
def check(self, context):
value = context.page.get("fb:app_id")
if not value:
return self.warn("Facebook app id is missing", "Set fb:app_id.")
return self.ok("Facebook app id is set", value)
@hookimpl
def opengraph_rules():
return [FacebookAppId()]
Expose it as an opengraph_linter entry point and it loads automatically:
[project.entry-points.opengraph_linter]
my-rules = "my_package.rules"
Two hooks are available:
opengraph_rules()— return rules to add. Reusing a built-inidreplaces it.opengraph_finding(finding, context)— return aFindingto override one, orNoneto leave it alone.
Rules are plain classes; everything they consume and emit — Page, ImageInfo,
Settings, Context, Finding — is a pydantic model.
Metadata
Release files for opengraph-linter 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| opengraph_linter-0.1.0.tar.gz | 20.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| opengraph_linter-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 47.5 kB
Release files / opengraph_linter-0.1.0.tar.gz
| Download URL | opengraph_linter-0.1.0.tar.gz |
|---|---|
| Size | 20.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a6bf1cd127417ad8198e44997083167ff9f5ec315f6b2089ec557590e08b57b8
|
|
BLAKE2b-256 checksum How to use checksums |
61cc4eb2d9ab61e9cce9af1fe91280360dae181d178551efd246a32a6962d14d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.
Transparency logRelease files / opengraph_linter-0.1.0-py3-none-any.whl
| Download URL | opengraph_linter-0.1.0-py3-none-any.whl |
|---|---|
| Size | 27.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fa2b8634f69f7609c531f76a07a6ae9efacaa99203fbd1892ae3b35085f909dc
|
|
BLAKE2b-256 checksum How to use checksums |
8f4070f5b976bc4d27c9168589c3796019452ebfcc91fea9af269b88505fa166
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.
Transparency log