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>
176 lines
8.7 KiB
TypeScript
176 lines
8.7 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const defaultEnvPath = 'C:\\Users\\uldvs\\OneDrive\\Desktop\\work\\pi-fresh-foundation\\.env'
|
|
mkdirSync(outRoot, { recursive: true })
|
|
|
|
function loadEnvFile(path: string) {
|
|
if (!existsSync(path)) return false
|
|
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
|
const match = line.match(/^\s*(DATAFORSEO_[A-Za-z0-9_]+)\s*=\s*(.*)\s*$/)
|
|
if (match && !process.env[match[1]]) process.env[match[1]] = match[2].trim().replace(/^['"]|['"]$/g, '')
|
|
}
|
|
return 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'
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const credentialEnvFile = process.env.DATAFORSEO_ENV_FILE || defaultEnvPath
|
|
const credentialEnvLoaded = loadEnvFile(credentialEnvFile)
|
|
const payloadPath = join(outRoot, 'dataforseo_amazon_reviews_task_payload.json')
|
|
const pilot = readJson(join(outRoot, 'dataforseo_pilot_manifest.json'), {})
|
|
const spendGate = readJson(join(outRoot, 'source_acquisition_spend_gate.json'), {})
|
|
const supportResponseGate = readJson(join(outRoot, 'dataforseo_support_response_gate.json'), {})
|
|
const reviewsDocsStatus = readJson(join(outRoot, 'dataforseo_reviews_docs_status.json'), {})
|
|
const budgetGate = readJson(join(outRoot, 'source_acquisition_budget_gate.json'), {})
|
|
const budget = budgetGate.budget || {}
|
|
const taskPayload = readJson(payloadPath, [])
|
|
const allowFlag = process.env.JV_SOURCE_ALLOW_PAID || ''
|
|
const loginPresent = Boolean(process.env.DATAFORSEO_LOGIN)
|
|
const passwordPresent = Boolean(process.env.DATAFORSEO_PASSWORD)
|
|
const explicitAllow = allowFlag === 'dataforseo-stage1'
|
|
const maxTasks = Number(budget.dataforseoStage1?.maxTasks || 0)
|
|
const maxDepth = Number(budget.dataforseoStage1?.maxReviewsPerAsin || 0)
|
|
const payloadRows = Array.isArray(taskPayload) ? taskPayload : []
|
|
const payloadWithinTaskCap = payloadRows.length > 0 && maxTasks > 0 && payloadRows.length <= maxTasks
|
|
const payloadWithinDepthCap = payloadRows.length > 0 && maxDepth > 0 && payloadRows.every((row: any) => Number(row.depth || 0) <= maxDepth)
|
|
const uniqueTags = new Set(payloadRows.map((row: any) => row.tag).filter(Boolean))
|
|
|
|
const checks = [
|
|
{
|
|
Check: 'Explicit paid-run flag set',
|
|
Status: explicitAllow ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: explicitAllow ? 'JV_SOURCE_ALLOW_PAID=dataforseo-stage1' : 'set JV_SOURCE_ALLOW_PAID=dataforseo-stage1 only when intentionally running paid Stage 1'
|
|
},
|
|
{
|
|
Check: 'DataForSEO credentials present in environment',
|
|
Status: loginPresent && passwordPresent ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `DATAFORSEO_LOGIN=${loginPresent ? 'present' : 'missing'}, DATAFORSEO_PASSWORD=${passwordPresent ? 'present' : 'missing'}`
|
|
},
|
|
{
|
|
Check: 'DataForSEO Reviews endpoint cleared by support',
|
|
Status: supportResponseGate.ready === true ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: supportResponseGate.ready === true ? 'support response gate ready' : `${supportResponseGate.counts?.passing || 0}/${supportResponseGate.counts?.checks || 0} support-response checks passing; do not retry Reviews endpoint yet`
|
|
},
|
|
{
|
|
Check: 'Official DataForSEO Reviews docs no longer mark endpoint unavailable',
|
|
Status: reviewsDocsStatus.ready === true ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: reviewsDocsStatus.ready === true
|
|
? 'official docs status gate ready'
|
|
: `endpointTemporarilyUnavailable=${reviewsDocsStatus.endpointTemporarilyUnavailable === true}; ${reviewsDocsStatus.counts?.passing || 0}/${reviewsDocsStatus.counts?.checks || 0} docs-status checks passing`
|
|
},
|
|
{
|
|
Check: 'Spend gate allows DataForSEO Stage 1',
|
|
Status: spendGate.spendAllowed?.dataforseoStage1 === true ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${spendGate.counts?.passing || 0}/${spendGate.counts?.checks || 0} spend checks passing`
|
|
},
|
|
{
|
|
Check: 'Budget gate allows DataForSEO Stage 1',
|
|
Status: budgetGate.budgetAllowed?.dataforseoStage1 === true ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${budgetGate.counts?.passing || 0}/${budgetGate.counts?.checks || 0} budget checks passing`
|
|
},
|
|
{
|
|
Check: 'Task payload has rows',
|
|
Status: payloadRows.length > 0 ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${payloadRows.length} task rows`
|
|
},
|
|
{
|
|
Check: 'Payload stays within task cap',
|
|
Status: payloadWithinTaskCap ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${payloadRows.length} task rows / maxTasks=${maxTasks || 'unset'}`
|
|
},
|
|
{
|
|
Check: 'Payload stays within depth cap',
|
|
Status: payloadWithinDepthCap ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${payloadRows.length ? Math.max(...payloadRows.map((row: any) => Number(row.depth || 0))) : 0} max payload depth / maxReviewsPerAsin=${maxDepth || 'unset'}`
|
|
},
|
|
{
|
|
Check: 'Payload tags are unique and traceable',
|
|
Status: payloadRows.length > 0 && uniqueTags.size === payloadRows.length && payloadRows.every((row: any) => String(row.tag || '').startsWith('jv:')) ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence: `${uniqueTags.size}/${payloadRows.length} unique tags`
|
|
}
|
|
]
|
|
|
|
const ready = checks.every(row => row.Status === 'pass')
|
|
const manifest = {
|
|
generatedAt,
|
|
ready,
|
|
endpoint: pilot.endpoint || 'POST https://api.dataforseo.com/v3/merchant/amazon/reviews/task_post',
|
|
command: 'bun run dataforseo:task-preflight',
|
|
paidRunCommand: `Credentials load from shell variables or ${credentialEnvFile}; set only JV_SOURCE_ALLOW_PAID=dataforseo-stage1 for the paid-run window, then run the guarded reviews-live command after this preflight is ready.`,
|
|
counts: {
|
|
checks: checks.length,
|
|
passing: checks.filter(row => row.Status === 'pass').length,
|
|
blocked: checks.filter(row => row.Status !== 'pass').length,
|
|
taskRows: payloadRows.length
|
|
},
|
|
files: {
|
|
payload: 'content_population_exports/dataforseo_amazon_reviews_task_payload.json',
|
|
curlTemplate: 'content_population_exports/dataforseo_amazon_reviews_curl_template.sh',
|
|
preflightMd: 'content_population_exports/dataforseo_task_preflight.md',
|
|
preflightCsv: 'content_population_exports/dataforseo_task_preflight.csv',
|
|
preflightJson: 'content_population_exports/dataforseo_task_preflight.json',
|
|
credentialEnvFile
|
|
},
|
|
credentialEnvLoaded,
|
|
checks,
|
|
blockers: checks.filter(row => row.Status !== 'pass').map(row => `${row.Check}: ${row.Evidence}`),
|
|
payloadPreview: payloadRows.slice(0, 12).map((row: any) => ({ asin: row.asin, depth: row.depth, tag: row.tag }))
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'dataforseo_task_preflight.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_task_preflight.csv'), csv(checks, ['Check', 'Status', 'Blocking', 'Evidence']), 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_task_preflight.md'), [
|
|
'# DataForSEO paid task preflight',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
`Ready to run paid Stage 1 task: ${ready ? 'YES' : 'NO'}`,
|
|
'',
|
|
'This preflight is a final local guard before any DataForSEO paid task creation. It does not call DataForSEO.',
|
|
'',
|
|
`Endpoint: ${manifest.endpoint}`,
|
|
'',
|
|
'## Checks',
|
|
...checks.map(row => `- ${row.Status === 'pass' ? '[pass]' : '[blocked]'} ${row.Check} — ${row.Evidence}`),
|
|
'',
|
|
'## Paid-run rule',
|
|
'- All checks must pass.',
|
|
`- Credentials are loaded from shell environment variables or \`${credentialEnvFile}\`; secrets are not written to the report.`,
|
|
'- `JV_SOURCE_ALLOW_PAID` must equal `dataforseo-stage1` for the paid run window.',
|
|
'- The official DataForSEO docs status gate must no longer show the Amazon Reviews endpoint as temporarily unavailable.',
|
|
'- The DataForSEO support response gate must confirm endpoint availability or a replacement endpoint before retrying after the archived 50304.',
|
|
'- Run only the generated Stage 1 payload, then stop and inspect raw task_get output/cost before any scale-up.',
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`DataForSEO task preflight ready: ${ready}`)
|
|
console.log(`Checks passing: ${manifest.counts.passing}/${manifest.counts.checks}`)
|
|
console.log(`Task rows: ${manifest.counts.taskRows}`)
|