Files
justvitamin/scripts/validate-dataforseo-asin-confirmations.ts
T
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

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