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>
141 lines
6.6 KiB
TypeScript
141 lines
6.6 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 asinMapPath = join(dataforseoRoot, 'asin-map.csv')
|
|
const asinCandidatesPath = join(dataforseoRoot, 'asin-candidates.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 url(base: string, params: Record<string, string>) {
|
|
const search = new URLSearchParams(params)
|
|
return `${base}?${search.toString()}`
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const asinRows = existsSync(asinMapPath) ? parseCsv(readFileSync(asinMapPath, 'utf8')) : []
|
|
const candidateRows = existsSync(asinCandidatesPath) ? parseCsv(readFileSync(asinCandidatesPath, 'utf8')) : []
|
|
const candidateBySku = new Map(candidateRows.map((row: any) => [row.SKU, row]))
|
|
const queue = asinRows.map((row: any) => {
|
|
const product = row.Product || row.SKU
|
|
const query = `Just Vitamins ${product}`.replace(/\s+/g, ' ').trim()
|
|
const asin = row.ASIN || ''
|
|
const productUrl = row.ProductUrl || ''
|
|
const candidate: any = candidateBySku.get(row.SKU) || {}
|
|
const status = asin ? 'asin_present' : 'needs_manual_lookup'
|
|
return {
|
|
SKU: row.SKU,
|
|
Product: product,
|
|
Priority: row.Priority,
|
|
Status: status,
|
|
CurrentASIN: asin,
|
|
CurrentProductUrl: productUrl,
|
|
CandidateASIN: candidate.CandidateASIN || '',
|
|
CandidateConfidence: candidate.Confidence || '',
|
|
CandidateMatchStatus: candidate.MatchStatus || '',
|
|
CandidateEvidenceUrl: candidate.EvidenceUrl || '',
|
|
AmazonSearchUrl: url('https://www.amazon.co.uk/s', { k: query }),
|
|
AmazonBrandSearchUrl: url('https://www.amazon.co.uk/s', { k: `Just Vitamins ${row.SKU}` }),
|
|
WebSearchQuery: `${query} site:amazon.co.uk/dp OR site:amazon.co.uk/Just-Vitamins`,
|
|
SuggestedAction: asin
|
|
? 'Verify ASIN/ProductUrl still match the target product before paid task creation.'
|
|
: candidate.CandidateASIN
|
|
? 'Open CandidateEvidenceUrl and CandidateProductUrl, confirm exact Amazon product match, then paste ASIN/ProductUrl into asin-map.csv only if confidence is high.'
|
|
: 'Open AmazonSearchUrl, confirm the exact Just Vitamins product, then paste ASIN and ProductUrl into data/sources/dataforseo/asin-map.csv.'
|
|
}
|
|
})
|
|
|
|
const ready = queue.length > 0 && queue.every(row => row.CurrentASIN)
|
|
const manifest = {
|
|
generatedAt,
|
|
ready,
|
|
counts: {
|
|
targets: queue.length,
|
|
asinPresent: queue.filter(row => row.CurrentASIN).length,
|
|
missingAsins: queue.filter(row => !row.CurrentASIN).length
|
|
},
|
|
guardrails: [
|
|
'Manual lookup only; this script does not call Amazon, DataForSEO, or Apify.',
|
|
'Only accept an ASIN when the product title/brand clearly matches Just Vitamins and the target SKU/product.',
|
|
'Do not create a DataForSEO paid task until every Stage 1 ASIN has been reviewed.',
|
|
'Candidate ASINs from third-party pages are lookup aids only; they do not count as ready until confirmed on Amazon.'
|
|
],
|
|
files: {
|
|
asinMap: 'data/sources/dataforseo/asin-map.csv',
|
|
asinCandidates: 'data/sources/dataforseo/asin-candidates.csv',
|
|
asinCandidateReport: 'content_population_exports/dataforseo_asin_candidate_report.md',
|
|
lookupQueueCsv: 'content_population_exports/dataforseo_asin_lookup_queue.csv',
|
|
lookupQueueJson: 'content_population_exports/dataforseo_asin_lookup_queue.json'
|
|
},
|
|
queue
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'dataforseo_asin_lookup_queue.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_asin_lookup_queue.csv'), csv(queue, ['SKU', 'Product', 'Priority', 'Status', 'CurrentASIN', 'CurrentProductUrl', 'CandidateASIN', 'CandidateConfidence', 'CandidateMatchStatus', 'CandidateEvidenceUrl', 'AmazonSearchUrl', 'AmazonBrandSearchUrl', 'WebSearchQuery', 'SuggestedAction']), 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_asin_lookup_queue.md'), [
|
|
'# DataForSEO ASIN lookup queue',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
'This is a no-spend prep queue for the Stage 1 DataForSEO pilot. It does not call Amazon, DataForSEO, or Apify.',
|
|
'',
|
|
`- Targets: ${manifest.counts.targets}`,
|
|
`- ASINs present: ${manifest.counts.asinPresent}`,
|
|
`- Missing ASINs: ${manifest.counts.missingAsins}`,
|
|
'',
|
|
'## Workflow',
|
|
'1. Open `dataforseo_asin_lookup_queue.csv`.',
|
|
'2. For each missing row, open the Amazon search URL. If a CandidateASIN is present, use it only as a shortcut to find/verify the Amazon product page.',
|
|
'3. Paste confirmed ASIN and product URL into `data/sources/dataforseo/asin-map.csv` only after exact Amazon product match is confirmed.',
|
|
'4. Rerun `bun run dataforseo:pilot`; the task payload should then show ready rows.',
|
|
'5. Review the payload before making any paid DataForSEO call.',
|
|
'',
|
|
'## Guardrails',
|
|
...manifest.guardrails.map(item => `- ${item}`),
|
|
'',
|
|
'## Targets',
|
|
...queue.map(row => `- ${row.CurrentASIN ? '[ready]' : '[missing]'} ${row.SKU} - ${row.Product} (${row.Priority})`),
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`DataForSEO ASIN lookup ready: ${manifest.ready}`)
|
|
console.log(`Targets: ${manifest.counts.targets}`)
|
|
console.log(`ASINs present: ${manifest.counts.asinPresent}`)
|
|
console.log(`Missing ASINs: ${manifest.counts.missingAsins}`)
|