056c47581f
Ships the second dashboard surface — a Pattern Library + Preview Theatre — that presents the 4-section PDP pilot batch back to Umar, compliance, and the board in an editorial format. Adds the full data layer that drives it: 5 source-backed per-SKU drafts at QA 100/100, 15 competitor PDP semantic extracts, PubMed evidence packs, EFSA claims library extension, JV brand voice guide, hand-curated product FAQs, and the Matrixify-ready CSV exports for Lewis. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
7.5 KiB
TypeScript
184 lines
7.5 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const budgetRoot = join(root, 'data', 'sources', 'budgets')
|
|
mkdirSync(outRoot, { recursive: true })
|
|
mkdirSync(budgetRoot, { recursive: true })
|
|
|
|
function readJson(path: string, fallback: any = null) {
|
|
if (!existsSync(path)) return fallback
|
|
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
function csv(rows: any[], headers: string[]) {
|
|
const esc = (value: any) => {
|
|
if (value === undefined || value === null) value = ''
|
|
if (Array.isArray(value)) value = value.join(' | ')
|
|
value = String(value)
|
|
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
|
}
|
|
return [headers.join(','), ...rows.map(row => headers.map(header => esc(row[header])).join(','))].join('\n') + '\n'
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const budgetPath = join(budgetRoot, 'source-acquisition-budget.json')
|
|
const pilot = readJson(join(outRoot, 'dataforseo_pilot_manifest.json'), {})
|
|
const apify = readJson(join(outRoot, 'apify_gapfill_manifest.json'), {})
|
|
const defaultBudget = {
|
|
currency: 'GBP',
|
|
owner: '',
|
|
updatedAt: '',
|
|
notes: 'Fill this before any paid DataForSEO or Apify run. Do not store provider credentials here.',
|
|
dataforseoStage1: {
|
|
enabled: false,
|
|
maxSpend: 0,
|
|
maxTasks: pilot.counts?.stageOneTargets || 9,
|
|
marketplace: 'amazon.co.uk',
|
|
maxReviewsPerAsin: pilot.guardrails?.recommendedDepth || 10,
|
|
hardStopAfterFirstRun: true,
|
|
approvalRef: 'data/sources/approvals/dataforseo-stage1-approval.json'
|
|
},
|
|
apifyStage2: {
|
|
enabled: false,
|
|
maxSpend: 0,
|
|
maxActorRuns: 1,
|
|
maxTargets: apify.counts?.firstBatchTargets || 3,
|
|
maxRequestsPerBrand: 3,
|
|
hardStopAfterFirstDataset: true,
|
|
approvalRef: 'data/sources/approvals/apify-stage2-approval.json'
|
|
}
|
|
}
|
|
if (!existsSync(budgetPath)) writeFileSync(budgetPath, JSON.stringify(defaultBudget, null, 2) + '\n', 'utf8')
|
|
const budget = readJson(budgetPath, defaultBudget)
|
|
const currency = budget.currency || 'GBP'
|
|
|
|
const dataforseo = budget.dataforseoStage1 || {}
|
|
const apifyBudget = budget.apifyStage2 || {}
|
|
const stageOneTargets = Number(pilot.counts?.stageOneTargets || 0)
|
|
const firstBatchTargets = Number(apify.counts?.firstBatchTargets || 0)
|
|
|
|
const rows = [
|
|
{
|
|
Provider: 'DataForSEO',
|
|
Stage: '1',
|
|
Check: 'Budget lane enabled',
|
|
Status: dataforseo.enabled === true ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: dataforseo.enabled === true ? 'enabled=true' : 'enabled=false in data/sources/budgets/source-acquisition-budget.json'
|
|
},
|
|
{
|
|
Provider: 'DataForSEO',
|
|
Stage: '1',
|
|
Check: 'Max spend recorded',
|
|
Status: Number(dataforseo.maxSpend || 0) > 0 ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${currency} ${Number(dataforseo.maxSpend || 0)}`
|
|
},
|
|
{
|
|
Provider: 'DataForSEO',
|
|
Stage: '1',
|
|
Check: 'Task cap covers pilot only',
|
|
Status: Number(dataforseo.maxTasks || 0) > 0 && Number(dataforseo.maxTasks || 0) <= 12 && Number(dataforseo.maxTasks || 0) >= stageOneTargets ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${Number(dataforseo.maxTasks || 0)} max tasks for ${stageOneTargets} pilot targets`
|
|
},
|
|
{
|
|
Provider: 'DataForSEO',
|
|
Stage: '1',
|
|
Check: 'Review depth capped',
|
|
Status: Number(dataforseo.maxReviewsPerAsin || 0) >= 100 && Number(dataforseo.maxReviewsPerAsin || 9999) <= 1000 ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${Number(dataforseo.maxReviewsPerAsin || 0)} reviews per ASIN (target range 100-1000)`
|
|
},
|
|
{
|
|
Provider: 'DataForSEO',
|
|
Stage: '1',
|
|
Check: 'Hard stop after first run',
|
|
Status: dataforseo.hardStopAfterFirstRun === true ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `hardStopAfterFirstRun=${dataforseo.hardStopAfterFirstRun === true}`
|
|
},
|
|
{
|
|
Provider: 'Apify',
|
|
Stage: '2',
|
|
Check: 'Budget lane enabled',
|
|
Status: apifyBudget.enabled === true ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: apifyBudget.enabled === true ? 'enabled=true' : 'enabled=false in data/sources/budgets/source-acquisition-budget.json'
|
|
},
|
|
{
|
|
Provider: 'Apify',
|
|
Stage: '2',
|
|
Check: 'Max spend recorded',
|
|
Status: Number(apifyBudget.maxSpend || 0) > 0 ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${currency} ${Number(apifyBudget.maxSpend || 0)}`
|
|
},
|
|
{
|
|
Provider: 'Apify',
|
|
Stage: '2',
|
|
Check: 'Actor and target caps preserve selective gap-fill',
|
|
Status: Number(apifyBudget.maxActorRuns || 0) >= 1 && Number(apifyBudget.maxTargets || 0) >= firstBatchTargets && Number(apifyBudget.maxRequestsPerBrand || 0) >= 100 && Number(apifyBudget.maxRequestsPerBrand || 9999) <= 1000 ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${Number(apifyBudget.maxActorRuns || 0)} actor runs, ${Number(apifyBudget.maxTargets || 0)} targets, ${Number(apifyBudget.maxRequestsPerBrand || 0)} requests/brand for ${firstBatchTargets} first-batch targets (target range 100-1000)`
|
|
},
|
|
{
|
|
Provider: 'Apify',
|
|
Stage: '2',
|
|
Check: 'Hard stop after first dataset',
|
|
Status: apifyBudget.hardStopAfterFirstDataset === true ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `hardStopAfterFirstDataset=${apifyBudget.hardStopAfterFirstDataset === true}`
|
|
}
|
|
]
|
|
|
|
const dataforseoAllowed = rows.filter(row => row.Provider === 'DataForSEO').every(row => row.Status === 'pass')
|
|
const apifyAllowed = rows.filter(row => row.Provider === 'Apify').every(row => row.Status === 'pass')
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: dataforseoAllowed && apifyAllowed,
|
|
budgetAllowed: {
|
|
dataforseoStage1: dataforseoAllowed,
|
|
apifyStage2: apifyAllowed
|
|
},
|
|
budgetFile: 'data/sources/budgets/source-acquisition-budget.json',
|
|
currency,
|
|
counts: {
|
|
checks: rows.length,
|
|
passing: rows.filter(row => row.Status === 'pass').length,
|
|
blocked: rows.filter(row => row.Status !== 'pass').length
|
|
},
|
|
checks: rows,
|
|
nextActions: rows.filter(row => row.Status !== 'pass').map(row => `${row.Provider} Stage ${row.Stage}: ${row.Check} - ${row.Evidence}`),
|
|
budget
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'source_acquisition_budget_gate.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'source_acquisition_budget_gate.csv'), csv(rows, ['Provider', 'Stage', 'Check', 'Status', 'Blocking', 'Evidence']), 'utf8')
|
|
writeFileSync(join(outRoot, 'source_acquisition_budget_gate.md'), [
|
|
'# Source acquisition budget gate',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
`DataForSEO Stage 1 budget allowed: ${dataforseoAllowed ? 'YES' : 'NO'}`,
|
|
`Apify Stage 2 budget allowed: ${apifyAllowed ? 'YES' : 'NO'}`,
|
|
'',
|
|
'This gate prevents provider subscriptions or paid calls from starting without an explicit local budget cap. It does not store credentials or pricing assumptions.',
|
|
'',
|
|
`Budget file: \`${manifest.budgetFile}\``,
|
|
'',
|
|
'## Checks',
|
|
...rows.map(row => `- ${row.Status === 'pass' ? '[pass]' : '[blocked]'} ${row.Provider} Stage ${row.Stage}: ${row.Check} — ${row.Evidence}`),
|
|
'',
|
|
'## Rule',
|
|
'A human approval file is not enough by itself. The matching budget lane must also be enabled with a max spend and hard-stop caps before `source:spend-gate` can allow paid provider usage.',
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`Source acquisition budget gate ready: ${manifest.ready}`)
|
|
console.log(`DataForSEO budget allowed: ${dataforseoAllowed}`)
|
|
console.log(`Apify budget allowed: ${apifyAllowed}`)
|
|
console.log(`Checks passing: ${manifest.counts.passing}/${manifest.counts.checks}`)
|