← back to Professional Directory
scripts/score_prospects.js
119 lines
#!/usr/bin/env node
/**
* Lead scoring + chain detection for small-biz organizations.
*
* Re-runnable. Updates two columns on `organizations`:
* • is_chain — TRUE if name matches a known multi-location brand
* • lead_score — 0–100, biased toward "ready to pitch a themed website"
*
* Score recipe (independent signals):
* +30 has phone
* +25 domain available + needs site
* +15 has address
* +15 has city
* +15 not a chain
* −20 if has_website (already has a site → not a prospect)
* −60 if is_chain
*
* Run:
* node scripts/score_prospects.js
*/
const { pool, query } = require('../agents/shared/db');
// LA-area / national chains we don't want to pitch — small-biz only.
const CHAIN_PATTERNS = [
// Cosmetology
/^supercuts\b/i, /^great clips\b/i, /^sport clips\b/i, /^fantastic sams\b/i,
/^cost cutters\b/i, /^hair cuttery\b/i, /^cookie cutters\b/i,
/^sephora\b/i, /^sephora at\b/i, /^ulta\b/i, /^mac cosmetics\b/i,
/^sally beauty\b/i, /^bath\s*&\s*body works\b/i, /^lush\b/i,
/^drybar\b/i, /^massage envy\b/i, /^european wax\b/i,
/^the(?:\s)?joint\b/i, /^the(?:\s)?joint chiropractic\b/i,
/^pacific dental\b/i, /^western dental\b/i, /^aspen dental\b/i, /^smile generation\b/i,
/^lenscrafters\b/i, /^pearle vision\b/i, /^visionworks\b/i, /^americas best\b/i,
/^banfield\b/i, /^vca\s/i, /^petsmart\b/i, /^petco\b/i,
/^ymca\b/i, /^ufc gym\b/i, /^24 hour fitness\b/i, /^lvac\b/i, /^crunch\b/i,
/^equinox\b/i, /^ltf\b/i, /^lifetime fitness\b/i, /^orangetheory\b/i,
/^f45\b/i, /^pure barre\b/i, /^solidcore\b/i, /^corepower yoga\b/i, /^yogasix\b/i,
/^anytime fitness\b/i, /^planet fitness\b/i,
/^my eyelab\b/i, /^stanton optical\b/i,
/\bcvs\b/i, /\bwalgreens\b/i, /\brite ?aid\b/i,
];
function classifyChain(name) {
if (!name) return false;
return CHAIN_PATTERNS.some(rx => rx.test(name));
}
function score(row) {
const hasPhone = !!row.phone && row.phone.trim() !== '';
const hasAddress = !!row.address && row.address.trim() !== '';
const hasCity = !!row.city && row.city.trim() !== '';
const hasSite = !!row.website && row.website.trim() !== '';
const domainAv = row.domain_available === true;
const isChain = classifyChain(row.name);
let s = 0;
if (hasPhone) s += 30;
if (domainAv && !hasSite) s += 25;
if (hasAddress) s += 15;
if (hasCity) s += 15;
if (!isChain) s += 15;
if (hasSite) s -= 20;
if (isChain) s -= 60;
return Math.max(0, Math.min(100, s));
}
async function main() {
// Only score the small-biz universe — the medical orgs from NPI/HCAI aren't
// sales prospects.
const SMALL_TYPES = [
'salon','barbershop','nail_salon','beauty_supply','spa',
'chiropractor_office',
'acupuncture_clinic','tcm_clinic','tcm_herbalist',
'dental_office',
'optometrist_office','optical_shop',
'yoga_studio','pilates_studio','gym',
'tattoo_studio','piercing_studio',
'vet_clinic','pet_grooming','pet_shop',
];
const r = await query(`
SELECT id, name, phone, address, city, website, domain_available
FROM organizations
WHERE type = ANY($1::text[])
`, [SMALL_TYPES]);
console.log(`[score] candidates=${r.rowCount}`);
let chains = 0, scored = 0;
for (const row of r.rows) {
const isChain = classifyChain(row.name);
const ls = score(row);
if (isChain) chains++;
await query(
`UPDATE organizations SET is_chain = $2, lead_score = $3 WHERE id = $1`,
[row.id, isChain, ls]
);
scored++;
if (scored % 500 === 0) console.log(`[score] processed=${scored} chains=${chains}`);
}
console.log(`[score] done. scored=${scored} chains_flagged=${chains}`);
// Snapshot top scores for sanity.
const top = await query(`
SELECT id, name, type, lead_score, phone, suggested_domain, domain_available, city
FROM organizations
WHERE type = ANY($1::text[])
ORDER BY lead_score DESC, name LIMIT 12
`, [SMALL_TYPES]);
console.table(top.rows);
await pool.end();
}
main().catch(async (err) => {
console.error('[score] fatal:', err);
try { await pool.end(); } catch (_) {}
process.exit(1);
});