#!/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}`)