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>
164 lines
8.2 KiB
TypeScript
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}`)
|