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>
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
#!/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 maxTotalChargeUsd = Number(Bun.env.APIFY_HEALTHSPAN_RETAILER_MAX_USD || 0.04)
|
|
|
|
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 * 6) {
|
|
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 pageFunction = String.raw`
|
|
async function pageFunction(context) {
|
|
const { request, $ } = context;
|
|
const clean = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
|
const text = clean($('body').text()).slice(0, 50000);
|
|
const headings = $('h1,h2,h3').toArray().map(el => clean($(el).text())).filter(Boolean).slice(0, 80);
|
|
const prices = $('[class*=price], [data-testid*=price], [aria-label*=price]').toArray().map(el => clean($(el).text())).filter(Boolean).slice(0, 50);
|
|
const links = $('a[href]').toArray().map(el => ({ text: clean($(el).text()).slice(0, 160), href: $(el).attr('href') })).filter(row => row.text || row.href).slice(0, 80);
|
|
return {
|
|
capturedAt: new Date().toISOString(),
|
|
sku: 'JV-VISISOFT',
|
|
sourceType: 'competitor_retailer_pdp',
|
|
label: 'Healthspan OptiVision retailer PDP fallback',
|
|
competitor: 'Healthspan',
|
|
retailer: 'Asda Pharmacy',
|
|
requestedUrl: request.url,
|
|
loadedUrl: request.loadedUrl || request.url,
|
|
title: clean($('title').text()),
|
|
h1: clean($('h1').first().text()),
|
|
headings,
|
|
prices,
|
|
links,
|
|
text,
|
|
textChars: text.length,
|
|
htmlChars: $.html().length,
|
|
matchContext: 'competitor_retailer_pdp_fallback',
|
|
note: 'Retailer PDP fallback for Healthspan OptiVision because the Healthspan direct PDP returned 0 items in Apify browser/content crawls.'
|
|
};
|
|
}`
|
|
|
|
const input = {
|
|
startUrls: [{ url: 'https://pharmacy.asda.com/medicines/healthspan-optivision' }],
|
|
maxRequestsPerCrawl: 1,
|
|
maxConcurrency: 1,
|
|
maxRequestRetries: 0,
|
|
requestTimeoutSecs: 45,
|
|
pageFunction,
|
|
proxyConfiguration: { useApifyProxy: true }
|
|
}
|
|
|
|
console.log(`Starting apify/cheerio-scraper for Healthspan OptiVision retailer fallback, maxRequests=1, cap=$${maxTotalChargeUsd}`)
|
|
const start = await apify(`/v2/acts/apify~cheerio-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: 'healthspan-retailer',
|
|
actor: 'apify/cheerio-scraper',
|
|
maxTotalChargeUsd,
|
|
input,
|
|
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
|
|
},
|
|
itemCount: Array.isArray(items) ? items.length : 0,
|
|
items
|
|
}
|
|
|
|
const stamp = generatedAt.replace(/[:.]/g, '-')
|
|
writeJson(join(rawRoot, `top3-specialist-healthspan-retailer-${stamp}.json`), result)
|
|
writeJson(join(rawRoot, 'top3-specialist-healthspan-retailer-latest.json'), result)
|
|
console.log(`Healthspan retailer fallback ${run.status}: ${result.itemCount} items, usd=${run.usageTotalUsd || 0}`)
|