DEV Community

AURA GEO
AURA GEO

Posted on

Structured Data That Matches the Page: Check Your JSON-LD Against Visible Content with Python

Structured data is a promise about the page it sits on. A JSON-LD block that says your app costs 12 USD, that support is at a certain address and that you're the same company as a given GitHub account is a set of claims. Search engines and AI assistants can only use those claims safely if the page backs them up.

Google is explicit about this. Its general structured data guidelines say: "Don't mark up content that is not visible to readers of the page. For example, if the JSON-LD markup describes a performer, the HTML body must describe that same performer." The same page adds that "your structured data must be a true representation of the page content."

Most validators don't check that part. They check syntax and required properties. This post shows how to write Organization and SoftwareApplication JSON-LD from facts that are already on the page, and how to catch drift between the two with about 90 lines of standard-library Python.

Start from the visible facts, not the schema

The usual order goes wrong in a predictable way. Someone opens schema.org, fills in every property that looks relevant, and ends up with markup that says more than the page does. An email address that only exists in the JSON-LD. A price from last quarter. A sameAs link to a profile nobody maintains.

Go the other way. List the facts the page actually shows a visitor, then mark up only those:

  • the organization or product name, exactly as written in the heading or logo text
  • the canonical URL of the site
  • prices with currency, as shown in the pricing table
  • a support email or phone number, if the page shows one
  • profile links that appear on the page (often in the footer)

If a fact isn't on the page and you want it in the markup, put it on the page first.

Organization: who you are

Google's Organization documentation says there are no required properties and recommends adding the ones that apply. It suggests placing the markup on the home page "or a single page that describes your organization, for example the about us page," and notes you "don't need to include it on every page of your site." For name, it says to "use the same name and alternateName that you're using for your site name."

sameAs is described as "the URL of a page on another website with additional information about your organization", for example a social or review profile. That's the property assistants and search engines can use to connect your site to your profiles elsewhere, so it should only list profiles that are really yours and still active.

A minimal block for a small SaaS company:

{
  "@type": "Organization",
  "@id": "https://example.com/#org",
  "name": "Example Labs",
  "url": "https://example.com/",
  "email": "support@example.com",
  "sameAs": [
    "https://github.com/example-labs",
    "https://x.com/examplelabs"
  ]
}
Enter fullscreen mode Exit fullscreen mode

SoftwareApplication: what you sell

For apps, Google's Software App documentation lists three required properties for the rich result: name, offers.price, and a rating or review (aggregateRating or review). If the app is free, it says to set offers.price to 0. If the price is above zero, it recommends adding offers.priceCurrency. applicationCategory (for example BusinessApplication) and operatingSystem are recommended.

Be careful with the rating requirement. Don't invent an aggregateRating to qualify for stars. The general guidelines specifically warn against marking up "fake reviews," and the review snippet guidelines say that when an entity controls the reviews about itself, its pages using Organization-type markup are ineligible for star reviews. If you have no genuine, visible ratings, leave the property out. The markup is still valid schema.org and still describes your product. It just won't be eligible for that particular rich result.

{
  "@type": "SoftwareApplication",
  "name": "Example Notes",
  "url": "https://example.com/",
  "applicationCategory": "BusinessApplication",
  "operatingSystem": "Web",
  "publisher": { "@id": "https://example.com/#org" },
  "offers": { "@type": "Offer", "price": 12.0, "priceCurrency": "USD" }
}
Enter fullscreen mode Exit fullscreen mode

The publisher reference uses the @id from the Organization node. Putting both nodes in one @graph keeps a single source of truth for the company and avoids repeating it per page.

If you sell a physical product, Google's Product and merchant listing docs apply instead, with their own required fields. The checking approach below works the same way.

The full block, in the page head

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "Organization", "@id": "https://example.com/#org",
      "name": "Example Labs", "url": "https://example.com/",
      "email": "support@example.com",
      "sameAs": ["https://github.com/example-labs", "https://x.com/examplelabs"] },
    { "@type": "SoftwareApplication", "name": "Example Notes",
      "url": "https://example.com/", "applicationCategory": "BusinessApplication",
      "operatingSystem": "Web", "publisher": { "@id": "https://example.com/#org" },
      "offers": { "@type": "Offer", "price": 12.0, "priceCurrency": "USD" } }
  ]
}
</script>
Enter fullscreen mode Exit fullscreen mode

And the visible part of the page, which is where things drift:

<h1>Example Notes</h1>
<p>Shared notes for small teams, made by Example Labs.</p>
<p>Team plan: 12 USD per user per month.</p>
<footer>
  <a href="https://github.com/example-labs">GitHub</a> ·
  <a href="/">Home</a>
</footer>
Enter fullscreen mode Exit fullscreen mode

Two claims in the markup have no visible counterpart: the support email and the X profile. Neither is a syntax error, so a validator won't flag them.

A small checker: does the markup match the page?

The script below fetches a page (or reads a saved HTML file), pulls out every application/ld+json block, and walks the JSON for a set of fact-like keys: names, URLs, sameAs, email, phone, price, currency and address parts. Then it checks each value against the page:

  • text facts must appear in the visible text, meaning anything outside <script>, <style>, <noscript>, <template>, <svg> and elements marked hidden or aria-hidden="true"
  • url and sameAs values must appear as a link (<a href>) on the page
  • missing sameAs profiles produce a WARN rather than a failure, since profile links are something to review, not always an error

It only uses the Python standard library.

#!/usr/bin/env python3
"""Check that JSON-LD facts on a page also appear in its visible HTML."""
import json, re, sys, urllib.request
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit

CHECK_KEYS = {"name", "legalName", "alternateName", "url", "sameAs", "email",
              "telephone", "price", "priceCurrency", "streetAddress",
              "addressLocality", "postalCode"}
SKIP_TAGS = {"script", "style", "noscript", "template", "svg"}

class Page(HTMLParser):
    VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input",
            "link", "meta", "source", "track", "wbr"}
    def __init__(self, base):
        super().__init__(convert_charrefs=True)
        self.base, self.stack, self.in_ld = base, [], False
        self.text, self.links, self.ld = [], set(), []
    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "a" and a.get("href"):
            self.links.add(norm(urljoin(self.base, a["href"])))
        if tag == "script" and a.get("type", "").lower() == "application/ld+json":
            self.in_ld = True
            self.ld.append("")
        if tag in self.VOID:
            return
        hidden = tag in SKIP_TAGS or "hidden" in a or a.get("aria-hidden") == "true"
        self.stack.append((tag, hidden))
    def handle_startendtag(self, tag, attrs):
        if tag == "a":
            self.handle_starttag(tag, attrs)   # records the link
            self.handle_endtag(tag)
    def handle_endtag(self, tag):
        if tag == "script":
            self.in_ld = False
        for i in range(len(self.stack) - 1, -1, -1):   # pop to the matching tag
            if self.stack[i][0] == tag:
                del self.stack[i:]
                break
    def handle_data(self, data):
        if self.in_ld:
            self.ld[-1] += data
        elif not any(h for _, h in self.stack):
            self.text.append(data)

def norm(u):
    p = urlsplit(u.strip())
    return f"{p.netloc.lower().removeprefix('www.')}{p.path.rstrip('/')}"

def walk(node, path=""):
    """Yield (path, key, value) for every scalar we want to check."""
    if isinstance(node, dict):
        for k, v in node.items():
            yield from walk(v, f"{path}.{k}" if path else k)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            yield from walk(v, f"{path}[{i}]")
    elif isinstance(node, (str, int, float)):
        if isinstance(node, float) and node.is_integer():
            node = int(node)                  # 19.0 -> "19"
        key = re.sub(r"\[\d+\]$", "", path).split(".")[-1]
        if key in CHECK_KEYS and not re.search(r"(logo|image)", path):
            yield path, key, str(node)

def main(url, saved=None):
    if saved:                                 # test a local build before deploy
        html = open(saved, encoding="utf-8").read()
    else:
        req = urllib.request.Request(url, headers={"User-Agent": "ld-check/0.1"})
        html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", "replace")
    page = Page(url)
    page.feed(html)
    text = re.sub(r"\s+", " ", " ".join(page.text)).lower()
    problems = 0
    for i, raw in enumerate(page.ld):
        try:
            data = json.loads(raw)
        except json.JSONDecodeError as e:
            print(f"block {i}: INVALID JSON ({e})"); problems += 1; continue
        for path, key, value in walk(data):
            if key in {"url", "sameAs"}:
                ok = norm(value) in page.links or norm(value) == norm(url)
                where = "link"
            else:
                ok = value.lower() in text
                where = "text"
            soft = key == "sameAs"          # profile links: review, don't fail
            status = "OK  " if ok else ("WARN" if soft else "MISS")
            problems += status == "MISS"
            print(f"{status} block {i} {path} = {value!r} ({where})")
    print(f"\n{len(page.ld)} JSON-LD block(s), {problems} problem(s)")
    return 1 if problems else 0

if __name__ == "__main__":
    sys.exit(main(*sys.argv[1:3]))
Enter fullscreen mode Exit fullscreen mode

Run it against a URL, or against a local build with the URL as the base for relative links:

python3 ldcheck.py https://example.com/
python3 ldcheck.py https://example.com/ dist/index.html
Enter fullscreen mode Exit fullscreen mode

For the example page above, the output is:

OK   block 0 @graph[0].name = 'Example Labs' (text)
OK   block 0 @graph[0].url = 'https://example.com/' (link)
MISS block 0 @graph[0].email = 'support@example.com' (text)
OK   block 0 @graph[0].sameAs[0] = 'https://github.com/example-labs' (link)
WARN block 0 @graph[0].sameAs[1] = 'https://x.com/examplelabs' (link)
OK   block 0 @graph[1].name = 'Example Notes' (text)
OK   block 0 @graph[1].url = 'https://example.com/' (link)
OK   block 0 @graph[1].offers.price = '12' (text)
OK   block 0 @graph[1].offers.priceCurrency = 'USD' (text)

1 JSON-LD block(s), 1 problem(s)
Enter fullscreen mode Exit fullscreen mode

The fix is on the page side, not the markup side. Show the support email in the footer, add the X link if the account is real and maintained, or remove both from the JSON-LD. The script exits with status 1 when anything is MISS, so you can run it in CI against a preview build and fail the job when someone changes a price in the template but not in the markup.

What the script doesn't do

It's a drift detector, not a validator. Know its limits:

  • Substring matching. A price of 12 also matches 120. For pricing pages, tighten the check to look for the formatted price string you actually render.
  • Raw HTML only. It reads the HTML the server returns. If your JSON-LD or your visible text is injected by JavaScript, the script won't see it. Google can process JavaScript-generated structured data and documents how, but not every crawler runs JavaScript, so server-rendering both the facts and the markup is the safer default.
  • CSS hiding. It skips hidden and aria-hidden elements, but not elements hidden with CSS classes. If you hide content in collapsed panels or display: none rules, check those by hand.
  • Image URLs. logo and image paths are skipped on purpose, since a logo URL isn't something a visitor reads as text.
  • Formatting. Phone numbers in E.164 (+1-555-...) won't match a display format like (555) .... Normalize them if you check phones.

Validate the syntax and eligibility too

The checker covers the "matches the page" rule. Two official tools cover the rest:

  • Schema.org Validator parses JSON-LD, Microdata and RDFa and shows the extracted items against the schema.org vocabulary. Use it to catch typos in types and property names.
  • Google Rich Results Test checks whether markup is eligible for Google's rich result types and reports missing required or recommended properties. Google recommends the URL input over the code input, because the code input has JavaScript limitations.

After deploying, Google's docs suggest using the URL Inspection tool in Search Console to see how Google renders the page, then requesting a recrawl. Even then, Google says it "does not guarantee that features that consume structured data will show up in search results."

Why this matters beyond rich results

AI assistants with web search read and quote pages. When the markup and the visible text agree, every reader gets the same facts: a crawler parsing JSON-LD, a model reading page text, and a person checking the source. When they disagree, you've published two versions of your company and can't control which one gets picked up.

Keep the check cheap and automatic. Run it on the home page, the pricing page and the about page whenever any of them change.

If you want this kind of check as part of a bigger workflow, AURA GEO can generate JSON-LD from the facts it fetched from your site, and its URL verification stores the raw HTML and JSON-LD a page actually served, so you can compare them. The MCP guide shows how to connect your own agent to run those checks and pick up the resulting tasks.

Top comments (0)