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