---
title: "Check Your Schema Without Google’s Rich Results Test, Using curl and Python"
url: https://hostmy.blog/check-schema-without-google/
date: 2026-09-22
modified: 2026-09-22
lang: en
author: "Aditya Sharma"
description: "One page at a time in a web form does not scale. Here is a script that pulls JSON-LD from any URL and tells you what is missing."
categories:
  - "WordPress"
image: https://hostmy.blog/wp-content/uploads/2026/09/hmb-card-1887-1024x538.jpg
word_count: 1533
---

# Check Your Schema Without Google’s Rich Results Test, Using curl and Python

Google's Rich Results Test is a good tool with one problem. It checks one URL at a time, in a browser, against Google's own eligibility rules.

That is fine when you are debugging a single page. It is useless when you want to know whether 400 posts all have an author, or whether your FAQ blocks are actually emitting anything.

It also answers a narrower question than the one you have. Rich Results Test asks whether Google will draw a rich snippet. What you want to know is whether the structured data on the page is valid, complete and machine readable at all, which is one of the concrete [WordPress jobs behind AI search advice](/ai-search-optimization-wordpress/).

Here is how to check that yourself, with curl for the quick look and a short Python script for the whole site.

## The quick look, with curl

First, does the page emit any JSON-LD?

`curl -sSL https://yoursite.com/your-post/ | grep -c 'application/ld+json'`

Expected output is a count:

`1`

Zero means no JSON-LD at all. That is worth knowing before you go further, and it happens more than people expect on custom themes.

Then pull the block out and read it. This works with BSD grep on macOS and GNU grep on Linux:

`curl -sSL https://yoursite.com/your-post/ \
| tr -d '\n' \
| grep -o '<script type="application/ld+json"[^>]*>[^<]*</script>' \
| sed -e 's/<[^>]*>//g' \
| python3 -m json.tool | head -40`

The `tr -d '\n'` matters. JSON-LD blocks are usually pretty printed across many lines, and `grep -o` works line by line, so without flattening first you get fragments.

`python3 -m json.tool` is doing the real work at the end. If the JSON is malformed it fails there with a line and column, which is the single most useful thing this pipeline tells you.

That is enough for one page. It does not scale, and it does not tell you which required fields are absent. Which fields count as required per type is set out in [the four WordPress schema types and the fields they need](/structured-data-wordpress/).

## The script

Save this as `schemacheck.py`. Standard library only, no packages.

`#!/usr/bin/env python3
"""Extract and validate JSON-LD from a URL."""
import json, sys, urllib.request
from html.parser import HTMLParser

REQUIRED = {
"Article": ["headline", "datePublished", "author"],
"BlogPosting": ["headline", "datePublished", "author"],
"NewsArticle": ["headline", "datePublished", "author"],
"FAQPage": ["mainEntity"],
"HowTo": ["name", "step"],
"Product": ["name", "offers"],
"Organization": ["name", "url"],
"BreadcrumbList": ["itemListElement"],
}

class LdCollector(HTMLParser):
def __init__(self):
super().__init__()
self.blocks, self._grab, self._buf = [], False, []
def handle_starttag(self, tag, attrs):
if tag.lower() != "script":
return
a = {k.lower(): (v or "").lower() for k, v in attrs}
if a.get("type", "").startswith("application/ld+json"):
self._grab, self._buf = True, []
def handle_data(self, data):
if self._grab:
self._buf.append(data)
def handle_endtag(self, tag):
if tag.lower() == "script" and self._grab:
self.blocks.append("".join(self._buf))
self._grab, self._buf = False, []

def fetch(url):
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (compatible; schema-check/1.0)"})
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
cs = r.headers.get_content_charset() or "utf-8"
return raw.decode(cs, "replace")

def walk(node, out, seen):
"""Collect every typed node once, including nodes inside @graph."""
if id(node) in seen:
return out
seen.add(id(node))
if isinstance(node, list):
for n in node:
walk(n, out, seen)
elif isinstance(node, dict):
if "@type" in node:
out.append(node)
for v in node.values():
if isinstance(v, (dict, list)):
walk(v, out, seen)
return out

def type_of(node):
t = node.get("@type")
if isinstance(t, list):
t = t[0] if t else None
return t if isinstance(t, str) else None

def main(url):
p = LdCollector()
p.feed(fetch(url))
if not p.blocks:
print("NO JSON-LD FOUND on", url)
return 1
print("%s\n%d JSON-LD block(s)\n" % (url, len(p.blocks)))
problems = 0
for i, raw in enumerate(p.blocks, 1):
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
print("block %d INVALID JSON: %s" % (i, e))
problems += 1
continue
nodes = walk(data, [], set())
if not nodes:
print("block %d parses, but no @type anywhere" % i)
problems += 1
for node in nodes:
t = type_of(node)
if t is None:
continue
missing = [f for f in REQUIRED.get(t, []) if not node.get(f)]
if missing:
problems += 1
print("block %d %-20s MISSING %s" % (i, t, ", ".join(missing)))
else:
print("block %d %-20s ok (%s)"
% (i, t, "checked" if t in REQUIRED else "no rule"))
print("\n%d problem(s)" % problems)
return 0 if problems == 0 else 1

if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: python3 schemacheck.py https://example.com/post/")
sys.exit(2)
sys.exit(main(sys.argv[1]))`

## Running it

```
python3 schemacheck.py https://yoursite.com/your-post/
```

Real output against a live page, September 2026:

`https://wordpress.org/plugins/rankready-ai-llm-seo/
1 JSON-LD block(s)

block 1 SoftwareApplication ok (no rule)
block 1 AggregateRating ok (no rule)
block 1 InteractionCounter ok (no rule)
block 1 Offer ok (no rule)
block 1 Organization ok (checked)

0 problem(s)`

And against a deliberately broken test page:

`2 JSON-LD block(s)

block 1 INVALID JSON: Illegal trailing comma before end of object: line 3 column 30 (char 117)
block 2 BlogPosting MISSING author
block 2 FAQPage MISSING mainEntity
block 2 Organization MISSING url

4 problem(s)`

A trailing comma is the most common breakage, and it usually arrives when a plugin interpolates an empty value into a template. The whole block is discarded by any strict parser, so one stray comma takes out everything you marked up on that page.

## Why walk recurses through @graph

This is the part most hand rolled schema checkers get wrong.

Modern SEO plugins do not emit one flat object, which is also why [adding schema without duplicating your SEO plugin's graph](/schema-plugin-wordpress/) takes some care. They emit a single block containing an `@graph` array with ten or more linked nodes: WebPage, WebSite, Organization, Person, BreadcrumbList, ImageObject and so on. A checker that only reads the top level `@type` sees nothing, reports clean, and misses every problem underneath.

The `walk` function descends through arrays and nested dictionaries, collecting each typed node once. The `seen` set of object identities stops it counting the same node twice when it is reachable by more than one path.

You can confirm the difference on a real page. This run found 14 typed nodes inside a single block:

`block 1 WebPage ok (no rule)
block 1 ReadAction ok (no rule)
block 1 ImageObject ok (no rule)
block 1 BreadcrumbList ok (checked)
block 1 ListItem ok (no rule)
block 1 WebSite ok (no rule)
block 1 SearchAction ok (no rule)
block 1 EntryPoint ok (no rule)
block 1 PropertyValueSpecification ok (no rule)
block 1 Organization ok (checked)
block 1 ImageObject ok (no rule)
block 1 Person ok (no rule)
block 1 Organization ok (checked)
block 1 Organization ok (checked)`

Fourteen nodes, one block. A top level check would have reported one.

## Running it across the whole site

Feed it a list of URLs and keep the exit codes:

`curl -sSL https://yoursite.com/sitemap.xml \
| grep -o '<loc>[^<]*</loc>' | sed -e 's/<[^>]*>//g' > urls.txt

while read -r u; do
out=$(python3 schemacheck.py "$u" 2>&1)
if [ $? -ne 0 ]; then
printf '=== %s\n%s\n' "$u" "$out"
fi
sleep 1
done < urls.txt`

That prints only the pages with problems, which is the report you actually want. The `sleep 1` is there so you are not hammering your own server. Schema is one of five things worth sweeping this way, and [the five layer audit run by hand with curl](/five-layer-ai-seo-audit/) covers the rest.

If your sitemap is an index of other sitemaps, that grep returns child sitemap URLs rather than pages. Run it once more against one of those to get the page list.

## What this does not tell you

Two honest limits.

The `REQUIRED` map is a small opinionated set, not the schema.org specification. It catches the fields that are commonly empty on WordPress: a missing author on a post, an FAQPage block with no questions in it, an Organization with no URL. Extend it with the types you actually use, starting from [the three schema types that survive being lifted](/schema-markup-guide-ai/).

And a valid block is not a ranking mechanism. Structured data makes the facts on a page explicit rather than inferred. That is a parsing benefit. Whether anything cites you is not something schema decides, and the wider audit of what a machine can read on your pages is in [spotting the pages too heavy to be worth reading](/find-pages-ai-cannot-parse/).

## If you would rather not run scripts

Running this by hand teaches you what your pages actually emit, which is worth an afternoon. Keeping 400 posts correct as themes and plugins change is a different job.

RankReady is a WordPress AI SEO plugin that generates and validates this markup in place: Article and FAQ output, author details, and a check that flags posts where a required field went empty.

It runs alongside Rank Math, Yoast, AIOSEO and SEOPress rather than replacing them. If one of those already writes your `@graph`, this reads it rather than duplicating it, so you do not end up with two competing blocks on the page. Setup is about five minutes.

The claim stays narrow: valid, complete markup means machines can parse your facts instead of guessing them. That is readability, not a promise about rankings. It is on [WordPress.org](https://wordpress.org/plugins/rankready-ai-llm-seo/). Once the markup is clean, the next thing worth measuring is whether the sentences around it are extractable, covered in [a script that measures quotability across an archive](/measure-quotable-writing/).

Run the script against ten of your posts. How many blocks turned out to be a single `@graph` you had never looked inside?