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"
]
}
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" }
}
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>
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>
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 markedhiddenoraria-hidden="true" -
urlandsameAsvalues must appear as a link (<a href>) on the page - missing
sameAsprofiles 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]))
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
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)
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
12also matches120. 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
hiddenandaria-hiddenelements, but not elements hidden with CSS classes. If you hide content in collapsed panels ordisplay: nonerules, check those by hand. -
Image URLs.
logoandimagepaths 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)