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>
200 lines
10 KiB
TypeScript
200 lines
10 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 budgetGate = readJson(join(outRoot, 'source_acquisition_budget_gate.json'), {})
|
|
const budget = budgetGate.budget || {}
|
|
const dataforseoPilot = readJson(join(outRoot, 'dataforseo_pilot_manifest.json'), {})
|
|
const apifyGapfill = readJson(join(outRoot, 'apify_gapfill_manifest.json'), {})
|
|
const costPlanPath = join(budgetRoot, 'source-acquisition-cost-plan.json')
|
|
const currency = budget.currency || 'GBP'
|
|
const stageOneTasks = Number(dataforseoPilot.counts?.stageOneTargets || budget.dataforseoStage1?.maxTasks || 9)
|
|
const reviewDepth = Number(budget.dataforseoStage1?.maxReviewsPerAsin || dataforseoPilot.guardrails?.recommendedDepth || 10)
|
|
const apifyTargets = Number(apifyGapfill.counts?.firstBatchTargets || budget.apifyStage2?.maxTargets || 3)
|
|
const apifyRequests = Number(budget.apifyStage2?.maxRequestsPerBrand || 3)
|
|
const apifyRuns = Number(budget.apifyStage2?.maxActorRuns || 1)
|
|
|
|
const defaultPlan = {
|
|
currency,
|
|
owner: '',
|
|
updatedAt: '',
|
|
notes: 'Fill unit costs/max expected charges before enabling provider budgets. Do not store provider credentials here.',
|
|
dataforseoStage1: {
|
|
pricingSourceUrl: '',
|
|
pricingCheckedAt: '',
|
|
unitCostPerTask: 0,
|
|
taskCount: stageOneTasks,
|
|
reviewDepth,
|
|
estimatedTotal: 0,
|
|
maxSpendRef: Number(budget.dataforseoStage1?.maxSpend || 0),
|
|
hardStopAfterFirstRun: true,
|
|
notes: 'Estimate the first DataForSEO Amazon Reviews pilot only. Keep full-catalogue scaling out of this plan.'
|
|
},
|
|
apifyStage2: {
|
|
pricingSourceUrl: '',
|
|
pricingCheckedAt: '',
|
|
estimatedActorRunCost: 0,
|
|
actorRuns: apifyRuns,
|
|
targetCount: apifyTargets,
|
|
maxRequestsPerBrand: apifyRequests,
|
|
estimatedTotal: 0,
|
|
maxSpendRef: Number(budget.apifyStage2?.maxSpend || 0),
|
|
hardStopAfterFirstDataset: true,
|
|
notes: 'Estimate one first-batch gap-fill actor run only, after DataForSEO pilot review names a gap.'
|
|
}
|
|
}
|
|
if (!existsSync(costPlanPath)) writeFileSync(costPlanPath, JSON.stringify(defaultPlan, null, 2) + '\n', 'utf8')
|
|
const costPlan = readJson(costPlanPath, defaultPlan)
|
|
|
|
function n(value: any) { return Number(value || 0) }
|
|
const dfs = costPlan.dataforseoStage1 || {}
|
|
const apify = costPlan.apifyStage2 || {}
|
|
const dfsDepthUnits = Math.max(1, Math.ceil(n(dfs.reviewDepth || reviewDepth) / 10))
|
|
const dfsEstimated = n(dfs.estimatedTotal) || (n(dfs.unitCostPerTask) * n(dfs.taskCount || stageOneTasks) * dfsDepthUnits)
|
|
const apifyEstimated = n(apify.estimatedTotal) || (n(apify.estimatedActorRunCost) * n(apify.actorRuns || apifyRuns))
|
|
const dfsMaxSpend = n(budget.dataforseoStage1?.maxSpend || dfs.maxSpendRef)
|
|
const apifyMaxSpend = n(budget.apifyStage2?.maxSpend || apify.maxSpendRef)
|
|
|
|
const rows = [
|
|
{
|
|
Provider: 'DataForSEO', Stage: '1', Check: 'Pricing source recorded',
|
|
Status: dfs.pricingSourceUrl && dfs.pricingCheckedAt ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: dfs.pricingSourceUrl && dfs.pricingCheckedAt ? `${dfs.pricingSourceUrl} checked ${dfs.pricingCheckedAt}` : 'pricingSourceUrl/pricingCheckedAt missing'
|
|
},
|
|
{
|
|
Provider: 'DataForSEO', Stage: '1', Check: 'Unit cost recorded',
|
|
Status: n(dfs.unitCostPerTask) > 0 ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `${currency} ${n(dfs.unitCostPerTask)} per task`
|
|
},
|
|
{
|
|
Provider: 'DataForSEO', Stage: '1', Check: 'Estimate matches capped task count',
|
|
Status: n(dfs.taskCount) === stageOneTasks && n(dfs.reviewDepth) === reviewDepth ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `${n(dfs.taskCount)} tasks / ${stageOneTasks} expected; depth ${n(dfs.reviewDepth)} / ${reviewDepth} expected`
|
|
},
|
|
{
|
|
Provider: 'DataForSEO', Stage: '1', Check: 'Estimated total is positive',
|
|
Status: dfsEstimated > 0 ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `${currency} ${dfsEstimated}`
|
|
},
|
|
{
|
|
Provider: 'DataForSEO', Stage: '1', Check: 'Estimated total fits max spend',
|
|
Status: dfsEstimated > 0 && dfsMaxSpend > 0 && dfsEstimated <= dfsMaxSpend ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `${currency} ${dfsEstimated} estimated / ${dfsMaxSpend} max spend`
|
|
},
|
|
{
|
|
Provider: 'DataForSEO', Stage: '1', Check: 'Hard stop preserved',
|
|
Status: dfs.hardStopAfterFirstRun === true ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `hardStopAfterFirstRun=${dfs.hardStopAfterFirstRun === true}`
|
|
},
|
|
{
|
|
Provider: 'Apify', Stage: '2', Check: 'Pricing source recorded',
|
|
Status: apify.pricingSourceUrl && apify.pricingCheckedAt ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: apify.pricingSourceUrl && apify.pricingCheckedAt ? `${apify.pricingSourceUrl} checked ${apify.pricingCheckedAt}` : 'pricingSourceUrl/pricingCheckedAt missing'
|
|
},
|
|
{
|
|
Provider: 'Apify', Stage: '2', Check: 'Estimated actor run cost recorded',
|
|
Status: n(apify.estimatedActorRunCost) > 0 ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `${currency} ${n(apify.estimatedActorRunCost)} per actor run`
|
|
},
|
|
{
|
|
Provider: 'Apify', Stage: '2', Check: 'Estimate matches first-batch caps',
|
|
Status: n(apify.actorRuns) === apifyRuns && n(apify.targetCount) === apifyTargets && n(apify.maxRequestsPerBrand) === apifyRequests ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `${n(apify.actorRuns)} runs / ${apifyRuns}; ${n(apify.targetCount)} targets / ${apifyTargets}; ${n(apify.maxRequestsPerBrand)} requests per brand / ${apifyRequests}`
|
|
},
|
|
{
|
|
Provider: 'Apify', Stage: '2', Check: 'Estimated total is positive',
|
|
Status: apifyEstimated > 0 ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `${currency} ${apifyEstimated}`
|
|
},
|
|
{
|
|
Provider: 'Apify', Stage: '2', Check: 'Estimated total fits max spend',
|
|
Status: apifyEstimated > 0 && apifyMaxSpend > 0 && apifyEstimated <= apifyMaxSpend ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `${currency} ${apifyEstimated} estimated / ${apifyMaxSpend} max spend`
|
|
},
|
|
{
|
|
Provider: 'Apify', Stage: '2', Check: 'Hard stop preserved',
|
|
Status: apify.hardStopAfterFirstDataset === true ? 'pass' : 'blocked', Blocking: 'yes',
|
|
Evidence: `hardStopAfterFirstDataset=${apify.hardStopAfterFirstDataset === true}`
|
|
}
|
|
]
|
|
|
|
const dataforseoReady = rows.filter(row => row.Provider === 'DataForSEO').every(row => row.Status === 'pass')
|
|
const apifyReady = rows.filter(row => row.Provider === 'Apify').every(row => row.Status === 'pass')
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: dataforseoReady && apifyReady,
|
|
costReady: { dataforseoStage1: dataforseoReady, apifyStage2: apifyReady },
|
|
costFile: 'data/sources/budgets/source-acquisition-cost-plan.json',
|
|
currency,
|
|
counts: {
|
|
checks: rows.length,
|
|
passing: rows.filter(row => row.Status === 'pass').length,
|
|
blocked: rows.filter(row => row.Status !== 'pass').length
|
|
},
|
|
estimates: {
|
|
dataforseoStage1: { estimatedTotal: dfsEstimated, maxSpend: dfsMaxSpend, taskCount: n(dfs.taskCount), reviewDepth: n(dfs.reviewDepth), depthUnits: dfsDepthUnits },
|
|
apifyStage2: { estimatedTotal: apifyEstimated, maxSpend: apifyMaxSpend, actorRuns: n(apify.actorRuns), targetCount: n(apify.targetCount), maxRequestsPerBrand: n(apify.maxRequestsPerBrand) }
|
|
},
|
|
guardrails: [
|
|
'This file is for local cost planning only; it does not call providers or store credentials.',
|
|
'Use current provider pricing/source URLs when filling unit costs; do not guess pricing in code.',
|
|
'Estimated total must fit the explicit max spend budget before approval/spend gates can unlock.',
|
|
'Full-catalogue scraping is intentionally outside this cost plan.'
|
|
],
|
|
checks: rows,
|
|
nextActions: rows.filter(row => row.Status !== 'pass').map(row => `${row.Provider} Stage ${row.Stage}: ${row.Check} - ${row.Evidence}`),
|
|
costPlan
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'source_acquisition_cost_plan.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'source_acquisition_cost_plan.csv'), csv(rows, ['Provider', 'Stage', 'Check', 'Status', 'Blocking', 'Evidence']), 'utf8')
|
|
writeFileSync(join(outRoot, 'source_acquisition_cost_plan.md'), [
|
|
'# Source acquisition cost plan',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
`DataForSEO Stage 1 cost ready: ${dataforseoReady ? 'YES' : 'NO'}`,
|
|
`Apify Stage 2 cost ready: ${apifyReady ? 'YES' : 'NO'}`,
|
|
'',
|
|
'This no-spend cost plan makes provider subscription decisions explicit before any credit is used.',
|
|
'',
|
|
`Cost file: \`${manifest.costFile}\``,
|
|
'',
|
|
'## Estimates',
|
|
`- DataForSEO Stage 1: ${currency} ${dfsEstimated} estimated / ${currency} ${dfsMaxSpend} max spend; ${n(dfs.taskCount)} tasks at depth ${n(dfs.reviewDepth)}.`,
|
|
`- Apify Stage 2: ${currency} ${apifyEstimated} estimated / ${currency} ${apifyMaxSpend} max spend; ${n(apify.actorRuns)} run(s), ${n(apify.targetCount)} target(s), ${n(apify.maxRequestsPerBrand)} requests per brand.`,
|
|
'',
|
|
'## Guardrails',
|
|
...manifest.guardrails.map(item => `- ${item}`),
|
|
'',
|
|
'## Checks',
|
|
...rows.map(row => `- ${row.Status === 'pass' ? '[pass]' : '[blocked]'} ${row.Provider} Stage ${row.Stage}: ${row.Check} — ${row.Evidence}`),
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`Source acquisition cost plan ready: ${manifest.ready}`)
|
|
console.log(`DataForSEO cost ready: ${dataforseoReady}`)
|
|
console.log(`Apify cost ready: ${apifyReady}`)
|
|
console.log(`Checks passing: ${manifest.counts.passing}/${manifest.counts.checks}`)
|