← back to Small Business Builder

scripts/build-all-analyses.mjs

204 lines

// Batch-builder: walks every row from listBuilds() and generates a 3-mockup
// website_analyses entry per project, using the directory name + memory
// description as the synthesized "site content". No external scraping —
// purely synthesizes mockups from existing local data.
//
//   USAGE: node scripts/build-all-analyses.mjs [--dry-run] [--rebuild]
//
//   --dry-run   prints what would be created, makes no DB writes
//   --rebuild   delete existing analyses for these slugs first

import 'dotenv/config';
import fs from 'node:fs';
import { listBuilds, isComplianceSensitive } from '../src/lib/builds-index.js';
import { detectVertical, pickThemesFor } from '../src/lib/vertical.js';
import { renderTheme } from '../src/render/website-analysis-themes.js';
import { one, query } from '../src/lib/db.js';
import { slugify } from '../src/lib/slug.js';

const args = new Set(process.argv.slice(2));
const DRY = args.has('--dry-run');
const REBUILD = args.has('--rebuild');

// Race guard: bail if enrich-copy-local.mjs is running (it UPDATEs summary_json
// concurrently — a SELECT→DELETE→INSERT here would silently lose enrichment that
// landed between the SELECT and DELETE).
if (REBUILD && !DRY) {
  try {
    const out = (await import('node:child_process')).execSync('pgrep -f enrich-copy-local.mjs', { encoding: 'utf8' }).trim();
    if (out) {
      console.error(`REFUSING TO RUN: enrich-copy-local.mjs is running (pid ${out}). Wait for it to finish or kill it first.`);
      process.exit(2);
    }
  } catch { /* pgrep exits 1 when no match — that's the happy path */ }
}

function pseudoUrl(row) {
  // Prefer real URL if it's parseable + public, else build a synthetic
  // file://~/Projects/<dir> placeholder so the schema's NOT-NULL `url` constraint
  // still has a meaningful value.
  if (row.url && /^https?:\/\//.test(row.url)) return row.url;
  return `local://projects/${row.name}`;
}

function isPublicUrl(u) {
  try {
    const p = new URL(u);
    if (p.protocol !== 'http:' && p.protocol !== 'https:') return false;
    // Block tailnet 100.x.x.x and RFC1918 IPs from being treated as public.
    const h = p.hostname;
    if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(h)) return false;
    if (h === 'localhost' || h.endsWith('.local')) return false;
    return true;
  } catch { return false; }
}

function buildCtx(row) {
  const desc = (row.description || '').trim();
  const title = row.memory_title?.split(/[—\-:]/)[0]?.trim()
    || row.name.replace(/-/g, ' ');
  // Vertical selection based on category + desc text.
  const verticalHint = {
    agent:        'saas-landing',
    factory:      'saas-landing',
    viewer:       'saas-landing',
    tooling:      'saas-landing',
    site:         'ecom',
    directory:    'advocacy',
    project:      'generic',
    feedback:     'generic',
    reference:    'generic',
  }[row.category] || 'generic';
  const vertical = desc ? detectVertical({ title, description: desc, html: '' }) : verticalHint;
  const themes = pickThemesFor(vertical);
  return { title, desc, vertical, themes };
}

async function buildOne(row) {
  const url = pseudoUrl(row);
  const slug = (slugify(row.name) || 'build').slice(0, 80);

  // Q2 dream team consensus: skip compliance-sensitive slugs entirely. Generating
  // mockups for them and hiding behind auth is data-liability-without-upside —
  // the only safe state is "the file does not exist." Dropping out before any DB
  // mutation keeps any prior copy_pack/audit_report untouched too.
  if (isComplianceSensitive(slug)) {
    return { slug, status: 'skip-compliance' };
  }

  // Preserve any qwen3-enriched copy_pack + audit_report across rebuilds —
  // the script used to DELETE then re-insert, nuking 30+ minutes of local-LLM
  // enrichment work. Now we read the existing summary_json first.
  const existing = await one('SELECT id, summary_json FROM website_analyses WHERE slug=$1', [slug]);
  if (existing && !REBUILD) return { slug, status: 'skip-exists' };
  const preservedSummary = existing?.summary_json || {};
  if (REBUILD) await query('DELETE FROM website_analyses WHERE slug=$1', [slug]);

  const { title, desc, vertical, themes } = buildCtx(row);
  // ★ Use enriched copy_pack when present — overrides synthesized title/tagline.
  const cp = preservedSummary.copy_pack;
  const ctx = {
    title:    cp?.headline || title,
    tagline:  cp?.tagline  || desc.slice(0, 240) || `${row.category} build at ~/Projects/${row.name}`,
    cta_primary:   cp?.cta_primary,
    cta_secondary: cp?.cta_secondary,
    cards:    cp?.cards,
    palette: [],
    hero_image: null,
    vertical,
    ig_handle: null,
    url: isPublicUrl(url) ? url : `https://${row.name}.local`,
  };

  if (DRY) return { slug, status: 'dry', themes };

  const summary = {
    grade: 'pending',
    top_3_issues: [],
    vertical,
    title,
    tagline: ctx.tagline,
    // ★ Preserve previously-computed enrichment so 30+ min of qwen3 work isn't wiped on rebuild.
    ...(preservedSummary.copy_pack    ? { copy_pack:    preservedSummary.copy_pack }    : {}),
    ...(preservedSummary.audit_report ? { audit_report: preservedSummary.audit_report } : {}),
  };
  const meta = {
    iframe_blocked: true,  // local builds don't embed
    palette: [],
    hero_image: null,
    title,
    tagline: ctx.tagline,
    source: 'build-all-analyses',
    project_path: row.path,
    compliance_sensitive: isComplianceSensitive(slug),
  };

  const inserted = await one(
    `INSERT INTO website_analyses (slug, url, ig_handle, vertical, status, summary_json, meta_json)
     VALUES ($1,$2,NULL,$3,'ready',$4::jsonb,$5::jsonb) RETURNING id, slug`,
    [slug, url, vertical, JSON.stringify(summary), JSON.stringify(meta)]
  );

  for (let n = 0; n < themes.length; n += 1) {
    const theme = themes[n];
    const mockupId = `${String(n + 1).padStart(2, '0')}-${theme}`;
    const html = renderTheme(theme, ctx);
    await query(
      `INSERT INTO website_analysis_mockups (analysis_id, mockup_id, title, theme, preview_html)
       VALUES ($1,$2,$3,$4,$5)`,
      [inserted.id, mockupId, theme.charAt(0).toUpperCase() + theme.slice(1).replace(/-/g, ' '), theme, html]
    );
  }
  return { slug, status: 'created', themes };
}

async function main() {
  const rows = listBuilds();
  console.log(`Found ${rows.length} builds. Mode: ${DRY ? 'DRY-RUN' : (REBUILD ? 'REBUILD' : 'CREATE-MISSING')}`);
  let created = 0, skipped = 0, errored = 0;
  const errs = [];
  for (let i = 0; i < rows.length; i += 1) {
    const row = rows[i];
    try {
      const r = await buildOne(row);
      if (r.status === 'created') created += 1;
      else if (r.status === 'skip-exists') skipped += 1;
      else if (r.status === 'skip-compliance') skipped += 1;
      else if (r.status === 'dry') created += 1;
      if (i % 10 === 0 || r.status === 'created' || r.status === 'skip-compliance') {
        const label = r.status === 'skip-compliance' ? '[SKIPPED-COMPLIANCE]' : r.status.padEnd(13);
        console.log(`[${String(i+1).padStart(3,' ')}/${rows.length}] ${label} ${r.slug}`);
      }
    } catch (e) {
      errored += 1;
      errs.push({ name: row.name, error: String(e?.message || e) });
      console.error(`[${String(i+1).padStart(3,' ')}/${rows.length}] ERROR        ${row.name}: ${e?.message}`);
    }
  }
  console.log(`\nDONE — created:${created} skipped:${skipped} errored:${errored}`);
  if (errs.length) console.log('Errors:', errs.slice(0, 10));

  // Standing rule (feedback_batch_failure_alerts.md): >10% failure rate must
  // page Steve via George. Send only on real failures, not dry-runs.
  const total = created + skipped + errored;
  const failRate = total > 0 ? errored / total : 0;
  if (!DRY && failRate > 0.10) {
    try {
      const body = `build-all-analyses run finished with elevated failure rate.\n\nTotals:\n  created: ${created}\n  skipped: ${skipped}\n  errored: ${errored}\n  fail rate: ${(failRate * 100).toFixed(1)}%\n\nFirst 10 errors:\n${errs.slice(0, 10).map(e => `  - ${e.name}: ${e.error}`).join('\n')}`;
      await fetch('http://localhost:9850/api/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          to: 'steveabramsdesigns@gmail.com',
          subject: `[smb-builder] mockup rebuild >10% fail (${(failRate * 100).toFixed(1)}%)`,
          body,
        }),
      });
      console.log(`Alerted Steve via George (fail rate ${(failRate * 100).toFixed(1)}% > 10%)`);
    } catch (e) { console.error('George alert failed:', e.message); }
  }
  process.exit(0);
}

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