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>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const root = process.cwd()
|
||||
const rawRoot = join(root, 'data', 'sources', 'apify', 'raw')
|
||||
const token = Bun.env.APIFY_TOKEN || Bun.env.APIFY_API_TOKEN
|
||||
const generatedAt = new Date().toISOString()
|
||||
if (!token) throw new Error('Missing APIFY_TOKEN or APIFY_API_TOKEN')
|
||||
|
||||
const maxReviews = Number(Bun.env.APIFY_AMAZON_JUNGLEE_MAX_REVIEWS || 5)
|
||||
const maxTotalChargeUsd = Number(Bun.env.APIFY_AMAZON_JUNGLEE_MAX_USD || 0.5)
|
||||
const onlySkus = (Bun.env.APIFY_AMAZON_JUNGLEE_ONLY || '')
|
||||
.split(',')
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const products = [
|
||||
{
|
||||
sku: 'JV-TURMERIC500',
|
||||
asin: 'B009756A58',
|
||||
url: 'https://www.amazon.co.uk/dp/B009756A58',
|
||||
context: 'exact_jv_listing',
|
||||
note: 'Exact ASIN from local DataForSEO asin-map.csv.'
|
||||
},
|
||||
{
|
||||
sku: 'JV-DEFF1000',
|
||||
asin: 'B09LR671LT',
|
||||
url: 'https://www.amazon.co.uk/dp/B09LR671LT',
|
||||
context: 'closest_effervescent_competitor',
|
||||
note: 'Closest Amazon effervescent vitamin D competitor from local DataForSEO product search; not JV product truth.'
|
||||
},
|
||||
{
|
||||
sku: 'JV-VISISOFT',
|
||||
asin: 'B009756BHA',
|
||||
url: 'https://www.amazon.co.uk/dp/B009756BHA',
|
||||
context: 'exact_jv_listing',
|
||||
note: 'Exact ASIN from local DataForSEO asin-map.csv.'
|
||||
}
|
||||
].filter(product => onlySkus.length === 0 || onlySkus.includes(product.sku))
|
||||
|
||||
if (!products.length) throw new Error('No products selected for Amazon review capture.')
|
||||
|
||||
function writeJson(path: string, data: any) {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8')
|
||||
}
|
||||
|
||||
async function apify(path: string, options: RequestInit = {}) {
|
||||
const sep = path.includes('?') ? '&' : '?'
|
||||
const res = await fetch(`https://api.apify.com${path}${sep}token=${encodeURIComponent(token!)}`, {
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
||||
})
|
||||
const text = await res.text()
|
||||
let data: any
|
||||
try { data = text ? JSON.parse(text) : null } catch { data = text }
|
||||
if (!res.ok) throw new Error(`Apify ${res.status} ${res.statusText}: ${typeof data === 'string' ? data : JSON.stringify(data)}`)
|
||||
return data
|
||||
}
|
||||
|
||||
async function waitForRun(runId: string, timeoutMs = 1000 * 60 * 12) {
|
||||
const started = Date.now()
|
||||
let run = (await apify(`/v2/actor-runs/${runId}`)).data
|
||||
while (!['SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT'].includes(run.status)) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error(`Timed out waiting for ${runId}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
run = (await apify(`/v2/actor-runs/${runId}`)).data
|
||||
console.log(`${runId}: ${run.status} usd=${run.usageTotalUsd || 0}`)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
const input = {
|
||||
productUrls: products.map(product => ({ url: product.url })),
|
||||
maxReviews,
|
||||
includeGdprSensitive: false,
|
||||
sort: 'recent',
|
||||
filterByRatings: ['allStars'],
|
||||
reviewsUseProductVariantFilter: false,
|
||||
scrapeProductDetails: false,
|
||||
reviewsAlwaysSaveCategoryData: true,
|
||||
deduplicateRedirectedAsins: true
|
||||
}
|
||||
|
||||
console.log(`Starting junglee/amazon-reviews-scraper for ${products.length} exact ASIN URLs, maxReviews=${maxReviews}, cap=$${maxTotalChargeUsd}`)
|
||||
const start = await apify(`/v2/acts/junglee~amazon-reviews-scraper/runs?maxTotalChargeUsd=${maxTotalChargeUsd}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
})
|
||||
const run = await waitForRun(start.data.id)
|
||||
const items = run.defaultDatasetId ? await apify(`/v2/datasets/${run.defaultDatasetId}/items?clean=true`) : []
|
||||
const result = {
|
||||
capturedAt: generatedAt,
|
||||
job: 'amazon-reviews-junglee',
|
||||
actor: 'junglee/amazon-reviews-scraper',
|
||||
note: 'Capped Amazon review capture for exact ASINs only. JV-DEFF1000 is excluded because no exact ASIN is confirmed.',
|
||||
maxTotalChargeUsd,
|
||||
input,
|
||||
productMap: products,
|
||||
run: {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
statusMessage: run.statusMessage,
|
||||
defaultDatasetId: run.defaultDatasetId,
|
||||
startedAt: run.startedAt,
|
||||
finishedAt: run.finishedAt,
|
||||
usageTotalUsd: run.usageTotalUsd,
|
||||
usage: run.usage,
|
||||
stats: run.stats,
|
||||
chargedEventCounts: run.chargedEventCounts
|
||||
},
|
||||
itemCount: Array.isArray(items) ? items.length : 0,
|
||||
items: Array.isArray(items) ? items.map((item: any) => {
|
||||
const raw = JSON.stringify(item)
|
||||
const matched = products.find(product => raw.includes(product.asin) || raw.includes(product.url))
|
||||
return matched ? { ...item, sku: matched.sku, matchedAsin: matched.asin, matchContext: matched.context, matchNote: matched.note } : item
|
||||
}) : items
|
||||
}
|
||||
|
||||
const stamp = generatedAt.replace(/[:.]/g, '-')
|
||||
writeJson(join(rawRoot, `top3-specialist-amazon-reviews-junglee-${stamp}.json`), result)
|
||||
writeJson(join(rawRoot, 'top3-specialist-amazon-reviews-junglee-latest.json'), result)
|
||||
console.log(`Amazon reviews junglee ${run.status}: ${result.itemCount} items, usd=${run.usageTotalUsd || 0}`)
|
||||
Reference in New Issue
Block a user