Files
justvitamin/scripts/generate-source-acquisition-plan.ts
T
Omair Saleh 056c47581f feat: editorial review dashboard + elite-grade pilot batch (5 SKUs)
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>
2026-06-02 18:50:09 +08:00

164 lines
8.2 KiB
TypeScript

#!/usr/bin/env bun
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'
import { join } from 'path'
const root = process.cwd()
const outRoot = join(root, 'content_population_exports')
const contentRoot = join(root, 'data', 'content')
const sourcesRoot = join(root, 'data', 'sources')
const catalogPath = join(root, 'data', 'pipeline', 'catalog.json')
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 (Array.isArray(value)) value = value.join(' | ')
if (value === undefined || value === null) value = ''
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'
}
function listJson(dir: string) {
if (!existsSync(dir)) return []
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return listJson(path)
return entry.isFile() && entry.name.endsWith('.json') ? [path] : []
})
}
mkdirSync(outRoot, { recursive: true })
const generatedAt = new Date().toISOString()
const catalog = readJson(catalogPath, [])
const productsBySku = new Map<string, any>()
for (const file of readdirSync(join(contentRoot, 'products')).filter(file => file.endsWith('.json'))) {
const product = readJson(join(contentRoot, 'products', file), {})
productsBySku.set(product.sku, product)
}
const competitorConfig = readJson(join(sourcesRoot, 'competitor-sources.json'), { competitors: [] })
const pilotSkus = [...productsBySku.values()].filter(product => product.status !== 'pending_intelligence').map(product => product.sku)
const topMissing = catalog
.filter((row: any) => !pilotSkus.includes(row.sku))
.sort((a: any, b: any) => Number(b.total_reviews || 0) - Number(a.total_reviews || 0))
.slice(0, 8)
.map((row: any) => row.sku)
const collagenSkus = [...productsBySku.values()]
.filter(product => /collagen/i.test(`${product.sku} ${product.name || ''}`))
.map(product => product.sku)
const stageRows = [
{
Stage: '0',
Provider: 'No paid provider',
Scope: 'Use existing Feefo-derived pilot intelligence, competitor PDP probes, source request pack, and queues.',
Targets: `pilot SKUs: ${pilotSkus.join(' | ')}`,
HardCap: '£0 / no paid credits',
StopRule: 'Stop when source_request_pack, intelligence queue, and handoff actions are current.',
OutputFolder: 'content_population_exports/',
Verification: 'bun run source:pack && bun run intelligence:queue'
},
{
Stage: '1',
Provider: 'DataForSEO first',
Scope: 'Small paid pilot for structured Amazon reviews/source data. Do not run full catalogue.',
Targets: [...new Set([...pilotSkus, ...topMissing.slice(0, 5), ...collagenSkus])].join(' | '),
HardCap: 'Max 8-12 SKU/ASIN tasks before review; stop after first invoice/credit sample.',
StopRule: 'Stop if output lacks review body/rating/date/source URL or if per-SKU cost is too high.',
OutputFolder: 'data/sources/amazon/',
Verification: 'bun run validate:sources && bun run intel:refresh && bun run intelligence:queue'
},
{
Stage: '2',
Provider: 'Apify selective',
Scope: 'Only fill gaps: competitor PDP pages, Amazon product detail pages, or Reddit/social if DataForSEO cannot provide enough detail.',
Targets: (competitorConfig.competitors || []).map((row: any) => row.brand).join(' | '),
HardCap: 'One actor, 1-3 targets, then inspect raw JSON before scaling.',
StopRule: 'Stop if actor output is shallow, duplicated, blocked, or does not include source URLs/evidence fields.',
OutputFolder: 'data/sources/competitor-pdps/ or data/sources/reddit/',
Verification: 'bun run validate:sources && bun run build:competitors'
},
{
Stage: '3',
Provider: 'Controlled expansion',
Scope: 'Batch by commercial priority, not full catalogue: collagen/NPD, top review SKUs, launch-critical products, then remaining catalogue.',
Targets: topMissing.join(' | '),
HardCap: 'One priority batch at a time; rerun handoff verification between batches.',
StopRule: 'Stop each batch when source validation passes and intelligence task count drops as expected.',
OutputFolder: 'data/sources/* and data/intelligence/*',
Verification: 'bun run verify:handoff'
}
]
const targetRows = [
...pilotSkus.map(sku => ({ Stage: '1', Provider: 'DataForSEO', Priority: 'pilot', SKU: sku, Product: productsBySku.get(sku)?.name || sku, Reason: 'Existing pilot content; validates provider output quality against known dashboard intelligence.' })),
...topMissing.map((sku, index) => ({ Stage: index < 5 ? '1' : '3', Provider: index < 5 ? 'DataForSEO' : 'Controlled expansion', Priority: index < 3 ? 'P0' : 'P1', SKU: sku, Product: productsBySku.get(sku)?.name || catalog.find((row: any) => row.sku === sku)?.name || sku, Reason: `High Feefo review count: ${catalog.find((row: any) => row.sku === sku)?.total_reviews || 0}` })),
...collagenSkus.map(sku => ({ Stage: '1', Provider: 'DataForSEO', Priority: 'NPD', SKU: sku, Product: productsBySku.get(sku)?.name || sku, Reason: 'Collagen/NPD research priority.' })),
...(competitorConfig.competitors || []).map((row: any) => ({ Stage: '2', Provider: 'Apify selective', Priority: 'competitor', SKU: '', Product: row.brand, Reason: `Competitor PDP/domain: ${row.domain}` }))
]
const manifest = {
generatedAt,
ready: false,
recommendation: 'Use DataForSEO first for a tightly capped structured pilot; use Apify only as a targeted gap-filler after inspecting pilot output.',
counts: {
stages: stageRows.length,
pilotSkus: pilotSkus.length,
stageOneTargets: targetRows.filter(row => row.Stage === '1').length,
apifyTargets: targetRows.filter(row => row.Stage === '2').length,
expansionTargets: targetRows.filter(row => row.Stage === '3').length,
amazonFiles: listJson(join(sourcesRoot, 'amazon')).length,
trustpilotFiles: listJson(join(sourcesRoot, 'trustpilot')).length,
redditFiles: listJson(join(sourcesRoot, 'reddit')).length
},
stages: stageRows,
targets: targetRows
}
writeFileSync(join(outRoot, 'source_acquisition_plan.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
writeFileSync(join(outRoot, 'source_acquisition_stages.csv'), csv(stageRows, ['Stage', 'Provider', 'Scope', 'Targets', 'HardCap', 'StopRule', 'OutputFolder', 'Verification']), 'utf8')
writeFileSync(join(outRoot, 'source_acquisition_targets.csv'), csv(targetRows, ['Stage', 'Provider', 'Priority', 'SKU', 'Product', 'Reason']), 'utf8')
const md = [
'# JV staged source acquisition plan',
'',
`Generated: ${generatedAt}`,
'',
`Recommendation: ${manifest.recommendation}`,
'',
'## Why staged',
'- The dashboard needs real Amazon/Trustpilot/Reddit/claims evidence, but full-catalogue scraping would waste credit before output quality is proven.',
'- DataForSEO is the first paid lane because it should give more predictable structured review/API output.',
'- Apify is reserved for specific gaps where a targeted actor produces better PDP/social coverage.',
'',
'## Stages',
...stageRows.map(row => [
`### Stage ${row.Stage} - ${row.Provider}`,
`- Scope: ${row.Scope}`,
`- Targets: ${row.Targets}`,
`- Hard cap: ${row.HardCap}`,
`- Stop rule: ${row.StopRule}`,
`- Output folder: \`${row.OutputFolder}\``,
`- Verification: \`${row.Verification}\``,
''
].join('\n')),
'## Current source state',
`- Amazon source files: ${manifest.counts.amazonFiles}`,
`- Trustpilot source files: ${manifest.counts.trustpilotFiles}`,
`- Reddit source files: ${manifest.counts.redditFiles}`,
'',
'## Rule',
'Do not scale beyond Stage 1 until raw output has been inspected and the validation/intelligence queues improve measurably.',
''
].join('\n')
writeFileSync(join(outRoot, 'source_acquisition_plan.md'), md, 'utf8')
console.log(`Source acquisition plan ready: ${manifest.ready}`)
console.log(`Stage 1 targets: ${manifest.counts.stageOneTargets}`)
console.log(`Apify gap-fill targets: ${manifest.counts.apifyTargets}`)