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