← back to Pattern Vault

scripts/etsy-list.mjs

192 lines

#!/usr/bin/env node
// Etsy listing pipeline for Pattern Vault — reads kits/etsy/listings.csv and prepares
// createDraftListing payloads (type=download) for all 38 designs.
//
// SAFE BY DESIGN: dry-run unless BOTH (a) LIVE=1 and (b) Etsy OAuth creds are present.
// Without a connected shop + listings_w token it CANNOT create a live listing — it just
// writes data/etsy-listings-plan.json and prints a summary. Once the shop is connected
// (ETSY_API_KEY, ETSY_ACCESS_TOKEN, ETSY_SHOP_ID, ETSY_TAXONOMY_ID), `LIVE=1` creates the
// drafts + uploads the preview images; publishing (draft->active) stays a separate step.
//
// Flow per row (Etsy API v3):
//   1. POST /v3/application/shops/{shop_id}/listings          (createDraftListing, type=download)
//   2. POST .../listings/{listing_id}/images                 (uploadListingImage — preview)
//   3. [GATED, later] POST .../listings/{listing_id}/files   (uploadListingFile — 150-DPI master from Kamatera)
//
// Usage:
//   node scripts/etsy-list.mjs                 # dry-run: build + validate payloads, write plan
//   LIVE=1 node scripts/etsy-list.mjs          # live (only proceeds if OAuth creds present)

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const CSV = path.join(ROOT, 'kits/etsy/listings.csv');
const OUT = path.join(ROOT, 'data/etsy-listings-plan.json');

// Etsy fixed fields for these products
const TYPE = 'download';
const WHO_MADE = 'i_did';
const WHEN_MADE = '2020_2026';
const QUANTITY = 999; // Etsy digital-download max
const PRICE = 149.0;

// OAuth / shop config — absent until Steve connects the Etsy shop.
const CFG = {
  apiKey: process.env.ETSY_API_KEY || null,
  token: process.env.ETSY_ACCESS_TOKEN || null,
  shopId: process.env.ETSY_SHOP_ID || null,
  // Digital surface-pattern category. Resolve the real numeric id at wire-up via
  // getSellerTaxonomyNodes (x-api-key only) and set ETSY_TAXONOMY_ID. Left null here on purpose.
  taxonomyId: process.env.ETSY_TAXONOMY_ID || null,
};
const LIVE = process.env.LIVE === '1';

// --- robust CSV parse (quoted fields, doubled quotes, embedded commas) ---
function parseCsv(s) {
  const rows = [];
  let f = [], cur = '', inq = false;
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (inq) { if (c === '"') { if (s[i + 1] === '"') { cur += '"'; i++; } else inq = false; } else cur += c; }
    else if (c === '"') inq = true;
    else if (c === ',') { f.push(cur); cur = ''; }
    else if (c === '\n') { f.push(cur); rows.push(f); f = []; cur = ''; }
    else if (c !== '\r') cur += c;
  }
  if (cur || f.length) { f.push(cur); rows.push(f); }
  return rows.filter((r) => r.length > 1 || r[0] !== '');
}

const raw = parseCsv(fs.readFileSync(CSV, 'utf8'));
const header = raw[0];
const idx = (name) => header.indexOf(name);
const rows = raw.slice(1).map((r) => ({
  image: r[idx('image')],
  title: r[idx('title')],
  description: r[idx('description')],
  tags: (r[idx('tags')] || '').split(',').map((t) => t.trim()).filter(Boolean),
}));

// Etsy caps: title <=140 chars; <=13 tags, each <=20 chars.
const clampTitle = (t) => (t.length <= 140 ? t : t.slice(0, 137).trimEnd() + '…');
// Etsy caps tags at 20 chars. DON'T drop long tags (that silently kills the most distinctive
// compound tags like "muybridge-plate · saddle-mocha") — split them into <=20-char atoms so the
// differentiating terms survive. Dedupe, keep order, cap at 13.
const clampTags = (tags) => {
  const atoms = [];
  for (const t of tags) {
    const s = t.trim();
    if (!s) continue;
    if (s.length <= 20) atoms.push(s);
    else s.split(/[\s·|/·]+|(?<=\w)-(?=\w)/).map((a) => a.trim()).filter((a) => a.length >= 3 && a.length <= 20).forEach((a) => atoms.push(a));
  }
  return [...new Set(atoms)].slice(0, 13);
};

function buildPayload(row) {
  const warnings = [];
  if (row.title.length > 140) warnings.push('title>140 (clamped)');
  const long = row.tags.filter((t) => t.length > 20);
  if (long.length) warnings.push(`tags split into <=20-char atoms (were >20): ${long.join('|')}`);
  return {
    imageFile: row.image, // relative to kits/  -> kits/_images/*.png
    body: {
      quantity: QUANTITY,
      title: clampTitle(row.title),
      description: row.description,
      price: PRICE,
      who_made: WHO_MADE,
      when_made: WHEN_MADE,
      taxonomy_id: CFG.taxonomyId ? Number(CFG.taxonomyId) : '<SET ETSY_TAXONOMY_ID>',
      type: TYPE,
      is_supply: false,
      tags: clampTags(row.tags),
      should_auto_renew: false,
    },
    warnings,
  };
}

const plan = rows.map(buildPayload);
const totalWarnings = plan.reduce((n, p) => n + p.warnings.length, 0);

fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, JSON.stringify({
  generatedFrom: 'kits/etsy/listings.csv',
  count: plan.length,
  fixed: { type: TYPE, who_made: WHO_MADE, when_made: WHEN_MADE, quantity: QUANTITY, price: PRICE },
  endpoint: 'POST /v3/application/shops/{shop_id}/listings (createDraftListing)',
  taxonomyIdResolved: CFG.taxonomyId || null,
  plan,
}, null, 2));

console.log(`[etsy] built ${plan.length} draft-listing payloads (type=${TYPE}) -> ${path.relative(ROOT, OUT)}`);
console.log(`[etsy] payload warnings: ${totalWarnings}`);
const missing = ['apiKey', 'token', 'shopId', 'taxonomyId'].filter((k) => !CFG[k]);
if (missing.length) {
  console.log(`[etsy] DRY-RUN only — missing config: ${missing.join(', ')}`);
  console.log('[etsy] connect the shop (OAuth listings_w) + set ETSY_* env, then run LIVE=1 to create drafts.');
}

async function form(body) {
  const p = new URLSearchParams();
  for (const [k, v] of Object.entries(body)) {
    if (Array.isArray(v)) v.forEach((x) => p.append(k, x));
    else p.append(k, String(v));
  }
  return p;
}

// Resolve the digital-pattern taxonomy_id from the live seller taxonomy (x-api-key only, no OAuth).
// Prefers a "Digital Prints"/"Digital Patterns" node, else the closest "Patterns" under Craft Supplies.
async function resolveTaxonomy(apiKey) {
  const res = await fetch('https://openapi.etsy.com/v3/application/seller-taxonomy/nodes', { headers: { 'x-api-key': apiKey } });
  if (!res.ok) throw new Error('seller-taxonomy: ' + res.status);
  const { results } = await res.json();
  const flat = [];
  const walk = (n, trail) => { const p = [...trail, n.name]; flat.push({ id: n.id, path: p.join(' > ') }); (n.children || []).forEach((c) => walk(c, p)); };
  (results || []).forEach((n) => walk(n, []));
  const score = (p) => (/digital prints/i.test(p) ? 3 : /digital.*pattern|pattern.*digital/i.test(p) ? 2 : /pattern/i.test(p) ? 1 : 0);
  const best = flat.filter((x) => score(x.path) > 0).sort((a, b) => score(b.path) - score(a.path))[0];
  return best || null;
}

if (LIVE && missing.filter((m) => m !== 'taxonomyId').length === 0 && !CFG.taxonomyId) {
  try {
    const t = await resolveTaxonomy(CFG.apiKey);
    if (t) { CFG.taxonomyId = String(t.id); console.log(`[etsy] resolved taxonomy_id ${t.id} — "${t.path}"`); }
    else console.log('[etsy] could not auto-resolve taxonomy — set ETSY_TAXONOMY_ID manually.');
  } catch (e) { console.log('[etsy] taxonomy resolve failed:', e.message); }
}

if (LIVE && CFG.apiKey && CFG.token && CFG.shopId && CFG.taxonomyId) {
  const TAX = Number(CFG.taxonomyId);
  const base = `https://openapi.etsy.com/v3/application/shops/${CFG.shopId}/listings`;
  const headers = { 'x-api-key': CFG.apiKey, Authorization: `Bearer ${CFG.token}`, 'Content-Type': 'application/x-www-form-urlencoded' };
  let ok = 0, fail = 0;
  for (const item of plan) {
    try {
      item.body.taxonomy_id = TAX; // inject resolved id (build-time value may be a placeholder)
      const res = await fetch(base, { method: 'POST', headers, body: await form(item.body) });
      const j = await res.json();
      if (!res.ok) { console.error(`[etsy] FAIL "${item.body.title}": ${JSON.stringify(j)}`); fail++; continue; }
      const listingId = j.listing_id;
      // upload preview image (multipart)
      const imgPath = path.join(ROOT, 'kits', item.imageFile);
      if (fs.existsSync(imgPath)) {
        const fd = new FormData();
        fd.append('image', new Blob([fs.readFileSync(imgPath)]), path.basename(imgPath));
        fd.append('rank', '1');
        await fetch(`${base}/${listingId}/images`, { method: 'POST', headers: { 'x-api-key': CFG.apiKey, Authorization: `Bearer ${CFG.token}` }, body: fd });
      }
      console.log(`[etsy] draft ${listingId} ✓ ${item.body.title}`);
      ok++;
    } catch (e) { console.error(`[etsy] ERROR "${item.body.title}": ${e.message}`); fail++; }
  }
  console.log(`[etsy] LIVE done — ${ok} drafts created, ${fail} failed. Publish (draft->active) is a separate step.`);
} else if (LIVE) {
  console.log('[etsy] LIVE requested but config incomplete — refused (nothing sent).');
}