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>
96 lines
5.2 KiB
TypeScript
96 lines
5.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, readFileSync, 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 intelRoot = join(root, 'data', 'intelligence')
|
|
const catalogPath = join(root, 'data', 'pipeline', 'catalog.json')
|
|
|
|
const requiredViews = [
|
|
{ view: 'review-aspects', source: 'Feefo raw reviews', gate: 'first_party' },
|
|
{ view: 'strengths-weaknesses', source: 'Feefo raw reviews', gate: 'first_party' },
|
|
{ view: 'conversion-blockers', source: 'Feefo raw reviews', gate: 'first_party' },
|
|
{ view: 'improvements', source: 'Feefo raw reviews', gate: 'first_party' },
|
|
{ view: 'image-audit', source: 'PDP/image crawl + Feefo blockers', gate: 'image' },
|
|
{ view: 'photo-brief', source: 'Image audit + product content', gate: 'image' },
|
|
{ view: 'competitor-comparison', source: 'Competitor PDP + Amazon/Trustpilot', gate: 'external_sources' },
|
|
{ view: 'amazon-reviews', source: 'Amazon source drops', gate: 'amazon' },
|
|
{ view: 'reddit-pulse', source: 'Reddit/social source drops', gate: 'reddit' },
|
|
{ view: 'flavor-intelligence', source: 'Competitor PDP + Amazon/Reddit', gate: 'npd' },
|
|
{ view: 'audience-profile', source: 'Feefo + Amazon + Reddit', gate: 'audience' },
|
|
{ view: 'claims-evidence', source: 'Clinical claims library', gate: 'claims' }
|
|
]
|
|
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 priority(totalReviews: number) {
|
|
if (totalReviews >= 500) return 'P0'
|
|
if (totalReviews >= 250) return 'P1'
|
|
if (totalReviews >= 100) return 'P2'
|
|
return 'P3'
|
|
}
|
|
const catalogBySku = Object.fromEntries(readJson(catalogPath, []).map((product: any) => [product.sku, product]))
|
|
const productFiles = (await Array.fromAsync(new Bun.Glob('*.json').scan({ cwd: join(contentRoot, 'products') })))
|
|
.sort()
|
|
.map(file => readJson(join(contentRoot, 'products', file)))
|
|
const rows: any[] = []
|
|
for (const product of productFiles) {
|
|
const catalog = catalogBySku[product.sku] || {}
|
|
const missingViews = []
|
|
for (const spec of requiredViews) {
|
|
const exists = existsSync(join(intelRoot, product.sku, `${spec.view}.json`))
|
|
if (!exists) {
|
|
rows.push({
|
|
Priority: priority(Number(catalog.total_reviews || 0)),
|
|
SKU: product.sku,
|
|
Product: product.name || catalog.name || '',
|
|
TotalReviews: catalog.total_reviews || 0,
|
|
View: spec.view,
|
|
RequiredSource: spec.source,
|
|
Gate: spec.gate,
|
|
TargetPath: `data/intelligence/${product.sku}/${spec.view}.json`,
|
|
Status: 'missing'
|
|
})
|
|
missingViews.push(spec.view)
|
|
}
|
|
}
|
|
}
|
|
rows.sort((a, b) => a.Priority.localeCompare(b.Priority) || Number(b.TotalReviews) - Number(a.TotalReviews) || a.SKU.localeCompare(b.SKU) || a.View.localeCompare(b.View))
|
|
const bySku = new Map<string, any>()
|
|
for (const row of rows) {
|
|
const entry = bySku.get(row.SKU) || { SKU: row.SKU, Product: row.Product, TotalReviews: row.TotalReviews, Priority: row.Priority, MissingViews: [], MissingCount: 0 }
|
|
entry.MissingViews.push(row.View)
|
|
entry.MissingCount += 1
|
|
bySku.set(row.SKU, entry)
|
|
}
|
|
const skuRows = [...bySku.values()].map(row => ({ ...row, MissingViews: row.MissingViews.join(' | ') })).sort((a, b) => a.Priority.localeCompare(b.Priority) || Number(b.TotalReviews) - Number(a.TotalReviews) || a.SKU.localeCompare(b.SKU))
|
|
const gateSummary = requiredViews.map(spec => ({
|
|
View: spec.view,
|
|
Gate: spec.gate,
|
|
RequiredSource: spec.source,
|
|
Missing: rows.filter(row => row.View === spec.view).length,
|
|
Present: productFiles.length - rows.filter(row => row.View === spec.view).length,
|
|
Total: productFiles.length
|
|
}))
|
|
writeFileSync(join(outRoot, 'intelligence_generation_workqueue.csv'), csv(rows, ['Priority', 'SKU', 'Product', 'TotalReviews', 'View', 'RequiredSource', 'Gate', 'TargetPath', 'Status']), 'utf8')
|
|
writeFileSync(join(outRoot, 'intelligence_generation_by_sku.csv'), csv(skuRows, ['Priority', 'SKU', 'Product', 'TotalReviews', 'MissingCount', 'MissingViews']), 'utf8')
|
|
writeFileSync(join(outRoot, 'intelligence_generation_summary.csv'), csv(gateSummary, ['View', 'Gate', 'RequiredSource', 'Present', 'Missing', 'Total']), 'utf8')
|
|
const manifest = { generatedAt: new Date().toISOString(), ready: rows.length === 0, totalProducts: productFiles.length, missingTasks: rows.length, affectedSkus: skuRows.length, gateSummary, topSkus: skuRows.slice(0, 25) }
|
|
writeFileSync(join(outRoot, 'intelligence_generation_workqueue.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
console.log(`Intelligence workqueue ready: ${manifest.ready}`)
|
|
console.log(`Missing tasks: ${manifest.missingTasks}`)
|
|
console.log(`Affected SKUs: ${manifest.affectedSkus}`)
|
|
if (!manifest.ready) process.exitCode = 1
|