DEV Community

Citation Builder
Citation Builder

Posted on

Extracting LocalBusiness Schema (JSON-LD) NAP Data with Plain Node.js

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

[].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,
  };
}
Enter fullscreen mode Exit fullscreen mode

For our sample page:

{
  "name": "Athens Dental Care",
  "phone": "+30 210 555 0134",
  "street": "12 Ermou St",
  "city": "Athens",
  "zip": "105 63",
  "country": "GR"
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
// []
Enter fullscreen mode Exit fullscreen mode

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"'
// ]
Enter fullscreen mode Exit fullscreen mode

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 __parseError blocks — 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)