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>
201 lines
8.6 KiB
TypeScript
201 lines
8.6 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
|
import { dirname, join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const rawRoot = join(root, 'data', 'sources', 'apify', 'raw')
|
|
const intelRoot = join(root, 'data', 'intelligence')
|
|
const profileRoot = join(root, 'data', 'research-profiles')
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const generatedAt = new Date().toISOString()
|
|
const top3 = ['JV-TURMERIC500', 'JV-DEFF1000', 'JV-VISISOFT']
|
|
const rawFiles = [
|
|
join(rawRoot, 'top3-comprehensive-source-scrape-latest.json'),
|
|
join(rawRoot, 'top3-social-source-scrape-latest.json')
|
|
]
|
|
const specialistFiles = existsSync(rawRoot)
|
|
? readdirSync(rawRoot)
|
|
.filter(file => /^top3-specialist-.*-latest\.json$/.test(file))
|
|
.map(file => join(rawRoot, file))
|
|
: []
|
|
|
|
function readJson(path: string, fallback: any = null) {
|
|
if (!existsSync(path)) return fallback
|
|
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
function writeJson(path: string, data: any) {
|
|
mkdirSync(dirname(path), { recursive: true })
|
|
writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8')
|
|
}
|
|
function compactItem(item: any) {
|
|
return {
|
|
sku: item.sku,
|
|
sourceType: item.sourceType,
|
|
label: item.label,
|
|
competitor: item.competitor || null,
|
|
requestedUrl: item.requestedUrl,
|
|
loadedUrl: item.loadedUrl,
|
|
statusCode: item.statusCode,
|
|
title: item.title,
|
|
metaDescription: item.metaDescription,
|
|
h1: item.h1,
|
|
headings: item.headings || [],
|
|
prices: item.prices || [],
|
|
reviewLike: item.reviewLike || [],
|
|
links: item.links || [],
|
|
text: item.text || '',
|
|
textChars: item.textChars || String(item.text || '').length,
|
|
htmlChars: item.htmlChars || 0,
|
|
capturedAt: item.capturedAt
|
|
}
|
|
}
|
|
function itemMatchesSku(item: any, sku: string) {
|
|
if (item.sku === sku || item.sku === 'ALL_TOP3') return true
|
|
if (item.sku) return false
|
|
const text = JSON.stringify(item).toLowerCase()
|
|
if (sku === 'JV-TURMERIC500') return /turmeric|curcumin|bioperine|black pepper/.test(text)
|
|
if (sku === 'JV-DEFF1000') return /vitamin d|1000iu|1000 iu|effervescent|fizz|deficien/.test(text)
|
|
if (sku === 'JV-VISISOFT') return /visisoft|lutein|zeaxanthin|bilberry|eye health|vision/.test(text)
|
|
return false
|
|
}
|
|
|
|
const runs = rawFiles.map(path => ({ path, data: readJson(path) })).filter(row => row.data)
|
|
const specialistRuns = specialistFiles.map(path => ({ path, data: readJson(path) })).filter(row => row.data)
|
|
const specialistJobs = Array.from(new Set([
|
|
'trustpilot',
|
|
'reddit',
|
|
'amazon-reviews',
|
|
...specialistRuns.map(run => run.data?.job).filter(Boolean)
|
|
])).sort()
|
|
const manifestRows: any[] = []
|
|
for (const sku of top3) {
|
|
const items = runs.flatMap(run => (run.data.items || [])
|
|
.filter((item: any) => item.sku === sku || item.sku === 'ALL_TOP3')
|
|
.map((item: any) => ({ ...compactItem(item), rawFile: run.path, runId: run.data.run?.id })))
|
|
const requested = runs.flatMap(run => (run.data.requestedTargets || [])
|
|
.filter((target: any) => target.sku === sku || target.sku === 'ALL_TOP3')
|
|
.map((target: any) => ({ ...target, rawFile: run.path, runId: run.data.run?.id })))
|
|
const capturedLabels = new Set(items.map((item: any) => item.label))
|
|
const missing = requested.filter((target: any) => !capturedLabels.has(target.label))
|
|
const byType = Object.fromEntries(['jv_public_pdp', 'competitor_pdp', 'amazon_search', 'trustpilot_brand', 'reddit_category'].map(type => [
|
|
type,
|
|
{
|
|
requested: requested.filter((row: any) => row.sourceType === type).length,
|
|
captured: items.filter((row: any) => row.sourceType === type).length
|
|
}
|
|
]))
|
|
const specialistItems = specialistRuns.flatMap(run => (run.data.items || [])
|
|
.filter((item: any) => run.data.job === 'trustpilot' || itemMatchesSku(item, sku))
|
|
.map((item: any) => ({
|
|
sourceType: `specialist_${run.data.job}`,
|
|
job: run.data.job,
|
|
actor: run.data.actor,
|
|
rawFile: run.path,
|
|
runId: run.data.run?.id,
|
|
item
|
|
})))
|
|
const exactProductEvidenceCount = specialistItems.filter((row: any) =>
|
|
row.item?.sku === sku &&
|
|
row.item?.matchContext !== 'closest_effervescent_competitor' &&
|
|
row.item?.matchContext !== 'exact_asin_absence_search' &&
|
|
row.item?.matchContext !== 'competitor_serp_fallback' &&
|
|
row.item?.matchContext !== 'competitor_retailer_pdp_fallback' &&
|
|
!['specialist_healthspan-serp', 'specialist_healthspan-browser', 'specialist_healthspan-content-crawler', 'specialist_healthspan-retailer'].includes(row.sourceType)
|
|
).length
|
|
const competitorContextEvidenceCount = specialistItems.filter((row: any) =>
|
|
row.item?.matchContext === 'closest_effervescent_competitor' ||
|
|
row.item?.matchContext === 'exact_asin_absence_search' ||
|
|
row.item?.matchContext === 'competitor_serp_fallback' ||
|
|
row.item?.matchContext === 'competitor_retailer_pdp_fallback' ||
|
|
row.sourceType === 'specialist_healthspan-serp' ||
|
|
row.sourceType === 'specialist_healthspan-browser' ||
|
|
row.sourceType === 'specialist_healthspan-content-crawler' ||
|
|
row.sourceType === 'specialist_healthspan-retailer'
|
|
).length
|
|
const specialistCoverage = Object.fromEntries(specialistJobs.map(job => [
|
|
job,
|
|
{
|
|
run: specialistRuns.some(run => run.data.job === job),
|
|
captured: specialistItems.filter((row: any) => row.job === job).length,
|
|
status: specialistRuns.find(run => run.data.job === job)?.data?.run?.status || 'not_run'
|
|
}
|
|
]))
|
|
const captureProfile = {
|
|
sku,
|
|
generatedAt,
|
|
generatedBy: 'ingest-apify-top3-source-captures.ts',
|
|
purpose: 'Raw Apify source captures for comprehensive PDP research profile.',
|
|
sourceFiles: runs.map(run => ({
|
|
path: run.path,
|
|
runId: run.data.run?.id,
|
|
status: run.data.run?.status,
|
|
requestCap: run.data.requestCap,
|
|
itemCount: run.data.itemCount,
|
|
usageTotalUsd: run.data.run?.usageTotalUsd
|
|
})),
|
|
coverage: byType,
|
|
specialistCoverage,
|
|
evidenceContext: {
|
|
exactProductEvidenceCount,
|
|
competitorContextEvidenceCount,
|
|
note: 'Exact product evidence is tied to the SKU/confirmed listing. Competitor context is useful for market/PDP learning but must not override JV product truth.'
|
|
},
|
|
capturedCount: items.length,
|
|
specialistCapturedCount: specialistItems.length,
|
|
requestedCount: requested.length,
|
|
missingCount: missing.length,
|
|
missing,
|
|
items,
|
|
specialistItems
|
|
}
|
|
writeJson(join(intelRoot, sku, 'apify-source-captures.json'), captureProfile)
|
|
const profilePath = join(profileRoot, `${sku}.json`)
|
|
const profile = readJson(profilePath)
|
|
if (profile) {
|
|
profile.rawSourceCaptureProfile = {
|
|
generatedAt,
|
|
source: `${sku}/apify-source-captures.json`,
|
|
coverage: byType,
|
|
capturedCount: items.length,
|
|
specialistCapturedCount: specialistItems.length,
|
|
missingCount: missing.length,
|
|
specialistCoverage,
|
|
evidenceContext: {
|
|
exactProductEvidenceCount,
|
|
competitorContextEvidenceCount,
|
|
note: 'Exact product evidence is tied to the SKU/confirmed listing. Competitor context is useful for market/PDP learning but must not override JV product truth.'
|
|
},
|
|
missing: missing.map((row: any) => ({ sourceType: row.sourceType, label: row.label, url: row.url }))
|
|
}
|
|
profile.sourceCoverage = {
|
|
...(profile.sourceCoverage || {}),
|
|
apifyRawCaptures: items.length,
|
|
apifySpecialistRawCaptures: specialistItems.length,
|
|
apifyMissingTargets: missing.length
|
|
}
|
|
writeJson(profilePath, profile)
|
|
writeJson(join(intelRoot, sku, 'research-profile.json'), profile)
|
|
}
|
|
manifestRows.push({ sku, captured: items.length, specialistCaptured: specialistItems.length, requested: requested.length, missing: missing.length, coverage: byType, specialistCoverage, evidenceContext: { exactProductEvidenceCount, competitorContextEvidenceCount } })
|
|
}
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: manifestRows.every(row => row.captured > 0),
|
|
note: 'Apify capture ingest is raw-source coverage, not content approval. Reddit/Trustpilot may remain missing when source pages block crawler access.',
|
|
rawFiles: runs.map(run => run.path),
|
|
specialistFiles: specialistRuns.map(run => run.path),
|
|
products: manifestRows
|
|
}
|
|
writeJson(join(outRoot, 'top3_apify_source_capture_ingest.json'), manifest)
|
|
writeFileSync(join(outRoot, 'top3_apify_source_capture_ingest.md'), [
|
|
'# Top-3 Apify source capture ingest',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
manifest.note,
|
|
'',
|
|
...manifestRows.map(row => `- ${row.sku}: ${row.captured}/${row.requested} page captures, ${row.specialistCaptured} specialist items, ${row.missing} missing`)
|
|
].join('\n') + '\n', 'utf8')
|
|
console.log(`Ingested Apify captures for ${manifestRows.length} products`)
|