← 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); });