← back to Small Business Builder

scripts/backfill-la-neighborhoods.js

235 lines

#!/usr/bin/env node
// Backfill `businesses.neighborhood` for BBCB shops sitting under the
// placeholder neighborhood='LOS ANGELES' (which is really a county tag, not a
// neighborhood). This is the difference between a directory that says "LA" on
// 12K rows and a directory that says "Silver Lake / Echo Park / K-Town / …"
// — i.e. the difference between a useless directory and a useful one.
//
// Source of truth: USPS ZIP code → LA neighborhood mapping. The City of LA
// formally adopted "Mapping LA" neighborhood boundaries (Times, 2009),
// but for ZIP-level routing we use the simpler postal mapping. Where a ZIP
// straddles two neighborhoods we pick the larger / more iconic one and rely
// on a future address-based pass to refine.
//
// For ZIPs that are NOT inside the City of LA (e.g. Beverly Hills, Torrance,
// Pasadena, Glendale) we set neighborhood = the city name, because those
// users will look for "salons in Beverly Hills" not "salons in Beverly Hills
// neighborhood of LA County".
//
//   node scripts/backfill-la-neighborhoods.js          # full backfill
//   DRY=1 node scripts/backfill-la-neighborhoods.js    # report-only
//   LIMIT=500 node scripts/backfill-la-neighborhoods.js # cap
//
// Idempotent — running it twice is a no-op.

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

const DRY = process.env.DRY === '1';
const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;

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

// ZIP5 → neighborhood (or city for non-LA-proper zips).
// City of LA neighborhoods are Title Case; standalone cities are also Title Case.
// Sources: USPS ZIP boundaries × LA Times Mapping LA × common postal usage.
const ZIP_TO_HOOD = {
  // ===== CITY OF LOS ANGELES =====
  // Downtown / DTLA
  '90012':'Downtown LA', '90013':'Downtown LA', '90014':'Downtown LA',
  '90015':'Downtown LA', '90017':'Downtown LA', '90021':'Downtown LA',
  '90071':'Downtown LA',
  // Koreatown / Mid-Wilshire / Westlake
  '90004':'Koreatown', '90005':'Koreatown', '90010':'Koreatown',
  '90020':'Koreatown', '90006':'Koreatown', '90019':'Mid-Wilshire',
  '90036':'Mid-Wilshire', '90057':'Westlake',
  // Hollywood / East Hollywood / Los Feliz / Silver Lake / Echo Park
  '90028':'Hollywood', '90038':'Hollywood', '90068':'Hollywood Hills',
  '90027':'Los Feliz', '90029':'East Hollywood',
  '90039':'Silver Lake', '90026':'Echo Park', '90065':'Highland Park',
  '90042':'Highland Park', '90041':'Eagle Rock', '90031':'Lincoln Heights',
  '90032':'El Sereno', '90033':'Boyle Heights', '90063':'East LA',
  // West LA / Westside
  '90024':'Westwood', '90025':'West LA', '90049':'Brentwood',
  '90064':'Cheviot Hills', '90034':'Palms', '90035':'Beverlywood',
  '90048':'Beverly Grove', '90069':'West Hollywood',
  '90046':'Hollywood Hills West', '90077':'Bel-Air',
  '90272':'Pacific Palisades', '90291':'Venice',
  '90293':'Playa del Rey', '90094':'Playa Vista',
  '90066':'Mar Vista',
  // South LA
  '90001':'South LA', '90002':'South LA', '90003':'South LA',
  '90007':'University Park', '90008':'Crenshaw',
  '90011':'South LA', '90016':'West Adams', '90018':'Jefferson Park',
  '90037':'South LA', '90043':'Hyde Park', '90044':'South LA',
  '90047':'South LA', '90056':'Ladera Heights', '90059':'Watts',
  '90061':'South LA', '90062':'South LA',
  // San Fernando Valley (City of LA portions)
  '91303':'Canoga Park', '91304':'Canoga Park', '91306':'Winnetka',
  '91307':'West Hills', '91311':'Chatsworth', '91316':'Encino',
  '91324':'Northridge', '91325':'Northridge', '91326':'Porter Ranch',
  '91330':'Northridge', '91335':'Reseda', '91340':'San Fernando',
  '91342':'Sylmar', '91343':'North Hills', '91344':'Granada Hills',
  '91345':'Mission Hills', '91352':'Sun Valley', '91356':'Tarzana',
  '91364':'Woodland Hills', '91367':'Woodland Hills',
  '91401':'Van Nuys', '91402':'Panorama City', '91403':'Sherman Oaks',
  '91405':'Van Nuys', '91406':'Van Nuys', '91411':'Sherman Oaks',
  '91423':'Sherman Oaks', '91436':'Encino',
  '91504':'Burbank', '91505':'Burbank', '91506':'Burbank',
  '91601':'North Hollywood', '91602':'Toluca Lake', '91604':'Studio City',
  '91605':'North Hollywood', '91606':'Valley Glen', '91607':'Valley Village',
  // Harbor / Wilmington / San Pedro
  '90731':'San Pedro', '90732':'San Pedro', '90744':'Wilmington',
  '90710':'Harbor City', '90717':'Lomita',

  // ===== STANDALONE LA COUNTY CITIES (non-LA proper) =====
  // Beverly Hills
  '90209':'Beverly Hills', '90210':'Beverly Hills',
  '90211':'Beverly Hills', '90212':'Beverly Hills',
  // Santa Monica
  '90401':'Santa Monica', '90402':'Santa Monica', '90403':'Santa Monica',
  '90404':'Santa Monica', '90405':'Santa Monica',
  // Culver City
  '90230':'Culver City', '90231':'Culver City', '90232':'Culver City',
  // Inglewood / Hawthorne / Lennox
  '90301':'Inglewood', '90302':'Inglewood', '90303':'Inglewood',
  '90304':'Lennox', '90305':'Inglewood',
  '90250':'Hawthorne', '90251':'Hawthorne',
  '90260':'Lawndale', '90261':'Lawndale',
  '90247':'Gardena', '90248':'Gardena', '90249':'Gardena',
  '90274':'Rancho Palos Verdes', '90275':'Rancho Palos Verdes',
  '90277':'Redondo Beach', '90278':'Redondo Beach',
  '90245':'El Segundo', '90266':'Manhattan Beach', '90254':'Hermosa Beach',
  // South Bay
  '90501':'Torrance', '90502':'Torrance', '90503':'Torrance',
  '90504':'Torrance', '90505':'Torrance', '90506':'Torrance',
  '90507':'Torrance', '90508':'Torrance', '90509':'Torrance',
  '90510':'Torrance',
  // Long Beach
  '90802':'Long Beach', '90803':'Long Beach', '90804':'Long Beach',
  '90805':'Long Beach', '90806':'Long Beach', '90807':'Long Beach',
  '90808':'Long Beach', '90809':'Long Beach', '90810':'Long Beach',
  '90813':'Long Beach', '90814':'Long Beach', '90815':'Long Beach',
  // Southeast LA county
  '90201':'Bell', '90202':'Bell Gardens', '90220':'Compton',
  '90221':'Compton', '90222':'Compton', '90255':'Huntington Park',
  '90262':'Lynwood', '90270':'Maywood', '90280':'South Gate',
  '90040':'Commerce', '90022':'East LA', '90023':'East LA',
  '90640':'Montebello', '90660':'Pico Rivera', '90670':'Santa Fe Springs',
  '90706':'Bellflower', '90712':'Lakewood', '90713':'Lakewood',
  '90715':'Lakewood', '90723':'Paramount',
  // East San Gabriel Valley
  '91006':'Arcadia', '91007':'Arcadia', '91016':'Monrovia',
  '91024':'Sierra Madre', '91030':'South Pasadena',
  '91101':'Pasadena', '91103':'Pasadena', '91104':'Pasadena',
  '91105':'Pasadena', '91106':'Pasadena', '91107':'Pasadena',
  '91108':'San Marino', '91123':'Pasadena', '91124':'Pasadena',
  '91125':'Pasadena', '91126':'Pasadena',
  '91201':'Glendale', '91202':'Glendale', '91203':'Glendale',
  '91204':'Glendale', '91205':'Glendale', '91206':'Glendale',
  '91207':'Glendale', '91208':'Glendale', '91214':'La Crescenta',
  '91501':'Burbank', '91502':'Burbank', '91503':'Burbank',
  '91706':'Baldwin Park', '91710':'Chino', '91711':'Claremont',
  '91722':'Covina', '91723':'Covina', '91724':'Covina',
  '91731':'El Monte', '91732':'El Monte', '91733':'South El Monte',
  '91740':'Glendora', '91741':'Glendora', '91744':'La Puente',
  '91745':'Hacienda Heights', '91746':'La Puente', '91748':'Rowland Heights',
  '91750':'La Verne', '91754':'Monterey Park', '91755':'Monterey Park',
  '91765':'Diamond Bar', '91766':'Pomona', '91767':'Pomona',
  '91768':'Pomona', '91770':'Rosemead', '91773':'San Dimas',
  '91775':'San Gabriel', '91776':'San Gabriel', '91780':'Temple City',
  '91789':'Walnut', '91790':'West Covina', '91791':'West Covina',
  '91792':'West Covina', '91801':'Alhambra', '91803':'Alhambra',
  // Santa Clarita Valley
  '91350':'Santa Clarita', '91351':'Canyon Country',
  '91354':'Valencia', '91355':'Valencia', '91381':'Stevenson Ranch',
  '91384':'Castaic', '91387':'Canyon Country', '91390':'Santa Clarita',
  // Antelope Valley
  '93534':'Lancaster', '93535':'Lancaster', '93536':'Lancaster',
  '93550':'Palmdale', '93551':'Palmdale', '93552':'Palmdale',

  // ===== second-pass coverage (top unmapped zips after first run) =====
  '90240':'Downey', '90241':'Downey', '90242':'Downey',
  '90601':'Whittier', '90602':'Whittier', '90603':'Whittier',
  '90604':'Whittier', '90605':'Whittier', '90606':'Whittier',
  '90638':'La Mirada', '90650':'Norwalk',
  '90701':'Artesia', '90703':'Cerritos', '90716':'Hawaiian Gardens',
  '90745':'Carson', '90746':'Carson', '90747':'Carson', '90749':'Long Beach',
  '90045':'Westchester', // Westchester / LAX
  '90265':'Malibu', '90290':'Topanga', '90292':'Marina del Rey',
  '91001':'Altadena', '91010':'Duarte', '91011':'La Cañada Flintridge',
  '91020':'Montrose', '91040':'Sunland', '91042':'Tujunga',
  '91301':'Agoura Hills', '91302':'Calabasas',
  '91321':'Newhall', '91331':'Pacoima', '91346':'Mission Hills',
  '91702':'Azusa',
  '91747':'Hacienda Heights',
  '91755':'Monterey Park', '91756':'Monterey Park',
  '91802':'Alhambra',
  // miscellaneous valley stragglers
  '91357':'Tarzana', '91365':'Woodland Hills', '91371':'West Hills',
  '91408':'Van Nuys', '91410':'Van Nuys', '91414':'Van Nuys',
  '91431':'Sherman Oaks', '91482':'Van Nuys',
  '91804':'Alhambra',
};

async function main() {
  log(`mode=${DRY ? 'DRY' : 'WRITE'}${LIMIT ? ` limit=${LIMIT}` : ''}`);

  // Pull every BBCB shop tagged with the placeholder county. Truncate ZIP+4 to
  // the 5-digit prefix. Skip rows without a usable ZIP.
  const rows = await query(
    `SELECT id, zip
       FROM businesses
      WHERE source_data_json->>'source' = 'dca_bbcb'
        AND neighborhood IN ('LOS ANGELES')
        AND zip IS NOT NULL AND zip != ''
      ${LIMIT ? `LIMIT ${LIMIT}` : ''}`
  );

  log(`fetched ${rows.rows.length} candidate rows`);

  let matched = 0, skipped = 0, updated = 0;
  const hoodHits = {};

  for (const r of rows.rows) {
    const zip5 = (r.zip || '').slice(0, 5);
    const hood = ZIP_TO_HOOD[zip5];
    if (!hood) { skipped++; continue; }
    matched++;
    hoodHits[hood] = (hoodHits[hood] || 0) + 1;
    if (DRY) continue;
    // Update both `neighborhood` and a JSON breadcrumb so a future pass can
    // tell which mapping source set it.
    await query(
      `UPDATE businesses
          SET neighborhood = $1,
              source_data_json = jsonb_set(COALESCE(source_data_json, '{}'::jsonb), '{neighborhood_source}', '"zip-map"'),
              updated_at = now()
        WHERE id = $2`,
      [hood, r.id]
    );
    updated++;
    if (updated % 500 === 0) log(`  …${updated} updated`);
  }

  log(`MATCHED: ${matched} · SKIPPED (no zip-map entry): ${skipped} · UPDATED: ${updated}`);
  // Top hoods
  const top = Object.entries(hoodHits).sort((a,b)=>b[1]-a[1]).slice(0, 20);
  log(`TOP NEIGHBORHOODS BY HIT COUNT:`);
  for (const [h, n] of top) console.log(`    ${String(n).padStart(5)} · ${h}`);

  if (!DRY) {
    const tally = await one(
      `SELECT COUNT(*) FILTER (WHERE neighborhood='LOS ANGELES') AS still_generic,
              COUNT(DISTINCT neighborhood) AS distinct_neighborhoods
         FROM businesses
        WHERE source_data_json->>'source' = 'dca_bbcb'`
    );
    log(`AFTER: still_generic=${tally.still_generic} distinct_neighborhoods=${tally.distinct_neighborhoods}`);
  }
  process.exit(0);
}

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