← back to Small Business Builder

scripts/ingest-hollywood-corridor.js

309 lines

#!/usr/bin/env node
// Hollywood Blvd Corridor ingest — every registered business on the ~5-mile
// stretch of Hollywood Boulevard from East Hollywood (Vermont) west through
// the Walk of Fame to Laurel Canyon Blvd in the Hollywood Hills.
//
// Hollywood Blvd is short but iconic — five miles of marquees, sound stages,
// and salons through:
//   - East Hollywood (Vermont/Hollywood, near LACC)
//   - Thai Town
//   - Central Hollywood (Vine, Pantages, Capitol Records)
//   - The Walk of Fame mile (Highland, Chinese Theater, Dolby)
//   - La Brea / west Hollywood Blvd
//   - Hollywood Hills frontage to Laurel Canyon Blvd
//
// "Registered with the city" = LA Office of Finance BTRC. Same approximation
// pattern as Sunset/Wilshire: pull every commercial OSM presence on/near
// Hollywood Blvd, tag them `corridor: 'hollywood-blvd-5mi'` for filtering.
//
//   node scripts/ingest-hollywood-corridor.js          # full corridor ingest
//   DRY=1 node scripts/ingest-hollywood-corridor.js    # parse only

import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import { query, one } from '../src/lib/db.js';
import { slugify } from '../src/lib/slug.js';

const TMP_DIR = '/tmp/osm-ingest';
const OVERPASS_URL = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
const CORRIDOR_TAG = 'hollywood-blvd-5mi';

// Bounding box covering Hollywood Blvd from East Hollywood (Vermont, ~ -118.290)
// west to Laurel Canyon Blvd in the Hollywood Hills (~ -118.380). Hollywood Blvd
// is essentially flat east-west, so the latitude band is narrow (34.090 - 34.108).
// Overpass uses "south,west,north,east".
const BBOX = '34.090,-118.380,34.108,-118.290';

// Reverse-mapping OSM tag → our category. Matches Wilshire corridor exactly so
// the editorial UI behaves the same.
const SHOP_TAG_TO_CATEGORY = {
  hairdresser: 'salon', beauty: 'salon', massage: 'spa', cosmetics: 'salon',
  // Eat/drink
  restaurant: 'restaurant', cafe: 'cafe', bakery: 'bakery', deli: 'deli',
  bar: 'bar', pub: 'bar', fast_food: 'restaurant', ice_cream: 'cafe',
  // Retail
  clothes: 'retail', shoes: 'retail', jewelry: 'retail', books: 'retail',
  florist: 'retail', gift: 'retail', wine: 'retail', alcohol: 'retail',
  optician: 'retail', furniture: 'retail', boutique: 'retail',
  supermarket: 'grocery', convenience: 'grocery',
  // Services
  dry_cleaning: 'service', laundry: 'service', tailor: 'service',
  car_repair: 'service', car: 'service',
  // Health / wellness
  pharmacy: 'pharmacy', chemist: 'pharmacy',
  // Pets
  pet: 'retail',
};
const AMENITY_TAG_TO_CATEGORY = {
  restaurant: 'restaurant', cafe: 'cafe', bar: 'bar', pub: 'bar',
  fast_food: 'restaurant', ice_cream: 'cafe', food_court: 'restaurant',
  bank: 'finance', atm: 'finance',
  pharmacy: 'pharmacy', dentist: 'health', doctors: 'health', clinic: 'health', veterinary: 'health',
  spa: 'spa',
  gym: 'fitness', fitness_centre: 'fitness',
  cinema: 'entertainment', theatre: 'entertainment', nightclub: 'entertainment',
  fuel: 'service', car_wash: 'service',
};

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

// BBCB zip codes that fall along Hollywood Blvd. Used in the SQL post-step to
// retroactively flag DCA-licensed shops that are already in the DB but
// weren't tagged with the corridor (their `addr:street` from BBCB usually
// mentions "Hollywood" if they're actually on the strip).
//
// 90027 = Los Feliz (east end touches Hollywood Blvd near Vermont)
// 90028 = central Hollywood (Walk of Fame, Vine, Highland)
// 90029 = East Hollywood (Vermont through Western)
// 90038 = south Hollywood (the south face of Hollywood Blvd west of Vine)
// 90068 = Hollywood Hills (the western terminus toward Laurel Canyon)
const HOLLYWOOD_ZIPS = [
  '90027', // Los Feliz
  '90028', // central Hollywood (Walk of Fame)
  '90029', // East Hollywood
  '90038', // south Hollywood
  '90068', // Hollywood Hills
];

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

function buildQuery() {
  return `[out:json][timeout:240];
(
  node["shop"](${BBOX});
  way["shop"](${BBOX});
  node["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
  way["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
  node["leisure"="spa"](${BBOX});
  way["leisure"="spa"](${BBOX});
  node["office"](${BBOX});
  way["office"](${BBOX});
);
out center tags;`;
}

async function fetchData() {
  const cachePath = path.join(TMP_DIR, 'hollywood-corridor.json');
  if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 1000) {
    log(`reusing cache (${(fs.statSync(cachePath).size / 1024).toFixed(0)} KB)`);
    return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
  }
  log('querying Overpass for the entire Hollywood Blvd corridor (~30-90s)…');
  const body = 'data=' + encodeURIComponent(buildQuery());
  const res = await fetch(OVERPASS_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Accept': 'application/json',
      'User-Agent': 'la-salon-directory/1.0 (+steveabramsdesigns@gmail.com)',
    },
    body,
  });
  if (!res.ok) throw new Error(`Overpass HTTP ${res.status}`);
  const j = await res.json();
  fs.writeFileSync(cachePath, JSON.stringify(j));
  log(`${j.elements?.length || 0} elements, cached → ${cachePath}`);
  return j;
}

// Determine category from OSM tags. Priority: shop → amenity → leisure → office.
function categorizeFromTags(t) {
  if (t.shop && SHOP_TAG_TO_CATEGORY[t.shop])         return { cat: SHOP_TAG_TO_CATEGORY[t.shop], rawTag: 'shop=' + t.shop };
  if (t.amenity && AMENITY_TAG_TO_CATEGORY[t.amenity]) return { cat: AMENITY_TAG_TO_CATEGORY[t.amenity], rawTag: 'amenity=' + t.amenity };
  if (t.leisure === 'spa')                            return { cat: 'spa', rawTag: 'leisure=spa' };
  if (t.office)                                       return { cat: 'office', rawTag: 'office=' + t.office };
  // Catch-all: any shop=* tag we didn't enumerate
  if (t.shop)                                         return { cat: 'retail', rawTag: 'shop=' + t.shop };
  return null;
}

// Address-based filter: only keep elements where addr:street contains
// "Hollywood" (Hollywood Blvd, Hollywood Boulevard, etc.) — this excludes
// everything within the bbox that's on a side street.
function isOnHollywoodBlvd(t) {
  const street = (t['addr:street'] || '').toLowerCase();
  return /hollywood/.test(street);
}

function refineSalonCategory(rawTag, name, base) {
  if (base !== 'salon') return base;
  if (/\b(barber|barbers|barbershop|fade|cuts?|men.?s)\b/i.test(name || '')) return 'barbershop';
  return 'salon';
}

function makeSlug(name, zip, osmId) {
  const base = slugify(name) || 'biz';
  if (zip && /^\d{5}/.test(zip)) return `${base}-${zip.slice(0, 5)}`;
  return `osm-${osmId}-${base}`.slice(0, 200);
}

function processElement(el) {
  const t = el.tags || {};
  const name = (t.name || '').trim();
  if (!name) return null;
  const cat = categorizeFromTags(t);
  if (!cat) return null;
  if (!isOnHollywoodBlvd(t)) return null;

  const lat = el.lat ?? el.center?.lat ?? null;
  const lon = el.lon ?? el.center?.lon ?? null;
  const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ').trim();
  const city = (t['addr:city'] || '').trim();
  const state = (t['addr:state'] || 'CA').trim();
  const zip = (t['addr:postcode'] || '').trim().slice(0, 10);
  const phone = (t.phone || t['contact:phone'] || '').trim().slice(0, 32) || null;
  const website = (t.website || t['contact:website'] || '').trim() || null;

  const finalCat = refineSalonCategory(cat.rawTag, name, cat.cat);

  return {
    slug: makeSlug(name, zip, el.id),
    name,
    category: finalCat,
    address: street || null,
    city: city || null,
    state,
    zip: zip || null,
    neighborhood: city || null,
    latitude: lat,
    longitude: lon,
    phone,
    website,
    source_data_json: {
      source: 'osm-corridor',
      source_id: `${el.type}/${el.id}`,
      source_url: `https://www.openstreetmap.org/${el.type}/${el.id}`,
      tier: 'B',
      retrieved_at: new Date().toISOString(),
      raw_tag: cat.rawTag,
      corridor: CORRIDOR_TAG,
      tags: t,
    },
  };
}

async function upsert(row) {
  const r = await query(
    `INSERT INTO businesses
       (slug, name, category, address, city, state, zip, neighborhood,
        latitude, longitude, phone, website, source_data_json, tier)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'free')
     ON CONFLICT (slug) DO UPDATE SET
       latitude       = COALESCE(businesses.latitude, EXCLUDED.latitude),
       longitude      = COALESCE(businesses.longitude, EXCLUDED.longitude),
       phone          = COALESCE(businesses.phone, EXCLUDED.phone),
       website        = COALESCE(businesses.website, EXCLUDED.website),
       neighborhood   = COALESCE(EXCLUDED.neighborhood, businesses.neighborhood),
       address        = COALESCE(NULLIF(businesses.address,''), EXCLUDED.address),
       source_data_json = jsonb_set(
         COALESCE(businesses.source_data_json, '{}'::jsonb),
         '{corridor}', to_jsonb(${"'" + CORRIDOR_TAG + "'"}::text)
       ),
       updated_at     = now()
     RETURNING (xmax = 0) AS was_insert`,
    [row.slug, row.name, row.category, row.address, row.city, row.state, row.zip,
     row.neighborhood, row.latitude, row.longitude, row.phone, row.website,
     JSON.stringify(row.source_data_json)]
  );
  return r.rows[0]?.was_insert;
}

// Tag any DCA-licensed BBCB shop whose zip falls in the Hollywood corridor zip
// list AND whose address contains "hollywood". Mirrors the Wilshire post-step.
async function tagBbcbAlongHollywood() {
  const zipPattern = '^(' + HOLLYWOOD_ZIPS.join('|') + ')';
  const r = await query(
    `UPDATE businesses
        SET source_data_json = jsonb_set(
              COALESCE(source_data_json, '{}'::jsonb),
              '{corridor}',
              to_jsonb($2::text)
            ),
            updated_at = now()
      WHERE source_data_json->>'source' = 'dca_bbcb'
        AND zip ~ $1
        AND address ~* 'hollywood'
      RETURNING slug`,
    [zipPattern, CORRIDOR_TAG]
  );
  return r.rowCount;
}

async function main() {
  fs.mkdirSync(TMP_DIR, { recursive: true });
  log(`mode=${DRY ? 'DRY' : 'WRITE'} corridor=${CORRIDOR_TAG} bbox=${BBOX}`);

  const data = await fetchData();
  let matched = 0, inserted = 0, updated = 0, skippedNotOnHollywood = 0, skippedNoCat = 0;
  const byCategory = {};
  const byCity = {};

  for (const el of data.elements || []) {
    const t = el.tags || {};
    const cat = categorizeFromTags(t);
    if (!cat || !t.name) { skippedNoCat++; continue; }
    if (!isOnHollywoodBlvd(t)) { skippedNotOnHollywood++; continue; }

    const row = processElement(el);
    if (!row) continue;
    matched++;
    byCategory[row.category] = (byCategory[row.category] || 0) + 1;
    if (row.city) byCity[row.city] = (byCity[row.city] || 0) + 1;

    if (DRY) {
      if (matched <= 12) console.log(`  ${(row.slug || '').padEnd(60)} ${row.name} · ${row.category} · ${row.city || '?'}`);
      continue;
    }
    try {
      const ins = await upsert(row);
      if (ins) inserted++; else updated++;
    } catch (e) {
      log(`  ERR ${row.slug}: ${e.message.slice(0, 200)}`);
    }
  }

  log(`MATCHED=${matched} inserted=${inserted} updated=${updated}`);
  log(`SKIPPED: not_on_hollywood=${skippedNotOnHollywood} no_category=${skippedNoCat}`);
  log(`BY CATEGORY: ${Object.entries(byCategory).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([k,v])=>`${k}=${v}`).join(' ')}`);
  log(`BY CITY: ${Object.entries(byCity).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([k,v])=>`${k}=${v}`).join(' ')}`);

  let bbcbTagged = 0;
  if (!DRY) {
    bbcbTagged = await tagBbcbAlongHollywood();
    log(`BBCB-along-Hollywood tagged: ${bbcbTagged}`);

    const tally = await one(`
      SELECT COUNT(*) AS n,
             COUNT(*) FILTER (WHERE latitude IS NOT NULL) AS geocoded
        FROM businesses
       WHERE source_data_json->>'corridor' = $1
    `, [CORRIDOR_TAG]).catch(() => null);
    if (tally) log(`DB now: corridor=${tally.n} geocoded=${tally.geocoded}`);
  }
  process.exit(0);
}

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