A raw list of local businesses isn't a lead list yet. It's a list of names. If you sell websites, SEO or ads, the useful question is which of those businesses have a visible problem you can fix: no website, a dead one, or one that hasn't been touched in years.
You can answer that for a few hundred businesses in about ten minutes with a small script. This walkthrough uses Node 18 or newer and has no dependencies.
Step 1: Get a CSV with four columns
The source doesn't matter. An export from a directory, a chamber of commerce member page or a list you already own all work. You need a header row with at least these columns:
name,phone,address,website Acme Roofing,(512) 555-0100,"100 Main St, Austin, TX",acmeroofing.example Leave website empty when a business has none.
Pick one niche in one city for your first run. A list of 200 roofers lets you write one pitch that fits all of them. A list of 200 mixed businesses doesn't.
Step 2: Save the script
Save this as audit-leads.mjs:
// usage: node audit-leads.mjs leads.csv > audited.csv import { readFileSync } from 'node:fs'; function parseCsv(text) { const rows = [[]]; let cell = '', quoted = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (quoted) { if (c === '"' && text[i + 1] === '"') { cell += '"'; i++; } else if (c === '"') quoted = false; else cell += c; } else if (c === '"') quoted = true; else if (c === ',') { rows.at(-1).push(cell); cell = ''; } else if (c === '\n') { rows.at(-1).push(cell); cell = ''; rows.push([]); } else if (c !== '\r') cell += c; } rows.at(-1).push(cell); const [head, ...body] = rows.filter(r => r.some(v => v.trim())); return body.map(r => Object.fromEntries( head.map((h, i) => [h.trim().toLowerCase(), (r[i] ?? '').trim()]))); } const phoneKey = s => (s || '').replace(/\D/g, '').slice(-10); const siteKey = s => (s || '').toLowerCase() .replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]; function dedupe(leads) { const seen = new Set(); return leads.filter(lead => { const keys = [phoneKey(lead.phone), siteKey(lead.website)].filter(Boolean); if (keys.some(k => seen.has(k))) return false; keys.forEach(k => seen.add(k)); return true; }); } async function checkSite(website) { if (!website) return { site: 'none', pitch: 'no website' }; const host = website.replace(/^https?:\/\//i, ''); for (const scheme of ['https', 'http']) { try { const res = await fetch(`${scheme}://${host}`, { signal: AbortSignal.timeout(10000), headers: { 'User-Agent': 'Mozilla/5.0' }, }); if ([401, 403, 429, 503].includes(res.status)) return { site: 'blocked', pitch: '' }; if (!res.ok) return { site: `error ${res.status}`, pitch: 'broken website' }; const html = await res.text(); const years = [...html.matchAll(/(?:©|©|copyright)[^<]{0,30}/gi)] .flatMap(m => m[0].match(/(?:19|20)\d\d/g) ?? []).map(Number); const out = { site: 'ok', https: res.url.startsWith('https:') ? 'yes' : 'no', mobile: /name=["']?viewport/i.test(html) ? 'yes' : 'no', copyright: years.length ? Math.max(...years) : '', }; const dated = out.https === 'no' || out.mobile === 'no' || (out.copyright && out.copyright < new Date().getFullYear() - 2); return { ...out, pitch: dated ? 'dated website' : '' }; } catch {} } return { site: 'dead', pitch: 'dead website' }; } const leads = dedupe(parseCsv(readFileSync(process.argv[2], 'utf8'))); const results = []; for (let i = 0; i < leads.length; i += 5) { const batch = leads.slice(i, i + 5); const checks = await Promise.all(batch.map(l => checkSite(l.website))); batch.forEach((l, j) => results.push({ ...l, ...checks[j] })); } const cols = [...new Set(results.flatMap(Object.keys))]; const esc = v => `"${String(v ?? '').replaceAll('"', '""')}"`; console.log(cols.join(',')); for (const r of results) console.log(cols.map(c => esc(r[c])).join(',')); Step 3: Run it
node audit-leads.mjs leads.csv > audited.csv It checks five sites at a time and gives each one 10 seconds. Open audited.csv in a spreadsheet and you'll have the original columns plus these:
-
site:none,ok,dead,blockedor an error code such aserror 404 -
https,mobile,copyright: filled in for working sites -
pitch: the problem to lead with, if there is one
Step 4: Check before you call
The script can get these wrong, so look before you use them in a pitch:
-
deadcan be a slow server. A site that takes longer than 10 seconds looks the same as one that's gone. Open each one in a browser. Telling an owner their live site is down ends the conversation. -
blockedmeans nothing. Bot protection often rejects scripts, so those sites get no pitch. Check by hand if the lead matters. -
nonemay be out of date. The directory may simply not have the URL. Search the business name before you tell them they don't have a site. -
copyrightis a clue, not proof. Plenty of well-kept sites never update the footer. It's most useful alongside another sign, such as no mobile viewport tag.
Step 5: Sort into three groups
Filter on pitch and you get three lists:
- No website or dead website. The easiest conversation, because the problem is yes or no. Open with what you saw: "I looked for your site and the link on your listing doesn't load."
- Dated website. Mention the one thing you can show them, such as how the site looks on a phone. A screenshot works better than a claim.
- Nothing flagged. Those businesses may still be good clients, but they need a different angle. Leave them for later.
Before you email anyone, check the rules that apply to you. In the US, CAN-SPAM requires a real postal address and a working opt-out in commercial email. Other countries are stricter, and some require consent first.
Doing this every month
New businesses open every month and existing sites go down, so save your input files and run the script again later. Comparing this month's output against last month's shows which sites went down recently, and those are good businesses to call.
If you'd rather skip building the list, I pull 250 local leads for $30 at fiverr.com/rel8ble, and my scrapers are at apify.com/rel8ble.
Written with AI assistance.
