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>
128 lines
6.0 KiB
TypeScript
128 lines
6.0 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const sourcesRoot = join(root, 'data', 'sources')
|
|
const templateRoot = join(sourcesRoot, '_templates')
|
|
const outRoot = join(root, 'content_population_exports')
|
|
mkdirSync(templateRoot, { recursive: true })
|
|
mkdirSync(outRoot, { recursive: true })
|
|
|
|
type SourceSpec = {
|
|
key: string
|
|
dir: string
|
|
required: string[]
|
|
optional: string[]
|
|
minFiles: number
|
|
template: any
|
|
note: string
|
|
}
|
|
|
|
const specs: SourceSpec[] = [
|
|
{
|
|
key: 'amazon',
|
|
dir: join(sourcesRoot, 'amazon'),
|
|
minFiles: 1,
|
|
required: ['source', 'reviews'],
|
|
optional: ['brand', 'sku', 'asin', 'productUrl', 'capturedAt'],
|
|
note: 'JV and competitor Amazon reviews. Each review should include review_body/comment, rating, date, and URL when available.',
|
|
template: {
|
|
source: 'amazon', brand: 'Just Vitamins or competitor brand', sku: 'JV-D1000 or blank for competitor-only export', asin: '', productUrl: '', capturedAt: new Date().toISOString(),
|
|
reviews: [{ review_title: '', review_body: '', rating: 5, date: '', helpful_count: 0, url: '' }]
|
|
}
|
|
},
|
|
{
|
|
key: 'trustpilot',
|
|
dir: join(sourcesRoot, 'trustpilot'),
|
|
minFiles: 1,
|
|
required: ['source', 'brand', 'reviews'],
|
|
optional: ['domain', 'capturedAt'],
|
|
note: 'Trustpilot/brand-level review exports for JV or competitor brands.',
|
|
template: {
|
|
source: 'trustpilot', brand: 'Brand name', domain: '', capturedAt: new Date().toISOString(),
|
|
reviews: [{ review_title: '', review_body: '', rating: 5, date: '', url: '' }]
|
|
}
|
|
},
|
|
{
|
|
key: 'reddit',
|
|
dir: join(sourcesRoot, 'reddit'),
|
|
minFiles: 1,
|
|
required: ['source', 'query', 'items'],
|
|
optional: ['capturedAt'],
|
|
note: 'Reddit/social listening exports for collagen and supplement category research.',
|
|
template: {
|
|
source: 'reddit', query: 'collagen flavour chalky sweetness mixability', capturedAt: new Date().toISOString(),
|
|
items: [{ subreddit: '', thread_url: '', post_title: '', comment_body: '', upvotes: 0, date: '' }]
|
|
}
|
|
}
|
|
]
|
|
|
|
function readJson(path: string) {
|
|
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
function listJson(dir: string) {
|
|
if (!existsSync(dir)) return []
|
|
return readdirSync(dir, { withFileTypes: true }).filter(entry => entry.isFile() && entry.name.endsWith('.json')).map(entry => join(dir, entry.name))
|
|
}
|
|
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 validateItems(kind: string, data: any) {
|
|
const issues: string[] = []
|
|
const rows = kind === 'reddit' ? data.items : data.reviews
|
|
if (!Array.isArray(rows) || rows.length === 0) issues.push(kind === 'reddit' ? 'items must be a non-empty array' : 'reviews must be a non-empty array')
|
|
for (const [index, row] of (Array.isArray(rows) ? rows : []).entries()) {
|
|
const text = kind === 'reddit' ? row.comment_body || row.post_title : row.review_body || row.comment || row.review_title
|
|
if (!text || String(text).trim().length < 5) issues.push(`row ${index}: missing review/comment text`)
|
|
if (kind !== 'reddit' && (row.rating === undefined || row.rating === null || row.rating === '')) issues.push(`row ${index}: missing rating`)
|
|
}
|
|
return issues
|
|
}
|
|
|
|
const rows: any[] = []
|
|
const details: any[] = []
|
|
for (const spec of specs) {
|
|
mkdirSync(spec.dir, { recursive: true })
|
|
writeFileSync(join(templateRoot, `${spec.key}-source-template.json`), JSON.stringify(spec.template, null, 2) + '\n', 'utf8')
|
|
const files = listJson(spec.dir).filter(file => !file.includes(`${spec.key}-status.json`))
|
|
if (files.length < spec.minFiles) {
|
|
rows.push({ Source: spec.key, File: '', Status: 'missing', Issue: `Need at least ${spec.minFiles} JSON source drop(s) in ${spec.dir}`, Note: spec.note })
|
|
}
|
|
for (const file of files) {
|
|
try {
|
|
const data = readJson(file)
|
|
const missing = spec.required.filter(field => data[field] === undefined || data[field] === null || data[field] === '' || (Array.isArray(data[field]) && data[field].length === 0))
|
|
const itemIssues = validateItems(spec.key, data)
|
|
const issues = [...missing.map(field => `missing required field ${field}`), ...itemIssues]
|
|
rows.push({ Source: spec.key, File: file, Status: issues.length ? 'invalid' : 'valid', Issue: issues.join('; '), Note: spec.note })
|
|
details.push({ source: spec.key, file, status: issues.length ? 'invalid' : 'valid', issues })
|
|
} catch (error: any) {
|
|
rows.push({ Source: spec.key, File: file, Status: 'invalid_json', Issue: error.message, Note: spec.note })
|
|
details.push({ source: spec.key, file, status: 'invalid_json', issues: [error.message] })
|
|
}
|
|
}
|
|
}
|
|
const ready = rows.every(row => row.Status === 'valid') && specs.every(spec => listJson(spec.dir).length >= spec.minFiles)
|
|
const manifest = {
|
|
generatedAt: new Date().toISOString(),
|
|
ready,
|
|
templateRoot,
|
|
requiredSources: specs.map(spec => ({ key: spec.key, dir: spec.dir, minFiles: spec.minFiles, required: spec.required, optional: spec.optional, note: spec.note })),
|
|
summary: specs.map(spec => ({ key: spec.key, fileCount: listJson(spec.dir).length, minFiles: spec.minFiles })),
|
|
issues: rows.filter(row => row.Status !== 'valid'),
|
|
details
|
|
}
|
|
writeFileSync(join(outRoot, 'source_drop_validation.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'source_drop_validation.csv'), csv(rows, ['Source', 'File', 'Status', 'Issue', 'Note']), 'utf8')
|
|
console.log(`Source drops ready: ${ready}`)
|
|
for (const item of manifest.summary) console.log(`${item.key}: ${item.fileCount}/${item.minFiles}`)
|
|
console.log(`Issues: ${manifest.issues.length}`)
|
|
if (!ready) process.exitCode = 1
|