Files
justvitamin/scripts/validate-source-drops.ts
Omair Saleh 056c47581f feat: editorial review dashboard + elite-grade pilot batch (5 SKUs)
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>
2026-06-02 18:50:09 +08:00

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