← back to Pattern Vault
wpb-uploaders/platforms/etsy.js
71 lines
/* Etsy uploader — brand: Wallpaper's Back · shop SteveAbramsStudios
* 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,
* digital download @ $149). This module BUILDS the createDraftListing payloads from that plan
* and can print them (--dry-run, $0, no network). The live POST is gated behind ETSY_OAUTH_TOKEN
* (Steve-gated) AND a resolved taxonomy_id + shop_id.
*/
const path = require('path'), os = require('os'), fs = require('fs');
const PLAN = path.join(os.homedir(), 'Projects/pattern-vault/data/etsy-listings-plan.json');
// Map one plan entry + the plan's shared `fixed` block → an Etsy v3 createDraftListing payload.
// Field names/types VERIFIED against the live Etsy Open API v3 createDraftListing schema (2026-07-09):
// request body uses `type` (NOT the response's `listing_type`), `price` is a float, and there is NO
// `state`/`is_digital` request field (createDraftListing is always a draft; digital = type:'download').
function toPayload(entry, fixed, taxonomyId) {
const b = entry.body || {};
return {
quantity: b.quantity || fixed.quantity || 999,
title: (b.title || '').slice(0, 140), // Etsy title max 140 chars
description: b.description || '',
price: Number(b.price || fixed.price), // float, required
who_made: b.who_made || fixed.who_made || 'i_did',
when_made: b.when_made || fixed.when_made || '2020_2026',
taxonomy_id: b.taxonomy_id || taxonomyId || null, // REQUIRED — resolve before live POST
type: b.type || fixed.type || 'download', // digital download (verified request field)
is_supply: b.is_supply === true, // required alongside who_made/when_made
should_auto_renew: b.should_auto_renew === true,
tags: Array.isArray(b.tags) ? b.tags.slice(0, 13) : [],// was DROPPED (contrarian catch); Etsy max 13
_imageFile: entry.imageFile || null, // internal: upload via uploadListingImage → image_ids
};
}
function buildPayloads() {
if (!fs.existsSync(PLAN)) return { error: `no plan file at ${PLAN}` };
const plan = JSON.parse(fs.readFileSync(PLAN, 'utf8'));
const fixed = plan.fixed || {};
const taxonomyId = plan.taxonomyIdResolved || null;
const payloads = (plan.plan || []).map(e => toPayload(e, fixed, taxonomyId));
return { count: payloads.length, taxonomyResolved: !!taxonomyId, price: fixed.price, payloads };
}
module.exports = {
key: 'etsy', name: 'Etsy', brand: "Wallpaper's Back", apiBased: true,
shop: 'SteveAbramsStudios',
planFile: PLAN,
endpoint: 'POST /v3/application/shops/{shop_id}/listings (createDraftListing)',
buildPayloads,
// dryRun: print the payloads we WOULD send (proves the mapping, $0, no network).
dryRun() {
const built = buildPayloads();
if (built.error) return built;
return {
dryRun: true, count: built.count, price: built.price,
taxonomyResolved: built.taxonomyResolved,
blockers: [
!process.env.ETSY_OAUTH_TOKEN && 'ETSY_OAUTH_TOKEN missing (Steve-gated OAuth)',
!built.taxonomyResolved && 'taxonomy_id unresolved (plan.taxonomyIdResolved is null)',
].filter(Boolean),
sample: built.payloads.slice(0, 2),
};
},
// live path — DISARMED: refuses without a token; even with one, the POST stays Steve-gated.
async uploadFn() {
if (!process.env.ETSY_OAUTH_TOKEN) return { reason: 'etsy DISARMED — no ETSY_OAUTH_TOKEN; run `node run.js etsy --dry-run` to preview payloads' };
return { reason: 'etsy DISARMED — token present but live createDraftListing POST is Steve-gated (arm via pending-approval)' };
},
};