← back to Ventura Corridor

src/enrich/classify_ownership.ts

212 lines

/**
 * Ownership classifier — heuristic, no LLM, no API calls.
 * Marks each business as corporate / franchise / chain / independent and
 * suggests the best contact path.
 *
 * Run: tsx src/enrich/classify_ownership.ts
 */
import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';

// Brands that operate via franchisees (local-franchisee path is real but
// route corporate first for media/PR/policy questions).
const FRANCHISE_BRANDS = new Set([
  'mcdonald\'s', 'mcdonalds', 'subway', 'domino\'s', 'dominos', 'pizza hut', 'kfc', 'taco bell',
  'chipotle', 'panera bread', 'panera', 'jersey mike\'s', 'jamba', 'jamba juice', "jersey mike's",
  'starbucks', 'dunkin', 'dunkin\' donuts', 'wendy\'s', "wendy's",
  'jack in the box', 'carl\'s jr', "carl's jr", 'chick-fil-a', 'in-n-out', 'in n out',
  '7-eleven', 'circle k', 'baskin-robbins', 'baskin robbins',
]);

// Brands that are wholly corporate-owned (no franchise model, single owner).
const CORPORATE_BRANDS = new Set([
  'cvs pharmacy', 'cvs', 'walgreens', 'rite aid',
  'whole foods market', 'whole foods', 'trader joe\'s', "trader joe's", 'ralphs', 'gelson\'s', 'gelsons',
  'target', 'walmart', 'costco',
  'chase', 'wells fargo', 'bank of america', 'citi', 'citibank',
  'home depot', 'lowes',
  'guitar center', 'best buy', 'apple store',
  'dollar tree', 'dollar general',
]);

// Domains we'll classify as corporate locator subdomains.
const LOCATOR_HOST_PATTERNS = [
  /^locations?\./i,
  /^locator\./i,
  /^stores?\./i,
  /^find\./i,
  /^restaurants?\./i,
  /^order\./i,
  /\.locations\./i,
  /\.stores?\./i,
];

// Third-party ordering / aggregator hosts (signals corporate or franchise restaurant).
const AGGREGATOR_HOSTS = new Set([
  'getreef.com', 'doordash.com', 'grubhub.com', 'ubereats.com', 'seamless.com',
  'chownow.com', 'toasttab.com', 'olo.com', 'ezcater.com',
]);

interface Row {
  id: number;
  name: string;
  category: string | null;
  website: string | null;
}

function hostOf(url: string | null): string | null {
  if (!url) return null;
  try {
    return new URL(/^https?:\/\//.test(url) ? url : 'https://' + url).hostname.replace(/^www\./, '');
  } catch { return null; }
}

function normName(s: string): string {
  return s.toLowerCase().replace(/[®™]/g, '').trim();
}

function classify(b: Row): {
  ownership_class: string;
  parent_brand: string | null;
  is_franchise: boolean;
  is_chain: boolean;
  best_contact_path: string;
  best_contact_note: string;
  signals: Record<string, any>;
} {
  const nm = normName(b.name);
  const host = hostOf(b.website);
  const sig: Record<string, any> = { host, nm };

  // 1. Franchise brand?
  for (const brand of FRANCHISE_BRANDS) {
    if (nm === brand || nm.startsWith(brand + ' ') || nm.endsWith(' ' + brand)) {
      sig.brand_match = brand;
      return {
        ownership_class: 'franchise',
        parent_brand: brand,
        is_franchise: true,
        is_chain: true,
        best_contact_path: 'franchisee',
        best_contact_note: 'Franchise model — local franchisee owns the storefront; corporate handles brand/PR. For local concerns contact the GM listed at this location; for media or policy go through corporate.',
        signals: sig,
      };
    }
  }

  // 2. Corporate brand?
  for (const brand of CORPORATE_BRANDS) {
    if (nm === brand || nm.startsWith(brand + ' ') || nm.endsWith(' ' + brand)) {
      sig.brand_match = brand;
      return {
        ownership_class: 'corporate',
        parent_brand: brand,
        is_franchise: false,
        is_chain: true,
        best_contact_path: 'corporate-pr',
        best_contact_note: 'Wholly corporate-owned chain. Local store managers handle ops only; brand decisions, PR, partnerships go through corporate communications.',
        signals: sig,
      };
    }
  }

  // 3. Locator-pattern host?
  if (host) {
    for (const pat of LOCATOR_HOST_PATTERNS) {
      if (pat.test(host)) {
        sig.locator_host = true;
        return {
          ownership_class: 'corporate',
          parent_brand: host.split('.').slice(-2, -1)[0] || host,
          is_franchise: false,
          is_chain: true,
          best_contact_path: 'corporate-pr',
          best_contact_note: `Listed via a corporate locator subdomain (${host}). Local manager handles ops; corporate handles policy/media.`,
          signals: sig,
        };
      }
    }
    // 4. Third-party aggregator?
    if (AGGREGATOR_HOSTS.has(host)) {
      sig.aggregator_host = true;
      return {
        ownership_class: 'unknown',
        parent_brand: null,
        is_franchise: false,
        is_chain: false,
        best_contact_path: 'unknown',
        best_contact_note: `Site URL points to a third-party ordering platform (${host}); the storefront\'s own contact channel needs separate research.`,
        signals: sig,
      };
    }
  }

  // 5. Default: independent. Best contact = owner-direct.
  return {
    ownership_class: 'independent',
    parent_brand: null,
    is_franchise: false,
    is_chain: false,
    best_contact_path: 'owner-direct',
    best_contact_note: 'No corporate parent detected. The website host matches the business name pattern, suggesting local ownership. Contact the owner / operator directly via the published channels.',
    signals: sig,
  };
}

async function main() {
  const r = await query<Row>(`
    SELECT id, name, category, website FROM businesses
    WHERE on_corridor
    ORDER BY id
  `);
  console.log(`[classify] ${r.rowCount} businesses to classify`);

  const tally: Record<string, number> = {};
  let inserted = 0;

  for (const b of r.rows) {
    const c = classify(b);
    tally[c.ownership_class] = (tally[c.ownership_class] || 0) + 1;

    await query(`
      INSERT INTO business_enrichment (
        business_id, ownership_class, parent_brand,
        is_franchise, is_chain, best_contact_path, best_contact_note,
        enrichment_sources, confidence, raw_signals
      ) VALUES ($1, $2, $3, $4, $5, $6, $7, ARRAY['classify-ownership']::TEXT[], $8, $9)
      ON CONFLICT (business_id) DO UPDATE SET
        enriched_at        = NOW(),
        ownership_class    = EXCLUDED.ownership_class,
        parent_brand       = EXCLUDED.parent_brand,
        is_franchise       = EXCLUDED.is_franchise,
        is_chain           = EXCLUDED.is_chain,
        best_contact_path  = EXCLUDED.best_contact_path,
        best_contact_note  = EXCLUDED.best_contact_note,
        enrichment_sources = (
          SELECT ARRAY(SELECT DISTINCT unnest(business_enrichment.enrichment_sources || ARRAY['classify-ownership']))
        ),
        confidence         = GREATEST(business_enrichment.confidence, EXCLUDED.confidence),
        raw_signals        = COALESCE(business_enrichment.raw_signals, '{}'::jsonb) || EXCLUDED.raw_signals
    `, [
      b.id, c.ownership_class, c.parent_brand,
      c.is_franchise, c.is_chain, c.best_contact_path, c.best_contact_note,
      0.85, JSON.stringify(c.signals),
    ]);
    inserted++;
  }

  console.log('');
  console.log(`[classify] inserted/updated ${inserted} enrichment rows`);
  console.log('[classify] ownership distribution:');
  for (const [k, v] of Object.entries(tally).sort((a, b) => b[1] - a[1])) {
    console.log(`        ${k.padEnd(14)} ${v}`);
  }

  await pool.end();
}

main().catch((e) => {
  console.error('[classify]', e);
  process.exit(1);
});