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>
237 lines
12 KiB
TypeScript
237 lines
12 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, 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 productsRawRoot = join(dataforseoRoot, 'products_raw')
|
|
const asinMapPath = join(dataforseoRoot, 'asin-map.csv')
|
|
const pilotTargetsPath = join(outRoot, 'dataforseo_pilot_targets.csv')
|
|
const payloadPath = join(outRoot, 'dataforseo_amazon_reviews_task_payload.json')
|
|
const defaultEnvPath = 'C:\\Users\\uldvs\\OneDrive\\Desktop\\work\\pi-fresh-foundation\\.env'
|
|
|
|
mkdirSync(outRoot, { recursive: true })
|
|
mkdirSync(dataforseoRoot, { recursive: true })
|
|
mkdirSync(productsRawRoot, { recursive: true })
|
|
|
|
function loadEnvFile(path: string) {
|
|
if (!existsSync(path)) return false
|
|
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
|
const match = line.match(/^\s*(DATAFORSEO_[A-Za-z0-9_]+)\s*=\s*(.*)\s*$/)
|
|
if (match) process.env[match[1]] = match[2].trim().replace(/^['"]|['"]$/g, '')
|
|
}
|
|
return true
|
|
}
|
|
function readJson(path: string, fallback: any = null) {
|
|
if (!existsSync(path)) return fallback
|
|
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
function csv(rows: any[], headers: string[]) {
|
|
const esc = (value: any) => {
|
|
if (value === undefined || value === null) value = ''
|
|
value = Array.isArray(value) ? value.join(' | ') : 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 i = 0; i < text.length; i += 1) {
|
|
const ch = text[i]
|
|
const next = text[i + 1]
|
|
if (quoted) {
|
|
if (ch === '"' && next === '"') { cell += '"'; i += 1 }
|
|
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); rows.push(row); row = []; cell = '' }
|
|
else if (ch !== '\r') cell += ch
|
|
}
|
|
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 tokenise(value: string) {
|
|
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(/\s+/).filter(token => token.length >= 3 && !['with', 'and', 'the', 'for', 'high', 'made', 'strength', 'vitamins', 'vitamin', 'supplement', 'capsules', 'tablets'].includes(token))
|
|
}
|
|
function asinFromUrl(url: string) {
|
|
const decoded = decodeURIComponent(String(url || ''))
|
|
return decoded.match(/\/dp\/([A-Z0-9]{10})/i)?.[1]?.toUpperCase() || ''
|
|
}
|
|
function productItemsFromRaw() {
|
|
const candidates: any[] = []
|
|
for (const entry of readdirSync(productsRawRoot, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.startsWith('products-task-get-') || !entry.name.endsWith('.json')) continue
|
|
const file = join(productsRawRoot, entry.name)
|
|
const raw = readJson(file, {})
|
|
for (const task of raw.tasks || []) {
|
|
const tag = String(task?.data?.tag || '')
|
|
const sku = tag.match(/jv:([^:]+):/i)?.[1] || ''
|
|
const keyword = task?.data?.keyword || ''
|
|
for (const result of task.result || []) {
|
|
for (const item of result.items || []) {
|
|
const url = decodeURIComponent(String(item.url || ''))
|
|
const asin = item.asin || asinFromUrl(url)
|
|
if (!sku || !asin) continue
|
|
candidates.push({
|
|
SKU: sku,
|
|
Tag: tag,
|
|
Keyword: keyword,
|
|
Rank: Number(item.rank_absolute || item.rank || 0),
|
|
ASIN: asin,
|
|
Title: item.title || '',
|
|
Url: url,
|
|
Rating: item.rating?.value || '',
|
|
Votes: item.rating?.votes_count || '',
|
|
RawFile: file
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
function scoreCandidate(candidate: any, target: any) {
|
|
const title = String(candidate.Title || '')
|
|
const url = String(candidate.Url || '')
|
|
const titleLower = title.toLowerCase()
|
|
const brandExact = /just\s*vitamins/i.test(title) || /just-vitamins/i.test(url)
|
|
const targetTokens = tokenise(`${target.Product || ''} ${target.SKU || ''}`)
|
|
const matchedTokens = targetTokens.filter(token => titleLower.includes(token.toLowerCase()))
|
|
const specialMatch =
|
|
(target.SKU === 'JV-VISISOFT' && /visisoft|lutein|zeaxanthin|bilberry/i.test(title)) ||
|
|
(target.SKU === 'JV-COQ10100' && /coq10|co[- ]?enzyme|q10/i.test(title)) ||
|
|
(target.SKU === 'JV-D4000' && /4000\s?i?u|100\s?µg|100\s?mcg/i.test(title)) ||
|
|
(target.SKU === 'JV-TURMERIC500' && /turmeric|bioperine|curcuminoids/i.test(title)) ||
|
|
(target.SKU === 'JV-STAR1000' && /starflower|borage|gla/i.test(title)) ||
|
|
(target.SKU === 'JV-B12' && /b12|methylcobalamin|sublingual/i.test(title))
|
|
const score = (brandExact ? 100 : 0) + matchedTokens.length * 12 + (specialMatch ? 40 : 0) - Math.max(0, Number(candidate.Rank || 0) - 1)
|
|
const exact = brandExact && (matchedTokens.length > 0 || specialMatch) && score >= 110
|
|
return { score, brandExact, matchedTokens, exact }
|
|
}
|
|
function rebuildAsinMapFromEvidence() {
|
|
const targets = existsSync(pilotTargetsPath) ? parseCsv(readFileSync(pilotTargetsPath, 'utf8')) : []
|
|
const existing = existsSync(asinMapPath) ? parseCsv(readFileSync(asinMapPath, 'utf8')) : targets
|
|
const candidates = productItemsFromRaw()
|
|
const enriched = candidates.map(candidate => {
|
|
const target = targets.find(row => row.SKU === candidate.SKU) || existing.find(row => row.SKU === candidate.SKU) || {}
|
|
const scored = scoreCandidate(candidate, target)
|
|
return { ...candidate, CandidateScore: scored.score, BrandExact: scored.brandExact ? 'yes' : 'no', MatchedTokens: scored.matchedTokens.join(' | '), ExactMatch: scored.exact ? 'yes' : 'no' }
|
|
}).sort((a, b) => String(a.SKU).localeCompare(String(b.SKU)) || Number(b.CandidateScore) - Number(a.CandidateScore) || Number(a.Rank) - Number(b.Rank))
|
|
|
|
const rows = (targets.length ? targets : existing).map(target => {
|
|
const current = existing.find(row => row.SKU === target.SKU) || {}
|
|
const best = enriched.find(row => row.SKU === target.SKU && row.ExactMatch === 'yes')
|
|
const asin = best?.ASIN || current.ASIN || ''
|
|
const productUrl = best?.ASIN ? `https://www.amazon.co.uk/dp/${best.ASIN}` : current.ProductUrl || ''
|
|
return {
|
|
SKU: target.SKU,
|
|
Product: target.Product,
|
|
Priority: target.Priority,
|
|
Provider: 'DataForSEO',
|
|
AmazonDomain: current.AmazonDomain || target.AmazonDomain || 'amazon.co.uk',
|
|
ASIN: asin,
|
|
ProductUrl: productUrl,
|
|
Depth: current.Depth || target.Depth || '10',
|
|
LocationName: current.LocationName || target.LocationName || 'United Kingdom',
|
|
LanguageName: current.LanguageName || target.LanguageName || 'English (United Kingdom)',
|
|
Status: asin ? 'ready_for_task_payload' : 'needs_asin'
|
|
}
|
|
})
|
|
writeFileSync(asinMapPath, csv(rows, ['SKU', 'Product', 'Priority', 'Provider', 'AmazonDomain', 'ASIN', 'ProductUrl', 'Depth', 'LocationName', 'LanguageName', 'Status']), 'utf8')
|
|
writeFileSync(join(productsRawRoot, 'all-asin-candidates-from-dataforseo.csv'), csv(enriched, ['SKU', 'Tag', 'Keyword', 'Rank', 'ASIN', 'CandidateScore', 'BrandExact', 'MatchedTokens', 'ExactMatch', 'Title', 'Url', 'Rating', 'Votes', 'RawFile']), 'utf8')
|
|
return { rows, candidates: enriched }
|
|
}
|
|
function writePayload(rows: any[]) {
|
|
const taskPayload = rows.filter(row => row.ASIN).map(row => ({
|
|
location_name: row.LocationName,
|
|
language_name: row.LanguageName,
|
|
asin: row.ASIN,
|
|
depth: Number(row.Depth) || 10,
|
|
tag: `jv:${row.SKU}:stage1`
|
|
}))
|
|
writeFileSync(payloadPath, JSON.stringify(taskPayload, null, 2) + '\n', 'utf8')
|
|
return taskPayload
|
|
}
|
|
async function accountCheck() {
|
|
const envFile = process.env.DATAFORSEO_ENV_FILE || defaultEnvPath
|
|
const envLoaded = loadEnvFile(envFile)
|
|
const login = process.env.DATAFORSEO_LOGIN
|
|
const password = process.env.DATAFORSEO_PASSWORD
|
|
if (!login || !password) return { envLoaded, status: 'missing_credentials' }
|
|
const auth = Buffer.from(`${login}:${password}`).toString('base64')
|
|
const response = await fetch('https://api.dataforseo.com/v3/appendix/user_data', { headers: { Authorization: `Basic ${auth}` } })
|
|
const body: any = await response.json()
|
|
return { envLoaded, status: body.status_message || response.statusText, status_code: body.status_code, httpStatus: response.status }
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const connection = await accountCheck()
|
|
const { rows, candidates } = rebuildAsinMapFromEvidence()
|
|
const payload = writePayload(rows)
|
|
const exact = rows.filter(row => row.ASIN)
|
|
const missing = rows.filter(row => !row.ASIN)
|
|
const latestReviewPost = readdirSync(join(dataforseoRoot, 'raw'), { withFileTypes: true }).filter(entry => entry.isFile() && entry.name.includes('reviews-task-post')).map(entry => readJson(join(dataforseoRoot, 'raw', entry.name), {})).find(raw => raw?.status_code)
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: connection.status_code === 20000 && payload.length > 0,
|
|
credentialSource: process.env.DATAFORSEO_ENV_FILE || defaultEnvPath,
|
|
credentialPrinted: false,
|
|
accountConnection: connection,
|
|
counts: {
|
|
targets: rows.length,
|
|
exactAsinsConfirmed: exact.length,
|
|
missingAsins: missing.length,
|
|
candidates: candidates.length,
|
|
taskPayloadRows: payload.length
|
|
},
|
|
files: {
|
|
asinMap: 'data/sources/dataforseo/asin-map.csv',
|
|
productRawDir: 'data/sources/dataforseo/products_raw',
|
|
candidateCsv: 'data/sources/dataforseo/products_raw/all-asin-candidates-from-dataforseo.csv',
|
|
taskPayload: 'content_population_exports/dataforseo_amazon_reviews_task_payload.json',
|
|
reportMd: 'content_population_exports/dataforseo_live_connection.md'
|
|
},
|
|
reviewEndpointStatus: latestReviewPost ? { status_code: latestReviewPost.status_code, status_message: latestReviewPost.status_message } : null,
|
|
confirmedRows: rows.map(row => ({ SKU: row.SKU, Product: row.Product, ASIN: row.ASIN, ProductUrl: row.ProductUrl, Status: row.Status })),
|
|
blocker: latestReviewPost?.status_code === 50304 ? 'DataForSEO Amazon Reviews task_post returned 50304 temporarily unavailable; no review task IDs were created.' : (payload.length ? 'Review task creation still requires local budget/spend gates before paid use.' : 'ASIN payload is empty.')
|
|
}
|
|
writeFileSync(join(outRoot, 'dataforseo_live_connection.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'dataforseo_live_connection.md'), [
|
|
'# DataForSEO live connection evidence',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
'## What was verified',
|
|
`- Loaded DataForSEO credentials from \`${manifest.credentialSource}\` without printing secrets.`,
|
|
`- Account check: \`${connection.status_code || 'n/a'} ${connection.status || ''}\`.`,
|
|
'- Parsed live Amazon Products task results saved under `data/sources/dataforseo/products_raw/`.',
|
|
'- Updated `data/sources/dataforseo/asin-map.csv` and regenerated `content_population_exports/dataforseo_amazon_reviews_task_payload.json`.',
|
|
'',
|
|
'## ASIN result',
|
|
'| SKU | ASIN | Status |',
|
|
'|---|---:|---|',
|
|
...rows.map(row => `| ${row.SKU} | ${row.ASIN || ''} | ${row.Status} |`),
|
|
'',
|
|
'## Current blocker',
|
|
`- ${manifest.blocker}`,
|
|
'',
|
|
'## Counts',
|
|
`- Exact ASINs confirmed from DataForSEO product results: ${manifest.counts.exactAsinsConfirmed}/${manifest.counts.targets}`,
|
|
`- Review task payload rows: ${manifest.counts.taskPayloadRows}`,
|
|
`- Missing exact ASINs: ${manifest.counts.missingAsins}`,
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`DataForSEO live evidence ready: ${manifest.ready}`)
|
|
console.log(`Account: ${connection.status_code || 'n/a'} ${connection.status || ''}`)
|
|
console.log(`Exact ASINs: ${manifest.counts.exactAsinsConfirmed}/${manifest.counts.targets}`)
|
|
console.log(`Task payload rows: ${manifest.counts.taskPayloadRows}`)
|
|
console.log(`Missing ASINs: ${manifest.counts.missingAsins}`)
|