When you list a business across dozens of directories, the same company shows up written a dozen different ways: "Joe's Plumbing LLC", "Joe Plumbing", "Joe's Plumbing & Heating". Search engines and AI assistants read those as maybe the same entity, and "maybe" is exactly what you do not want. Detecting these near-duplicates is a fuzzy-string-matching problem, and you can get surprisingly far with a few dozen lines of plain JavaScript.
Here is the approach behind the matching layer of a free NAP consistency checker, with runnable, dependency-free code. Every output below is the actual result of running the snippet.
Step 1: normalize before you compare
Raw string similarity is noise until you strip the parts that carry no identity. For business names that means lower-casing, folding accents, dropping the possessive 's, removing punctuation, and discarding legal suffixes and filler words.
const LEGAL_SUFFIXES = new Set([
'llc', 'inc', 'incorporated', 'ltd', 'limited', 'co', 'corp',
'corporation', 'gmbh', 'ltda', 'plc', 'ae', 'oe', 'epe', 'ike',
]);
const NOISE = new Set(['the', 'and', 'of']);
function normalizeName(raw) {
return raw
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '') // strip accents: café -> cafe
.replace(/['’]s\b/g, '') // drop possessive 's: joe's -> joe
.replace(/[^a-z0-9\s]/g, ' ') // punctuation (incl. &) -> space
.split(/\s+/)
.filter((w) => w.length > 1 && !LEGAL_SUFFIXES.has(w) && !NOISE.has(w))
.join(' ')
.trim();
}
normalizeName("Joe's Plumbing, LLC"); // "joe plumbing"
normalizeName('The Joe & Sons Co.'); // "joe sons"
normalizeName('Café Böhme GmbH'); // "cafe bohme"
That one function removes most false differences before any scoring happens. Do not skip it: comparing un-normalized strings is where naive dedupe scripts go wrong.
Step 2: score similarity with token set ratio
Levenshtein distance alone breaks on word reordering ("Athens Dental Care" vs "Dental Care Athens" looks very different character by character). The trick popularized by Python's fuzzywuzzy is the token set ratio: treat each name as a set of words and compare the shared tokens against the leftovers. It is word-order independent and length tolerant.
function tokenSetRatio(a, b) {
const ta = new Set(normalizeName(a).split(' ').filter(Boolean));
const tb = new Set(normalizeName(b).split(' ').filter(Boolean));
if (!ta.size || !tb.size) return 0;
const shared = [...ta].filter((t) => tb.has(t)).length;
const onlyA = ta.size - shared;
const onlyB = tb.size - shared;
const union = ta.size + tb.size - shared;
const jaccard = shared / union;
// Penalize each side's unique tokens a little, so a tagline or an added
// service word lowers the score without killing it.
const drift = (onlyA + onlyB) / (ta.size + tb.size);
return Math.round(100 * jaccard * (1 - 0.35 * drift));
}
tokenSetRatio("Athens Dental Care", "Dental Care Athens"); // 100
tokenSetRatio("Joe's Plumbing LLC", "Joe Plumbing"); // 100
tokenSetRatio("Joe's Plumbing", "Joe's Plumbing & Heating"); // 62
tokenSetRatio("Bright Smile Dental", "Bright Star Cafe"); // 15
Set a threshold (I use 85) above which two names are "probably the same business". Everything roughly between 60 and 85 is the interesting zone: real duplicates hiding behind a tagline, a franchise suffix, or a typo, worth a human glance.
Step 3: do the same for the address
Names are only half of NAP (name, address, phone). Addresses have their own normalization: collapse the common abbreviations, then reuse the same ratio.
const STREET_ABBR = {
street: 'st', avenue: 'ave', boulevard: 'blvd', road: 'rd',
drive: 'dr', suite: 'ste', apartment: 'apt', floor: 'fl',
};
function normalizeAddress(raw) {
let s = raw.toLowerCase().replace(/[.,]/g, ' ');
for (const [long, short] of Object.entries(STREET_ABBR)) {
s = s.replace(new RegExp(`\\b${long}\\b`, 'g'), short);
}
return s.replace(/\s+/g, ' ').trim();
}
function addressMatches(a, b) {
return tokenSetRatio(normalizeAddress(a), normalizeAddress(b)) >= 80;
}
normalizeAddress("123 Main Street, Suite 4"); // "123 main st ste 4"
addressMatches("123 Main Street", "123 Main St."); // true
Putting it together
A minimal duplicate finder over listings pulled from different directories:
function findDuplicates(listings) {
const groups = [];
for (const listing of listings) {
const hit = groups.find((g) =>
tokenSetRatio(g[0].name, listing.name) >= 85 &&
addressMatches(g[0].address, listing.address));
if (hit) hit.push(listing);
else groups.push([listing]);
}
return groups.filter((g) => g.length > 1); // only the collisions
}
findDuplicates([
{ name: "Joe's Plumbing LLC", address: '123 Main Street' },
{ name: 'Joe Plumbing', address: '123 Main St.' },
{ name: 'Bright Star Cafe', address: '9 River Road' },
]);
// => [ [ "Joe's Plumbing LLC", "Joe Plumbing" ] ]
It clusters the same business as it appears across directories while keeping genuinely different ones apart.
Where the simple version stops
This is deliberately dependency-free and good enough to flag most inconsistencies. Two honest limits:
- Phonetic misspellings ("Katherine" vs "Catherine") slip through token matching. Add a Double Metaphone pass if that matters for your data.
- Real-world address messiness (unit numbers, PO boxes, localized formats across countries) needs a proper geocoder to resolve. Comparing against a structured source like the OpenStreetMap Nominatim API beats string matching once you cross borders.
If you just want to see how consistent a real business's listings are without wiring any of this up, that is what our NAP checker does against live directory data, and there is more on why the inconsistencies matter in this write-up on finding and fixing duplicate listings.
The core idea travels well beyond local SEO: normalize hard, compare as token sets, and treat the 60-to-85 band as the place worth a human look. That heuristic has saved me from a lot of bad dedupe.
Top comments (0)