← back to Whatsmystyle
scripts/clothing-apis/headless-spa.js
189 lines
/**
* Headless-SPA adapter — scrape rendered DOM via Browserbase cloud Chrome.
*
* For brands whose products live behind a React/Next.js SPA that doesn't
* expose /products.json or /wp-json (Reformation, Kotn, Pact, Frank+Oak,
* Burton). The /products page renders client-side, so a static fetch sees
* only the SPA shell. Browserbase serves a real Chromium that runs the JS
* and gives us the rendered DOM.
*
* Cost: ~$0.001 per page (Steve already pays the Browserbase subscription).
* Gate this adapter behind the `headless_spa_enabled` config flag so it
* doesn't surprise-bill if Steve hasn't opted in for a given tick.
*
* Per-brand selector dict — each brand needs a small CSS-selector spec to
* pluck product cards out of the rendered DOM (title, price, image, href).
* That spec lives in brands.json under `headless_spa[]` and is fragile to
* vendor redesigns; expect ~quarterly maintenance.
*
* Env:
* BROWSERBASE_API_KEY — required
* BROWSERBASE_PROJECT_ID — required
*
* The adapter loads them from the secrets-managed skill .env so we don't
* have to duplicate. See ~/.claude/skills/browserbase/.env.
*/
const fetch = global.fetch || require('node-fetch');
const fs = require('fs');
const path = require('path');
// Load BB creds from the skill .env if not already in process.env.
function loadSkillEnv() {
if (process.env.BROWSERBASE_API_KEY) return;
try {
const envFile = fs.readFileSync(path.join(process.env.HOME, '.claude', 'skills', 'browserbase', '.env'), 'utf8');
envFile.split('\n').forEach(line => {
const m = line.match(/^([A-Z_]+)=(.+)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
});
} catch {}
}
loadSkillEnv();
const BB_KEY = process.env.BROWSERBASE_API_KEY;
const BB_PROJECT = process.env.BROWSERBASE_PROJECT_ID;
const COST_CENTS_PER_PAGE = Number(process.env.BB_COST_CENTS_PER_PAGE || 0.1);
function available() { return !!(BB_KEY && BB_PROJECT); }
/**
* Open a Browserbase session, navigate to URL, return rendered HTML.
* Uses the BB REST API directly (no SDK) to keep the dep surface tiny.
*/
async function fetchRenderedHtml(url, { waitMs = 3000 } = {}) {
if (!available()) throw new Error('BROWSERBASE_API_KEY or _PROJECT_ID not set');
// 1. Create a session
const s = await fetch('https://api.browserbase.com/v1/sessions', {
method: 'POST',
headers: { 'x-bb-api-key': BB_KEY, 'content-type': 'application/json' },
body: JSON.stringify({ projectId: BB_PROJECT }),
});
if (!s.ok) throw new Error(`BB session create ${s.status}`);
const session = await s.json();
const sessionId = session.id;
try {
// 2. Navigate via the BB Connect (CDP) endpoint isn't trivial without
// playwright; cleanest path is the Stagehand-less HTTP browser-action
// API, but that's also Beta. For v0.1 we use the official live debug
// URL pattern + a wait, then fetch the rendered HTML from the
// session's recording.
//
// SHORTCUT: Browserbase supports a `goto` action via the Sessions
// actions endpoint. Format may shift — wrap in try/catch.
const r = await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}/goto`, {
method: 'POST',
headers: { 'x-bb-api-key': BB_KEY, 'content-type': 'application/json' },
body: JSON.stringify({ url, waitUntil: 'networkidle', timeout: 30000 }),
});
if (!r.ok) throw new Error(`BB goto ${r.status}: ${await r.text()}`);
await new Promise(res => setTimeout(res, waitMs));
// 3. Pull the rendered HTML
const h = await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}/html`, {
headers: { 'x-bb-api-key': BB_KEY },
});
if (!h.ok) throw new Error(`BB html ${h.status}: ${await h.text()}`);
return await h.text();
} finally {
// 4. Always close the session — leaving them open burns credit.
await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'x-bb-api-key': BB_KEY, 'content-type': 'application/json' },
body: JSON.stringify({ status: 'REQUEST_RELEASE' }),
}).catch(() => {});
}
}
/**
* Parse rendered DOM with cheerio per a brand's selector spec.
*
* spec shape (in brands.json):
* {
* id, name, domain, products_path, sustain_tier, pro_grade,
* selectors: {
* card: '.product-card',
* title: '.product-card__title',
* price: '.product-card__price',
* image: 'img',
* link: 'a'
* }
* }
*/
function parseProducts(html, spec) {
let cheerio;
try { cheerio = require('cheerio'); }
catch { throw new Error('cheerio not installed — run `npm i cheerio`'); }
const $ = cheerio.load(html);
const out = [];
$(spec.selectors.card).each((i, el) => {
const $el = $(el);
const title = $el.find(spec.selectors.title).first().text().trim();
const priceRaw = $el.find(spec.selectors.price).first().text().trim();
const m = priceRaw.match(/\$?\s*([0-9,]+\.?\d{0,2})/);
const price_cents = m ? Math.round(Number(m[1].replace(/,/g, '')) * 100) : null;
const imgEl = $el.find(spec.selectors.image).first();
const image = imgEl.attr('src') || imgEl.attr('data-src') || imgEl.attr('srcset')?.split(',')[0]?.trim().split(' ')[0];
const href = $el.find(spec.selectors.link).first().attr('href') || $el.attr('href');
if (!title || !image || !price_cents) return;
out.push({
external_id: `spa:${spec.domain}:${Buffer.from(href || title).toString('base64url').slice(0, 32)}`,
title: title.slice(0, 200),
price_cents,
image_url: image.startsWith('//') ? 'https:' + image : (image.startsWith('http') ? image : `https://${spec.domain}${image}`),
product_url: href ? (href.startsWith('http') ? href : `https://${spec.domain}${href}`) : null,
});
});
return out;
}
async function importBrand(db, brandEntry) {
if (!available()) return { brand: brandEntry.name, ok: false, skipped: 'BB creds not configured' };
const { name, domain, products_path, sustain_tier, pro_grade, selectors } = brandEntry;
if (!selectors) return { brand: name, ok: false, skipped: 'no selectors spec — needs hand-craft per brand' };
const t0 = Date.now();
const url = `https://${domain}${products_path || '/products'}`;
let html;
try { html = await fetchRenderedHtml(url); }
catch (e) { return { brand: name, ok: false, err: e.message, ms: Date.now() - t0 }; }
const parsed = parseProducts(html, brandEntry);
const insert = db.prepare(`
INSERT OR IGNORE INTO items
(source, external_id, title, brand, category, price_cents, currency, image_url, product_url, tags, pro_grade)
VALUES
(@source, @external_id, @title, @brand, 'top', @price_cents, 'USD', @image_url, @product_url, @tags, @pro_grade)
`);
let imported = 0;
const tx = db.transaction(() => {
for (const p of parsed) {
const res = insert.run({
source: `spa:${domain}`,
external_id: p.external_id,
title: p.title,
brand: name,
price_cents: p.price_cents,
image_url: p.image_url,
product_url: p.product_url,
tags: JSON.stringify([]),
pro_grade,
});
if (res.changes > 0) imported++;
}
});
tx();
// Log the BB cost.
try {
db.prepare(`INSERT INTO provider_costs (provider, kind, cost_cents, meta)
VALUES ('browserbase', 'catalog-scrape', ?, ?)`)
.run(COST_CENTS_PER_PAGE, JSON.stringify({ domain, fetched: parsed.length }));
} catch {}
return { brand: name, domain, ok: true, fetched: parsed.length, imported, ms: Date.now() - t0, cost_cents: COST_CENTS_PER_PAGE };
}
module.exports = { importBrand, available, fetchRenderedHtml };