Files
justvitamin/api/server.ts
T

139 lines
5.5 KiB
TypeScript

import { join } from 'path'
const PORT = 3456
const PIPELINE_ROOT = process.env.PIPELINE_ROOT ||
join(import.meta.dir, '..', 'data', 'pipeline')
const INTEL_ROOT = join(import.meta.dir, '..', 'data', 'intelligence')
const DIST_ROOT = join(import.meta.dir, '..', 'dist')
const IS_PROD = process.env.NODE_ENV === 'production'
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Content-Type': 'application/json'
}
async function readJson(path: string) {
const f = Bun.file(path)
if (!(await f.exists())) return null
return f.json()
}
async function getProducts() {
const catalog = await readJson(join(PIPELINE_ROOT, 'catalog.json')) as any[]
const images = await readJson(join(PIPELINE_ROOT, 'product-images.json')) as Record<string, any>
return catalog.map((p: any) => {
const img = images?.[p.sku] || {}
const intelPath = join(INTEL_ROOT, p.sku, 'review-aspects.json')
return {
sku: p.sku,
name: img.name || p.sku,
totalReviews: p.total_reviews || 0,
variants: p.variants || [],
imageUrl: img.image_url || null,
productUrl: img.url ? `https://www.justvitamins.co.uk${img.url}` : null,
hasIntelligence: Bun.file(intelPath).exists()
}
}).sort((a: any, b: any) => b.totalReviews - a.totalReviews)
}
async function serveStatic(pathname: string): Promise<Response | null> {
if (!IS_PROD) return null
// Strip query string, decode
const clean = decodeURIComponent(pathname.split('?')[0])
const filePath = join(DIST_ROOT, clean === '/' ? 'index.html' : clean)
const f = Bun.file(filePath)
if (await f.exists()) {
const isHtml = filePath.endsWith('.html')
return new Response(f, { headers: isHtml ? { 'Cache-Control': 'no-cache, no-store, must-revalidate' } : {} })
}
// SPA fallback — serve index.html for all non-file routes
const index = Bun.file(join(DIST_ROOT, 'index.html'))
if (await index.exists()) return new Response(index, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache, no-store, must-revalidate' } })
return null
}
// In-memory cache for scraped image URLs per SKU
const imageCache: Record<string, string[]> = {}
async function scrapeProductImages(sku: string): Promise<string[]> {
if (imageCache[sku]) return imageCache[sku]
const images = await readJson(join(PIPELINE_ROOT, 'product-images.json')) as Record<string, any>
const productPath = images?.[sku]?.url
if (!productPath) return []
try {
const res = await fetch(`https://www.justvitamins.co.uk${productPath}`, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }
})
const html = await res.text()
// Extract filenames like {uuid}-{n}.jpg from anywhere in the page
const matches = [...html.matchAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-\d+)\.jpg/g)]
const filenames = [...new Set(matches.map(m => m[1]))]
const urls = filenames.map(f => `https://images.justvitamins.co.uk/product-images/Normal/${f}.jpg`)
imageCache[sku] = urls
return urls
} catch { return [] }
}
const server = Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url)
const path = url.pathname
if (req.method === 'OPTIONS') return new Response(null, { headers: cors })
// API routes
if (path.startsWith('/api/')) {
// GET /api/products
if (path === '/api/products') {
const products = await getProducts()
const resolved = await Promise.all(
products.map(async (p: any) => ({ ...p, hasIntelligence: await p.hasIntelligence }))
)
return new Response(JSON.stringify(resolved), { headers: cors })
}
// GET /api/product-images/:sku
const imgMatch = path.match(/^\/api\/product-images\/([^/]+)$/)
if (imgMatch) {
const urls = await scrapeProductImages(imgMatch[1])
return new Response(JSON.stringify({ urls }), { headers: cors })
}
// GET /api/intelligence/:sku/:view
const intelMatch = path.match(/^\/api\/intelligence\/([^/]+)\/([^/]+)$/)
if (intelMatch) {
const [, sku, view] = intelMatch
if (view === 'all') {
const views = ['review-aspects', 'conversion-driver', 'strengths-weaknesses', 'conversion-blockers', 'improvements', 'image-audit', 'photo-brief', 'competitor-analysis']
const result: Record<string, any> = {}
for (const v of views) {
const data = await readJson(join(INTEL_ROOT, sku, `${v}.json`))
if (data) result[v.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase())] = data
}
return Object.keys(result).length
? new Response(JSON.stringify(result), { headers: cors })
: new Response(JSON.stringify({ error: 'No intelligence data' }), { status: 404, headers: cors })
}
const data = await readJson(join(INTEL_ROOT, sku, `${view}.json`))
return data
? new Response(JSON.stringify(data), { headers: cors })
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
}
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
}
// Static file serving in production
const static_ = await serveStatic(path)
if (static_) return static_
return new Response('Not found', { status: 404 })
}
})
console.log(`JV Dashboard running on http://localhost:${PORT} (${IS_PROD ? 'production' : 'dev'})`)