← 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
M whimsical-compare/daily/scripts/upload_sf.js
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 →