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