Tutorial

Find Pages That Share One Default OG Image

A site template often sets one og:image for every page. Each page looks fine in the browser, but every shared link shows the same card. The script in this tutorial reads your sitemap, checks each page's static tags, and lists the pages that share an image URL or have none.

Why a shared default image is a problem

A default card is fine for a homepage. On a blog post or docs page, it tells the reader nothing about the link. When we audited the New York Times social previews, the World section's og:image was the same promo image the homepage used. A shared World link looked like a homepage link.

The shared image is hard to spot by hand. You have to open the tags for each page, and a site with a few hundred pages is too many to paste into a debugger one by one.

Spot-check five pages first

For a quick look, paste your sitemap into the Bulk OG Checker. It checks the first five page URLs in the sitemap and shows each page's tags and image. If those five pages already show the same image, you know enough to start fixing the template.

The bulk checker does not group pages by image, and it stops at five. For the whole sitemap, run the script below on your own machine or in CI.

What the script does

The script reads sitemap.xml (a URL or a local file), checks each page with the free myog-checker CLI, and groups the pages by the og:image URL they declare. It reports:

  • image URLs shared by two or more pages, with the pages listed
  • pages with no og:image or twitter:image
  • pages whose image comes only from twitter:image, which some platforms do not read
  • pages that returned an HTTP error, and checks that failed

The script compares declared URLs, not image files. It never downloads an image. Two different URLs can serve the same picture, one URL can serve different bytes over time, and a unique URL can still be broken. Use a page-level checker such as the OG preview tool when you need to see the image itself.

Install and run it

You need Node.js 22.19.0 or later, which is what myog-checker requires. No MyOG account or API key is needed; every request goes from your machine to your site.

mkdir og-audit && cd og-audit
npm install myog-checker@0.1.1 htmlparser2@10.0.0
curl -O https://myog.social/downloads/og-image-sitemap-audit.mjs
Download og-image-sitemap-audit.mjs

One MIT-licensed file, about 670 lines. It needs only the two packages above. Read it before you run it.

Then point it at a sitemap:

# A live sitemap
node og-image-sitemap-audit.mjs https://example.com/sitemap.xml

# A sitemap file your build wrote (the pages in it are still fetched live), at most 50 pages
node og-image-sitemap-audit.mjs ./dist/sitemap.xml --max-pages 50

# JSON for scripts, exit code 3 when pages share an image or have none
node og-image-sitemap-audit.mjs ./dist/sitemap.xml --json --strict

Run it only against sites you own or are allowed to test. It checks one page at a time with a 500 ms pause between pages (--delay-ms changes it), and stops after 100 pages unless you raise --max-pages (up to 1,000).

Sample output

The output below comes from the script's offline test fixtures, which mock a small site on fixtures.example. The homepage, about page, and pricing page declare the same default card. One blog post has no image tags at all, and the pricing page only has twitter:image.

Pages checked on fixtures.example: 9
  Sharing an image URL: 3
  Shared image URLs: 1
  With a unique image URL: 2
  With no image URL: 1
  Not compared (query-string image URLs): 2
  HTTP errors: 1
  Failed checks: 0

Shared by 3 pages: https://fixtures.example/images/default-card.png
    https://fixtures.example/audit/
    https://fixtures.example/audit/about
    https://fixtures.example/audit/pricing

No og:image or twitter:image:
    https://fixtures.example/audit/blog/second-post

Cannot compare (query string redacted by the checker), 2 pages: https://img.fixtures.example/og?REDACTED
    https://fixtures.example/audit/docs/a
    https://fixtures.example/audit/docs/b

Image from twitter:image only (no og:image tag):
    https://fixtures.example/audit/pricing

HTTP errors (not grouped):
    404 https://fixtures.example/audit/gone

Notes:
  <loc> entries skipped (not public URLs on fixtures.example): 1

Image files were not fetched. A shared URL can still serve different bytes, and a unique URL can still be broken.

How the script works

The script parses the sitemap with htmlparser2 in XML mode. It accepts a <urlset> or a <sitemapindex> root and rejects a truncated file. Extension entries such as <image:loc> and hreflang <xhtml:link> alternates are ignored, so only the page URLs in <loc> are checked.

The myog-checker CLI checks one URL per run, so the script starts one child process per page and reads the JSON it prints:

const child = spawn(process.execPath, [checkerCLI, "check", pageURL, "--json"], {
  stdio: ["ignore", "pipe", "ignore"],
})

Each JSON report has metadata.imageURL, the effective image after the checker's fallbacks: Open Graph image tags first, then twitter:image. Relative URLs are already resolved against the final page URL, so /images/card.png and the absolute form group together. The grouping is a map keyed by that URL:

const { http, metadata, tags } = page.check.result
if (http.statusCode >= 400) {
  httpErrors.push({ url: page.url, statusCode: http.statusCode })
  continue
}
if (!metadata.imageURL) {
  missing.push({ url: page.url })
  continue
}
const entry = { url: page.url, source: imageSource(tags) }
const target = metadata.imageURL.endsWith("?REDACTED") ? queryRedacted : byImage
target.set(metadata.imageURL, [...(target.get(metadata.imageURL) ?? []), entry])

The limits are in one LIMITS object at the top of the file:

  • Only page URLs on one hostname are checked: the sitemap URL's host, or for a local file, the host of the first <loc>. If your sitemap lists www. pages under a different host, pass --host.
  • A sitemap index is followed one level deep, up to 25 child sitemaps on the same host. Nested indexes are skipped and reported.
  • Sitemap requests time out after 15 seconds, follow at most three same-host redirects, and stop at 10 MB. Gzip-compressed .xml.gz files are rejected; pass the uncompressed XML.
  • Each checker run is killed after 20 seconds or 256 KB of output. The checker has its own 10-second deadline.
  • Before each sitemap request, the script refuses localhost and private IP addresses, and checks that the hostname does not resolve to one. That basic guard does not stop DNS rebinding. Page checks go through the checker's own public-address rules, which connect to the address they checked.

Read the results

A shared image on a few pages is often intentional. A homepage, pricing page, and legal pages can reasonably share the brand card. Look at which pages share it. If blog posts, docs pages, or product pages are in the group, the template is likely setting one image for everything.

Pages with no image URL usually come from a layout that never adds the tag, or from tags added by JavaScript after the page loads. The checker reads only the HTML the server returns, like most social crawlers. If your tags are added client-side, you need server rendering or prerendering first; the Vue prerendering guide shows one way to do that.

A twitter:image-only page has an image for X but no og:image tag. Add the Open Graph tag too; Facebook, LinkedIn, Slack, and most chat apps read og:image.

Query-string image URLs cannot be compared. The checker replaces query strings with ?REDACTED so reports do not leak tokens. Generators that put page data in the image URL, such as /api/og?title=Hello, look identical after redaction. The script lists those pages under "Cannot compare" instead of calling them shared.

A redacted URL used by only one page still counts as unique, because its path differs. Next.js opengraph-image URLs (for example /blog/hello-world/opengraph-image?869a…) fall in that case.

Failed checks show the checker's error code, such as timeout, blocked_url, or non_html. Sites that block unknown bots usually return 403, which shows under HTTP errors. The checker identifies itself as MyOGBot.

Run it in CI

With --strict, the script exits with code 3 when any page shares an image URL or has none. Exit code 1 means the sitemap or setup failed, and 2 means bad arguments. With the script committed to your repository, GitHub Actions steps against a public staging site look like this:

- uses: actions/setup-node@v4
  with:
    node-version: 22
- run: npm install myog-checker@0.1.1 htmlparser2@10.0.0
- run: node og-image-sitemap-audit.mjs https://staging.example.com/sitemap.xml --max-pages 200 --strict

If a few pages should share the default card, --strict will fail on them too. In that case, use --json and filter the groups you expect in your own step.

Fix the pages, then run it again

The fix depends on where your tags come from:

  • If your CMS has a featured or social image field, fill it in and check that the template uses it for og:image.
  • If pages have no natural hero image, a fallback is still better as a per-page card with the page title than as one site-wide image.
  • To generate an image for every page from code or a service, compare the options in how to generate OG images for every page: build-time scripts, request-time functions, hosted template APIs, and hosted URL-based generation.

MyOG is one of those options: one tag per page, and MyOG generates the image from the page's own title, description, and brand signals. It needs an account, your exact hostname registered, and available credits. The script and the checker are free and do not need any of that.

After the fix is deployed, run the script again and confirm the groups you expected are gone. Social platforms cache old previews, so use the cache guide if a shared link still shows the old card.

2170061aae9a0ead7c222e25a54af7a4001eea6f