#!/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 candidatePath = join(dataforseoRoot, 'asin-candidates.csv') const confirmationReportPath = join(outRoot, 'dataforseo_asin_confirmation_report.json') 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 normalizeAmazonDpUrl(asin: string, productUrl: string) { const url = String(productUrl || '').trim() if (/^https:\/\/www\.amazon\.co\.uk\/dp\/[A-Z0-9]{10}/.test(url)) return `https://www.amazon.co.uk/dp/${asin}` if (/^https:\/\/amazon\.co\.uk\/dp\/[A-Z0-9]{10}/.test(url)) return `https://www.amazon.co.uk/dp/${asin}` return url } const generatedAt = new Date().toISOString() const asinRows = existsSync(asinMapPath) ? parseCsv(readFileSync(asinMapPath, 'utf8')) : [] const candidateRows = existsSync(candidatePath) ? parseCsv(readFileSync(candidatePath, 'utf8')) : [] const confirmationReport = existsSync(confirmationReportPath) ? JSON.parse(readFileSync(confirmationReportPath, 'utf8').replace(/^\uFEFF/, '')) : { confirmations: [] } const candidateBySku = new Map(candidateRows.map((row: any) => [row.SKU, row])) const confirmationBySku = new Map((confirmationReport.confirmations || []).map((row: any) => [row.SKU, row])) const asinMapHeaders = ['SKU', 'Product', 'Priority', 'Provider', 'AmazonDomain', 'ASIN', 'ProductUrl', 'Depth', 'LocationName', 'LanguageName', 'Status'] const promotionRows = asinRows.map((row: any) => { const candidate: any = candidateBySku.get(row.SKU) || {} const candidateAsin = String(candidate.CandidateASIN || '').trim().toUpperCase() const candidateUrl = normalizeAmazonDpUrl(candidateAsin, candidate.CandidateProductUrl || '') const asinLooksValid = /^[A-Z0-9]{10}$/.test(candidateAsin) const amazonUrlValid = candidateUrl === `https://www.amazon.co.uk/dp/${candidateAsin}` const alreadyFilled = Boolean(row.ASIN) const confirmed = candidate.MatchStatus === 'amazon_confirmed_exact_match' const highConfidence = candidate.Confidence === 'high' const confirmation: any = confirmationBySku.get(row.SKU) || {} const confirmationReady = confirmation.ReadyForPromotion === 'yes' const promotable = !alreadyFilled && asinLooksValid && amazonUrlValid && confirmed && highConfidence && confirmationReady const blockers = [] if (!candidate.CandidateASIN) blockers.push('no candidate') if (alreadyFilled) blockers.push('asin-map already has ASIN') if (candidate.CandidateASIN && !asinLooksValid) blockers.push('candidate ASIN shape invalid') if (candidate.CandidateASIN && !amazonUrlValid) blockers.push('candidate ProductUrl is not canonical amazon.co.uk/dp/ASIN') if (candidate.CandidateASIN && !confirmed) blockers.push('MatchStatus is not amazon_confirmed_exact_match') if (candidate.CandidateASIN && !highConfidence) blockers.push(`Confidence is ${candidate.Confidence || 'blank'}`) if (candidate.CandidateASIN && !confirmationReady) blockers.push(`confirmation gate not ready${confirmation.Blockers ? `: ${confirmation.Blockers}` : ''}`) return { SKU: row.SKU, Product: row.Product, CurrentASIN: row.ASIN || '', CandidateASIN: candidateAsin, CandidateProductUrl: candidateUrl, MatchStatus: candidate.MatchStatus || '', Confidence: candidate.Confidence || '', ConfirmationReady: confirmationReady ? 'yes' : 'no', Promote: promotable ? 'yes' : 'no', Blockers: blockers.join(' | ') } }) const promoted = promotionRows.filter(row => row.Promote === 'yes') const promotedBySku = new Map(promoted.map(row => [row.SKU, row])) let updatedAsinRows = asinRows if (promoted.length) { updatedAsinRows = asinRows.map((row: any) => { const promotion: any = promotedBySku.get(row.SKU) if (!promotion) return row return { ...row, ASIN: promotion.CandidateASIN, ProductUrl: promotion.CandidateProductUrl, Status: 'ready_for_task_payload' } }) writeFileSync(asinMapPath, csv(updatedAsinRows, asinMapHeaders), 'utf8') } const manifest = { generatedAt, ready: promoted.length > 0, applied: promoted.length > 0, counts: { asinMapRows: asinRows.length, candidateRows: candidateRows.length, promotable: promoted.length, blocked: promotionRows.filter(row => row.Promote !== 'yes').length, asinMapReadyAfterPromotion: updatedAsinRows.filter((row: any) => row.ASIN).length, asinMapMissingAfterPromotion: updatedAsinRows.filter((row: any) => !row.ASIN).length }, files: { asinMap: 'data/sources/dataforseo/asin-map.csv', candidates: 'data/sources/dataforseo/asin-candidates.csv', confirmations: 'data/sources/dataforseo/asin-confirmations.csv', confirmationReport: 'content_population_exports/dataforseo_asin_confirmation_report.json', promotionReportMd: 'content_population_exports/dataforseo_asin_promotion_report.md', promotionReportCsv: 'content_population_exports/dataforseo_asin_promotion_report.csv', promotionReportJson: 'content_population_exports/dataforseo_asin_promotion_report.json' }, guardrails: [ 'Only candidates with MatchStatus=amazon_confirmed_exact_match and Confidence=high are promoted.', 'The separate ASIN confirmation report must mark the row ReadyForPromotion=yes.', 'Candidate ProductUrl must be canonical https://www.amazon.co.uk/dp/ASIN.', 'Existing ASIN map rows are never overwritten by this script.', 'Promotion only prepares a payload; source:spend-gate still blocks paid provider use until budget and approval gates pass.' ], promotions: promotionRows } writeFileSync(join(outRoot, 'dataforseo_asin_promotion_report.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8') writeFileSync(join(outRoot, 'dataforseo_asin_promotion_report.csv'), csv(promotionRows, ['SKU', 'Product', 'CurrentASIN', 'CandidateASIN', 'CandidateProductUrl', 'MatchStatus', 'Confidence', 'ConfirmationReady', 'Promote', 'Blockers']), 'utf8') writeFileSync(join(outRoot, 'dataforseo_asin_promotion_report.md'), [ '# DataForSEO ASIN promotion report', '', `Generated: ${generatedAt}`, '', 'This no-spend step safely copies only Amazon-confirmed, high-confidence candidate ASINs into `data/sources/dataforseo/asin-map.csv`.', '', `- Candidate rows: ${manifest.counts.candidateRows}`, `- Promoted rows: ${manifest.counts.promotable}`, `- ASIN map ready after promotion: ${manifest.counts.asinMapReadyAfterPromotion}`, `- ASIN map missing after promotion: ${manifest.counts.asinMapMissingAfterPromotion}`, '', '## Guardrails', ...manifest.guardrails.map(item => `- ${item}`), '', '## Promotion rows', ...promotionRows.map(row => `- [${row.Promote === 'yes' ? 'promoted' : 'blocked'}] ${row.SKU} — ${row.CandidateASIN || 'no candidate'} — ${row.Blockers || 'all checks passed'}`), '' ].join('\n'), 'utf8') console.log(`DataForSEO ASIN promotion ready: ${manifest.ready}`) console.log(`Promoted rows: ${manifest.counts.promotable}`) console.log(`ASINs ready after promotion: ${manifest.counts.asinMapReadyAfterPromotion}`) console.log(`ASINs missing after promotion: ${manifest.counts.asinMapMissingAfterPromotion}`)