DEV Community

Citation Builder
Citation Builder

Posted on

Scoring a Local Business Page for the Signals Local Search Reads (the Check Validators Skip)

Schema validators tell you whether your LocalBusiness JSON-LD is well-formed. They do not tell you whether it agrees with the page it sits on, and that disagreement is the thing local search actually penalises: a phone number in the markup that differs from the one in the footer is two claims about the same business, not one.

This post is a small Node script that scores a page for the local signals a crawler reads, including the one check the validators skip. About 80 lines, no dependencies, runs against a URL or against the three fixtures at the bottom.

What we check, and why

Local ranking is decided per business entity, not per page. A crawler assembles the entity from the Google profile, the directories and the website, and counts agreement. So the page-level checks that matter are:

  1. Is there valid JSON-LD at all?
  2. Is there a LocalBusiness node, or a subtype (Dentist, Plumber, Restaurant), rather than only Organization or WebPage?
  3. Does it carry the fields that describe a local entity: name, address.streetAddress, address.addressLocality, telephone, opening hours, geo, url?
  4. Does the visible page say the same phone and the same street as the markup?
  5. Is the type specific? A bare LocalBusiness is valid but tells a crawler less than Dentist. The mapping from real categories to subtypes is in LocalBusiness schema types.

Check 4 is the one worth the script. The validator guide explains why Schema Markup Validator, the Rich Results Test and Search Console all stop at syntax: they have no idea what the page says in prose.

The script

// local-signals.mjs
// Score a local business page for the signals local search actually reads.
// Usage: node local-signals.mjs <url>   (or run the built-in fixtures with --demo)

const LOCAL_TYPES = new Set([
  'LocalBusiness', 'Dentist', 'Plumber', 'Electrician', 'Restaurant', 'Hotel', 'RealEstateAgent',
  'Locksmith', 'HVACBusiness', 'MedicalBusiness', 'Physician', 'LegalService', 'Attorney',
  'AccountingService', 'InsuranceAgency', 'HomeAndConstructionBusiness', 'AutoRepair', 'HairSalon',
]);

function jsonLdBlocks(html) {
  const out = [];
  const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
  let m;
  while ((m = re.exec(html))) {
    try {
      const parsed = JSON.parse(m[1].trim());
      const nodes = Array.isArray(parsed) ? parsed : parsed['@graph'] ? parsed['@graph'] : [parsed];
      out.push(...nodes);
    } catch {
      out.push({ __invalid: true });
    }
  }
  return out;
}

function localNode(nodes) {
  return nodes.find((n) => {
    const t = Array.isArray(n['@type']) ? n['@type'] : [n['@type']];
    return t.some((x) => LOCAL_TYPES.has(x));
  });
}

const digits = (s) => String(s || '').replace(/\D/g, '');
const text = (html) => html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');

export function score(html) {
  const nodes = jsonLdBlocks(html);
  const node = localNode(nodes);
  const body = text(html);
  const checks = [];
  const add = (name, ok, note) => checks.push({ name, ok, note });

  add('valid JSON-LD', nodes.length > 0 && !nodes.some((n) => n.__invalid), nodes.length ? `${nodes.length} node(s)` : 'none found');
  add('LocalBusiness (or subtype) present', !!node, node ? `@type ${[].concat(node['@type']).join('/')}` : 'only Organization/WebPage or nothing');

  if (node) {
    const addr = node.address || {};
    add('name', !!node.name, node.name || 'missing');
    add('address.streetAddress', !!addr.streetAddress, addr.streetAddress || 'missing');
    add('address.addressLocality', !!addr.addressLocality, addr.addressLocality || 'missing');
    add('telephone', !!node.telephone, node.telephone || 'missing');
    add('openingHours or openingHoursSpecification', !!(node.openingHours || node.openingHoursSpecification), '');
    add('geo', !!(node.geo && node.geo.latitude), node.geo ? `${node.geo.latitude},${node.geo.longitude}` : 'missing');
    add('url matches page', typeof node.url === 'string' && node.url.length > 0, node.url || 'missing');
    // The check validators cannot do: does the visible page agree with the markup?
    const tel = digits(node.telephone);
    add('phone in visible text matches schema', !!tel && digits(body).includes(tel), tel ? `looked for ${tel}` : 'no telephone to compare');
    add('street in visible text matches schema', !!addr.streetAddress && body.toLowerCase().includes(String(addr.streetAddress).toLowerCase()), addr.streetAddress || '');
    const specific = [].concat(node['@type']).some((t) => t !== 'LocalBusiness');
    add('specific subtype rather than bare LocalBusiness', specific, specific ? '' : 'consider Dentist, Plumber, Restaurant...');
  }

  const passed = checks.filter((c) => c.ok).length;
  return { passed, total: checks.length, checks };
}

export function report(label, html) {
  const r = score(html);
  console.log(`\n${label}: ${r.passed}/${r.total}`);
  for (const c of r.checks) console.log(`  ${c.ok ? 'PASS' : 'FAIL'}  ${c.name}${c.note ? `  (${c.note})` : ''}`);
}

if (process.argv[2] && /^https?:/.test(process.argv[2])) {
  const html = await (await fetch(process.argv[2], { headers: { 'user-agent': 'local-signals/1.0' } })).text();
  report(process.argv[2], html);
}
Enter fullscreen mode Exit fullscreen mode

Two implementation notes. The phone comparison strips everything but digits on both sides, so +1 555 010 0100 in the markup matches +1 (555) 010-0100 in the footer; formatting differences are not inconsistencies, different numbers are. And text() removes script bodies before flattening the HTML, otherwise the JSON-LD would "agree with itself" and every page would pass check 4.

Three fixtures, real output

A complete page, a page with only Organization markup, and a page whose markup disagrees with its own footer:

const GOOD = `<!doctype html><html><head><title>Riverside Dental | Dentist in Riverside</title>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Dentist","name":"Riverside Dental",
"url":"https://example.com/","telephone":"+1 555 010 0100",
"address":{"@type":"PostalAddress","streetAddress":"12 Bridge Street","addressLocality":"Riverside","postalCode":"00000","addressCountry":"US"},
"geo":{"@type":"GeoCoordinates","latitude":40.0,"longitude":-74.0},
"openingHoursSpecification":[{"@type":"OpeningHoursSpecification","dayOfWeek":["Monday","Tuesday"],"opens":"08:00","closes":"17:00"}]}</script>
</head><body><h1>Riverside Dental</h1><p>Family dentistry on Bridge Street.</p>
<footer>Riverside Dental, 12 Bridge Street, Riverside. Call +1 (555) 010-0100</footer></body></html>`;

const WEAK = `<!doctype html><html><head><title>Best Dentist Near You</title>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Organization","name":"Riverside Dental","url":"https://example.com/"}</script>
</head><body><h1>Welcome</h1><p>Call us today!</p><footer>Tel 555-010-0199</footer></body></html>`;

const MISMATCH = `<!doctype html><html><head><title>Riverside Dental</title>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"LocalBusiness","name":"Riverside Dental","url":"https://example.com/",
"telephone":"+1 555 010 0100","address":{"@type":"PostalAddress","streetAddress":"12 Bridge Street","addressLocality":"Riverside"}}</script>
</head><body><footer>Riverside Dental, 14 Bridge St, Riverside. Call (555) 010-0199</footer></body></html>`;

report('complete page', GOOD);
report('no LocalBusiness markup', WEAK);
report('markup disagrees with the page', MISMATCH);
Enter fullscreen mode Exit fullscreen mode

Output, verbatim from node local-signals.mjs --demo:

complete page: 12/12
  PASS  valid JSON-LD  (1 node(s))
  PASS  LocalBusiness (or subtype) present  (@type Dentist)
  PASS  name  (Riverside Dental)
  PASS  address.streetAddress  (12 Bridge Street)
  PASS  address.addressLocality  (Riverside)
  PASS  telephone  (+1 555 010 0100)
  PASS  openingHours or openingHoursSpecification
  PASS  geo  (40,-74)
  PASS  url matches page  (https://example.com/)
  PASS  phone in visible text matches schema  (looked for 15550100100)
  PASS  street in visible text matches schema  (12 Bridge Street)
  PASS  specific subtype rather than bare LocalBusiness

no LocalBusiness markup: 1/2
  PASS  valid JSON-LD  (1 node(s))
  FAIL  LocalBusiness (or subtype) present  (only Organization/WebPage or nothing)

markup disagrees with the page: 7/12
  PASS  valid JSON-LD  (1 node(s))
  PASS  LocalBusiness (or subtype) present  (@type LocalBusiness)
  PASS  name  (Riverside Dental)
  PASS  address.streetAddress  (12 Bridge Street)
  PASS  address.addressLocality  (Riverside)
  PASS  telephone  (+1 555 010 0100)
  FAIL  openingHours or openingHoursSpecification
  FAIL  geo  (missing)
  PASS  url matches page  (https://example.com/)
  FAIL  phone in visible text matches schema  (looked for 15550100100)
  FAIL  street in visible text matches schema  (12 Bridge Street)
  FAIL  specific subtype rather than bare LocalBusiness  (consider Dentist, Plumber, Restaurant...)
Enter fullscreen mode Exit fullscreen mode

The third fixture is the interesting one. Every validator on the market passes it: the JSON-LD is well-formed and the required properties exist. The page still says 14 Bridge St and 010-0199 where the markup says 12 Bridge Street and 010-0100. A crawler that reads both now holds two addresses and two phones for one business, and the markup you added to make the entity clearer has made it less clear.

What the script does not do

It scores one page. Local search scores an entity across every page and every directory that describes it, and a page can be 12/12 while the same business reads differently on the profiles and listings around it. The page check is the cheap first step; the cross-source check is the rest of the job, and it is most of the work in local SEO once the profile is claimed. For a single-location owner doing it alone, the order that pays off, with the hours per step, is in local SEO for small business.

Extend it as you like: add areaServed for service area businesses, check that url equals the canonical, or fetch the sitemap and score every location page in one run. The point of keeping it small is that you can read all of it and trust what it says.

Top comments (0)