← back to Pattern Vault
wpb-uploaders: disarmed per-platform framework (etsy/CM/redbubble/society6/patternbank) — anti-bot base, fail-loud, Steve-gated arming
5542bd821831cfb4169f562d901bcda3705c3091 · 2026-07-09 16:23:51 -0700 · Steve
Files touched
A wpb-uploaders/README.mdA wpb-uploaders/lib/base.jsA wpb-uploaders/lib/human.jsA wpb-uploaders/platforms/creativemarket.jsA wpb-uploaders/platforms/etsy.jsA wpb-uploaders/platforms/patternbank.jsA wpb-uploaders/platforms/redbubble.jsA wpb-uploaders/platforms/society6.jsA wpb-uploaders/run.js
Diff
commit 5542bd821831cfb4169f562d901bcda3705c3091
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 9 16:23:51 2026 -0700
wpb-uploaders: disarmed per-platform framework (etsy/CM/redbubble/society6/patternbank) — anti-bot base, fail-loud, Steve-gated arming
---
wpb-uploaders/README.md | 40 ++++++++++++++++++
wpb-uploaders/lib/base.js | 67 +++++++++++++++++++++++++++++++
wpb-uploaders/lib/human.js | 36 +++++++++++++++++
wpb-uploaders/platforms/creativemarket.js | 18 +++++++++
wpb-uploaders/platforms/etsy.js | 17 ++++++++
wpb-uploaders/platforms/patternbank.js | 15 +++++++
wpb-uploaders/platforms/redbubble.js | 15 +++++++
wpb-uploaders/platforms/society6.js | 14 +++++++
wpb-uploaders/run.js | 40 ++++++++++++++++++
9 files changed, 262 insertions(+)
diff --git a/wpb-uploaders/README.md b/wpb-uploaders/README.md
new file mode 100644
index 0000000..4e8e25a
--- /dev/null
+++ b/wpb-uploaders/README.md
@@ -0,0 +1,40 @@
+# WPB Marketplace Uploaders
+
+Per-platform design uploaders for the Wallpaper's Back marketplace operation, one module
+each (the "every vendor its own uploader" rule). Spoonflower/Fernwick keeps its own dedicated
+pipeline at `../whimsical-compare/daily/` — it is **not** here.
+
+## Platforms
+
+| Platform | Brand | Type | Status |
+|----------|-------|------|--------|
+| Etsy | Wallpaper's Back (SteveAbramsStudios) | API (`createDraftListing`) | DISARMED — needs OAuth |
+| Creative Market | Wallpaper's Back | web (Playwright) | DISARMED — needs session + DOM calibration |
+| Redbubble | Wallpaper's Back | web (Playwright) | DISARMED — AI-hostile, anti-bot critical |
+| Society6 | Wallpaper's Back | web (Playwright) | DISARMED — account pending |
+| Patternbank | Designer Wallcoverings (exception) | web (Playwright) | DISARMED — account pending |
+
+## Hard safety (baked into `lib/base.js`)
+
+- **DISARMED by default.** A live upload only fires if `platforms/<key>.ARMED` exists.
+ Absent → dry-run (no live action). **Arming any platform is Steve-gated.**
+- **Anti-bot cadence:** ≤ 4 designs/run (hard cap), human-typed fields (`lib/human.js`),
+ sporadic 30–90s gaps between uploads.
+- **Fail-loud:** a design counts as uploaded ONLY if its `uploadFn` returns a real `{id}`.
+ No id → `failed[]` with the reason. Never a false-green (the exact bug that plagued Spoonflower).
+
+## Run
+
+```sh
+node run.js list # show platforms + armed state
+node run.js creativemarket <manifest.json> # dry-run (DISARMED) — no live action
+```
+
+## Arming a platform (Steve-gated, per platform)
+
+1. Capture a login session → `sessions/<key>-state.json` (or set `ETSY_OAUTH_TOKEN` for Etsy).
+2. Calibrate the platform's `uploadFn` against the live DOM (the stub refuses to false-green until then).
+3. `touch platforms/<key>.ARMED` — ONLY on Steve's explicit go.
+
+Each platform's `uploadFn` is a DISARMED stub today: it navigates to the known upload URL and
+returns a fail-loud "not yet calibrated" reason rather than pretending to upload.
diff --git a/wpb-uploaders/lib/base.js b/wpb-uploaders/lib/base.js
new file mode 100644
index 0000000..f230f51
--- /dev/null
+++ b/wpb-uploaders/lib/base.js
@@ -0,0 +1,67 @@
+/*
+ * Shared uploader base for every WPB marketplace. Each platform module supplies a
+ * config { key, name, sessionFile, healthUrl, uploadUrl, uploadFn } and calls run().
+ *
+ * HARD SAFETY (matches the Fernwick lessons):
+ * - DISARMED by default: an upload only fires live if the platform's ARMED file exists
+ * (wpb-uploaders/platforms/<key>.ARMED). Absent => dry-run (no live action). Arming is
+ * Steve-gated.
+ * - ANTI-BOT CADENCE: at most MAX_PER_DAY (4) designs per run; human-typed fields.
+ * - FAIL-LOUD: a design only counts as uploaded if the platform's uploadFn returns a real
+ * id/url. No id => it goes to failed[] with the reason — never a false-green.
+ */
+const path = require('path'), fs = require('fs'), os = require('os');
+const CHROMIUM = '/Users/macstudio3/.npm-global/lib/node_modules/playwright';
+const { chromium } = require(CHROMIUM);
+const { rnd, sleep } = require('./human');
+
+const MAX_PER_DAY = 4; // anti-bot cadence hard cap
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/126 Safari/537.36';
+
+async function run(cfg, manifestPath) {
+ const HERE = __dirname;
+ const armedFile = path.join(HERE, '..', 'platforms', `${cfg.key}.ARMED`);
+ const ARMED = fs.existsSync(armedFile);
+ const result = { platform: cfg.key, armed: ARMED, uploaded: [], failed: [], skipped: 0, sessionExpired: false };
+
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ let designs = (manifest.designs || []).slice(0, MAX_PER_DAY); // enforce cadence cap
+ if ((manifest.designs || []).length > MAX_PER_DAY) result.skipped = manifest.designs.length - MAX_PER_DAY;
+
+ if (!fs.existsSync(cfg.sessionFile)) {
+ result.sessionExpired = true;
+ result.error = `no session file for ${cfg.name} (${cfg.sessionFile}) — capture login first`;
+ console.log(JSON.stringify(result, null, 2));
+ return result;
+ }
+
+ const b = await chromium.launch({ headless: process.env.WPB_HEADLESS === '1' ? true : !ARMED });
+ const ctx = await b.newContext({ storageState: cfg.sessionFile, userAgent: UA });
+ const pg = await ctx.newPage();
+ try {
+ // session health
+ await pg.goto(cfg.healthUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
+ await sleep(rnd(2000, 3500));
+ if (pg.url().includes('/login') || pg.url().includes('signin')) {
+ result.sessionExpired = true; console.log(JSON.stringify(result, null, 2)); await b.close(); return result;
+ }
+
+ for (const d of designs) {
+ try {
+ if (!ARMED) { result.uploaded.push({ title: d.title, dryRun: true, id: null }); await sleep(rnd(400, 900)); continue; }
+ // platform-specific upload → must return { id, url } or throw / return null
+ const r = await cfg.uploadFn(pg, d, { rnd, sleep });
+ if (r && r.id) result.uploaded.push({ title: d.title, id: r.id, url: r.url || null, private: true });
+ else result.failed.push({ title: d.title, reason: (r && r.reason) || 'no design id returned (fail-loud)' });
+ await sleep(rnd(30000, 90000)); // sporadic human gap between uploads (anti-bot)
+ } catch (e) {
+ result.failed.push({ title: d.title, reason: String(e).slice(0, 180) });
+ }
+ }
+ } catch (e) { result.error = String(e).slice(0, 200); }
+ await b.close();
+ console.log(JSON.stringify(result, null, 2));
+ return result;
+}
+
+module.exports = { run, MAX_PER_DAY, UA };
diff --git a/wpb-uploaders/lib/human.js b/wpb-uploaders/lib/human.js
new file mode 100644
index 0000000..5eaf6eb
--- /dev/null
+++ b/wpb-uploaders/lib/human.js
@@ -0,0 +1,36 @@
+/*
+ * Shared anti-bot human-behavior helpers for every WPB marketplace uploader.
+ * Enforces the marketplace-uploader-anti-bot-cadence rule: human-like typing
+ * (typos + backspace, junk-then-retype), variable delays. Reused by all platforms.
+ */
+const rnd = (a, b) => Math.floor(a + Math.random() * (b - a));
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// type with occasional typo + backspace correction and variable cadence
+async function humanType(el, text) {
+ for (const ch of text) {
+ if (Math.random() < 0.06) { // slip: type a wrong char then fix it
+ await el.type(String.fromCharCode(97 + rnd(0, 26)), { delay: rnd(40, 140) });
+ await sleep(rnd(120, 320));
+ await el.press('Backspace');
+ await sleep(rnd(80, 220));
+ }
+ await el.type(ch, { delay: rnd(45, 165) });
+ }
+}
+
+// sometimes type junk, pause, select-all, delete, then type the real value
+async function messyType(el, text) {
+ if (Math.random() < 0.4) {
+ const junk = 'asdf'.slice(0, rnd(2, 4));
+ await humanType(el, junk);
+ await sleep(rnd(300, 900));
+ await el.press('Control+A').catch(() => {});
+ await el.press('Meta+A').catch(() => {});
+ await el.press('Backspace');
+ await sleep(rnd(200, 600));
+ }
+ await humanType(el, text);
+}
+
+module.exports = { rnd, sleep, humanType, messyType };
diff --git a/wpb-uploaders/platforms/creativemarket.js b/wpb-uploaders/platforms/creativemarket.js
new file mode 100644
index 0000000..799e120
--- /dev/null
+++ b/wpb-uploaders/platforms/creativemarket.js
@@ -0,0 +1,18 @@
+/* Creative Market uploader — brand: Wallpaper's Back · email info@wallpapersback.com
+ * DISARMED. Digital seamless-pattern packs. Needs a captured login session +
+ * live DOM calibration of the upload form before arming (Steve-gated). */
+const path = require('path'), os = require('os');
+module.exports = {
+ key: 'creativemarket', name: 'Creative Market', brand: "Wallpaper's Back",
+ sessionFile: path.join(os.homedir(), 'Projects/pattern-vault/wpb-uploaders/sessions/creativemarket-state.json'),
+ healthUrl: 'https://creativemarket.com/account',
+ uploadUrl: 'https://creativemarket.com/shops/products/new',
+ // Returns {id,url} on real success, else {reason} → fail-loud. TODO: calibrate against live DOM.
+ async uploadFn(pg, d, { rnd, sleep }) {
+ await pg.goto(this.uploadUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
+ await sleep(rnd(2500, 4500));
+ // NOT YET CALIBRATED — refuse to false-green. Real steps (file, title, category,
+ // "Includes AI-generated assets" checkbox, price, publish) go here once armed + watched.
+ return { reason: 'creativemarket uploadFn not yet calibrated — DISARMED stub (needs live DOM + Steve arm)' };
+ },
+};
diff --git a/wpb-uploaders/platforms/etsy.js b/wpb-uploaders/platforms/etsy.js
new file mode 100644
index 0000000..84cb74d
--- /dev/null
+++ b/wpb-uploaders/platforms/etsy.js
@@ -0,0 +1,17 @@
+/* Etsy uploader — brand: Wallpaper's Back · shop SteveAbramsStudios
+ * DISARMED. Etsy is API-based (v3 createDraftListing) — NOT a Playwright web upload.
+ * The listing plan already exists at pattern-vault/data/etsy-listings-plan.json (38 planned,
+ * price $149 digital download). Arming = obtaining Etsy OAuth for the shop + pushing drafts.
+ * This module is API-shaped, so base.js's Playwright driver is bypassed for Etsy (see run.js). */
+const path = require('path'), os = require('os');
+module.exports = {
+ key: 'etsy', name: 'Etsy', brand: "Wallpaper's Back", apiBased: true,
+ shop: 'SteveAbramsStudios',
+ planFile: path.join(os.homedir(), 'Projects/pattern-vault/data/etsy-listings-plan.json'),
+ endpoint: 'POST /v3/application/shops/{shop_id}/listings (createDraftListing)',
+ // returns {id,url} per created draft, else {reason}. Needs ETSY_OAUTH_TOKEN + shop_id.
+ async uploadFn() {
+ if (!process.env.ETSY_OAUTH_TOKEN) return { reason: 'etsy DISARMED — no ETSY_OAUTH_TOKEN; obtain shop OAuth then push drafts from etsy-listings-plan.json' };
+ return { reason: 'etsy uploadFn not yet wired — DISARMED (token present but draft-push not enabled; Steve-gated)' };
+ },
+};
diff --git a/wpb-uploaders/platforms/patternbank.js b/wpb-uploaders/platforms/patternbank.js
new file mode 100644
index 0000000..ab7d238
--- /dev/null
+++ b/wpb-uploaders/platforms/patternbank.js
@@ -0,0 +1,15 @@
+/* Patternbank uploader — brand: Designer Wallcoverings (permanent info@dw exception)
+ * DISARMED. Curated marketplace; account 'pending' (awaiting confirmation). Needs session +
+ * live DOM calibration before arming (Steve-gated). */
+const path = require('path'), os = require('os');
+module.exports = {
+ key: 'patternbank', name: 'Patternbank', brand: 'Designer Wallcoverings',
+ sessionFile: path.join(os.homedir(), 'Projects/pattern-vault/wpb-uploaders/sessions/patternbank-state.json'),
+ healthUrl: 'https://patternbank.com/dashboard',
+ uploadUrl: 'https://patternbank.com/designer/upload',
+ async uploadFn(pg, d, { rnd, sleep }) {
+ await pg.goto(this.uploadUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
+ await sleep(rnd(2500, 5000));
+ return { reason: 'patternbank uploadFn not yet calibrated — DISARMED stub (account pending; needs live DOM + Steve arm)' };
+ },
+};
diff --git a/wpb-uploaders/platforms/redbubble.js b/wpb-uploaders/platforms/redbubble.js
new file mode 100644
index 0000000..303d1be
--- /dev/null
+++ b/wpb-uploaders/platforms/redbubble.js
@@ -0,0 +1,15 @@
+/* Redbubble uploader — brand: Wallpaper's Back · account "wallpapersback"
+ * DISARMED. AI-HOSTILE platform — anti-bot cadence (<=4/day, human-typed, sporadic) is
+ * mandatory; disclose AI per platform. Needs session + live DOM calibration before arming. */
+const path = require('path'), os = require('os');
+module.exports = {
+ key: 'redbubble', name: 'Redbubble', brand: "Wallpaper's Back",
+ sessionFile: path.join(os.homedir(), 'Projects/pattern-vault/wpb-uploaders/sessions/redbubble-state.json'),
+ healthUrl: 'https://www.redbubble.com/portfolio/manage_works',
+ uploadUrl: 'https://www.redbubble.com/portfolio/images/new',
+ async uploadFn(pg, d, { rnd, sleep }) {
+ await pg.goto(this.uploadUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
+ await sleep(rnd(3000, 6000));
+ return { reason: 'redbubble uploadFn not yet calibrated — DISARMED stub (AI-hostile; needs live DOM + Steve arm)' };
+ },
+};
diff --git a/wpb-uploaders/platforms/society6.js b/wpb-uploaders/platforms/society6.js
new file mode 100644
index 0000000..d0d572f
--- /dev/null
+++ b/wpb-uploaders/platforms/society6.js
@@ -0,0 +1,14 @@
+/* Society6 uploader — brand: Wallpaper's Back · DISARMED. POD; AI-hostile → anti-bot critical.
+ * Account is 'pending' (awaiting confirmation) — cannot arm until account is live. */
+const path = require('path'), os = require('os');
+module.exports = {
+ key: 'society6', name: 'Society6', brand: "Wallpaper's Back",
+ sessionFile: path.join(os.homedir(), 'Projects/pattern-vault/wpb-uploaders/sessions/society6-state.json'),
+ healthUrl: 'https://society6.com/artist-studio',
+ uploadUrl: 'https://society6.com/artist-studio/new',
+ async uploadFn(pg, d, { rnd, sleep }) {
+ await pg.goto(this.uploadUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
+ await sleep(rnd(3000, 6000));
+ return { reason: 'society6 uploadFn not yet calibrated — DISARMED stub (account pending; needs live DOM + Steve arm)' };
+ },
+};
diff --git a/wpb-uploaders/run.js b/wpb-uploaders/run.js
new file mode 100644
index 0000000..07026dd
--- /dev/null
+++ b/wpb-uploaders/run.js
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/*
+ * WPB marketplace uploader dispatcher.
+ * node run.js <platform> <manifest.json>
+ * node run.js list
+ *
+ * Platforms: etsy, creativemarket, redbubble, society6, patternbank.
+ * ALL DISARMED by default — an upload only fires live if platforms/<key>.ARMED exists
+ * (arming is Steve-gated). Without it, every platform runs dry-run (no live action).
+ * Fernwick/Spoonflower keeps its own dedicated pipeline (whimsical-compare/daily) — not here.
+ */
+const path = require('path'), fs = require('fs');
+const { run } = require('./lib/base');
+
+const PLATFORMS = ['etsy', 'creativemarket', 'redbubble', 'society6', 'patternbank'];
+const [, , platform, manifestArg] = process.argv;
+
+function loadPlatform(key) { return require(path.join(__dirname, 'platforms', `${key}.js`)); }
+
+(async () => {
+ if (!platform || platform === 'list') {
+ console.log('WPB uploaders (all DISARMED until Steve arms each):');
+ for (const k of PLATFORMS) {
+ const c = loadPlatform(k);
+ const armed = fs.existsSync(path.join(__dirname, 'platforms', `${k}.ARMED`));
+ console.log(` ${k.padEnd(14)} → ${c.name.padEnd(16)} brand=${c.brand.padEnd(24)} ${c.apiBased ? '[API]' : '[web]'} armed=${armed}`);
+ }
+ return;
+ }
+ if (!PLATFORMS.includes(platform)) { console.error(`unknown platform "${platform}" — one of ${PLATFORMS.join(', ')}`); process.exit(1); }
+ const cfg = loadPlatform(platform);
+ const manifest = manifestArg || path.join(require('os').homedir(), 'Projects/pattern-vault/data/catalog.json');
+
+ if (cfg.apiBased) { // Etsy path — no browser
+ const r = await cfg.uploadFn();
+ console.log(JSON.stringify({ platform: cfg.key, apiBased: true, result: r }, null, 2));
+ return;
+ }
+ await run(cfg, manifest);
+})();
← 6393b54 fernwick: fail-loud upload_sf (no false-green id:null), file
·
back to Pattern Vault
·
daily_run: count only real-id uploads (dry-run id:null no lo 0c248af →