← 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);
});