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>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
#!/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 dataforseoRoot = join(root, 'data', 'sources', 'dataforseo')
|
||||
const asinMapPath = join(dataforseoRoot, 'asin-map.csv')
|
||||
const candidatePath = join(dataforseoRoot, 'asin-candidates.csv')
|
||||
const confirmationReportPath = join(outRoot, 'dataforseo_asin_confirmation_report.json')
|
||||
mkdirSync(outRoot, { recursive: true })
|
||||
mkdirSync(dataforseoRoot, { recursive: true })
|
||||
|
||||
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 normalizeAmazonDpUrl(asin: string, productUrl: string) {
|
||||
const url = String(productUrl || '').trim()
|
||||
if (/^https:\/\/www\.amazon\.co\.uk\/dp\/[A-Z0-9]{10}/.test(url)) return `https://www.amazon.co.uk/dp/${asin}`
|
||||
if (/^https:\/\/amazon\.co\.uk\/dp\/[A-Z0-9]{10}/.test(url)) return `https://www.amazon.co.uk/dp/${asin}`
|
||||
return url
|
||||
}
|
||||
|
||||
const generatedAt = new Date().toISOString()
|
||||
const asinRows = existsSync(asinMapPath) ? parseCsv(readFileSync(asinMapPath, 'utf8')) : []
|
||||
const candidateRows = existsSync(candidatePath) ? parseCsv(readFileSync(candidatePath, 'utf8')) : []
|
||||
const confirmationReport = existsSync(confirmationReportPath) ? JSON.parse(readFileSync(confirmationReportPath, 'utf8').replace(/^\uFEFF/, '')) : { confirmations: [] }
|
||||
const candidateBySku = new Map(candidateRows.map((row: any) => [row.SKU, row]))
|
||||
const confirmationBySku = new Map((confirmationReport.confirmations || []).map((row: any) => [row.SKU, row]))
|
||||
const asinMapHeaders = ['SKU', 'Product', 'Priority', 'Provider', 'AmazonDomain', 'ASIN', 'ProductUrl', 'Depth', 'LocationName', 'LanguageName', 'Status']
|
||||
|
||||
const promotionRows = asinRows.map((row: any) => {
|
||||
const candidate: any = candidateBySku.get(row.SKU) || {}
|
||||
const candidateAsin = String(candidate.CandidateASIN || '').trim().toUpperCase()
|
||||
const candidateUrl = normalizeAmazonDpUrl(candidateAsin, candidate.CandidateProductUrl || '')
|
||||
const asinLooksValid = /^[A-Z0-9]{10}$/.test(candidateAsin)
|
||||
const amazonUrlValid = candidateUrl === `https://www.amazon.co.uk/dp/${candidateAsin}`
|
||||
const alreadyFilled = Boolean(row.ASIN)
|
||||
const confirmed = candidate.MatchStatus === 'amazon_confirmed_exact_match'
|
||||
const highConfidence = candidate.Confidence === 'high'
|
||||
const confirmation: any = confirmationBySku.get(row.SKU) || {}
|
||||
const confirmationReady = confirmation.ReadyForPromotion === 'yes'
|
||||
const promotable = !alreadyFilled && asinLooksValid && amazonUrlValid && confirmed && highConfidence && confirmationReady
|
||||
const blockers = []
|
||||
if (!candidate.CandidateASIN) blockers.push('no candidate')
|
||||
if (alreadyFilled) blockers.push('asin-map already has ASIN')
|
||||
if (candidate.CandidateASIN && !asinLooksValid) blockers.push('candidate ASIN shape invalid')
|
||||
if (candidate.CandidateASIN && !amazonUrlValid) blockers.push('candidate ProductUrl is not canonical amazon.co.uk/dp/ASIN')
|
||||
if (candidate.CandidateASIN && !confirmed) blockers.push('MatchStatus is not amazon_confirmed_exact_match')
|
||||
if (candidate.CandidateASIN && !highConfidence) blockers.push(`Confidence is ${candidate.Confidence || 'blank'}`)
|
||||
if (candidate.CandidateASIN && !confirmationReady) blockers.push(`confirmation gate not ready${confirmation.Blockers ? `: ${confirmation.Blockers}` : ''}`)
|
||||
return {
|
||||
SKU: row.SKU,
|
||||
Product: row.Product,
|
||||
CurrentASIN: row.ASIN || '',
|
||||
CandidateASIN: candidateAsin,
|
||||
CandidateProductUrl: candidateUrl,
|
||||
MatchStatus: candidate.MatchStatus || '',
|
||||
Confidence: candidate.Confidence || '',
|
||||
ConfirmationReady: confirmationReady ? 'yes' : 'no',
|
||||
Promote: promotable ? 'yes' : 'no',
|
||||
Blockers: blockers.join(' | ')
|
||||
}
|
||||
})
|
||||
|
||||
const promoted = promotionRows.filter(row => row.Promote === 'yes')
|
||||
const promotedBySku = new Map(promoted.map(row => [row.SKU, row]))
|
||||
let updatedAsinRows = asinRows
|
||||
if (promoted.length) {
|
||||
updatedAsinRows = asinRows.map((row: any) => {
|
||||
const promotion: any = promotedBySku.get(row.SKU)
|
||||
if (!promotion) return row
|
||||
return {
|
||||
...row,
|
||||
ASIN: promotion.CandidateASIN,
|
||||
ProductUrl: promotion.CandidateProductUrl,
|
||||
Status: 'ready_for_task_payload'
|
||||
}
|
||||
})
|
||||
writeFileSync(asinMapPath, csv(updatedAsinRows, asinMapHeaders), 'utf8')
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
generatedAt,
|
||||
ready: promoted.length > 0,
|
||||
applied: promoted.length > 0,
|
||||
counts: {
|
||||
asinMapRows: asinRows.length,
|
||||
candidateRows: candidateRows.length,
|
||||
promotable: promoted.length,
|
||||
blocked: promotionRows.filter(row => row.Promote !== 'yes').length,
|
||||
asinMapReadyAfterPromotion: updatedAsinRows.filter((row: any) => row.ASIN).length,
|
||||
asinMapMissingAfterPromotion: updatedAsinRows.filter((row: any) => !row.ASIN).length
|
||||
},
|
||||
files: {
|
||||
asinMap: 'data/sources/dataforseo/asin-map.csv',
|
||||
candidates: 'data/sources/dataforseo/asin-candidates.csv',
|
||||
confirmations: 'data/sources/dataforseo/asin-confirmations.csv',
|
||||
confirmationReport: 'content_population_exports/dataforseo_asin_confirmation_report.json',
|
||||
promotionReportMd: 'content_population_exports/dataforseo_asin_promotion_report.md',
|
||||
promotionReportCsv: 'content_population_exports/dataforseo_asin_promotion_report.csv',
|
||||
promotionReportJson: 'content_population_exports/dataforseo_asin_promotion_report.json'
|
||||
},
|
||||
guardrails: [
|
||||
'Only candidates with MatchStatus=amazon_confirmed_exact_match and Confidence=high are promoted.',
|
||||
'The separate ASIN confirmation report must mark the row ReadyForPromotion=yes.',
|
||||
'Candidate ProductUrl must be canonical https://www.amazon.co.uk/dp/ASIN.',
|
||||
'Existing ASIN map rows are never overwritten by this script.',
|
||||
'Promotion only prepares a payload; source:spend-gate still blocks paid provider use until budget and approval gates pass.'
|
||||
],
|
||||
promotions: promotionRows
|
||||
}
|
||||
|
||||
writeFileSync(join(outRoot, 'dataforseo_asin_promotion_report.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
||||
writeFileSync(join(outRoot, 'dataforseo_asin_promotion_report.csv'), csv(promotionRows, ['SKU', 'Product', 'CurrentASIN', 'CandidateASIN', 'CandidateProductUrl', 'MatchStatus', 'Confidence', 'ConfirmationReady', 'Promote', 'Blockers']), 'utf8')
|
||||
writeFileSync(join(outRoot, 'dataforseo_asin_promotion_report.md'), [
|
||||
'# DataForSEO ASIN promotion report',
|
||||
'',
|
||||
`Generated: ${generatedAt}`,
|
||||
'',
|
||||
'This no-spend step safely copies only Amazon-confirmed, high-confidence candidate ASINs into `data/sources/dataforseo/asin-map.csv`.',
|
||||
'',
|
||||
`- Candidate rows: ${manifest.counts.candidateRows}`,
|
||||
`- Promoted rows: ${manifest.counts.promotable}`,
|
||||
`- ASIN map ready after promotion: ${manifest.counts.asinMapReadyAfterPromotion}`,
|
||||
`- ASIN map missing after promotion: ${manifest.counts.asinMapMissingAfterPromotion}`,
|
||||
'',
|
||||
'## Guardrails',
|
||||
...manifest.guardrails.map(item => `- ${item}`),
|
||||
'',
|
||||
'## Promotion rows',
|
||||
...promotionRows.map(row => `- [${row.Promote === 'yes' ? 'promoted' : 'blocked'}] ${row.SKU} — ${row.CandidateASIN || 'no candidate'} — ${row.Blockers || 'all checks passed'}`),
|
||||
''
|
||||
].join('\n'), 'utf8')
|
||||
|
||||
console.log(`DataForSEO ASIN promotion ready: ${manifest.ready}`)
|
||||
console.log(`Promoted rows: ${manifest.counts.promotable}`)
|
||||
console.log(`ASINs ready after promotion: ${manifest.counts.asinMapReadyAfterPromotion}`)
|
||||
console.log(`ASINs missing after promotion: ${manifest.counts.asinMapMissingAfterPromotion}`)
|
||||
Reference in New Issue
Block a user