← back to Small Business Builder

scripts/flag-sole-prop-barbers.js

132 lines

#!/usr/bin/env node
// Flag BBCB shops whose registered name looks like a person's name —
// "MORAGA BERNIE", "NUNEZ HAIR STYLING", "PEDRO LOPEZ BARBERSHOP". These
// sole-proprietor shops are the ones where the chair IS the brand: SEO
// gold for "<owner first name> barber {neighborhood}" long-tail queries.
//
// Heuristic (conservative — false negatives are fine, false positives are
// noisy):
//   1. Shop name has 2-4 tokens
//   2. First token looks like a Latin/American/Asian SURNAME (or first name)
//   3. NO corporate suffixes (LLC / INC / CORP / & / GROUP / SALON / etc.
//      ARE allowed but if all 3 tokens are corporate words, reject)
//   4. At least one token contains only letters (no digits/symbols)
//
// We intentionally don't try to be clever — a Census surname list would be
// the proper way, but the flag's only purpose is "promote this shop's
// proprietor in the page metadata", and the false-positive cost is small.
//
// Stores `source_data_json.shop_personality = 'sole_prop'` on hits.
//
//   node scripts/flag-sole-prop-barbers.js
//   DRY=1 node scripts/flag-sole-prop-barbers.js

import 'dotenv/config';
import { query, one } from '../src/lib/db.js';

const DRY = process.env.DRY === '1';

function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
function log(m) { console.log(`[${ts()}] ${m}`); }

// Words that, if PRESENT, mean the shop is corporate-style (NOT sole-prop).
const CORPORATE_TOKENS = new Set([
  'LLC','INC','CORP','GROUP','HOLDINGS','PARTNERS','LP','LTD','TRUST','TRUSTEES',
  'COLLECTIVE','COMPANY','CO','LIMITED','INCORPORATED','CORPORATION',
  'STUDIO','STUDIOS','BAR','LOUNGE','PARLOR','PARLOUR','HOUSE',
  // Establishment with internal salon — owner is institution, not person
  'CLUB','COUNTRY','COMMUNITY','CHURCH','HOSPITAL','HOTEL','SCHOOL',
  'UNIVERSITY','COLLEGE','CITY','COUNTY','RESORT','SPA',
]);

// Geographic place names commonly used as "{place} BARBER SHOP" — these
// are PLACE-brand shops, not owner-brand sole-props. Rough list, not
// exhaustive — only the high-frequency offenders we want to drop.
const PLACE_TOKENS = new Set([
  'PALISADES','MANHATTAN','VENICE','HOLLYWOOD','BRENTWOOD','MALIBU',
  'BURBANK','GLENDALE','PASADENA','TORRANCE','LANCASTER','PALMDALE',
  'COMPTON','CRENSHAW','INGLEWOOD','HAWTHORNE','SANTA','LOS','EAST',
  'WEST','NORTH','SOUTH','VALLEY','DOWNEY','COVINA','POMONA','WHITTIER',
  'NEWPORT','LAGUNA','ANAHEIM','IRVINE','RIVERSIDE','MORENO','MURRIETA',
  'TEMECULA','CORONA','FONTANA','ONTARIO','UPLAND','RANCHO','VENTURA',
  'OXNARD','THOUSAND','SIMI','CAMARILLO','MANILA','PHILIPPINE','VIETNAM',
  'KOREAN','MEXICO','LATIN','CHINESE','LONG','BEACH','CARSON',
]);

// Words that are common in sole-prop names AND don't kill the signal.
const SHOPWORDS = new Set([
  'BARBERSHOP','BARBER','BARBERS','BARBERSHOP\'S','BARBERING',
  'SALON','SALONS','HAIR','STYLING','STYLE','STUDIO','SHOP',
  'CUTS','CUT','BEAUTY','SPA','NAILS','NAIL','LASH','BROW','BROWS',
  'AND','&','BY','THE','OF','FOR','AT','ON','IN',
]);

function looksLikeSoleProp(name) {
  if (!name) return false;
  const tokens = name.toUpperCase().replace(/[^A-Z0-9 &'\-.]/g, '').split(/\s+/).filter(Boolean);
  if (tokens.length < 2 || tokens.length > 4) return false;
  // Reject if any token is corporate or a known place name
  if (tokens.some(t => CORPORATE_TOKENS.has(t) || PLACE_TOKENS.has(t))) return false;
  // Reject pure-numeric tokens (e.g., "SIRA 2000 HAIR SALON")
  if (tokens.some(t => /^\d+$/.test(t))) return false;
  // Reject possessives like "GEORGE'S" — these are usually brand-style
  // even though they reference an owner. Edge case: legitimate sole-prop
  // possessives also exist, but the SEO value of the flag is to surface
  // un-branded LASTNAME FIRSTNAME registrations, not "Joe's Barber".
  if (tokens.some(t => t.includes("'S"))) return false;
  // Strip out generic shop words; what remains MUST be at least 2 words
  // that are plausible name tokens (3-12 letters, all-alpha). The TWO-name
  // requirement is what catches "MORAGA BERNIE" / "TAYLOR WILLIAM C" while
  // rejecting brand names like "MAGIC HAIRCUTS" or "EYECANDY LASH".
  const remaining = tokens.filter(t => !SHOPWORDS.has(t));
  if (remaining.length < 2) return false;
  const nameLike = remaining.filter(t => /^[A-Z]{3,12}$/.test(t));
  if (nameLike.length < 2) return false;
  return true;
}

async function main() {
  log(`mode=${DRY ? 'DRY' : 'WRITE'}`);
  // Only consider barbershops + salons that don't already carry the flag.
  const rows = await query(
    `SELECT id, slug, name, neighborhood
       FROM businesses
      WHERE source_data_json->>'source' = 'dca_bbcb'
        AND category IN ('barbershop','salon','beard')
        AND (source_data_json->>'shop_personality') IS NULL`
  );
  log(`fetched ${rows.rows.length} candidate rows`);

  let flagged = 0;
  const samples = [];
  for (const r of rows.rows) {
    if (!looksLikeSoleProp(r.name)) continue;
    flagged++;
    if (samples.length < 20) samples.push(`${r.name} · ${r.neighborhood || '?'}`);
    if (DRY) continue;
    await query(
      `UPDATE businesses
          SET source_data_json = jsonb_set(COALESCE(source_data_json, '{}'::jsonb), '{shop_personality}', '"sole_prop"'),
              updated_at = now()
        WHERE id = $1`,
      [r.id]
    );
    if (flagged % 1000 === 0) log(`  …${flagged} flagged`);
  }
  log(`FLAGGED: ${flagged} of ${rows.rows.length} (${(flagged / rows.rows.length * 100).toFixed(1)}%)`);
  log(`SAMPLES:`);
  for (const s of samples) console.log(`    ${s}`);

  if (!DRY) {
    const tally = await one(
      `SELECT COUNT(*) FILTER (WHERE source_data_json->>'shop_personality' = 'sole_prop') AS sole_prop,
              COUNT(*) FILTER (WHERE category IN ('barbershop','salon','beard')) AS total_chairs
         FROM businesses
        WHERE source_data_json->>'source' = 'dca_bbcb'`
    );
    log(`POST: sole_prop=${tally.sole_prop} of ${tally.total_chairs} chairs (${(tally.sole_prop/tally.total_chairs*100).toFixed(1)}%)`);
  }
}

main().catch(e => { console.error(e); process.exit(2); });