Files
justvitamin/scripts/generate-pilot-content.ts
T
Omair Saleh 2fbf6f35b5 feat(editorial): catchy customer-hook science copy + generic-category comparison tables
Scientific Studies now lead with a customer-relevant outcome (matching how Heights,
Ritual, Nothing Fishy hook attention) before weaving in the PMID citation —
not the academic 'one of the most-studied supplements...' framing. Comparison
tables drop named brands ('Vitabiotics Immunace Fizz', 'Feel') and use generic
category labels ('Standard multivitamins', 'Bovine collagen brands') the way
Grüns, Elavate, Hunter & Gather, and Nothing Fishy actually structure theirs.
Per-SKU comparisonDraft curated in pilot-skus.json with rows derived from JV
product DB + 15-brand competitor section extracts + EFSA register.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 19:43:25 +08:00

980 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
// Generate elite-grade first-draft content for the 4 new PDP section types per
// the 21 May + 1 June 2026 Loom briefs from Umar. Inputs:
// - data/pipeline/pilot-skus.json (lock + phase schedule)
// - data/content/brand-voice/elite-faq-templates.json (brand-voice FAQs)
// - data/evidence/<ingredient>.json (PubMed meta-analyses + RCTs)
// - data/intelligence/<sku>/{claims-evidence,competitor-comparison,
// conversion-blockers,review-time-mentions,
// audience-profile}.json (per-SKU intelligence)
// - data/sources/jv-product-truth/<sku>.json (product DB truth, if present)
// - data/content/products/<sku>.json (existing product content)
// Outputs:
// - data/content/pilot-extended/<sku>.json (per-SKU drafts with QA scores)
// - data/content/pilot-extended/_summary.json (batch QA roll-up)
//
// Usage: bun scripts/generate-pilot-content.ts [SKU1 SKU2 ...]
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
const ROOT = join(import.meta.dir, '..')
const PILOT_FILE = join(ROOT, 'data', 'pipeline', 'pilot-skus.json')
const ELITE_FAQ_FILE = join(ROOT, 'data', 'content', 'brand-voice', 'elite-faq-templates.json')
const VOICE_GUIDE_PATH = 'data/content/brand-voice/voice-guide.md'
const INTEL_DIR = join(ROOT, 'data', 'intelligence')
const EVIDENCE_DIR = join(ROOT, 'data', 'evidence')
const PRODUCT_TRUTH_DIR = join(ROOT, 'data', 'sources', 'jv-product-truth')
const PRODUCTS_DIR = join(ROOT, 'data', 'content', 'products')
const OUT_DIR = join(ROOT, 'data', 'content', 'pilot-extended')
// Per-SKU primary-active-ingredient → evidence file mapping
const INGREDIENT_MAP: Record<string, string[]> = {
'JV-D1000': ['cholecalciferol'],
'JV-D4000': ['cholecalciferol', 'menaquinone-7'],
'JV-TURMERIC500': ['curcumin', 'piperine-bioperine'],
'JV-CALCIT': ['calcium-citrate'],
'JV-COLLAGEN400': ['marine-collagen-peptides']
}
// Forbidden phrases per voice-guide.md
const FORBIDDEN_PHRASES = [
'game-changer', 'game changer', 'life-changing', 'miracle', 'transforms your',
'doctor-formulated', 'clinically proven', 'scientifically proven',
'boosts', 'supercharges', 'fights',
'detox', 'cleanse',
'powerhouse', 'potent', 'pure ', 'premium ',
'amazing', 'incredible', 'revolutionary',
'[draft]', '[draft ', 'draft template', 'lorem ipsum', 'todo', 'tbd'
]
// Engineering / internal jargon that must never reach customer-facing copy
const ENGINEERING_JARGON = [
'contamination', 'extraction', 'tagging', 'sku', 'pdp', 'metafield',
'top3', 'minority', 'cohort', 'cross-product', 'evaluator',
'expectation management', 'compliance gate', 'source-backed',
'workstream', 'snippet', 'datapoint'
]
type PilotFaq = { question: string; answer: string; evidenceTier?: string; complianceNotes?: string }
type PubMedCitation = { title?: string; firstAuthor?: string; journal?: string; year?: string; pmid?: string; url?: string }
type EfsaCitation = { title?: string; register?: string; url?: string }
type CuratedStatTile = { value: string; label: string; citation?: 'primary' | 'efsa' | 'product_truth' | string }
type ScientificStudyDraft = {
headline?: string
studySummary?: string
efsaClaimVerbatim?: string
efsaClaimContext?: string
statTiles?: CuratedStatTile[]
primaryCitation?: PubMedCitation
supportingCitations?: PubMedCitation[]
efsaCitation?: EfsaCitation
complianceNotes?: string
}
type CuratedCompareCol = { name: string; is_us?: boolean; accent_colour?: string }
type CuratedCompareRow = { feature: string; us: string; competitor_values?: string[] }
type ComparisonDraft = {
section_title?: string
subtitle?: string
columns?: CuratedCompareCol[]
rows?: CuratedCompareRow[]
complianceNotes?: string
}
type Pilot = {
sku: string
name: string
category: string
rationale: string
timelineCadence: 'weeks' | 'months' | string
phases: string[]
productSpecificFaqs?: PilotFaq[]
scientificStudyDraft?: ScientificStudyDraft
comparisonDraft?: ComparisonDraft
}
// Structured citation surface — what the dashboard renders. Every claim,
// stat tile, and supporting line resolves to one of these. Never a file path.
type CitationLink = {
type: 'efsa' | 'pubmed' | 'regulation' | 'product_truth' | 'review' | 'voice_guide' | 'category_template' | 'other'
label: string
href?: string
meta?: string
}
type Pilots = { skus: Pilot[] }
type Claim = {
handle?: string
heading?: string
description?: string
// claims-library.json uses source_url / source_title (not url / title)
citation?: {
source_title?: string
source_url?: string
study_or_regulatory_reference?: string
evidence_type?: string
}
stat_1_value?: string | number
stat_1_name?: string
stat_2_value?: string | number
stat_2_name?: string
stat_3_value?: string | number
stat_3_name?: string
disclaimer?: string
status?: string
skus?: string[]
}
type ClaimsEvidence = { sku: string; name?: string; n?: number; claims?: Claim[]; complianceGate?: string }
type Competitor = {
rank?: number; brand?: string; domain?: string; url?: string; status?: string
signals?: string[]; whitespace?: string
priceSignal?: string; format?: string; dose?: string; claims?: string[]
}
type ComparisonRow = { rank?: number; attribute?: string; justVitamins?: string; competitorEvidence?: string; recommendation?: string }
type CompetitorComparison = { sku: string; name?: string; competitors?: Competitor[]; comparison?: ComparisonRow[]; jvSignals?: { rank?: number; signal?: string }[] }
type Blocker = { rank?: number; blocker?: string; evidence?: string; fix?: string; confidence?: string }
type ConversionBlockers = { sku: string; blockers?: Blocker[] }
type TimeMention = { reviewIdx: number; stars: number; days: number; matchedPhrase: string; sentence: string }
type ReviewTimeMentions = { sku: string; totalMentions?: number; phases?: { label: string; windowDays: number[]; mentionCount: number; topMentions: TimeMention[] }[] }
type PubMedStudy = { pmid: string; title: string; journal: string; year: string; firstAuthor: string; doi: string; url: string; abstract: string }
type Evidence = { handle: string; searchTerm: string; totalHits?: number; studies?: PubMedStudy[] }
type EliteFaqTemplate = { questionHandle: string; question: string; answer: string; evidenceTier: string; complianceNotes: string }
type EliteFaqLib = { standardFaqs: Record<string, EliteFaqTemplate[]> }
// Shape produced by scripts/sync-jv-product-truth.ts — see that file.
type ProductTruth = {
sku?: string
title?: string
handle?: string
format?: string
strength?: string
strapline?: string
tags?: string // Shopify comma-joined tags (includes dietary like "Vegan,Vegetarian,Gluten Free")
recommendedIntake?: string
ingredients?: string // rich text JSON
allergens?: string
warning?: string
legalCategory?: string
dimensions?: string
variants?: Array<{
sku?: string
option?: string
price?: string
compareAtPrice?: string
barcode?: string
inventoryQty?: string
weight?: string
weightUnit?: string
}>
}
function readJson<T = any>(p: string, fallback: T | null = null): T | null {
if (!existsSync(p)) return fallback
try { return JSON.parse(readFileSync(p, 'utf8')) as T } catch { return fallback }
}
function slug(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60)
}
function brandHandle(brand: string): string {
return slug(brand || 'competitor')
}
function loadEvidence(skuInfo: Pilot): { ingredients: string[]; evidence: Record<string, Evidence | null> } {
const ingredients = INGREDIENT_MAP[skuInfo.sku] || []
const out: Record<string, Evidence | null> = {}
for (const ing of ingredients) {
out[ing] = readJson<Evidence>(join(EVIDENCE_DIR, `${ing}.json`))
}
return { ingredients, evidence: out }
}
// ============================================================================
// Quality checks — run inline as we build content
// ============================================================================
type QaCheck = { pass: boolean; field: string; reason?: string }
function qaText(field: string, text: string): QaCheck[] {
const checks: QaCheck[] = []
if (!text || !text.trim()) {
checks.push({ pass: false, field, reason: 'empty' })
return checks
}
const lower = text.toLowerCase()
for (const ph of FORBIDDEN_PHRASES) {
if (lower.includes(ph)) {
checks.push({ pass: false, field, reason: `forbidden phrase: "${ph}"` })
}
}
for (const j of ENGINEERING_JARGON) {
// word-boundary match to avoid hits in URLs / handles
const re = new RegExp(`\\b${j.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`, 'i')
if (re.test(text)) {
checks.push({ pass: false, field, reason: `engineering jargon: "${j}"` })
}
}
if (/\b(treats|cures|prevents disease|reverses)\b/i.test(text)) {
checks.push({ pass: false, field, reason: 'medical claim (treats/cures/prevents)' })
}
// Catch unresolved placeholder markers
if (/\[.{2,40}\]/.test(text)) {
checks.push({ pass: false, field, reason: 'unresolved [...] placeholder' })
}
// Catch repeated template scaffolds (same opener used >=2 times in a single block;
// detected at block-level via qaScaffold below — flag here only the obviously templated)
if (/^\s*\[draft/i.test(text)) {
checks.push({ pass: false, field, reason: 'draft prefix' })
}
if (checks.length === 0) checks.push({ pass: true, field })
return checks
}
// Block-level check: identical opening clause across multiple sub-fields ⇒ scaffold
function qaScaffoldRepetition(field: string, texts: string[]): QaCheck[] {
if (texts.length < 2) return []
const openings = texts.map(t => (t || '').trim().split(/[,.]/)[0].toLowerCase().slice(0, 60))
const counts: Record<string, number> = {}
for (const o of openings) counts[o] = (counts[o] || 0) + 1
const worst = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
if (worst && worst[1] >= 2 && worst[0].length > 20) {
return [{ pass: false, field, reason: `scaffold repetition: opener "${worst[0]}..." repeats ${worst[1]}×` }]
}
return [{ pass: true, field }]
}
function scoreArtefact(checks: QaCheck[]): { score: number; failed: QaCheck[] } {
const total = checks.length
const failed = checks.filter(c => !c.pass)
const score = total === 0 ? 0 : Math.round((1 - failed.length / total) * 100)
return { score, failed }
}
// ============================================================================
// SCIENTIFIC STUDY
// ============================================================================
function generateScientificStudy(skuInfo: Pilot, claimsEv: ClaimsEvidence | null, evidence: Record<string, Evidence | null>, ingredients: string[]) {
const qa: QaCheck[] = []
const efsaClaim = (claimsEv?.claims || []).find(c => c.citation?.source_url && c.heading)
const pubmedStudies: PubMedStudy[] = []
for (const ing of ingredients) {
const ev = evidence[ing]
if (ev?.studies && ev.studies.length > 0) {
// Prefer recent meta-analyses / systematic reviews
const sorted = ev.studies.slice().sort((a, b) => Number(b.year || '0') - Number(a.year || '0'))
pubmedStudies.push(sorted[0])
}
}
// ====================================================================
// TIER 0 — hand-curated scientificStudyDraft from pilot-skus.json
// (highest quality; mirrors how Wild Nutrition / Ritual / Nothing Fishy
// position science: study framing first, EFSA wording quoted separately,
// every stat tile + supporting line resolves to a real URL.)
// ====================================================================
const draft = skuInfo.scientificStudyDraft
if (draft && draft.headline && draft.studySummary && draft.efsaClaimVerbatim) {
const handle = `${slug(skuInfo.sku)}-scientific-study`.slice(0, 60)
const headline = draft.headline
// Body = study framing + verbatim authorised quote + brief context line.
// The verbatim EFSA quote is the ONLY therapeutic-sounding sentence on the
// page — that is the regulatory-safe pattern competitors use.
const body_copy = `${draft.studySummary.trim()}\n\nAuthorised health claim: "${draft.efsaClaimVerbatim.trim()}"\n\n${(draft.efsaClaimContext || '').trim()}`.trim()
// Stat tiles from the curated list. Each tile carries its own citation tag
// ('primary' → primary PubMed; 'efsa' → EFSA register; 'product_truth' → DB).
const resolveTileCitation = (tag?: string): string | undefined => {
if (tag === 'primary' && draft.primaryCitation?.url) return draft.primaryCitation.url
if (tag === 'efsa' && draft.efsaCitation?.url) return draft.efsaCitation.url
if (tag === 'product_truth') return `data/sources/jv-product-truth/${skuInfo.sku}.json`
return undefined
}
const stats = (draft.statTiles || []).map(t => ({
value: t.value,
label: t.label,
source: resolveTileCitation(t.citation)
}))
// Structured citation surface — what the dashboard renders. Real URLs only.
const sourceLinks: CitationLink[] = []
if (draft.primaryCitation) {
const c = draft.primaryCitation
sourceLinks.push({
type: 'pubmed',
label: c.title ? `${c.firstAuthor ? c.firstAuthor + ' — ' : ''}${c.title}` : (c.journal || 'Primary citation'),
href: c.url,
meta: [c.journal, c.year, c.pmid ? `PMID ${c.pmid}` : ''].filter(Boolean).join(' · ')
})
}
for (const c of draft.supportingCitations || []) {
sourceLinks.push({
type: 'pubmed',
label: c.title || c.journal || 'Supporting citation',
href: c.url,
meta: [c.journal, c.year, c.pmid ? `PMID ${c.pmid}` : ''].filter(Boolean).join(' · ')
})
}
if (draft.efsaCitation) {
sourceLinks.push({
type: draft.efsaCitation.register?.toLowerCase().includes('regulation') || draft.efsaCitation.title?.toLowerCase().includes('regulation') ? 'regulation' : 'efsa',
label: draft.efsaCitation.title || 'EU / UK nutrition and health claims register',
href: draft.efsaCitation.url,
meta: draft.efsaCitation.register
})
}
// Legacy `sources` array (kept for the CSV exporter) — readable strings.
const sources = sourceLinks
.map(s => s.href ? `${s.label}${s.href}` : s.label)
.filter(Boolean)
qa.push(...qaText('scientificStudy.headline', headline))
qa.push(...qaText('scientificStudy.body_copy', body_copy))
return {
handle,
name_internal: `${skuInfo.name}${headline}`,
headline,
body_copy,
// Keep these for the existing CSV column shape:
link_url: draft.primaryCitation?.url || draft.efsaCitation?.url || '',
link_label: draft.primaryCitation?.journal
? `${draft.primaryCitation.journal} (${draft.primaryCitation.year || ''})`.trim()
: (draft.efsaCitation?.title || 'Source'),
stats,
image: `${slug(skuInfo.sku)}-scientific-study.png`,
status: 'curated_compliance_reviewed',
sources,
sourceLinks,
efsaClaim: {
verbatim: draft.efsaClaimVerbatim,
context: draft.efsaClaimContext || ''
},
pubmedReferences: [
...(draft.primaryCitation ? [{
pmid: draft.primaryCitation.pmid,
year: draft.primaryCitation.year,
journal: draft.primaryCitation.journal,
title: draft.primaryCitation.title,
url: draft.primaryCitation.url
}] : []),
...(draft.supportingCitations || []).map(c => ({
pmid: c.pmid, year: c.year, journal: c.journal, title: c.title, url: c.url
}))
],
complianceNotes: draft.complianceNotes,
qa: scoreArtefact(qa)
}
}
let handle: string, headline: string, body_copy: string, link_url: string, link_label: string, status: string, sources: string[]
if (efsaClaim) {
// Tier 1: source-backed claim from claims-evidence (EFSA / UK NHC)
handle = `${slug(skuInfo.sku)}-${efsaClaim.handle || 'scientific-study'}`.slice(0, 60)
// Polish multi-slash claim wording into a single readable headline:
// "Vitamin D contributes to normal immune function / bones / muscle function"
// → "Vitamin D contributes to normal immune function, bones, and muscle function"
const rawHeading = efsaClaim.heading || `${skuInfo.name} — what the research says`
headline = rawHeading
.replace(/\s*\/\s*/g, ', ')
.replace(/, ([^,]+)$/, ', and $1')
.replace(/\s+,/g, ',')
// Build a clean, compliance-precise body
// 1. The regulated claim itself (authorised wording is the headline)
// 2. A short, honest framing line
// 3. Optional supporting research note that does NOT make a new claim
// Polish journal names: drop trailing ": NLM-abbrev" suffixes ("Phytotherapy research : PTR")
const cleanJournal = (j: string) => (j || '').replace(/\s*:\s*[A-Z]{2,6}$/i, '').trim()
const supporting = pubmedStudies.length > 0
? ` Independent published research on this ingredient — including a recent ${pubmedStudies[0].year} article in ${cleanJournal(pubmedStudies[0].journal)} — continues to investigate dose, form, and population response.`
: ''
body_copy = `${headline}. This is an authorised European nutrient claim; the wording is regulatory-compliant.${supporting}`.trim()
link_url = efsaClaim.citation?.source_url || ''
link_label = efsaClaim.citation?.source_title || 'Source'
status = 'source_backed_draft'
sources = [`data/intelligence/${skuInfo.sku}/claims-evidence.json#${efsaClaim.handle}`, link_url].filter(Boolean)
} else if (pubmedStudies.length > 0) {
// Tier 2: no regulated claim, but PubMed evidence exists — frame as experience/research summary
const top = pubmedStudies[0]
const ingName = ingredients[0].replace(/-/g, ' ')
handle = `${slug(skuInfo.sku)}-research-summary`
headline = `What the research on ${ingName} suggests`
body_copy = `${ingName.charAt(0).toUpperCase() + ingName.slice(1)} continues to be studied in adult populations. The dose and form in this product are guided by the consensus across published research. No clinical-effect claim is made on this page until human-reviewed compliance language is in place — please refer to the linked study for primary source.`
link_url = top.url
link_label = `${top.journal} (${top.year})`
status = 'needs_compliance_review'
sources = [`data/evidence/${ingredients[0]}.json#PMID:${top.pmid}`, top.url]
} else {
handle = `${slug(skuInfo.sku)}-scientific-study-placeholder`
headline = 'Scientific evidence (pending)'
body_copy = 'This block is held back until the citation chain is complete. PubMed and EFSA / UK NHC register lookups are required for this ingredient before any clinical-sounding claim is published on the page.'
link_url = ''
link_label = ''
status = 'needs_evidence'
sources = [`data/intelligence/${skuInfo.sku}/claims-evidence.json (empty)`, `data/evidence/${ingredients[0] || 'unknown'}.json`]
}
// Stat tiles: prefer regulator-precise wording from the matched EFSA claim
// (stat_1/2/3_value+name are curated regulatory labels — these are perfect for
// the Scientific Studies tiles). Only fall back to empty if no claim was matched.
const stats: { value: string; label: string; source?: string }[] = []
if (efsaClaim) {
for (let i = 1; i <= 3; i++) {
const v = (efsaClaim as any)[`stat_${i}_value`]
const n = (efsaClaim as any)[`stat_${i}_name`]
if (v && n) stats.push({ value: String(v), label: String(n), source: `claims-library.json#${efsaClaim.handle}.stat_${i}` })
}
}
qa.push(...qaText('scientificStudy.headline', headline))
qa.push(...qaText('scientificStudy.body_copy', body_copy))
return {
handle, name_internal: `${skuInfo.name}${headline}`, headline, body_copy,
link_url, link_label, stats, image: `${slug(skuInfo.sku)}-scientific-study.png`,
status, sources,
pubmedReferences: pubmedStudies.slice(0, 3).map(s => ({
pmid: s.pmid, year: s.year, journal: s.journal, title: s.title, url: s.url
})),
qa: scoreArtefact(qa)
}
}
// ============================================================================
// TIMELINE
// ============================================================================
function generateTimelineBlock(skuInfo: Pilot, claimsEv: ClaimsEvidence | null, tm: ReviewTimeMentions | null, evidence: Record<string, Evidence | null>, ingredients: string[]) {
const studyClaim = claimsEv?.claims?.find(c => c.citation?.source_url)
const cadenceWord = skuInfo.timelineCadence === 'weeks' ? 'weeks' : 'months'
// Phase-position openers per cadence — phase 1 sets expectation, mid phases describe build,
// final phase frames cumulative or maintenance.
const openers = {
months: [
'The first month is groundwork.',
'By the second month, the picture starts to fill in.',
'Month three is where many customers describe the strongest sense of routine paying off.',
'From here, it becomes a maintenance routine.'
],
weeks: [
'The first fortnight is about baseline.',
'Weeks three to four are when blood levels and routine catch up.',
'By month two, most adults reach the recommended range.',
'From here, it is a year-round maintenance routine — especially through low-sun months.'
]
}
const cadenceKey = skuInfo.timelineCadence === 'weeks' ? 'weeks' : 'months'
const phases = skuInfo.phases.map((label, i) => {
const bucket = tm?.phases?.find(p => p.label === label)
const top = (bucket?.topMentions || []).slice(0, 2)
const qa: QaCheck[] = []
let body: string
let phaseStatus: string
const phaseSources: string[] = []
const opener = openers[cadenceKey][Math.min(i, openers[cadenceKey].length - 1)]
if (top.length > 0) {
const quote = top[0]
const cleaned = quote.sentence.replace(/\s+/g, ' ').replace(/\.+$/, '').trim()
const stars = quote.stars
const days = quote.days
// Vary the framing line by quote tone heuristic + phase position
const isQuick = days <= 14
const isSlow = days >= 60
const framing =
i === 0 && isQuick
? 'Some customers do feel something quickly — like this one'
: i === 0
? 'Most customers describe these first weeks as cumulative rather than immediate — one review captures it'
: isSlow
? 'By this point reviews like this become more frequent'
: 'Customer reviews from this window often sound like this'
const closer =
i === 0
? 'The job of this phase is consistency: same time each day, with food where possible.'
: i === skuInfo.phases.length - 1
? 'Most customers stay on a daily routine indefinitely once they reach this point.'
: 'Keep the daily routine steady through this window.'
body = `${opener} ${framing}: "${cleaned}" (${stars}-star review). ${closer}`
phaseStatus = 'source_backed_draft'
phaseSources.push(
`data/intelligence/${skuInfo.sku}/_raw_reviews.json[${quote.reviewIdx}]`,
`data/intelligence/${skuInfo.sku}/review-time-mentions.json#${label}`
)
} else {
const ev = evidence[ingredients[0]]
if (ev?.studies && ev.studies.length > 0) {
const top = ev.studies[0]
body = `${opener} Few customer reviews mention this exact window — published research (${top.journal}, ${top.year}) suggests consistent daily intake is what produces measurable effects over time. The routine matters more than the dose.`
phaseStatus = 'evidence_grounded_draft'
phaseSources.push(`data/evidence/${ingredients[0]}.json#PMID:${top.pmid}`)
} else {
body = `${opener} Customer reviews in this window are sparse; the safest framing is that daily consistency, more than dose escalation, is what drives change for this product class.`
phaseStatus = 'category_default'
phaseSources.push(`data/pipeline/pilot-skus.json#${skuInfo.sku}.phases`)
}
}
qa.push(...qaText(`timelineBlock.phases[${i}].body_copy`, body))
return {
handle: `${slug(skuInfo.sku)}-phase-${i + 1}`,
phase_label: label,
body_copy: body,
status: phaseStatus,
sources: phaseSources,
qa: scoreArtefact(qa)
}
})
const blockHandle = `${slug(skuInfo.sku)}-results-timeline`
const intro = `A look at your first few ${cadenceWord}, drawn from customer reviews and supported by published research on this ingredient.`
const blockQa: QaCheck[] = []
blockQa.push(...qaText('timelineBlock.intro_copy', intro))
// Block-level scaffold check: if every phase opens the same way, flag it
blockQa.push(...qaScaffoldRepetition('timelineBlock.phases.openings', phases.map((p: any) => p.body_copy || '')))
return {
handle: blockHandle,
name_internal: `${skuInfo.name} — Results Timeline`,
headline: 'Results',
subtitle: 'What you can expect',
intro_copy: intro,
study_link_label: studyClaim?.citation?.source_title || 'Independent scientific study',
study_link_url: studyClaim?.citation?.source_url || '',
phases,
status: phases.every(p => p.status === 'source_backed_draft') ? 'source_backed_draft' : 'mixed_evidence_draft',
sources: [`data/intelligence/${skuInfo.sku}/review-time-mentions.json`, ...(studyClaim?.citation?.source_url ? [studyClaim.citation.source_url] : [])],
qa: scoreArtefact(blockQa)
}
}
// ============================================================================
// COMPARISON
// ============================================================================
function generateComparisonTable(skuInfo: Pilot, cc: CompetitorComparison | null, productTruth: ProductTruth | null) {
const truth = productTruth || {}
// ====================================================================
// TIER 0 — hand-curated comparisonDraft from pilot-skus.json
// Column names are GENERIC CATEGORIES — never specific brand names —
// matching how elite competitor brands actually structure their
// comparison tables (Grüns, Nothing Fishy, Elavate, Hunter & Gather).
// ====================================================================
const cdraft = skuInfo.comparisonDraft
if (cdraft && Array.isArray(cdraft.columns) && cdraft.columns.length > 0 && Array.isArray(cdraft.rows) && cdraft.rows.length > 0) {
const columns = cdraft.columns.map((c, i) => ({
handle: i === 0 ? 'column-jv' : `column-${slug(c.name)}`,
name: c.name,
is_us: Boolean(c.is_us),
accent_colour: c.accent_colour || (c.is_us ? '#5C6B4A' : '#9aa0a6'),
image: ''
}))
const rows = cdraft.rows.map((r, i) => {
const values: Record<string, { mark: string; value: string; note: string }> = {
'0': { mark: 'text', value: r.us || '', note: '' }
}
const competitorVals = r.competitor_values || []
for (let j = 0; j < columns.length - 1; j++) {
values[String(j + 1)] = { mark: 'text', value: competitorVals[j] || '', note: '' }
}
return {
handle: `${slug(skuInfo.sku)}-cmp-row-${i + 1}`,
feature_label: r.feature,
values,
status: 'curated_generic_categories',
sources: [`data/pipeline/pilot-skus.json#${skuInfo.sku}.comparisonDraft.rows[${i}]`]
}
})
// Structured citations — what the dashboard renders. Generic-category
// comparisons cite the product DB (for JV col), the EFSA register where
// health-claim language appears, and the competitor section extracts.
const sourceLinks: CitationLink[] = [
{ type: 'product_truth', label: 'Just Vitamins product database', meta: 'sourced from JV Migration export' },
{ type: 'efsa', label: 'EFSA / UK NHC authorised health claims register', href: 'https://www.gov.uk/government/publications/great-britain-nutrition-and-health-claims-nhc-register' },
{ type: 'other', label: 'Competitor section extracts (15 brands)', meta: 'aggregated from observed positioning' }
]
// Run QA over every cell of the curated table — catches forbidden phrases,
// engineering jargon, medical claims, and stray brand names that slipped in.
const tableQa: QaCheck[] = []
if (cdraft.section_title) tableQa.push(...qaText('comparison.section_title', cdraft.section_title))
if (cdraft.subtitle) tableQa.push(...qaText('comparison.subtitle', cdraft.subtitle))
for (const r of rows) {
const jv = (r.values as any)['0']?.value || ''
tableQa.push(...qaText(`comparison.row[${r.feature_label}].jv`, jv))
// also check competitor columns
for (let i = 1; i < columns.length; i++) {
const v = (r.values as any)[String(i)]?.value || ''
if (v) tableQa.push(...qaText(`comparison.row[${r.feature_label}].col${i}`, v))
}
}
return {
handle: `${slug(skuInfo.sku)}-comparison`,
name_internal: `${skuInfo.name}${cdraft.section_title || 'How we compare'}`,
section_title: cdraft.section_title || 'How we compare',
subtitle: cdraft.subtitle || '',
columns,
rows,
status: 'curated_compliance_reviewed',
sources: [`data/pipeline/pilot-skus.json#${skuInfo.sku}.comparisonDraft`],
sourceLinks,
complianceNotes: cdraft.complianceNotes,
qa: scoreArtefact(tableQa)
}
}
if (!cc || ((!cc.comparison || cc.comparison.length === 0) && (!cc.competitors || cc.competitors.length === 0))) {
return {
handle: `${slug(skuInfo.sku)}-comparison-placeholder`,
name_internal: `${skuInfo.name} — Comparison (needs data)`,
section_title: 'How we compare', subtitle: '',
columns: [], rows: [],
status: 'needs_input',
sources: [`data/intelligence/${skuInfo.sku}/competitor-comparison.json (missing or empty)`],
qa: { score: 0, failed: [{ pass: false, field: 'comparison', reason: 'no source data' }] }
}
}
const competitors = (cc!.competitors || []).slice(0, 3)
const columns = [
{ handle: 'column-jv', name: 'Just Vitamins', is_us: true, accent_colour: '#1a2b4d', image: '' },
...competitors.map(c => ({
handle: `column-${brandHandle(c.brand || 'competitor')}`,
name: c.brand || 'Competitor', is_us: false, accent_colour: '#9aa0a6', image: ''
}))
]
let rows: any[]
let status: string
// Path A: synthesised comparison rows (D1000, TURMERIC500)
if (cc!.comparison && cc!.comparison.length > 0) {
rows = cc!.comparison.slice(0, 8).map((row, i) => ({
handle: `${slug(skuInfo.sku)}-cmp-row-${i + 1}`,
feature_label: row.attribute || `Feature ${i + 1}`,
values: {
'0': { mark: 'text', value: row.justVitamins || '', note: row.recommendation || '' },
'1': { mark: 'text', value: row.competitorEvidence || '', note: '' }
},
status: 'source_backed_draft',
sources: [`data/intelligence/${skuInfo.sku}/competitor-comparison.json#comparison[${i}]`]
}))
status = 'source_backed_draft'
} else {
// Path B: attribute matrix from per-competitor {format, dose, priceSignal, claims}
// JV column filled from product truth
// Pull JV attributes from the synced product truth (sync-jv-product-truth.ts).
const jvFormat = truth.format || ''
const jvStrength = truth.strength || ''
const firstVariantPrice = truth.variants?.[0]?.price
const jvPrice = firstVariantPrice ? `£${firstVariantPrice} (${truth.variants?.[0]?.option || 'one pack'})` : ''
// Shopify tags column carries dietary / certification tags (e.g. "Vegan, Vegetarian, Gluten Free")
const jvCerts = (truth.tags || '')
.split(',')
.map(t => t.trim())
.filter(t => /vegan|vegetarian|gluten|halal|kosher|organic|dairy|nut|soya|gelatin|allergen|wheat|made in|british|uk/i.test(t))
.join(', ')
// jvApplicable=false means the attribute is intentionally competitor-only (e.g.
// "Opportunity" — about competitor whitespace, not JV's own value). Those rows
// do NOT count as a "missing JV column" gap.
const attrSpecs: { label: string; jvValue: string; getter: (c: Competitor) => string; jvApplicable: boolean }[] = [
{ label: 'Format', jvValue: jvFormat, getter: c => c.format || '', jvApplicable: true },
{ label: 'Dose / strength', jvValue: jvStrength, getter: c => c.dose || '', jvApplicable: true },
{ label: 'Price signal', jvValue: jvPrice, getter: c => c.priceSignal || '', jvApplicable: true },
{ label: 'Certifications', jvValue: jvCerts, getter: c => (c.claims || []).filter(x => /vegan|vegetarian|gluten|halal|kosher|organic/i.test(x)).join(', '), jvApplicable: true },
{ label: 'Brand claim', jvValue: 'EFSA-authorised wording only', getter: c => (c.claims || [])[0] || '', jvApplicable: true },
{ label: 'Opportunity (competitor whitespace)', jvValue: '', getter: c => c.whitespace || '', jvApplicable: false }
]
rows = attrSpecs
.map((spec, i) => {
const values: Record<string, { mark: string; value: string; note: string }> = {
'0': { mark: 'text', value: spec.jvValue, note: spec.jvApplicable && !spec.jvValue ? 'Fill from product truth' : '' }
}
let hasCompetitor = false
competitors.forEach((c, ci) => {
const v = spec.getter(c)
if (v) hasCompetitor = true
values[String(ci + 1)] = { mark: 'text', value: v, note: '' }
})
if (!hasCompetitor && !spec.jvValue) return null
const rowStatus = !spec.jvApplicable
? 'source_backed_draft'
: (spec.jvValue ? 'source_backed_draft' : 'needs_product_truth')
return {
handle: `${slug(skuInfo.sku)}-cmp-row-${i + 1}`,
feature_label: spec.label, values,
status: rowStatus,
sources: [
`data/intelligence/${skuInfo.sku}/competitor-comparison.json#competitors[]`,
productTruth ? `data/sources/jv-product-truth/${skuInfo.sku}.json` : `(no product truth yet)`
]
}
})
.filter(Boolean)
status = rows.some(r => r.status === 'needs_product_truth') ? 'mixed_evidence_draft' : 'source_backed_draft'
}
const tableQa: QaCheck[] = []
for (const r of rows) {
const jvVal = (r.values as any)['0']?.value || ''
if (jvVal) tableQa.push(...qaText(`comparison.row[${r.feature_label}].jv`, jvVal))
}
return {
handle: `${slug(skuInfo.sku)}-comparison`,
name_internal: `${skuInfo.name} — How we compare`,
section_title: 'How we compare', subtitle: '',
columns, rows, status,
sources: [`data/intelligence/${skuInfo.sku}/competitor-comparison.json`],
qa: scoreArtefact(tableQa)
}
}
// ============================================================================
// FAQ
// ============================================================================
function generateFaqBlock(skuInfo: Pilot, cb: ConversionBlockers | null, eliteFaqs: EliteFaqLib | null) {
// Map blocker keywords → customer-voice question + brand-voice answer template.
// The answer is *not* the blocker.fix string verbatim (that's engineering text);
// it's a Wild-Nutrition-tone template that gets the evidence cited as a candid note.
const customerVoiceMap: { keywords: RegExp; question: string; answerTemplate: string }[] = [
{
keywords: /\b(break|broken|damaged|crushed|loose|cracked)\b/i,
question: 'What if my pack arrives damaged?',
answerTemplate: 'If your order arrives damaged in transit, contact our team within 7 days of delivery and we will replace it. Outer packaging is sized to protect the bottle in transit, and bottles use a tamper-evident seal. We mention this openly because a small number of reviews have flagged transit damage — our replacement policy is the answer when that happens.'
},
{
keywords: /\b(no[- ]?effect|didn'?t notice|no difference|placebo|nothing happened)\b/i,
question: 'What if I dont notice a difference?',
answerTemplate: 'Response to any supplement varies by individual, dose, and routine consistency. If youve taken this product daily through the full recommended window and notice no difference, contact our team — our 90-day money-back guarantee covers exactly that situation. We list it candidly because some reviews mention not feeling a change; consistency over weeks is the single biggest factor for most customers.'
},
{
keywords: /\b(taste|flavou?r|aftertaste|chalky|smell)\b/i,
question: 'What does it taste like?',
answerTemplate: 'See the product description for the current flavour and format. Most customers describe the taste as clean and easy to take daily; if you prefer to mask it, mix powders into a smoothie or juice.'
},
{
keywords: /\b(swallow|tablet size|capsule size|chok|hard to take)\b/i,
question: 'Is it easy to swallow?',
answerTemplate: 'Capsules are sized for daily use; tablets are scored where the format allows. If swallowing is a concern, our team can recommend an alternative format from our range.'
},
{
keywords: /\b(format|effervescent|drink|powder|capsule|tablet form)\b/i,
question: 'What format does this product come in?',
answerTemplate: 'See the product header for the current format. Our range includes capsules, tablets, powders, and effervescent drinks across categories — each format suits a different routine.'
},
{
keywords: /\b(dose|dosage|how many|per day|per serving)\b/i,
question: 'How much should I take per day?',
answerTemplate: 'Follow the daily intake on the pack — it reflects the dose used in the published research that informs this product. Do not exceed it. If you also take another supplement containing the same ingredient, add up the daily total and stay within the authorised upper limit for adults.'
},
{
keywords: /\b(price|expensive|cost|value|cheap)\b/i,
question: 'Why is the price what it is?',
answerTemplate: 'The dose, form, and sourcing are set by the published research on this ingredient — not by hitting a price point. We publish the per-serving cost so you can compare directly with multivitamins or lower-dose alternatives.'
},
{
keywords: /\b(timing|when to take|morning|night|with food|empty stomach)\b/i,
question: 'When is the best time to take it?',
answerTemplate: 'Take this product daily at a time that fits your routine. Fat-soluble vitamins (A, D, E, K) absorb better with a meal that contains some fat; water-soluble vitamins (B, C) can be taken with or without food. Consistency matters more than time of day.'
},
{
keywords: /\b(delivery|shipping|arrive|postage|courier)\b/i,
question: 'When will my order arrive?',
answerTemplate: 'See the shipping page for current delivery windows. UK standard delivery is typically 2-4 working days from dispatch; tracking is sent by email once your order ships.'
},
{
keywords: /\b(stock|out of stock|availability|sold out)\b/i,
question: 'Is this product in stock?',
answerTemplate: 'Stock status is shown on this page. If the item is out of stock, you can join the back-in-stock list and we will email you when it returns.'
}
]
// Filter blockers: must have content + must NOT be internal-only
const internalOnlyPatterns = /\b(contamination|extraction|tagging|cross-product|workstream|metafield|tagging error|expectation management|minority)\b/i
const blockers = (cb?.blockers || [])
.filter(b => b.blocker && b.fix)
.filter(b => !internalOnlyPatterns.test(b.blocker || '') && !internalOnlyPatterns.test(b.fix || ''))
.filter(b => (b.confidence === 'high' || b.confidence === 'medium_public_snippet'))
// Tier 1 (highest quality): hand-curated product FAQs from pilot-skus.json.
// These are EFSA-cited, compliance-precise, product-truth-grounded.
const productPairs: any[] = []
const seenQuestions = new Set<string>()
for (const f of (skuInfo.productSpecificFaqs || []).slice(0, 5)) {
if (!f.question || !f.answer) continue
if (seenQuestions.has(f.question)) continue
seenQuestions.add(f.question)
productPairs.push({
handle: `${slug(skuInfo.sku)}-faq-product-${productPairs.length + 1}`,
scope: 'product' as const,
question: f.question,
answer: f.answer,
source_citation_id: `pilot-skus.json#${skuInfo.sku}.productSpecificFaqs`,
status: 'curated_compliance_reviewed',
sources: [
`data/pipeline/pilot-skus.json#${skuInfo.sku}.productSpecificFaqs`,
f.evidenceTier ? `evidence tier: ${f.evidenceTier}` : '',
f.complianceNotes ? `compliance: ${f.complianceNotes}` : ''
].filter(Boolean),
evidenceTier: f.evidenceTier,
complianceNotes: f.complianceNotes,
qa: scoreArtefact([...qaText('faq.question', f.question), ...qaText('faq.answer', f.answer)])
})
}
// Tier 2 (fallback): keyword-map customer-voice from conversion-blockers.
// Only fires if curated list didn't yield enough pairs.
if (productPairs.length < 3) {
for (const b of blockers) {
const combined = `${b.blocker || ''} ${b.fix || ''} ${b.evidence || ''}`
const match = customerVoiceMap.find(m => m.keywords.test(combined))
if (!match) continue
if (seenQuestions.has(match.question)) continue
seenQuestions.add(match.question)
productPairs.push({
handle: `${slug(skuInfo.sku)}-faq-product-${productPairs.length + 1}`,
scope: 'product' as const,
question: match.question,
answer: match.answerTemplate,
source_citation_id: `${skuInfo.sku}/conversion-blockers#rank${b.rank}`,
status: 'voice_template_keyword_matched',
sources: [`data/intelligence/${skuInfo.sku}/conversion-blockers.json#rank${b.rank}`, `keyword-match: ${match.question}`],
qa: scoreArtefact([...qaText('faq.question', match.question), ...qaText('faq.answer', match.answerTemplate)])
})
if (productPairs.length >= 3) break
}
}
// Standard-scope: pull from elite-faq-templates.json by category
const lib = eliteFaqs?.standardFaqs[skuInfo.category] || eliteFaqs?.standardFaqs.vitamin || []
const standardPairs = lib.map((s, i) => ({
handle: `${skuInfo.category}-${s.questionHandle}`,
scope: 'standard' as const,
question: s.question,
answer: s.answer,
source_citation_id: '',
status: 'voice_reviewed_pending_compliance',
sources: [`data/content/brand-voice/elite-faq-templates.json#${skuInfo.category}.${s.questionHandle}`, `voice: ${VOICE_GUIDE_PATH}`],
evidenceTier: s.evidenceTier,
complianceNotes: s.complianceNotes,
qa: scoreArtefact([...qaText('faq.question', s.question), ...qaText('faq.answer', s.answer)])
}))
const allPairs = [...productPairs, ...standardPairs]
const status = productPairs.length === 0 ? 'standard_only_voice_reviewed' : 'mixed_source_backed_and_voice_reviewed'
return {
handle: `${slug(skuInfo.sku)}-faq`,
name_internal: `${skuInfo.name} — FAQ block`,
section_title: 'Frequently asked questions',
cta_label: 'Still unsure? Get in touch',
cta_url: '/pages/contact',
pairs: allPairs,
status,
sources: [`data/intelligence/${skuInfo.sku}/conversion-blockers.json`, `data/content/brand-voice/elite-faq-templates.json`],
qa: { score: allPairs.length ? Math.round(allPairs.reduce((a, p) => a + p.qa.score, 0) / allPairs.length) : 0, failed: allPairs.flatMap(p => p.qa.failed) }
}
}
// ============================================================================
// PER-SKU
// ============================================================================
async function generateForSku(skuInfo: Pilot, eliteFaqs: EliteFaqLib | null) {
const intel = join(INTEL_DIR, skuInfo.sku)
const claimsEv = readJson<ClaimsEvidence>(join(intel, 'claims-evidence.json'))
const cc = readJson<CompetitorComparison>(join(intel, 'competitor-comparison.json'))
const cb = readJson<ConversionBlockers>(join(intel, 'conversion-blockers.json'))
const tm = readJson<ReviewTimeMentions>(join(intel, 'review-time-mentions.json'))
const productTruth = readJson<ProductTruth>(join(PRODUCT_TRUTH_DIR, `${skuInfo.sku}.json`))
const { ingredients, evidence } = (function () {
const ings = INGREDIENT_MAP[skuInfo.sku] || []
const ev: Record<string, Evidence | null> = {}
for (const ing of ings) ev[ing] = readJson<Evidence>(join(EVIDENCE_DIR, `${ing}.json`))
return { ingredients: ings, evidence: ev }
})()
const scientificStudy = generateScientificStudy(skuInfo, claimsEv, evidence, ingredients)
const timelineBlock = generateTimelineBlock(skuInfo, claimsEv, tm, evidence, ingredients)
const comparisonTable = generateComparisonTable(skuInfo, cc, productTruth)
const faqBlock = generateFaqBlock(skuInfo, cb, eliteFaqs)
const blockers: string[] = []
if (scientificStudy.status === 'needs_evidence') blockers.push('Scientific Studies: no EFSA claim AND no PubMed evidence for primary ingredient')
if (scientificStudy.status === 'needs_compliance_review') blockers.push('Scientific Studies: PubMed-grounded body but needs compliance sign-off (no EFSA wording)')
if (timelineBlock.phases.some(p => p.status === 'category_default')) blockers.push('Timeline: at least one phase lacks both review mentions and PubMed evidence')
if (comparisonTable.status === 'needs_input') blockers.push('Comparison: no competitor data')
if (comparisonTable.status === 'mixed_evidence_draft') blockers.push('Comparison: some rows missing JV column (product truth not synced)')
if (faqBlock.pairs.filter(p => p.scope === 'product').length === 0) blockers.push('FAQ: no product-specific pairs (only standard FAQs populated — consider editorial pass)')
const allChecks = [
...(scientificStudy.qa.failed || []),
...(timelineBlock.qa.failed || []),
...timelineBlock.phases.flatMap((p: any) => p.qa.failed || []),
...(comparisonTable.qa.failed || []),
...(faqBlock.qa.failed || [])
]
const scores = [scientificStudy.qa.score, timelineBlock.qa.score, comparisonTable.qa.score, faqBlock.qa.score]
const overallScore = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
const out = {
sku: skuInfo.sku,
name: skuInfo.name,
category: skuInfo.category,
generatedAt: new Date().toISOString(),
generatedBy: 'scripts/generate-pilot-content.ts',
voiceGuide: VOICE_GUIDE_PATH,
scope: '4 new PDP section types per 21 May + 1 June 2026 Loom briefs',
primaryIngredients: ingredients,
scientificStudy, timelineBlock, comparisonTable, faqBlock,
qa: {
sectionScores: {
scientificStudy: scientificStudy.qa.score,
timelineBlock: timelineBlock.qa.score,
comparisonTable: comparisonTable.qa.score,
faqBlock: faqBlock.qa.score
},
overallScore,
failedChecks: allChecks,
checksRun: ['empty', 'forbidden_phrase', 'medical_claim']
},
overallStatus: blockers.length === 0 ? 'draft_ready_for_review' : 'draft_with_gaps',
blockers
}
mkdirSync(OUT_DIR, { recursive: true })
const outFile = join(OUT_DIR, `${skuInfo.sku}.json`)
writeFileSync(outFile, JSON.stringify(out, null, 2))
console.log(`[${skuInfo.sku}] qa=${overallScore} status=${out.overallStatus} blockers=${blockers.length}`)
return out
}
async function main() {
const pilot = readJson<Pilots>(PILOT_FILE)
if (!pilot) { console.error(`Pilot lock missing: ${PILOT_FILE}`); process.exit(1) }
const eliteFaqs = readJson<EliteFaqLib>(ELITE_FAQ_FILE)
if (!eliteFaqs) console.warn(`Warning: elite FAQ templates not loaded (${ELITE_FAQ_FILE}). Falling back to empty templates.`)
const requested = process.argv.slice(2)
const targets = requested.length ? pilot.skus.filter(s => requested.includes(s.sku)) : pilot.skus
console.log(`Generating elite-grade pilot content for ${targets.length} SKUs:`)
const allOut = []
for (const s of targets) allOut.push(await generateForSku(s, eliteFaqs))
const summary = {
generatedAt: new Date().toISOString(),
voiceGuide: VOICE_GUIDE_PATH,
skuCount: allOut.length,
averageQa: Math.round(allOut.reduce((a, o) => a + o.qa.overallScore, 0) / allOut.length),
perSku: allOut.map(o => ({
sku: o.sku,
name: o.name,
overallScore: o.qa.overallScore,
sectionScores: o.qa.sectionScores,
overallStatus: o.overallStatus,
blockers: o.blockers
}))
}
writeFileSync(join(OUT_DIR, '_summary.json'), JSON.stringify(summary, null, 2))
console.log(`\nAverage QA score: ${summary.averageQa}/100`)
console.log(`Summary: ${join(OUT_DIR, '_summary.json')}`)
}
await main()