Files
justvitamin/scripts/generate-scraping-only-source-model.ts
Omair Saleh 056c47581f 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>
2026-06-02 18:50:09 +08:00

187 lines
7.8 KiB
TypeScript

#!/usr/bin/env bun
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
const root = process.cwd()
const migrationCsv = join(root, '..', 'JV Migration to shopify', 'products_import_lewis_v4.csv')
const sourceRoot = join(root, 'data', 'sources')
const outRoot = join(root, 'content_population_exports')
const modelPath = join(sourceRoot, 'scraping-only-source-model.json')
const reportPath = join(outRoot, 'scraping_only_source_model.md')
const reportJsonPath = join(outRoot, 'scraping_only_source_model.json')
const intendedTop3 = [
{ intent: 'Turmeric with BioPerine', currentSku: 'JV-TURMERIC500', canonicalSku: 'JV-TURMERIC500' },
{ intent: 'Vitamin D Effervescent 1000iu', currentSku: 'JV-D1000', canonicalSku: 'JV-DEFF1000' },
{ intent: 'Visisoft Original 10mg', currentSku: 'JV-VISISOFT', canonicalSku: 'JV-VISISOFT' }
]
function writeJson(path: string, data: any) {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8')
}
function parseCsv(text: string) {
const rows: string[][] = []
let row: string[] = []
let cell = ''
let quoted = false
for (let i = 0; i < text.length; i++) {
const char = text[i]
const next = text[i + 1]
if (char === '"') {
if (quoted && next === '"') {
cell += '"'
i++
} else {
quoted = !quoted
}
} else if (char === ',' && !quoted) {
row.push(cell)
cell = ''
} else if ((char === '\n' || char === '\r') && !quoted) {
if (char === '\r' && next === '\n') i++
row.push(cell)
if (row.some(value => value !== '')) rows.push(row)
row = []
cell = ''
} else {
cell += char
}
}
row.push(cell)
if (row.some(value => value !== '')) rows.push(row)
const headers = rows.shift() || []
return rows.map(values => Object.fromEntries(headers.map((header, index) => [header, values[index] || ''])))
}
function clip(value: string, limit = 700) {
value = String(value || '').replace(/_x000D_/g, '\n')
return value.length > limit ? `${value.slice(0, limit)}` : value
}
function truthForSku(rows: any[], sku: string) {
const variants = rows.filter(row => row['Variant SKU'] === sku || String(row['Variant SKU'] || '').startsWith(`${sku}-`))
const master = variants.find(row => row.Title || row['Body HTML']) || variants[0]
if (!master) return null
return {
sku,
handle: master.Handle,
title: master.Title,
oldUrl: master['Metafield: temp.url [single_line_text_field]'],
format: master['Metafield: filter.format [single_line_text_field]'],
strength: master['Metafield: custom.strength [single_line_text_field]'] || master['Metafield: filter.strength [single_line_text_field]'],
strapline: master['Metafield: pdp.strapline [single_line_text_field]'],
recommendedIntake: master['Metafield: pdp.recommended_intake [single_line_text_field]'],
ingredients: master['Metafield: pdp.ingredients [rich_text_field]'],
uspList: master['Metafield: usp.list [list.single_line_text_field]'],
bodyHtmlPreview: clip(master['Body HTML']),
variantSkus: variants.map(row => row['Variant SKU']).filter(Boolean),
variantCount: variants.length
}
}
function sourceStatus(sku: string) {
const files = {
jvDatabase: existsSync(migrationCsv),
amazonDataforseo: existsSync(join(sourceRoot, 'dataforseo', 'products_raw')) &&
readFileSync(join(sourceRoot, 'dataforseo', 'products_raw', 'latest-products-task-summary.json'), 'utf8') !== undefined,
publicEvidence: existsSync(join(sourceRoot, 'top3-public-evidence', `${sku}.json`)),
redditRaw: existsSync(join(sourceRoot, 'reddit', 'supplements-vitamin-d-turmeric-public-snippets-2026-05-19.json')),
trustpilotRaw: existsSync(join(sourceRoot, 'trustpilot', 'just-vitamins-trustpilot-public-snippet-2026-05-19.json')),
apifyCompetitor: existsSync(join(sourceRoot, 'apify', 'raw', 'top3-market-competitor-capped-apify.json'))
}
return files
}
const rows = parseCsv(readFileSync(migrationCsv, 'utf8').replace(/^\uFEFF/, ''))
const products = intendedTop3.map(target => {
const currentTruth = truthForSku(rows, target.currentSku)
const canonicalTruth = truthForSku(rows, target.canonicalSku)
return {
...target,
status: target.currentSku === target.canonicalSku ? 'canonical_ok' : 'current_sku_mismatch_use_canonical',
currentTruth,
canonicalTruth,
scrapeOnlySources: {
productTruth: {
role: 'ground truth only; not scraped from internet',
source: migrationCsv,
status: canonicalTruth ? 'available' : 'missing'
},
amazon: {
role: 'marketplace listing/review evidence',
provider: 'DataForSEO Amazon Products/Reviews or capped Apify/Amazon actor',
status: sourceStatus(target.canonicalSku).amazonDataforseo ? 'partial_raw_available' : 'missing'
},
competitors: {
role: 'competitor PDP page copy, offer, proof, price and objections',
provider: 'Apify capped crawler or direct fetch where allowed',
status: sourceStatus(target.canonicalSku).apifyCompetitor ? 'partial_raw_available' : 'missing'
},
trustpilot: {
role: 'brand-level review voice and trust blockers',
provider: 'Trustpilot raw public snippets / approved scraper',
status: sourceStatus(target.canonicalSku).trustpilotRaw ? 'brand_raw_available' : 'missing'
},
reddit: {
role: 'category language, anxieties, comparison mental models',
provider: 'Reddit public snippets/API-safe capture',
status: sourceStatus(target.canonicalSku).redditRaw ? 'partial_category_raw_available' : 'missing'
}
}
}
})
const model = {
generatedAt: new Date().toISOString(),
mode: 'scraping_only_first',
principle: 'No PDP content generation should run until raw source capture exists and the JV migration database has supplied product truth.',
sourceRoles: [
'JV database = product truth: title, SKU, variant, format, ingredients, directions, warnings, old URL, price, barcode, stock.',
'Amazon = marketplace listing/review evidence, not product truth.',
'Competitors = positioning and offer comparison, not product truth.',
'Trustpilot = brand trust/review voice, mostly brand-level unless product-specific rows are captured.',
'Reddit = category language and objections, not product claims.'
],
products
}
writeJson(modelPath, model)
writeJson(reportJsonPath, model)
mkdirSync(dirname(reportPath), { recursive: true })
writeFileSync(reportPath, [
'# Scraping-only source model',
'',
`Generated: ${model.generatedAt}`,
'',
'## Principle',
model.principle,
'',
'## Source roles',
...model.sourceRoles.map(row => `- ${row}`),
'',
'## Top-3 canonical scrape targets',
...products.map(product => [
'',
`### ${product.intent}`,
`- Current dashboard SKU: ${product.currentSku}`,
`- Canonical scrape SKU: ${product.canonicalSku}`,
`- Status: ${product.status}`,
`- JV DB title: ${product.canonicalTruth?.title || 'MISSING'}`,
`- JV DB variant SKUs: ${(product.canonicalTruth?.variantSkus || []).join(', ') || 'MISSING'}`,
`- Product truth source: ${product.scrapeOnlySources.productTruth.status}`,
`- Amazon: ${product.scrapeOnlySources.amazon.status}`,
`- Competitors: ${product.scrapeOnlySources.competitors.status}`,
`- Trustpilot: ${product.scrapeOnlySources.trustpilot.status}`,
`- Reddit: ${product.scrapeOnlySources.reddit.status}`
].join('\n')),
'',
'## Critical correction',
'- Vitamin D Effervescent 1000iu is `JV-DEFF1000` in the JV migration database. `JV-D1000` is High Strength Vitamin D3 1000iu tablets. Scraping and downstream research must use the canonical SKU for the actual product being researched.',
''
].join('\n'), 'utf8')
console.log(`Wrote ${modelPath}`)
console.log(`Wrote ${reportPath}`)