← 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); });