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
6.7 KiB
TypeScript
176 lines
6.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')
|
|
mkdirSync(outRoot, { recursive: true })
|
|
|
|
function parseCsv(text: string) {
|
|
const rows: string[][] = []
|
|
let row: string[] = []
|
|
let cell = ''
|
|
let quoted = false
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i]
|
|
const next = text[i + 1]
|
|
if (quoted) {
|
|
if (ch === '"' && next === '"') {
|
|
cell += '"'
|
|
i++
|
|
} else if (ch === '"') {
|
|
quoted = false
|
|
} else {
|
|
cell += ch
|
|
}
|
|
} else if (ch === '"') {
|
|
quoted = true
|
|
} else if (ch === ',') {
|
|
row.push(cell)
|
|
cell = ''
|
|
} else if (ch === '\n') {
|
|
row.push(cell.replace(/\r$/, ''))
|
|
rows.push(row)
|
|
row = []
|
|
cell = ''
|
|
} else {
|
|
cell += ch
|
|
}
|
|
}
|
|
if (cell || row.length) {
|
|
row.push(cell.replace(/\r$/, ''))
|
|
rows.push(row)
|
|
}
|
|
const headers = rows.shift() || []
|
|
return rows
|
|
.filter(values => values.some(value => value !== ''))
|
|
.map(values => Object.fromEntries(headers.map((header, index) => [header, values[index] || ''])))
|
|
}
|
|
|
|
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 readCsv(path: string) {
|
|
if (!existsSync(path)) return []
|
|
return parseCsv(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const asinMapPath = join(root, 'data', 'sources', 'dataforseo', 'asin-map.csv')
|
|
const candidatePath = join(root, 'data', 'sources', 'dataforseo', 'products_raw', 'all-asin-candidates-from-dataforseo.csv')
|
|
const asinMap = readCsv(asinMapPath)
|
|
const candidates = readCsv(candidatePath)
|
|
const unresolvedTargets = asinMap.filter((row: any) => !String(row.ASIN || '').trim())
|
|
const rows = unresolvedTargets.map((target: any) => {
|
|
const skuCandidates = candidates
|
|
.filter((row: any) => row.SKU === target.SKU)
|
|
.sort((a: any, b: any) => Number(b.CandidateScore || 0) - Number(a.CandidateScore || 0) || Number(a.Rank || 999) - Number(b.Rank || 999))
|
|
const brandExact = skuCandidates.filter((row: any) => row.BrandExact === 'yes')
|
|
const exactMatch = skuCandidates.filter((row: any) => row.ExactMatch === 'yes')
|
|
const top = skuCandidates[0] || {}
|
|
const topRejectedBecause = !top.ASIN
|
|
? 'no DataForSEO product candidates were returned'
|
|
: top.BrandExact !== 'yes'
|
|
? 'top candidate is not a Just Vitamins brand/title match'
|
|
: top.ExactMatch !== 'yes'
|
|
? 'top candidate does not match the target product strongly enough'
|
|
: 'requires manual Amazon-page confirmation before promotion'
|
|
return {
|
|
SKU: target.SKU,
|
|
Product: target.Product,
|
|
CurrentStatus: target.Status || 'needs_asin',
|
|
CandidateRows: skuCandidates.length,
|
|
BrandExactRows: brandExact.length,
|
|
ExactMatchRows: exactMatch.length,
|
|
TopCandidateASIN: top.ASIN || '',
|
|
TopCandidateScore: top.CandidateScore || '',
|
|
TopCandidateBrandExact: top.BrandExact || '',
|
|
TopCandidateExactMatch: top.ExactMatch || '',
|
|
TopCandidateTitle: top.Title || '',
|
|
TopCandidateUrl: top.Url || '',
|
|
Decision: exactMatch.length > 0 ? 'manual_confirm_before_promotion' : 'keep_out_of_paid_payload',
|
|
Evidence: topRejectedBecause
|
|
}
|
|
})
|
|
|
|
const readyForManualConfirmation = rows.filter(row => row.Decision === 'manual_confirm_before_promotion').length
|
|
const keepOut = rows.filter(row => row.Decision === 'keep_out_of_paid_payload').length
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: true,
|
|
recommendation: keepOut > 0
|
|
? 'Do not guess unresolved ASINs. Keep these SKUs outside the paid DataForSEO Reviews payload unless a later Amazon-page confirmation proves an exact Just Vitamins match.'
|
|
: 'Exact-match candidates exist but still require manual Amazon-page confirmation before promotion.',
|
|
counts: {
|
|
unresolvedTargets: rows.length,
|
|
candidateRows: rows.reduce((sum, row) => sum + Number(row.CandidateRows || 0), 0),
|
|
brandExactRows: rows.reduce((sum, row) => sum + Number(row.BrandExactRows || 0), 0),
|
|
exactMatchRows: rows.reduce((sum, row) => sum + Number(row.ExactMatchRows || 0), 0),
|
|
readyForManualConfirmation,
|
|
keepOutOfPaidPayload: keepOut
|
|
},
|
|
files: {
|
|
asinMap: 'data/sources/dataforseo/asin-map.csv',
|
|
dataforseoCandidates: 'data/sources/dataforseo/products_raw/all-asin-candidates-from-dataforseo.csv',
|
|
reportMd: 'content_population_exports/dataforseo_unresolved_asin_evidence.md',
|
|
reportCsv: 'content_population_exports/dataforseo_unresolved_asin_evidence.csv',
|
|
reportJson: 'content_population_exports/dataforseo_unresolved_asin_evidence.json'
|
|
},
|
|
unresolvedTargets: rows
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'dataforseo_unresolved_asin_evidence.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_unresolved_asin_evidence.csv'), csv(rows, [
|
|
'SKU',
|
|
'Product',
|
|
'CurrentStatus',
|
|
'CandidateRows',
|
|
'BrandExactRows',
|
|
'ExactMatchRows',
|
|
'TopCandidateASIN',
|
|
'TopCandidateScore',
|
|
'TopCandidateBrandExact',
|
|
'TopCandidateExactMatch',
|
|
'TopCandidateTitle',
|
|
'TopCandidateUrl',
|
|
'Decision',
|
|
'Evidence'
|
|
]), 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_unresolved_asin_evidence.md'), [
|
|
'# DataForSEO unresolved ASIN evidence',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
manifest.recommendation,
|
|
'',
|
|
`Unresolved targets: ${manifest.counts.unresolvedTargets}`,
|
|
`DataForSEO candidate rows inspected: ${manifest.counts.candidateRows}`,
|
|
`Brand-exact rows: ${manifest.counts.brandExactRows}`,
|
|
`Exact-match rows: ${manifest.counts.exactMatchRows}`,
|
|
`Keep out of paid payload: ${manifest.counts.keepOutOfPaidPayload}`,
|
|
'',
|
|
'## Targets',
|
|
...rows.map(row => [
|
|
`### ${row.SKU} — ${row.Product}`,
|
|
`- Decision: ${row.Decision}`,
|
|
`- Evidence: ${row.Evidence}`,
|
|
`- Candidate rows: ${row.CandidateRows}; brand-exact: ${row.BrandExactRows}; exact-match: ${row.ExactMatchRows}`,
|
|
`- Top candidate: ${row.TopCandidateASIN || 'none'} — ${row.TopCandidateTitle || 'none'}`,
|
|
''
|
|
].join('\n')),
|
|
'## Guardrail',
|
|
'These rows are evidence for *not* guessing. They do not promote ASINs and they do not unlock provider spend.',
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`DataForSEO unresolved ASIN evidence ready: ${manifest.ready}`)
|
|
console.log(`Unresolved targets: ${manifest.counts.unresolvedTargets}`)
|
|
console.log(`Keep out of paid payload: ${manifest.counts.keepOutOfPaidPayload}`)
|