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>
210 lines
9.2 KiB
TypeScript
210 lines
9.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { basename, join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const dataforseoRoot = join(root, 'data', 'sources', 'dataforseo')
|
|
const rawRoot = join(dataforseoRoot, 'raw')
|
|
const amazonRoot = join(root, 'data', 'sources', 'amazon')
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const asinMapPath = join(dataforseoRoot, 'asin-map.csv')
|
|
|
|
mkdirSync(rawRoot, { recursive: true })
|
|
mkdirSync(amazonRoot, { recursive: true })
|
|
mkdirSync(outRoot, { 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 parseCsv(text: string) {
|
|
const rows: string[][] = []
|
|
let row: string[] = []
|
|
let cell = ''
|
|
let quoted = false
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
const char = text[index]
|
|
const next = text[index + 1]
|
|
if (quoted) {
|
|
if (char === '"' && next === '"') { cell += '"'; index += 1 }
|
|
else if (char === '"') quoted = false
|
|
else cell += char
|
|
} else if (char === '"') quoted = true
|
|
else if (char === ',') { row.push(cell); cell = '' }
|
|
else if (char === '\n') { row.push(cell); rows.push(row); row = []; cell = '' }
|
|
else if (char !== '\r') cell += char
|
|
}
|
|
if (cell || row.length) { row.push(cell); rows.push(row) }
|
|
const headers = rows.shift()?.map(header => header.trim()) || []
|
|
return rows.filter(values => values.some(value => value.trim())).map(values => Object.fromEntries(headers.map((header, index) => [header, (values[index] || '').trim()])))
|
|
}
|
|
|
|
function ratingValue(value: any) {
|
|
if (value === undefined || value === null || value === '') return null
|
|
if (typeof value === 'object') return ratingValue(value.value)
|
|
const number = Number(value)
|
|
return Number.isFinite(number) ? number : value
|
|
}
|
|
|
|
function tagSku(task: any) {
|
|
const tag = task?.data?.tag || task?.tag || ''
|
|
const match = String(tag).match(/jv:([^:]+):/i)
|
|
return match?.[1] || ''
|
|
}
|
|
|
|
function normaliseTaskResponse(raw: any, file: string, asinRows: any[]) {
|
|
const outputs: any[] = []
|
|
const issues: string[] = []
|
|
const tasks = Array.isArray(raw?.tasks) ? raw.tasks : []
|
|
if (!tasks.length) issues.push('missing tasks array')
|
|
|
|
for (const task of tasks) {
|
|
const results = Array.isArray(task?.result) ? task.result : []
|
|
if (!results.length) {
|
|
issues.push(`${file}: task ${task?.id || 'unknown'} has no result array`)
|
|
continue
|
|
}
|
|
for (const result of results) {
|
|
const asin = result?.asin || task?.data?.asin || ''
|
|
const mapped = asinRows.find(row => row.ASIN && row.ASIN.toUpperCase() === String(asin).toUpperCase()) || asinRows.find(row => row.SKU === tagSku(task)) || {}
|
|
const sku = mapped.SKU || tagSku(task) || asin || basename(file, '.json')
|
|
const items = Array.isArray(result?.items) ? result.items : []
|
|
if (!items.length) issues.push(`${file}: ${sku} has no review items`)
|
|
const reviews = items.map((item: any) => ({
|
|
review_title: item?.title || '',
|
|
review_body: item?.review_text || item?.review_body || '',
|
|
rating: ratingValue(item?.rating),
|
|
date: item?.publication_date || item?.date || '',
|
|
helpful_count: item?.helpful_votes ?? item?.helpful_count ?? 0,
|
|
url: item?.url || '',
|
|
verified: item?.verified ?? null,
|
|
reviewer_name: item?.user_profile?.name || '',
|
|
reviewer_url: item?.user_profile?.url || ''
|
|
}))
|
|
const missingText = reviews.filter((review: any) => !review.review_body || String(review.review_body).trim().length < 5).length
|
|
const missingRating = reviews.filter((review: any) => review.rating === null || review.rating === '').length
|
|
if (missingText) issues.push(`${file}: ${sku} has ${missingText} review item(s) without text`)
|
|
if (missingRating) issues.push(`${file}: ${sku} has ${missingRating} review item(s) without rating`)
|
|
outputs.push({
|
|
source: 'amazon',
|
|
provider: 'dataforseo',
|
|
brand: mapped.Product ? 'Just Vitamins' : '',
|
|
sku,
|
|
asin,
|
|
productUrl: mapped.ProductUrl || result?.check_url || result?.image?.url || '',
|
|
capturedAt: result?.datetime || new Date().toISOString(),
|
|
taskId: task?.id || '',
|
|
cost: task?.cost ?? raw?.cost ?? null,
|
|
productTitle: result?.title || mapped.Product || '',
|
|
amazonDomain: result?.se_domain || mapped.AmazonDomain || '',
|
|
averageRating: ratingValue(result?.rating),
|
|
reviewsCount: result?.reviews_count ?? null,
|
|
reviews
|
|
})
|
|
}
|
|
}
|
|
return { outputs, issues }
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const asinRows = existsSync(asinMapPath) ? parseCsv(readFileSync(asinMapPath, 'utf8')) : []
|
|
const rawFiles = readdirSync(rawRoot, { withFileTypes: true })
|
|
.filter(entry => entry.isFile() && entry.name.endsWith('.json') && !entry.name.startsWith('_'))
|
|
.map(entry => join(rawRoot, entry.name))
|
|
|
|
const example = {
|
|
note: 'Paste DataForSEO /v3/merchant/amazon/reviews/task_get/advanced/{id} JSON responses into this raw/ folder. Files starting with _ are ignored.',
|
|
expectedEndpoint: 'GET https://api.dataforseo.com/v3/merchant/amazon/reviews/task_get/advanced/{id}',
|
|
expectedFields: ['tasks[].id', 'tasks[].data.asin', 'tasks[].result[].items[].review_text', 'tasks[].result[].items[].rating.value', 'tasks[].result[].items[].publication_date', 'tasks[].result[].items[].url']
|
|
}
|
|
writeFileSync(join(rawRoot, '_example-amazon-reviews-task_get-advanced.json'), JSON.stringify(example, null, 2) + '\n', 'utf8')
|
|
|
|
const rows: any[] = []
|
|
const allIssues: string[] = []
|
|
let convertedFiles = 0
|
|
let convertedReviews = 0
|
|
for (const file of rawFiles) {
|
|
try {
|
|
const raw = readJson(file)
|
|
const { outputs, issues } = normaliseTaskResponse(raw, file, asinRows)
|
|
allIssues.push(...issues)
|
|
for (const output of outputs) {
|
|
const safeSku = String(output.sku || output.asin).replace(/[^a-z0-9_-]/gi, '-').toUpperCase()
|
|
const outPath = join(amazonRoot, `dataforseo-${safeSku}.json`)
|
|
writeFileSync(outPath, JSON.stringify(output, null, 2) + '\n', 'utf8')
|
|
convertedFiles += 1
|
|
convertedReviews += output.reviews.length
|
|
rows.push({ RawFile: file, SKU: output.sku, ASIN: output.asin, Reviews: output.reviews.length, OutputFile: outPath, Status: issues.length ? 'converted_with_warnings' : 'converted' })
|
|
}
|
|
if (!outputs.length) rows.push({ RawFile: file, SKU: '', ASIN: '', Reviews: 0, OutputFile: '', Status: 'no_outputs' })
|
|
} catch (error: any) {
|
|
allIssues.push(`${file}: ${error.message}`)
|
|
rows.push({ RawFile: file, SKU: '', ASIN: '', Reviews: 0, OutputFile: '', Status: 'invalid_json' })
|
|
}
|
|
}
|
|
|
|
if (!rawFiles.length) {
|
|
rows.push({ RawFile: '', SKU: '', ASIN: '', Reviews: 0, OutputFile: '', Status: 'waiting_for_raw_dataforseo_results' })
|
|
}
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: rawFiles.length > 0 && convertedFiles > 0 && allIssues.length === 0,
|
|
rawInputDir: 'data/sources/dataforseo/raw',
|
|
amazonOutputDir: 'data/sources/amazon',
|
|
docs: ['https://docs.dataforseo.com/v3/merchant/amazon/reviews/task_get/advanced/'],
|
|
counts: {
|
|
rawFiles: rawFiles.length,
|
|
convertedFiles,
|
|
convertedReviews,
|
|
issues: allIssues.length,
|
|
asinMapRows: asinRows.length
|
|
},
|
|
issues: allIssues,
|
|
outputs: rows
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'dataforseo_ingest_validation.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_ingest_validation.csv'), csv(rows, ['RawFile', 'SKU', 'ASIN', 'Reviews', 'OutputFile', 'Status']), 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_ingest_validation.md'), [
|
|
'# DataForSEO Amazon reviews ingest validation',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
`Ready: ${manifest.ready ? 'YES' : 'NO'}`,
|
|
'',
|
|
'## Counts',
|
|
`- Raw files: ${manifest.counts.rawFiles}`,
|
|
`- Converted Amazon source files: ${manifest.counts.convertedFiles}`,
|
|
`- Converted reviews: ${manifest.counts.convertedReviews}`,
|
|
`- Issues: ${manifest.counts.issues}`,
|
|
'',
|
|
'## How to use after the paid pilot',
|
|
'1. Collect DataForSEO advanced task results from `/v3/merchant/amazon/reviews/task_get/advanced/{id}`.',
|
|
'2. Save each raw response as JSON in `data/sources/dataforseo/raw/`.',
|
|
'3. Run `bun run dataforseo:ingest`.',
|
|
'4. Run `bun run validate:sources` to prove the normalized Amazon drops are usable by the existing source gate.',
|
|
'',
|
|
'## Issues',
|
|
...(allIssues.length ? allIssues.map(issue => `- ${issue}`) : ['- None']),
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`DataForSEO ingest ready: ${manifest.ready}`)
|
|
console.log(`Raw files: ${manifest.counts.rawFiles}`)
|
|
console.log(`Converted files: ${manifest.counts.convertedFiles}`)
|
|
console.log(`Converted reviews: ${manifest.counts.convertedReviews}`)
|
|
console.log(`Issues: ${manifest.counts.issues}`)
|
|
if (rawFiles.length > 0 && !manifest.ready) process.exitCode = 1
|