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>
213 lines
10 KiB
TypeScript
213 lines
10 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const contentRoot = join(root, 'data', 'content')
|
|
const intelRoot = join(root, 'data', 'intelligence')
|
|
const sourcesRoot = join(root, 'data', 'sources')
|
|
const outRoot = join(root, 'content_population_exports')
|
|
|
|
function readJson(path: string, fallback: any = null) {
|
|
if (!existsSync(path)) return fallback
|
|
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
function csvEscape(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
|
|
}
|
|
function writeCsv(path: string, rows: any[], headers: string[]) {
|
|
const body = [headers.join(','), ...rows.map(row => headers.map(header => csvEscape(row[header])).join(','))].join('\n') + '\n'
|
|
writeFileSync(path, body, 'utf8')
|
|
}
|
|
function listJson(dir: string): 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] : []
|
|
})
|
|
}
|
|
function slug(value: string) {
|
|
return String(value || '').toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
}
|
|
function relative(path: string) { return path.replace(root + '\\', '').replaceAll('\\', '/') }
|
|
|
|
mkdirSync(outRoot, { recursive: true })
|
|
const generatedAt = new Date().toISOString()
|
|
const products = readdirSync(join(contentRoot, 'products'))
|
|
.filter(file => file.endsWith('.json'))
|
|
.map(file => readJson(join(contentRoot, 'products', file)))
|
|
.sort((a, b) => a.sku.localeCompare(b.sku))
|
|
const competitors = readJson(join(sourcesRoot, 'competitor-sources.json'), { competitors: [] }).competitors || []
|
|
const pilotSkus = products.filter(product => product.status !== 'pending_intelligence').map(product => product.sku)
|
|
|
|
const requestRows: any[] = []
|
|
for (const product of products) {
|
|
requestRows.push({
|
|
Priority: product.status === 'pending_intelligence' ? 'catalogue' : 'pilot',
|
|
Lane: 'amazon_jv_reviews',
|
|
Owner: product.sku,
|
|
Name: product.name,
|
|
TargetFolder: 'data/sources/amazon/',
|
|
RequiredFormat: 'JSON export with sku/asin/url/review_title/review_body/rating/date/helpful_count when available',
|
|
WhyNeeded: 'Validate whether Feefo themes also appear in Amazon shopper language before market-wide claims are used.',
|
|
CurrentStatus: listJson(join(sourcesRoot, 'amazon')).length ? 'source_drop_available' : 'missing_source_drop'
|
|
})
|
|
}
|
|
for (const competitor of competitors) {
|
|
requestRows.push({
|
|
Priority: 'competitor',
|
|
Lane: 'amazon_competitor_reviews',
|
|
Owner: competitor.brand,
|
|
Name: competitor.domain,
|
|
TargetFolder: 'data/sources/amazon/',
|
|
RequiredFormat: 'JSON export grouped by competitor brand/product with review_title/review_body/rating/date/url',
|
|
WhyNeeded: 'Compare JV objections, benefits, price/value language, taste/flavour issues, and claim framing against Umar competitor set.',
|
|
CurrentStatus: 'missing_source_drop'
|
|
})
|
|
requestRows.push({
|
|
Priority: 'competitor',
|
|
Lane: 'trustpilot_competitor_reviews',
|
|
Owner: competitor.brand,
|
|
Name: competitor.domain,
|
|
TargetFolder: 'data/sources/trustpilot/',
|
|
RequiredFormat: 'JSON export grouped by competitor brand with review_title/review_body/rating/date/url',
|
|
WhyNeeded: 'Validate brand-level trust objections and delivery/service issues that Amazon/PDPs do not cover.',
|
|
CurrentStatus: listJson(join(sourcesRoot, 'trustpilot')).length ? 'source_drop_available' : 'missing_source_drop'
|
|
})
|
|
}
|
|
requestRows.push({
|
|
Priority: 'npd',
|
|
Lane: 'reddit_social_listening',
|
|
Owner: 'Collagen NPD + broad supplement research',
|
|
Name: 'Reddit/social listening export',
|
|
TargetFolder: 'data/sources/reddit/',
|
|
RequiredFormat: 'JSON export with subreddit/thread_url/post_title/comment_body/upvotes/date/query',
|
|
WhyNeeded: 'Capture unprompted language around collagen flavour, chalkiness, sweetness, mixability, gut/skin/hair motivations, and purchase objections.',
|
|
CurrentStatus: listJson(join(sourcesRoot, 'reddit')).length ? 'source_drop_available' : 'missing_source_drop'
|
|
})
|
|
requestRows.push({
|
|
Priority: 'compliance',
|
|
Lane: 'clinical_claims_library',
|
|
Owner: 'Regulatory / Umar / clinical source owner',
|
|
Name: 'Claims evidence library',
|
|
TargetFolder: 'data/sources/claims/claims-library.json',
|
|
RequiredFormat: 'JSON claims array with skus, claim handle, heading, description, stats, disclaimer, citation/source_url, approval owner/date',
|
|
WhyNeeded: 'Required before any pdp.clinically_shown_to metaobject can be populated or approved.',
|
|
CurrentStatus: existsSync(join(sourcesRoot, 'claims', 'claims-library.json')) ? 'source_drop_available' : 'missing_source_drop'
|
|
})
|
|
|
|
const clinicalRows: any[] = []
|
|
for (const sku of pilotSkus) {
|
|
const claims = readJson(join(intelRoot, sku, 'claims-evidence.json'), { candidateNonClinicalThemes: [], claims: [] })
|
|
const product = products.find(p => p.sku === sku)
|
|
for (const theme of claims.candidateNonClinicalThemes || []) {
|
|
clinicalRows.push({
|
|
SKU: sku,
|
|
Product: product?.name || sku,
|
|
CandidateTheme: theme.theme,
|
|
CurrentSource: theme.source,
|
|
AllowedUse: theme.allowedUse,
|
|
RequiredEvidence: 'Clinical/library citation required before clinically_shown_to use',
|
|
Status: claims.claims?.length ? 'source_backed_claim_available' : 'blocked_until_claims_library'
|
|
})
|
|
}
|
|
}
|
|
|
|
const imageRows: any[] = []
|
|
for (const product of products) {
|
|
const photo = readJson(join(intelRoot, product.sku, 'photo-brief.json'), {})
|
|
const competitor = readJson(join(intelRoot, product.sku, 'competitor-comparison.json'), {})
|
|
for (const field of ['pdpResults', 'pdpResultsMobile', 'pdpHowWeCompare']) {
|
|
imageRows.push({
|
|
SKU: product.sku,
|
|
Product: product.name,
|
|
Field: field,
|
|
ExistingFilename: product.metafields?.[field] || '',
|
|
CurrentStatus: product.metafields?.[field] ? 'filename_assigned_validate_upload' : 'needs_filename_and_asset',
|
|
PhotoBrief: photo.brief || photo.summary || JSON.stringify(photo).slice(0, 400),
|
|
CompetitorReference: competitor.competitors?.map((row: any) => `${row.brand}: ${row.whitespace || row.status}`).join(' | ') || '',
|
|
RequiredAction: field === 'pdpHowWeCompare' ? 'Produce comparison graphic using approved competitor evidence.' : 'Produce PDP results image and upload to Shopify Files before Matrixify import.'
|
|
})
|
|
}
|
|
}
|
|
|
|
const sourceSummary = {
|
|
amazonFiles: listJson(join(sourcesRoot, 'amazon')).length,
|
|
trustpilotFiles: listJson(join(sourcesRoot, 'trustpilot')).length,
|
|
redditFiles: listJson(join(sourcesRoot, 'reddit')).length,
|
|
claimsLibrary: existsSync(join(sourcesRoot, 'claims', 'claims-library.json')),
|
|
competitorPdpFiles: listJson(join(sourcesRoot, 'competitor-pdps')).length,
|
|
pilotSkus,
|
|
productCount: products.length,
|
|
competitorCount: competitors.length
|
|
}
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: false,
|
|
sourceSummary,
|
|
outputs: {
|
|
sourceRequestItems: 'source_request_items.csv',
|
|
clinicalClaimsRequest: 'clinical_claims_request.csv',
|
|
imageProductionBrief: 'image_production_brief.csv',
|
|
sourceRequestPack: 'source_request_pack.md'
|
|
},
|
|
counts: {
|
|
sourceRequestItems: requestRows.length,
|
|
clinicalClaimRows: clinicalRows.length,
|
|
imageProductionRows: imageRows.length
|
|
},
|
|
requiredDropFolders: [
|
|
'data/sources/amazon/',
|
|
'data/sources/trustpilot/',
|
|
'data/sources/reddit/',
|
|
'data/sources/claims/claims-library.json',
|
|
'C:/Users/uldvs/OneDrive/Desktop/jv-assets/'
|
|
]
|
|
}
|
|
|
|
writeCsv(join(outRoot, 'source_request_items.csv'), requestRows, ['Priority', 'Lane', 'Owner', 'Name', 'TargetFolder', 'RequiredFormat', 'WhyNeeded', 'CurrentStatus'])
|
|
writeCsv(join(outRoot, 'clinical_claims_request.csv'), clinicalRows, ['SKU', 'Product', 'CandidateTheme', 'CurrentSource', 'AllowedUse', 'RequiredEvidence', 'Status'])
|
|
writeCsv(join(outRoot, 'image_production_brief.csv'), imageRows, ['SKU', 'Product', 'Field', 'ExistingFilename', 'CurrentStatus', 'PhotoBrief', 'CompetitorReference', 'RequiredAction'])
|
|
writeFileSync(join(outRoot, 'source_request_manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
|
|
const md = [
|
|
'# JV source request pack',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
'This pack turns the current dashboard blockers into concrete source/data requests. It does not invent missing evidence.',
|
|
'',
|
|
'## Current evidence status',
|
|
`- Products in content workspace: ${products.length}`,
|
|
`- Pilot SKUs with Feefo-derived intelligence: ${pilotSkus.join(', ') || 'none'}`,
|
|
`- Competitor PDP probes: ${sourceSummary.competitorPdpFiles}/${competitors.length}`,
|
|
`- Amazon source drops: ${sourceSummary.amazonFiles}`,
|
|
`- Trustpilot source drops: ${sourceSummary.trustpilotFiles}`,
|
|
`- Reddit/social source drops: ${sourceSummary.redditFiles}`,
|
|
`- Clinical claims library present: ${sourceSummary.claimsLibrary ? 'yes' : 'no'}`,
|
|
'',
|
|
'## Files generated',
|
|
'- `source_request_items.csv` - exact raw evidence/source drops needed by lane.',
|
|
'- `clinical_claims_request.csv` - candidate VoC themes that need clinical/regulatory evidence before use in `clinically_shown_to`.',
|
|
'- `image_production_brief.csv` - PDP results/mobile/compare image production queue for every SKU.',
|
|
'- `source_request_manifest.json` - machine-readable counts and target folders.',
|
|
'',
|
|
'## Drop locations',
|
|
...manifest.requiredDropFolders.map(path => `- \`${path}\``),
|
|
'',
|
|
'## Non-negotiable gate',
|
|
'Feefo/customer review language may support benefit copy, FAQs, audience framing, and conversion blockers. It must not be used as clinical evidence for `pdp.clinically_shown_to` without a source-backed claims-library entry and human compliance approval.',
|
|
''
|
|
].join('\n')
|
|
writeFileSync(join(outRoot, 'source_request_pack.md'), md, 'utf8')
|
|
|
|
console.log(`Wrote source request pack to ${outRoot}`)
|
|
console.log(`Source request items: ${requestRows.length}`)
|
|
console.log(`Clinical claim request rows: ${clinicalRows.length}`)
|
|
console.log(`Image production rows: ${imageRows.length}`)
|
|
|