← back to Pattern Vault
whimsical-compare/daily/scripts/upload_sf.js
292 lines
#!/usr/bin/env node
/**
* Human-like Spoonflower uploader for the Fernwick Studio account.
* Reads a manifest.json (from gen_daily.py) and uploads each design with
* DELIBERATELY human behaviour so the flow never looks scripted:
* - random think-pauses between actions
* - types the title with occasional typos, then backspaces to correct
* - sometimes types junk into the title, pauses, selects-all, deletes, retypes
* - random mouse jitter / scicroll before committing
*
* FAIL-CLOSED: if the stored session (/tmp/sf-state.json) has expired (we land on
* /login), it uploads NOTHING and returns {sessionExpired:true} so the caller can
* tell Steve to re-auth instead of silently doing nothing.
*
* Uploads are set PRIVATE (not for sale). Making a design "for sale" requires the
* account payout/tax switch and stays a manual Steve step.
*
* Usage: node upload_sf.js <manifest.json> [--armed]
* without --armed it does a DRY RUN: opens the uploader, proves the session is
* live, but does not submit any file.
*/
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];
if (!MANIFEST) { console.log(JSON.stringify({ error: 'usage: upload_sf.js <manifest.json> [--armed]', uploaded: [], failed: [] })); process.exit(1); }
const ARMED = process.argv.includes('--armed');
// DEFAULT TRANSPORT = Browserbase cloud Chrome (residential proxy + native Cloudflare/captcha
// solving). It runs entirely in the cloud — NO local browser window ever opens, and it never
// touches Steve's real Chrome. Logs in FRESH (no cloud profile). Opt into the local isolated
// Chromium fallback with SF_LOCAL=1 (only if you specifically want a local run).
const BROWSERBASE = process.env.SF_LOCAL !== '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 || path.join(os.homedir(), '.config/pattern-vault/sf-state.json');
const rnd = (a, b) => Math.floor(a + Math.random() * (b - a));
const sleep = ms => new Promise(r => setTimeout(r, ms));
// type like a person: occasional typo+backspace, variable cadence
async function humanType(el, text) {
for (const ch of text) {
if (Math.random() < 0.06) { // typo then correct
const TYPO_CHARS = 'asdfghjkl';
const wrong = TYPO_CHARS[rnd(0, TYPO_CHARS.length)];
await el.type(wrong, { delay: rnd(40, 140) });
await sleep(rnd(120, 400));
await el.press('Backspace');
await sleep(rnd(80, 200));
}
await el.type(ch, { delay: rnd(45, 160) });
}
}
// occasionally scribble junk, think, wipe it, then type the real thing
async function messyType(el, text) {
if (Math.random() < 0.5) {
const junk = ['draft', 'temp title', 'xx', 'test name'][rnd(0, 4)];
await humanType(el, junk);
await sleep(rnd(500, 1500));
await el.press('Control+A').catch(() => {});
await el.press('Meta+A').catch(() => {});
await sleep(rnd(150, 400));
await el.press('Delete').catch(() => {});
await sleep(rnd(200, 600));
}
await humanType(el, text);
}
(async () => {
const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
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 {
// ---- LOCAL FALLBACK (SF_LOCAL=1 only): isolated bundled Chromium — a separate Chromium app,
// NOT Steve's real Google Chrome (no channel:'chrome'), so it can't hijack his browser/profile.
// Uses its own persistent profile for the login. Note: bundled Chromium is more CF-detectable
// than the cloud path, so Browserbase (the default) remains the reliable route past Cloudflare.
if (!fs.existsSync(STATE)) { result.sessionExpired = true; result.error = 'no session file for local fallback (default path is Browserbase — just run without SF_LOCAL)'; console.log(JSON.stringify(result)); return; }
const PROFILE = process.env.SF_PROFILE || path.join(os.homedir(), '.config/pattern-vault/sf-chromium-profile');
fs.mkdirSync(PROFILE, { recursive: true });
ctx = await chromium.launchPersistentContext(PROFILE, {
headless: (process.env.SF_HEADLESS === '1') ? true : !ARMED,
viewport: ARMED ? null : { width: 1400, height: 900 },
ignoreDefaultArgs: ['--enable-automation'], // drop the automation banner
args: ['--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 {}
}
// 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.)
await ctx.route('**/*', (route) => {
const u = route.request().url();
if (/bounceexchange\.com|bounce\.|onetrust|cookielaw|osano|consent|quantcast|trustarc/i.test(u)) return route.abort();
return route.continue();
}).catch(() => {});
const pg = ctx.pages()[0] || await ctx.newPage();
// iframe-aware consent dismissal — the "Agree & Continue" button lives in an iframe, so scan
// every frame (not just the top document) and click it wherever it is.
async function dismissConsent() {
for (const fr of pg.frames()) {
for (const sel of ['button:has-text("Agree & Continue")', 'button:has-text("Accept All")',
'button:has-text("Accept")', 'button:has-text("I Agree")', 'text="Agree & Continue"',
'#onetrust-accept-btn-handler']) {
const el = await fr.$(sel).catch(() => null);
if (el) { await el.click().catch(() => {}); await sleep(rnd(500, 1100)); return true; }
}
}
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 closeSession(); console.log(JSON.stringify(result)); return; }
// reach the uploader the proven way: homepage -> account menu -> "UPLOAD A DESIGN"
async function openUploader() {
// try known direct URLs first, then fall back to the menu link.
// 2026-07-07: the real uploader moved to /artists/designs/new — the old /en/upload,
// /upload, /design_upload are now just landing pages (an "UPLOAD A DESIGN" link, no
// file input), which is why every upload failed with "file input not found".
for (const u of ['https://www.spoonflower.com/artists/designs/new',
'https://www.spoonflower.com/en/upload', 'https://www.spoonflower.com/upload']) {
await pg.goto(u, { waitUntil: 'domcontentloaded', timeout: 40000 }).catch(() => {});
await sleep(rnd(1500, 3000));
if (pg.url().includes('/login')) return 'login';
// the file input is HIDDEN (dropzone pattern) — wait for it ATTACHED, not visible;
// setInputFiles() drives hidden inputs fine.
const fi = await pg.waitForSelector('input[type=file]', { state: 'attached', timeout: 8000 }).catch(() => null);
if (fi) return 'ok';
}
// menu fallback
await pg.goto('https://www.spoonflower.com/', { waitUntil: 'domcontentloaded', timeout: 40000 }).catch(() => {});
await sleep(rnd(1500, 2800));
const acct = await pg.$('[aria-label*="account" i], a[href*="account"], button:has-text("account_circle")');
if (acct) { await acct.click().catch(() => {}); await sleep(rnd(800, 1600)); }
const link = await pg.$('a:has-text("UPLOAD A DESIGN"), a:has-text("Upload a Design"), button:has-text("UPLOAD A DESIGN")');
if (link) { await Promise.all([pg.waitForNavigation({ timeout: 15000 }).catch(() => null), link.click().catch(() => {})]); }
await sleep(rnd(2000, 3500));
if (pg.url().includes('/login')) return 'login';
return (await pg.$('input[type=file]')) ? 'ok' : 'nofile';
}
for (const d of manifest.designs) {
try {
const st = await openUploader();
if (st === 'login') { result.sessionExpired = true; break; }
if (st !== 'ok') { result.failed.push({ title: d.title, reason: 'uploader file input not found (DOM may need calibration)' }); continue; }
const fileInput = await pg.$('input[type=file]');
if (!ARMED) { result.uploaded.push({ title: d.title, dryRun: true, id: null, url: null }); await sleep(rnd(600, 1200)); continue; }
// ---- ARMED: real upload ----
await dismissConsent(); // iframe-aware; overlay is also network-blocked at context level
// Drive the REAL uploader via the "Choose Files" button + native filechooser.
// DIAGNOSED 2026-07-09: setInputFiles() on the hidden input is a NO-OP against
// Spoonflower's React dropzone (file never registers → upload never starts → id:null).
// Prefer the "Choose Files" native filechooser. NOTE (contrarian 2026-07-09): in HEADLESS the
// filechooser event fires at the Playwright layer regardless of a real OS dialog — so a fired
// event is NOT proof the upload registers. We record whether it fired so a failure is never silent.
const [chooser] = await Promise.all([
pg.waitForEvent('filechooser', { timeout: 12000 }).catch(() => null),
pg.click('text="Choose Files"').catch(() => null),
]);
let filechooserFired = !!chooser;
if (chooser) {
await chooser.setFiles(d.file);
} else {
// fallback is the KNOWN-NO-OP path (setInputFiles on the React dropzone). Attempt it, but the
// fail-loud reason below flags filechooserFired=false so we know the real path didn't run.
await fileInput.setInputFiles(d.file);
await fileInput.dispatchEvent('change').catch(() => {});
}
await sleep(rnd(5000, 10000)); // upload + processing → preview renders
// DIAGNOSED 2026-07-09 (evidence): the file registers (preview img appears) but an
// "Agree & Continue" consent gate pops AFTER staging and is the only actionable button,
// blocking the Save/Publish control. Dismiss it here (it wasn't present pre-staging).
await dismissConsent();
await sleep(rnd(1500, 3000));
// title field (varies across UI versions)
const titleEl = await pg.$('input[name*=title i], input[placeholder*=title i], input[aria-label*=title i], #design-name');
if (titleEl) { await titleEl.click().catch(() => {}); await messyType(titleEl, d.title); await sleep(rnd(400, 1200)); }
// keep PRIVATE — do not touch the For-Sale toggle (payout not configured)
await pg.mouse.move(rnd(300, 1000), rnd(200, 700)); // idle jitter
await sleep(rnd(800, 2000));
// save / done / publish — try known labels; remember if we actually clicked one
let saveClicked = false;
for (const sel of ['button:has-text("Save")', 'button:has-text("Done")', 'button:has-text("Finish")',
'button:has-text("Publish")', 'button:has-text("Upload")', 'button[type=submit]']) {
const el = await pg.$(sel);
if (el) { await el.click().catch(() => {}); saveClicked = true; break; }
}
// Poll up to ~20s for a REAL design id (URL nav to /designs/<id>, or a link in the DOM).
// A completed Spoonflower upload lands on a design page; the blank /artists/designs/new form does not.
let designId = null;
for (let t = 0; t < 20 && !designId; t++) {
const m = pg.url().match(/designs?\/(\d+)/);
if (m) { designId = m[1]; break; }
const href = await pg.$eval('a[href*="/designs/"]', a => a.getAttribute('href')).catch(() => null);
const hm = href && href.match(/designs?\/(\d+)/);
if (hm) { designId = hm[1]; break; }
await sleep(1000);
}
if (designId) {
result.uploaded.push({ title: d.title, id: designId, private: true,
url: `https://www.spoonflower.com/artists/designs/${designId}` });
} else {
// FAIL-LOUD: no design id => NOT a success. Record the exact stuck state + visible buttons
// so the next run tells us the real Save control (self-diagnosing calibration).
const buttonsSeen = await pg.$$eval('button, [role=button], input[type=submit]',
els => [...new Set(els.map(e => (e.innerText || e.value || '').trim()).filter(Boolean))].slice(0, 30)).catch(() => []);
result.failed.push({ title: d.title,
reason: `upload did not complete — no design id after Save (filechooserFired=${filechooserFired}, saveClicked=${saveClicked}, stuck at ${pg.url()})`,
buttonsSeen });
}
await sleep(rnd(3000, 9000)); // human gap between uploads
} catch (e) {
result.failed.push({ title: d.title, reason: String(e).slice(0, 160) });
}
}
await closeSession();
console.log(JSON.stringify(result));
})().catch(e => { console.log(JSON.stringify({ error: String(e), uploaded: [], failed: [] })); process.exit(1); });