Files
justvitamin/scripts/validate-manual-amazon-review-capture.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

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}`)