← back to Ventura Claw Leads

scripts/rotate-headline-variants.js

117 lines

#!/usr/bin/env node
/**
 * rotate-headline-variants.js
 *
 * Diversify the 1,819 templated headlines using `id % 3` deterministic
 * variant selection. Each (type, city) combo gets 3 phrasings instead of 1:
 *
 *   variant 0:  "Hair salon in Sherman Oaks."        (current)
 *   variant 1:  "Sherman Oaks hair salon."           (city first)
 *   variant 2:  "Hair salon · Sherman Oaks corridor." (mid-dot, corridor cue)
 *
 * Triples distinct headlines: 170 → ~510. Avg rows-per-phrasing drops from
 * ~11 to ~4. Same template flavor; same SEO targets; less visible repetition
 * on the find grid.
 *
 * Idempotent — the variant is fully determined by `(template, city, id % 3)`,
 * so re-running produces the same output. Only edits headlines that still
 * end with ' in <city>.' (matches the post-headline-rewrite shape from
 * commit 5a49679 — won't touch owner-customized headlines).
 */

require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
const db = require('../lib/db');

// Convert "Hair salon" / "Sit-down restaurant" / "Sushi restaurant" into
// the three canonical variants. Lowercase the type when it's mid-sentence
// so "Sherman Oaks hair salon" reads naturally.
function variants(type, city) {
  const tLower = type.charAt(0).toLowerCase() + type.slice(1);
  return [
    `${type} in ${city}.`,                 // 0: original
    `${city} ${tLower}.`,                  // 1: city-first
    `${type} · ${city} corridor.`          // 2: mid-dot variant
  ];
}

(async () => {
  // Pull every public_records row with an end-of-headline " in <city>." shape
  // — that's the rewritten template format from commit 5a49679. Owners who've
  // claimed and customized their headline write something different, so they
  // won't match this regex and will be left alone.
  const rows = await db.many(`
    SELECT id, slug, headline, neighborhood, city
      FROM businesses
     WHERE source='public_records'
       AND status IN ('active','hidden')
       AND headline ~ ' in [A-Za-z][A-Za-z ]+\\.$'
  `);
  console.log(`[rotate] candidate rows: ${rows.length}`);

  let updated = 0, skipped = 0;
  // Batch updates in a transaction — 1,819 rows shouldn't be 1,819 round
  // trips. (PROSECUTOR caught this pattern in an earlier debate.)
  const client = await db.pool.connect();
  try {
    await client.query('BEGIN');

    for (const r of rows) {
      // Parse "<Type> in <City>." → [type, city]
      const m = r.headline.match(/^(.+?) in ([A-Za-z][A-Za-z ]+)\.$/);
      if (!m) { skipped++; continue; }
      const type = m[1];
      const city = m[2];

      // City must match the row's neighborhood/city (sanity check).
      const expected = (r.neighborhood || r.city || '').trim();
      if (expected && expected.toLowerCase() !== city.toLowerCase()) {
        skipped++; continue;
      }

      const variantIdx = Number(r.id) % 3;
      const newHeadline = variants(type, expected || city)[variantIdx];

      if (newHeadline === r.headline) { skipped++; continue; }

      // Re-stitch description. Keep the prefix sentence intact, swap the
      // trailing headline echo to match the new headline.
      const verticalPrefixRe = new RegExp(`^(.+? is a [a-z _]+ business on the Ventura Blvd corridor in ${expected || city}\\. ).+$`);
      // Update the headline; let the description regenerate via raw SQL
      // concatenation instead of regex (faster + less error-prone).
      await client.query(
        `UPDATE businesses
            SET headline = $1,
                description = (
                  business_name || ' is a ' || REPLACE(vertical, '_', ' ')
                  || ' business on the Ventura Blvd corridor in ' || COALESCE(neighborhood, city)
                  || '. ' || $1
                )
          WHERE id = $2`,
        [newHeadline, r.id]
      );
      updated++;
    }

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }

  // Tally distinct headlines now
  const distinct = await db.one(`
    SELECT COUNT(DISTINCT headline)::int AS n
      FROM businesses WHERE source='public_records' AND status='active'
  `);
  console.log(`[rotate] updated=${updated} skipped=${skipped} distinct_headlines_now=${distinct.n}`);

  await db.pool.end();
  process.exit(0);
})().catch(async (err) => {
  console.error('[rotate] fatal', err);
  try { await db.pool.end(); } catch (_) {}
  process.exit(1);
});