← back to Pattern Vault

scripts/signup/bb.js

105 lines

// Browserbase session helper — create/reuse a cloud Chromium session and
// expose a Playwright page. Session id + connect URL persist in session.json
// so successive script runs can keep working in the same browser (keepAlive).
const fs = require('fs');
const path = require('path');
const os = require('os');

const ENV_PATH = path.join(os.homedir(), '.claude/skills/browserbase/.env');
for (const line of fs.readFileSync(ENV_PATH, 'utf8').split('\n')) {
  const m = line.match(/^([A-Z_]+)=(.+)$/);
  if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
}

const API = 'https://api.browserbase.com/v1';
const HDRS = {
  'X-BB-API-Key': process.env.BROWSERBASE_API_KEY,
  'Content-Type': 'application/json',
};
const STATE = path.join(__dirname, 'session.json');

async function createSession() {
  const res = await fetch(`${API}/sessions`, {
    method: 'POST',
    headers: HDRS,
    body: JSON.stringify({
      projectId: process.env.BROWSERBASE_PROJECT_ID,
      keepAlive: true,
      // residential-ish fingerprint reduces signup-page bot friction
      browserSettings: { fingerprint: { devices: ['desktop'], locales: ['en-US'], operatingSystems: ['macos'] } },
    }),
  });
  if (!res.ok) throw new Error(`create session ${res.status}: ${await res.text()}`);
  return res.json();
}

async function getSession(id) {
  const res = await fetch(`${API}/sessions/${id}`, { headers: HDRS });
  if (!res.ok) return null;
  return res.json();
}

async function connect({ fresh = false } = {}) {
  const { chromium } = require(path.join(os.homedir(), '.npm-global/lib/node_modules/playwright'));
  let sess = null;
  if (!fresh && fs.existsSync(STATE)) {
    const saved = JSON.parse(fs.readFileSync(STATE, 'utf8'));
    const live = await getSession(saved.id);
    if (live && live.status === 'RUNNING') sess = { id: saved.id, connectUrl: saved.connectUrl };
  }
  if (!sess) {
    const s = await createSession();
    sess = { id: s.id, connectUrl: s.connectUrl };
    fs.writeFileSync(STATE, JSON.stringify(sess, null, 1));
    console.log(`[bb] new session ${s.id}`);
  } else {
    console.log(`[bb] reusing session ${sess.id}`);
  }
  const browser = await chromium.connectOverCDP(sess.connectUrl);
  const ctx = browser.contexts()[0] || (await browser.newContext());
  const page = ctx.pages()[0] || (await ctx.newPage());
  return { browser, ctx, page, sessionId: sess.id };
}

async function release(id) {
  await fetch(`${API}/sessions/${id}`, {
    method: 'POST',
    headers: HDRS,
    body: JSON.stringify({ projectId: process.env.BROWSERBASE_PROJECT_ID, status: 'REQUEST_RELEASE' }),
  }).catch(() => {});
}

// Kill common marketing/consent popups (Klaviyo et al.) that intercept clicks.
async function killPopups(page) {
  await page.evaluate(() => {
    const sels = [
      '[class*="klaviyo"]', '[id*="klaviyo"]', '[class*="kl-private"]',
      '[aria-label="POPUP Form"]', '.needsclick[role="dialog"]',
      '#onetrust-consent-sdk', '[id*="sailthru"]', '[class*="newsletter-modal"]',
    ];
    for (const s of sels) document.querySelectorAll(s).forEach((el) => el.remove());
  }).catch(() => {});
}

// Dump what's actionable on the page so flows can be written from evidence.
async function explore(page, tag) {
  await killPopups(page);
  const info = await page.evaluate(() => {
    const vis = (el) => { const r = el.getBoundingClientRect(); return r.width > 2 && r.height > 2; };
    const grab = (sel, fn) => [...document.querySelectorAll(sel)].filter(vis).slice(0, 60).map(fn);
    return {
      url: location.href,
      title: document.title,
      inputs: grab('input, select, textarea', (i) => ({ tag: i.tagName, type: i.type, name: i.name, id: i.id, ph: i.placeholder, aria: i.getAttribute('aria-label') })),
      buttons: grab('button, [role="button"], input[type="submit"]', (b) => (b.innerText || b.value || b.getAttribute('aria-label') || '').trim().slice(0, 60)).filter(Boolean),
      links: grab('a', (a) => ({ t: (a.innerText || '').trim().slice(0, 50), h: a.getAttribute('href') })).filter((l) => l.t),
    };
  });
  const shot = path.join(__dirname, 'shots', `${tag}.png`);
  await page.screenshot({ path: shot, fullPage: false }).catch(() => {});
  console.log(JSON.stringify(info, null, 1));
  console.log(`[shot] ${shot}`);
}

module.exports = { connect, release, killPopups, explore, STATE };