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:
- Is there valid JSON-LD at all?
- Is there a
LocalBusinessnode, or a subtype (Dentist,Plumber,Restaurant), rather than onlyOrganizationorWebPage? - Does it carry the fields that describe a local entity:
name,address.streetAddress,address.addressLocality,telephone, opening hours,geo,url? - Does the visible page say the same phone and the same street as the markup?
- Is the type specific? A bare
LocalBusinessis valid but tells a crawler less thanDentist. 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);
}
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);
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...)
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)