← back to Sister Parish Onboarding
scripts/private_label.js
150 lines
#!/usr/bin/env node
/**
* Private-label the Sister Parish line on the DW sandbox.
* 1. De-dupe: group live SP products by title, keep the richest row
* (most images, tie-break lowest id), DELETE the rest.
* 2. Renumber: sorted by title, assign flat DWPH-10001..DWPH-100NN.
* roll variant -> DWPH-100NN
* sample variant-> DWPH-100NN-S (detected via /samp/i in old SKU)
* 3. Tag each kept product: add product tag `display_variant`; add metafields
* dwc.series="Saybrook House", dwc.series_code="DWPH", and
* dwc.mfr_number="SISTERPARISH-<vendor sku>" (internal reorder reference,
* NOT front-facing). Warn on any product missing a sample variant.
* 4. Write output/sku_map.json (old vendor SKU -> new DW SKU, recoverable).
*
* --plan : print the dedupe + renumber plan, change nothing.
*/
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');
const SHOP = process.env.SHOPIFY_STORE;
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
const BASE = `https://${SHOP}/admin/api/2026-01`;
const RATE_DELAY_MS = 350;
const SERIES = 'Saybrook House';
const SERIES_CODE = 'DWPH';
const START = 10001;
const PLAN = process.argv.includes('--plan');
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function shopify(method, url, body) {
const res = await fetch(url.startsWith('http') ? url : `${BASE}${url}`, {
method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!res.ok) { const e = new Error(`${method} ${url} -> ${res.status}: ${text.slice(0,300)}`); e.status = res.status; throw e; }
return { json, link: res.headers.get('link') || '' };
}
const nextPage = link => {
const m = link.split(',').find(s => s.includes('rel="next"'));
return m ? m.match(/<([^>]+)>/)[1] : null;
};
const isSample = v => /samp/i.test(v.sku || '') || /sample/i.test(v.option3 || '') || /sample/i.test(v.title || '');
(async () => {
// ---- fetch all live SP products ----
const products = [];
let url = `${BASE}/products.json?vendor=${encodeURIComponent('Sister Parish')}&limit=250`;
while (url) {
const { json, link } = await shopify('GET', url);
products.push(...json.products);
url = nextPage(link);
if (url) await sleep(RATE_DELAY_MS);
}
console.log(`Live SP products: ${products.length}`);
// ---- dedupe: group by title ----
const byTitle = new Map();
for (const p of products) {
const t = p.title;
if (!byTitle.has(t)) byTitle.set(t, []);
byTitle.get(t).push(p);
}
const keepers = [], deletes = [];
for (const [, group] of byTitle) {
group.sort((a, b) => (b.images?.length || 0) - (a.images?.length || 0) || a.id - b.id);
keepers.push(group[0]);
deletes.push(...group.slice(1));
}
keepers.sort((a, b) => a.title.localeCompare(b.title));
console.log(`Unique titles: ${keepers.length} Duplicates to delete: ${deletes.length}`);
// ---- build renumber plan ----
const VENDOR_TOKEN = 'SISTERPARISH';
const recoverMfr = sku => (sku || '').replace(/^DWSP-/i, '').replace(/^SAMP[ -]+/i, '').trim();
const map = [];
keepers.forEach((p, i) => {
const base = `${SERIES_CODE}-${START + i}`;
const samples = p.variants.filter(isSample);
const rolls = p.variants.filter(v => !isSample(v));
const vmap = [];
rolls.forEach((v, j) => vmap.push({ id: v.id, old: v.sku, new: j === 0 ? base : `${base}-R${j+1}` }));
samples.forEach((v, j) => vmap.push({ id: v.id, old: v.sku, new: j === 0 ? `${base}-S` : `${base}-S${j+1}` }));
const vendorSku = recoverMfr((rolls[0] || p.variants[0] || {}).sku);
const tags = [...new Set([...(p.tags || '').split(',').map(s => s.trim()).filter(Boolean), 'display_variant'])].join(', ');
map.push({
product_id: p.id, title: p.title, dw_base: base, variants: vmap, tags,
mfr_number: `${VENDOR_TOKEN}-${vendorSku}`, has_sample: samples.length > 0,
});
});
const missingSample = map.filter(m => !m.has_sample);
if (missingSample.length) {
console.log(`\n⚠ ${missingSample.length} product(s) have NO sample variant — fix manually:`);
missingSample.forEach(m => console.log(` ${m.title}`));
}
if (PLAN) {
console.log('\n--- DELETE ---');
deletes.forEach(p => console.log(` ✗ #${p.id} ${p.title}`));
console.log('\n--- RENUMBER ---');
map.forEach(m => {
console.log(` ${m.dw_base} ${m.title} [mfr: ${m.mfr_number}]`);
m.variants.forEach(v => console.log(` ${v.old} -> ${v.new}`));
});
return;
}
// ---- 1. delete dupes ----
let del = 0;
for (const p of deletes) {
try { await shopify('DELETE', `/products/${p.id}.json`); del++; console.log(`✗ deleted dupe #${p.id} ${p.title}`); }
catch (e) { console.log(`! delete failed #${p.id}: ${e.message.slice(0,120)}`); }
await sleep(RATE_DELAY_MS);
}
console.log(`Deleted ${del}/${deletes.length} duplicates.\n`);
// ---- 2. renumber + 3. series metafield ----
let ok = 0, fail = 0;
for (let i = 0; i < map.length; i++) {
const m = map[i];
const stamp = `[${String(i+1).padStart(3,'0')}/${map.length}]`;
try {
await shopify('PUT', `/products/${m.product_id}.json`, {
product: {
id: m.product_id,
tags: m.tags,
variants: m.variants.map(v => ({ id: v.id, sku: v.new })),
metafields: [
{ namespace: 'dwc', key: 'series', type: 'single_line_text_field', value: SERIES },
{ namespace: 'dwc', key: 'series_code', type: 'single_line_text_field', value: SERIES_CODE },
{ namespace: 'dwc', key: 'mfr_number', type: 'single_line_text_field', value: m.mfr_number },
],
},
});
ok++; console.log(`${stamp} ✓ ${m.dw_base} ${m.title}`);
} catch (e) {
fail++; console.log(`${stamp} ✗ ${m.title}: ${e.message.slice(0,150)}`);
}
await sleep(RATE_DELAY_MS);
}
fs.writeFileSync(path.join(__dirname, '..', 'output', 'sku_map.json'),
JSON.stringify({ generated: new Date().toISOString(), series: SERIES, series_code: SERIES_CODE, deleted: deletes.map(p => ({ id: p.id, title: p.title })), products: map }, null, 2));
console.log(`\nRenumbered: ${ok} Failed: ${fail} · map -> output/sku_map.json`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });