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>
157 lines
6.1 KiB
TypeScript
157 lines
6.1 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join, normalize } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const amazonRoot = join(root, 'data', 'sources', 'amazon')
|
|
mkdirSync(outRoot, { recursive: true })
|
|
mkdirSync(amazonRoot, { recursive: true })
|
|
|
|
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 (value === undefined || value === null) value = ''
|
|
if (Array.isArray(value)) value = value.join(' | ')
|
|
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 asText(value: any) {
|
|
return String(value ?? '').trim()
|
|
}
|
|
|
|
function matches(expected: any, actual: any) {
|
|
return asText(expected).toLowerCase() === asText(actual).toLowerCase()
|
|
}
|
|
|
|
function sourcePathFor(row: any) {
|
|
return join(root, normalize(row.SourceDropPath || ''))
|
|
}
|
|
|
|
function reviewText(row: any) {
|
|
return asText(row.review_body || row.comment || row.body || row.text || row.review_title)
|
|
}
|
|
|
|
function validateReview(row: any, index: number) {
|
|
const issues: string[] = []
|
|
const text = reviewText(row)
|
|
const rating = Number(row.rating)
|
|
if (text.length < 5) issues.push(`review ${index}: missing text`)
|
|
if (!Number.isFinite(rating) || rating < 1 || rating > 5) issues.push(`review ${index}: rating must be 1-5`)
|
|
if (!asText(row.url || row.review_url || row.source_url)) issues.push(`review ${index}: missing review/source URL`)
|
|
return issues
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const queueManifest = readJson(join(outRoot, 'manual_amazon_review_capture.json'), {})
|
|
const queue = Array.isArray(queueManifest.queue) ? queueManifest.queue : []
|
|
|
|
const rows = queue.map((queueRow: any) => {
|
|
const filePath = sourcePathFor(queueRow)
|
|
const issues: string[] = []
|
|
let reviewsCaptured = 0
|
|
let status = 'missing'
|
|
if (!existsSync(filePath)) {
|
|
issues.push(`missing source drop ${queueRow.SourceDropPath}`)
|
|
} else {
|
|
try {
|
|
const data = readJson(filePath, {})
|
|
const reviews = Array.isArray(data.reviews) ? data.reviews : []
|
|
reviewsCaptured = reviews.length
|
|
if (data.source !== 'amazon') issues.push('source must be amazon')
|
|
if (!matches(queueRow.SKU, data.sku)) issues.push(`sku mismatch: expected ${queueRow.SKU}`)
|
|
if (!matches(queueRow.ASIN, data.asin)) issues.push(`asin mismatch: expected ${queueRow.ASIN}`)
|
|
if (!asText(data.brand)) issues.push('missing brand')
|
|
if (!matches(queueRow.ProductUrl, data.productUrl)) issues.push('productUrl mismatch')
|
|
if (!asText(data.capturedAt)) issues.push('missing capturedAt')
|
|
if (reviews.length === 0) issues.push('reviews must be non-empty')
|
|
for (const [index, review] of reviews.entries()) {
|
|
issues.push(...validateReview(review, index))
|
|
}
|
|
} catch (error: any) {
|
|
issues.push(`invalid JSON: ${error.message}`)
|
|
}
|
|
}
|
|
status = issues.length ? (existsSync(filePath) ? 'invalid' : 'missing') : 'valid'
|
|
return {
|
|
SKU: queueRow.SKU,
|
|
Product: queueRow.Product,
|
|
ASIN: queueRow.ASIN,
|
|
SourceDropPath: queueRow.SourceDropPath,
|
|
Status: status,
|
|
ReviewsCaptured: reviewsCaptured,
|
|
Issues: issues,
|
|
FilePresent: existsSync(filePath) ? 'yes' : 'no'
|
|
}
|
|
})
|
|
|
|
const filesPresent = rows.filter(row => row.FilePresent === 'yes').length
|
|
const filesValid = rows.filter(row => row.Status === 'valid').length
|
|
const reviewsCaptured = rows.reduce((sum, row) => sum + row.ReviewsCaptured, 0)
|
|
const blockers = rows.filter(row => row.Status !== 'valid')
|
|
const ready = queue.length > 0 && filesValid === queue.length
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready,
|
|
recommendation: ready
|
|
? 'Manual Amazon source drops are present and valid for the queued Stage 1 ASINs. Run bun run validate:sources next.'
|
|
: 'Manual Amazon source drops are not complete. Do not treat the fallback queue as captured source evidence until every listed JSON file exists and validates.',
|
|
queueFile: 'content_population_exports/manual_amazon_review_capture.json',
|
|
sourceDropDir: 'data/sources/amazon',
|
|
counts: {
|
|
targets: queue.length,
|
|
filesPresent,
|
|
filesValid,
|
|
reviewsCaptured,
|
|
blockers: blockers.length
|
|
},
|
|
rows,
|
|
blockers
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'manual_amazon_review_capture_validation.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'manual_amazon_review_capture_validation.csv'), csv(rows.map(row => ({
|
|
...row,
|
|
Issues: row.Issues
|
|
})), ['SKU', 'Product', 'ASIN', 'SourceDropPath', 'Status', 'FilePresent', 'ReviewsCaptured', 'Issues']), 'utf8')
|
|
writeFileSync(join(outRoot, 'manual_amazon_review_capture_validation.md'), [
|
|
'# Manual Amazon review capture validation',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
`Ready: ${ready ? 'YES' : 'NO'}`,
|
|
'',
|
|
manifest.recommendation,
|
|
'',
|
|
`Targets: ${manifest.counts.targets}`,
|
|
`Files present: ${manifest.counts.filesPresent}/${manifest.counts.targets}`,
|
|
`Files valid: ${manifest.counts.filesValid}/${manifest.counts.targets}`,
|
|
`Reviews captured: ${manifest.counts.reviewsCaptured}`,
|
|
`Blockers: ${manifest.counts.blockers}`,
|
|
'',
|
|
'## Target status',
|
|
...rows.map(row => [
|
|
`### ${row.SKU} — ${row.Status}`,
|
|
`- ASIN: ${row.ASIN}`,
|
|
`- File: \`${row.SourceDropPath}\``,
|
|
`- Reviews captured: ${row.ReviewsCaptured}`,
|
|
`- Issues: ${row.Issues.length ? row.Issues.join('; ') : 'none'}`,
|
|
''
|
|
].join('\n')),
|
|
'## Rule',
|
|
'This gate validates completed manual capture files only. It never creates fake Amazon reviews and it does not call Amazon, DataForSEO, Apify, or Shopify.',
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`Manual Amazon capture validation ready: ${ready}`)
|
|
console.log(`Files valid: ${manifest.counts.filesValid}/${manifest.counts.targets}`)
|
|
console.log(`Reviews captured: ${manifest.counts.reviewsCaptured}`)
|