Every local business page that wants rich results in Google carries a LocalBusiness JSON-LD block: name, phone, address, hours. That makes structured data the most machine-readable source of a business's NAP (Name, Address, Phone) — and the first place to look when you audit whether a business is represented consistently across the web. This is exactly the kind of structured citation data that search engines cross-reference before deciding to trust a listing.
In this post we build a small, dependency-free Node.js pipeline that extracts LocalBusiness schema from any HTML page and diffs the NAP it declares against a canonical record. It is a simplified version of the extraction layer behind a free NAP consistency checker. Every output shown is the real result of running the code.
Step 1: pull the JSON-LD blocks out of the HTML
JSON-LD lives in <script type="application/ld+json"> tags. You do not need a full DOM parser to get at them — the tags cannot nest, so a regex over the raw HTML is fine for this job:
function extractJsonLd(html) {
const blocks = [];
const re = /<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(html)) !== null) {
try {
blocks.push(JSON.parse(m[1].trim()));
} catch {
// real-world JSON-LD is often broken; skip and count it
blocks.push({ __parseError: true });
}
}
return blocks;
}
The try/catch is not defensive paranoia. In the wild you will hit trailing commas, PHP warnings printed inside the script tag, and template placeholders that never got rendered. A parse failure is itself a finding worth reporting — Google won't read that block either.
Take this sample page — a typical Yoast/Rank-Math-style setup with an @graph plus a second script tag for breadcrumbs:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", "name": "Athens Dental Care", "url": "https://example.com" },
{
"@type": ["Dentist", "LocalBusiness"],
"name": "Athens Dental Care",
"telephone": "+30 210 555 0134",
"address": {
"@type": "PostalAddress",
"streetAddress": "12 Ermou St",
"addressLocality": "Athens",
"postalCode": "105 63",
"addressCountry": "GR"
}
}
]
}
</script>
<script type="application/ld+json">
{ "@type": "BreadcrumbList", "itemListElement": [] }
</script>
Running extractJsonLd on it finds 2 blocks.
Step 2: flatten @graph into a plain node list
Real-world JSON-LD comes in three shapes: a single node, an array of nodes, or a @graph wrapper (what most WordPress SEO plugins emit). Normalize all three into one flat list:
function flattenNodes(block) {
if (Array.isArray(block)) return block.flatMap(flattenNodes);
if (block && typeof block === 'object') {
if (Array.isArray(block['@graph'])) return block['@graph'].flatMap(flattenNodes);
return [block];
}
return [];
}
const nodes = blocks.flatMap(flattenNodes);
// 3 nodes: "WebSite", ["Dentist","LocalBusiness"], "BreadcrumbList"
Step 3: find the business node
Here is the trap that breaks naive implementations: @type can be a string or an array, and hundreds of schema.org subtypes (Dentist, Restaurant, Plumber, HairSalon...) are valid local-business types that may never mention LocalBusiness at all. Checking node['@type'] === 'LocalBusiness' misses most real pages.
function isLocalBusiness(node) {
const types = [].concat(node['@type'] ?? []);
if (types.includes('LocalBusiness')) return true;
// Hundreds of schema.org subtypes (Dentist, Restaurant, Plumber...) never
// say "LocalBusiness" explicitly — fall back to the shape of the data.
return Boolean(node.name && node.address && (node.telephone || node.openingHours || node.openingHoursSpecification));
}
const biz = nodes.find(isLocalBusiness);
// picked: ["Dentist","LocalBusiness"] Athens Dental Care
[].concat(x) is a compact way to treat "string or array" uniformly. The shape-based fallback (has a name, an address, and either a phone or opening hours) catches subtypes without maintaining a 400-entry type list.
Step 4: reduce it to a flat NAP record
function toNap(node) {
const addr = node.address && typeof node.address === 'object' ? node.address : {};
return {
name: node.name ?? null,
phone: node.telephone ?? null,
street: addr.streetAddress ?? null,
city: addr.addressLocality ?? null,
zip: addr.postalCode ?? null,
country: addr.addressCountry ?? null,
};
}
For our sample page:
{
"name": "Athens Dental Care",
"phone": "+30 210 555 0134",
"street": "12 Ermou St",
"city": "Athens",
"zip": "105 63",
"country": "GR"
}
Step 5: diff against the canonical record — normalize first
Comparing raw strings produces false alarms everywhere: +30 210 555 0134 vs 00302105550134 is the same phone; 12 Ermou St vs 12 Ermou Street is the same address. Normalize both sides before comparing:
const digits = (s) => (s ?? '').replace(/\D/g, '').replace(/^00/, '');
const squash = (s) => (s ?? '').toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '')
.replace(/\b(st|street|ave|avenue|rd|road|blvd|suite|ste|unit)\b\.?/g, '')
.replace(/[^a-z0-9]/g, '');
digits('+30 210 555 0134') === digits('00302105550134'); // both "302105550134"
squash('12 Ermou St') === squash('12 Ermou Street'); // both "12ermou"
digits keeps only numbers and strips the international 00 prefix so E.164 and dialed formats collide into one value. squash lower-cases, folds accents (normalize('NFKD') + stripping combining marks with \p{M}), drops street-type abbreviations, and removes everything non-alphanumeric.
The diff itself is then trivial:
function diffNap(found, canonical) {
const issues = [];
if (digits(found.phone) !== digits(canonical.phone)) {
issues.push(`phone mismatch: "${found.phone}" vs "${canonical.phone}"`);
}
if (squash(found.street) !== squash(canonical.street)) {
issues.push(`street mismatch: "${found.street}" vs "${canonical.street}"`);
}
if (squash(found.zip) !== squash(canonical.zip)) {
issues.push(`zip mismatch: "${found.zip}" vs "${canonical.zip}"`);
}
return issues;
}
Against the correct canonical record (00302105550134, 12 Ermou Street, 10563) the sample page produces no issues — the formatting differences are all absorbed by normalization:
diffNap(toNap(biz), canonical);
// []
Against a record with a genuinely different phone and street number it reports exactly the two real problems:
diffNap(toNap(biz), wrong);
// [
// 'phone mismatch: "+30 210 555 0134" vs "+30 210 555 0199"',
// 'street mismatch: "12 Ermou St" vs "14 Ermou Street"'
// ]
That asymmetry is the entire point of the normalization layer: zero false positives on cosmetic differences, loud and specific on real ones.
Where to take it
- Feed it live pages with
fetch(url).then(r => r.text())and you have a schema-aware NAP auditor for any list of URLs (your own locations, or every directory listing a business appears on). - Report
__parseErrorblocks — broken JSON-LD is invisible in the browser but costs rich results. - The same normalize-then-compare pattern extends to business names, where fuzzy matching earns its keep; I covered that in a previous post on detecting duplicate listings.
If you'd rather see the production version of this idea, the NAP checker runs this pipeline (plus a few data sources beyond schema) against any business name — free, no signup.
Top comments (0)