[object Object]

← back to Pattern Vault

spoonflower: add rung-3 Browserbase upload path (SF_BROWSERBASE=1) — cloud Chrome, residential proxy + native captcha/Cloudflare solve, fresh login; smoke-verified CF clears

c528dae2b3a914b6b0471b5855bae64862e794aa · 2026-07-16 09:39:07 -0700 · Steve Abrams

Files touched

Diff

commit c528dae2b3a914b6b0471b5855bae64862e794aa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 16 09:39:07 2026 -0700

    spoonflower: add rung-3 Browserbase upload path (SF_BROWSERBASE=1) — cloud Chrome, residential proxy + native captcha/Cloudflare solve, fresh login; smoke-verified CF clears
---
 whimsical-compare/daily/scripts/upload_sf.js | 106 ++++++++++++++++++++-------
 1 file changed, 80 insertions(+), 26 deletions(-)

diff --git a/whimsical-compare/daily/scripts/upload_sf.js b/whimsical-compare/daily/scripts/upload_sf.js
index 78b9555..7304780 100755
--- a/whimsical-compare/daily/scripts/upload_sf.js
+++ b/whimsical-compare/daily/scripts/upload_sf.js
@@ -21,9 +21,20 @@
  */
 const { chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright');
 const fs = require('fs');
+const path = require('path');
+const os = require('os');
 
 const MANIFEST = process.argv[2];
 const ARMED = process.argv.includes('--armed');
+// RUNG 3: cloud Chrome (residential proxy + native Cloudflare/captcha solving) when the local
+// browser is fingerprint-blocked. SF_BROWSERBASE=1 selects it. Logs in FRESH (no cloud profile).
+const BROWSERBASE = process.env.SF_BROWSERBASE === '1';
+// minimal KEY=value .env parser (no dotenv dep) — same shape capture_sf_session.js uses
+function loadEnvFile(p) { const o = {}; try { for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m) o[m[1]] = m[2].replace(/^"(.*)"$/, '$1'); } } catch {} return o; }
+const rzEnv = loadEnvFile(path.join(os.homedir(), '.claude/skills/resize-it/.env'));
+const bbEnv = loadEnvFile(path.join(os.homedir(), '.claude/skills/browserbase/.env'));
+const EMAIL = process.env.SPOONFLOWER_EMAIL || rzEnv.SPOONFLOWER_EMAIL;
+const PASSWORD = process.env.SPOONFLOWER_PASSWORD || rzEnv.SPOONFLOWER_PASSWORD;
 // Persistent session path (was /tmp/sf-state.json — /tmp is wiped on reboot, which
 // silently expired the Spoonflower session every restart). Override with SF_STATE.
 const STATE = process.env.SF_STATE || require('path').join(require('os').homedir(), '.config/pattern-vault/sf-state.json');
@@ -62,33 +73,55 @@ async function messyType(el, text) {
 
 (async () => {
   const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
-  const result = { armed: ARMED, date: manifest.date, uploaded: [], failed: [], sessionExpired: false };
-
-  if (!fs.existsSync(STATE)) { result.sessionExpired = true; result.error = 'no session file'; console.log(JSON.stringify(result)); return; }
-
-  // REAL Google Chrome + the SAME persistent profile capture_sf_session.js cleared Cloudflare in.
-  // The old chromium.launch() drove Playwright's bundled Chromium, which CF fingerprints and blocks;
-  // the spoofed Chrome/126 UA (const UA) mismatched the binary and made it worse. channel:'chrome'
-  // + no UA override = one honest fingerprint, and the profile carries a valid cf_clearance.
-  const PROFILE = process.env.SF_PROFILE || require('path').join(require('os').homedir(), '.config/pattern-vault/sf-chrome-profile');
-  require('fs').mkdirSync(PROFILE, { recursive: true });
-  const ctx = await chromium.launchPersistentContext(PROFILE, {
-    headless: (process.env.SF_HEADLESS === '1') ? true : !ARMED,
-    channel: 'chrome',
-    viewport: ARMED ? null : { width: 1400, height: 900 },
-    ignoreDefaultArgs: ['--enable-automation'],              // drop the automation banner
-    args: ['--start-maximized', '--disable-blink-features=AutomationControlled'], // hide navigator.webdriver
-  });
-  // Bridge the existing Spoonflower login from the legacy storageState file (portable auth cookies)
-  // in case this profile is fresh. cf_clearance is fingerprint-bound and intentionally NOT imported —
-  // real Chrome earns its own valid one.
-  try {
-    if (fs.existsSync(STATE)) {
+  const result = { armed: ARMED, date: manifest.date, uploaded: [], failed: [], sessionExpired: false, transport: BROWSERBASE ? 'browserbase' : 'local-chrome' };
+
+  let ctx, browser, bbSession;
+  const t0 = Date.now();
+  if (BROWSERBASE) {
+    // ---- RUNG 3: Browserbase cloud Chrome ----
+    // No local profile in the cloud, so we log in FRESH below with creds. proxies:true gives a
+    // residential IP (datacenter IPs are what CF auto-blocks); solveCaptchas clears the reCAPTCHA
+    // AND the Cloudflare interstitial server-side — no 2captcha, no local fingerprint at all.
+    if (!EMAIL || !PASSWORD) { result.sessionExpired = true; result.error = 'browserbase mode needs SPOONFLOWER_EMAIL/PASSWORD (resize-it/.env)'; console.log(JSON.stringify(result)); return; }
+    if (!bbEnv.BROWSERBASE_API_KEY || !bbEnv.BROWSERBASE_PROJECT_ID) { result.sessionExpired = true; result.error = 'missing BROWSERBASE_API_KEY/PROJECT_ID (skills/browserbase/.env)'; console.log(JSON.stringify(result)); return; }
+    const Browserbase = require(path.join(os.homedir(), '.claude/skills/browserbase/node_modules/@browserbasehq/sdk')).default;
+    const bb = new Browserbase({ apiKey: bbEnv.BROWSERBASE_API_KEY });
+    bbSession = await bb.sessions.create({
+      projectId: bbEnv.BROWSERBASE_PROJECT_ID,
+      proxies: true,
+      browserSettings: { solveCaptchas: true },
+    });
+    result.bbSessionId = bbSession.id;
+    // COST: Browserbase bills per session-minute (residential proxy adds a per-GB surcharge).
+    // Estimate ~$0.05–0.15 for a short upload run; actual duration is printed at close.
+    console.error(`💸 Browserbase session ${bbSession.id} started — est ~$0.05–0.15 (residential proxy + solveCaptchas). Live view: ${bbSession.debuggerUrl || bbSession.debugUrl || 'n/a'}`);
+    browser = await chromium.connectOverCDP(bbSession.connectUrl);
+    ctx = browser.contexts()[0];
+  } else {
+    // ---- RUNGS 1-2: local REAL Google Chrome + the SAME persistent profile the capture step
+    // cleared Cloudflare in. Bundled Chromium (the old chromium.launch) was what CF blocked;
+    // the spoofed Chrome/126 UA made it worse. channel:'chrome' + no UA override = one honest
+    // fingerprint, and the on-disk profile carries a valid cf_clearance.
+    if (!fs.existsSync(STATE)) { result.sessionExpired = true; result.error = 'no session file (run capture_sf_session.js --headful, or use SF_BROWSERBASE=1)'; console.log(JSON.stringify(result)); return; }
+    const PROFILE = process.env.SF_PROFILE || path.join(os.homedir(), '.config/pattern-vault/sf-chrome-profile');
+    fs.mkdirSync(PROFILE, { recursive: true });
+    ctx = await chromium.launchPersistentContext(PROFILE, {
+      headless: (process.env.SF_HEADLESS === '1') ? true : !ARMED,
+      channel: 'chrome',
+      viewport: ARMED ? null : { width: 1400, height: 900 },
+      ignoreDefaultArgs: ['--enable-automation'],              // drop the automation banner
+      args: ['--start-maximized', '--disable-blink-features=AutomationControlled'], // hide navigator.webdriver
+    });
+    // Bridge the existing Spoonflower login from the legacy storageState file (portable auth cookies)
+    // in case this profile is fresh. cf_clearance is fingerprint-bound and intentionally NOT imported.
+    try {
       const saved = JSON.parse(fs.readFileSync(STATE, 'utf8')).cookies || [];
       const authCookies = saved.filter(c => !/^cf_|__cf|cf_clearance/i.test(c.name));
       if (authCookies.length) await ctx.addCookies(authCookies).catch(() => {});
-    }
-  } catch {}
+    } catch {}
+  }
+  // transport-aware teardown: end the remote SESSION for browserbase, close the local context otherwise
+  const closeSession = async () => { try { if (browser) { await browser.close(); } else { await ctx.close(); } } catch {} finally { if (BROWSERBASE) console.error(`💸 Browserbase session ${bbSession && bbSession.id} ended after ${((Date.now() - t0) / 1000).toFixed(0)}s`); } };
   // DIAGNOSED 2026-07-09: an intermittent consent/marketing overlay (bounceexchange iframe +
   // "Your Privacy Choices / Agree & Continue") intercepts the uploader clicks. Block those
   // vendors at the network layer so the overlay never loads. (Spoonflower's own uploader is unaffected.)
@@ -113,10 +146,31 @@ async function messyType(el, text) {
     return false;
   }
 
+  // BROWSERBASE: fresh login (cloud browser has no profile). Browserbase's solveCaptchas clears the
+  // reCAPTCHA + Cloudflare interstitial server-side while we drive the form; it can take 20-40s.
+  if (BROWSERBASE) {
+    await pg.goto('https://www.spoonflower.com/login', { waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {});
+    await sleep(3000);
+    await dismissConsent();
+    const emailEl = await pg.$('input[type=email], input[name*=email i], input[placeholder*=email i]');
+    const pwEl = await pg.$('input[type=password]');
+    if (emailEl && pwEl) {
+      await emailEl.click().catch(() => {}); await emailEl.type(EMAIL, { delay: rnd(30, 90) });
+      await pwEl.click().catch(() => {}); await pwEl.type(PASSWORD, { delay: rnd(30, 90) });
+      const submit = await pg.$('button[type=submit], button:has-text("Log In"), button:has-text("Sign In")');
+      if (submit) await submit.click().catch(() => {});
+    } else {
+      result.sessionExpired = true; result.error = 'browserbase: login form not found'; await closeSession(); console.log(JSON.stringify(result)); return;
+    }
+    let signedIn = false;
+    for (let i = 0; i < 40 && !signedIn; i++) { await sleep(2000); if (!pg.url().includes('/login')) signedIn = true; }
+    if (!signedIn) { result.sessionExpired = true; result.error = 'browserbase login stuck on /login (captcha/challenge not cleared)'; await closeSession(); console.log(JSON.stringify(result)); return; }
+  }
+
   // session health check
   await pg.goto('https://www.spoonflower.com/en/my_account', { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
   await sleep(2500);
-  if (pg.url().includes('/login')) { result.sessionExpired = true; await ctx.close(); console.log(JSON.stringify(result)); return; }
+  if (pg.url().includes('/login')) { result.sessionExpired = true; await closeSession(); console.log(JSON.stringify(result)); return; }
 
   // reach the uploader the proven way: homepage -> account menu -> "UPLOAD A DESIGN"
   async function openUploader() {
@@ -230,6 +284,6 @@ async function messyType(el, text) {
     }
   }
 
-  await ctx.close();
+  await closeSession();
   console.log(JSON.stringify(result));
 })().catch(e => { console.log(JSON.stringify({ error: String(e), uploaded: [], failed: [] })); process.exit(1); });

← ee917f9 spoonflower: add plain-real-Chrome manual-login fallback (sf  ·  back to Pattern Vault  ·  spoonflower: default to Browserbase cloud (no local Chrome); 42412e9 →