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>
166 lines
8.7 KiB
TypeScript
166 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 dataforseoRoot = join(root, 'data', 'sources', 'dataforseo')
|
|
const candidatePath = join(dataforseoRoot, 'asin-candidates.csv')
|
|
const asinMapPath = join(dataforseoRoot, 'asin-map.csv')
|
|
const confirmationPath = join(dataforseoRoot, 'asin-confirmations.csv')
|
|
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 amazonSearchUrl(product: string) {
|
|
return `https://www.amazon.co.uk/s?k=${encodeURIComponent(`Just Vitamins ${product}`.replace(/\s+/g, ' ').trim())}`
|
|
}
|
|
|
|
const headers = ['SKU', 'Product', 'LookupStatus', 'AmazonSearchUrl', 'CandidateASIN', 'CandidateProductUrl', 'CandidateEvidenceUrl', 'AmazonConfirmedUrl', 'AmazonConfirmedTitle', 'BrandMatchesJustVitamins', 'ProductMatchesTarget', 'PackSizeMatchesTarget', 'Status', 'Confidence', 'ConfirmedBy', 'ConfirmedAt', 'EvidenceNotes']
|
|
const generatedAt = new Date().toISOString()
|
|
const asinRows = existsSync(asinMapPath) ? parseCsv(readFileSync(asinMapPath, 'utf8')) : []
|
|
const candidates = existsSync(candidatePath) ? parseCsv(readFileSync(candidatePath, 'utf8')) : []
|
|
const existing = existsSync(confirmationPath) ? parseCsv(readFileSync(confirmationPath, 'utf8')) : []
|
|
const candidateBySku = new Map(candidates.map((row: any) => [row.SKU, row]))
|
|
const existingBySku = new Map(existing.map((row: any) => [row.SKU, row]))
|
|
|
|
const confirmationRows = asinRows.map((target: any) => {
|
|
const candidate: any = candidateBySku.get(target.SKU) || {}
|
|
const prior: any = existingBySku.get(target.SKU) || {}
|
|
const product = target.Product || candidate.Product || target.SKU
|
|
return {
|
|
SKU: target.SKU,
|
|
Product: product,
|
|
LookupStatus: candidate.CandidateASIN ? 'candidate_available' : 'needs_manual_search',
|
|
AmazonSearchUrl: prior.AmazonSearchUrl || amazonSearchUrl(product),
|
|
CandidateASIN: candidate.CandidateASIN || prior.CandidateASIN || '',
|
|
CandidateProductUrl: candidate.CandidateProductUrl || prior.CandidateProductUrl || '',
|
|
CandidateEvidenceUrl: candidate.EvidenceUrl || prior.CandidateEvidenceUrl || '',
|
|
AmazonConfirmedUrl: prior.AmazonConfirmedUrl || '',
|
|
AmazonConfirmedTitle: prior.AmazonConfirmedTitle || '',
|
|
BrandMatchesJustVitamins: prior.BrandMatchesJustVitamins || 'no',
|
|
ProductMatchesTarget: prior.ProductMatchesTarget || 'no',
|
|
PackSizeMatchesTarget: prior.PackSizeMatchesTarget || 'unknown',
|
|
Status: prior.Status || 'pending',
|
|
Confidence: prior.Confidence || candidate.Confidence || 'unknown',
|
|
ConfirmedBy: prior.ConfirmedBy || '',
|
|
ConfirmedAt: prior.ConfirmedAt || '',
|
|
EvidenceNotes: prior.EvidenceNotes || ''
|
|
}
|
|
})
|
|
writeFileSync(confirmationPath, csv(confirmationRows, headers), 'utf8')
|
|
|
|
const reportRows = confirmationRows.map((row: any) => {
|
|
const asin = String(row.CandidateASIN || '').trim().toUpperCase()
|
|
const canonicalUrl = asin ? `https://www.amazon.co.uk/dp/${asin}` : ''
|
|
const urlOk = Boolean(asin) && (row.AmazonConfirmedUrl === canonicalUrl || row.CandidateProductUrl === canonicalUrl)
|
|
const confirmed = row.Status === 'confirmed'
|
|
const high = row.Confidence === 'high'
|
|
const brand = row.BrandMatchesJustVitamins === 'yes'
|
|
const product = row.ProductMatchesTarget === 'yes'
|
|
const pack = row.PackSizeMatchesTarget === 'yes' || row.PackSizeMatchesTarget === 'not_applicable'
|
|
const hasReviewer = Boolean(row.ConfirmedBy && row.ConfirmedAt)
|
|
const hasTitle = Boolean(row.AmazonConfirmedTitle)
|
|
const pass = confirmed && high && brand && product && pack && hasReviewer && hasTitle && urlOk
|
|
const blockers = []
|
|
if (!row.CandidateASIN) blockers.push('no candidate ASIN; manual Amazon search required')
|
|
if (!confirmed) blockers.push(`Status is ${row.Status}`)
|
|
if (!high) blockers.push(`Confidence is ${row.Confidence}`)
|
|
if (!brand) blockers.push('brand not confirmed')
|
|
if (!product) blockers.push('target product not confirmed')
|
|
if (!pack) blockers.push(`pack size match is ${row.PackSizeMatchesTarget}`)
|
|
if (!hasTitle) blockers.push('AmazonConfirmedTitle missing')
|
|
if (!hasReviewer) blockers.push('ConfirmedBy/ConfirmedAt missing')
|
|
if (row.CandidateASIN && !urlOk) blockers.push('canonical Amazon URL not confirmed')
|
|
return { ...row, CanonicalAmazonUrl: canonicalUrl, ReadyForPromotion: pass ? 'yes' : 'no', Blockers: blockers.join(' | ') }
|
|
})
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: reportRows.some((row: any) => row.ReadyForPromotion === 'yes'),
|
|
counts: {
|
|
confirmations: reportRows.length,
|
|
candidateRows: reportRows.filter((row: any) => row.LookupStatus === 'candidate_available').length,
|
|
manualSearchRows: reportRows.filter((row: any) => row.LookupStatus === 'needs_manual_search').length,
|
|
readyForPromotion: reportRows.filter((row: any) => row.ReadyForPromotion === 'yes').length,
|
|
pending: reportRows.filter((row: any) => row.Status === 'pending').length,
|
|
rejected: reportRows.filter((row: any) => row.Status === 'rejected').length,
|
|
blocked: reportRows.filter((row: any) => row.ReadyForPromotion !== 'yes').length
|
|
},
|
|
files: {
|
|
confirmationCsv: 'data/sources/dataforseo/asin-confirmations.csv',
|
|
reportCsv: 'content_population_exports/dataforseo_asin_confirmation_report.csv',
|
|
reportJson: 'content_population_exports/dataforseo_asin_confirmation_report.json',
|
|
reportMd: 'content_population_exports/dataforseo_asin_confirmation_report.md'
|
|
},
|
|
guardrails: [
|
|
'Open the Amazon UK product page before setting Status=confirmed.',
|
|
'Rows without candidates still need manual Amazon search using AmazonSearchUrl.',
|
|
'Record the visible Amazon title and reviewer/timestamp before promotion.',
|
|
'BrandMatchesJustVitamins and ProductMatchesTarget must both be yes.',
|
|
'PackSizeMatchesTarget must be yes or not_applicable.',
|
|
'This gate still does not call Amazon, DataForSEO, or Apify.'
|
|
],
|
|
confirmations: reportRows
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'dataforseo_asin_confirmation_report.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_asin_confirmation_report.csv'), csv(reportRows, [...headers, 'CanonicalAmazonUrl', 'ReadyForPromotion', 'Blockers']), 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_asin_confirmation_report.md'), [
|
|
'# DataForSEO ASIN confirmation report',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
'This is the manual evidence gate between candidate/manual ASIN lookup and promotion into the DataForSEO ASIN map.',
|
|
'',
|
|
`- Confirmation rows: ${manifest.counts.confirmations}`,
|
|
`- Candidate rows: ${manifest.counts.candidateRows}`,
|
|
`- Manual-search rows: ${manifest.counts.manualSearchRows}`,
|
|
`- Ready for promotion: ${manifest.counts.readyForPromotion}`,
|
|
`- Pending: ${manifest.counts.pending}`,
|
|
`- Rejected: ${manifest.counts.rejected}`,
|
|
'',
|
|
'## Guardrails',
|
|
...manifest.guardrails.map(item => `- ${item}`),
|
|
'',
|
|
'## Rows',
|
|
...reportRows.map((row: any) => `- [${row.ReadyForPromotion === 'yes' ? 'ready' : 'blocked'}] ${row.SKU} — ${row.CandidateASIN || 'manual search'} — ${row.Blockers || 'all checks passed'}`),
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`DataForSEO ASIN confirmations ready: ${manifest.ready}`)
|
|
console.log(`Confirmation rows: ${manifest.counts.confirmations}`)
|
|
console.log(`Ready for promotion: ${manifest.counts.readyForPromotion}`)
|
|
console.log(`Blocked: ${manifest.counts.blocked}`)
|