[object Object]

← back to Dw Yolo Loop

Schumacher title fix (517 doubled-Wallcoverings) + Cole&Son MAP audit/apply + build-line title-strip handles redundant Wallcoverings

c106b30ac14a91247097675e59a796929ef2e25a · 2026-06-12 13:56:30 -0700 · Steve Abrams

Files touched

Diff

commit c106b30ac14a91247097675e59a796929ef2e25a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 12 13:56:30 2026 -0700

    Schumacher title fix (517 doubled-Wallcoverings) + Cole&Son MAP audit/apply + build-line title-strip handles redundant Wallcoverings
---
 artmura-site/build-line.js                    |  3 +-
 scripts/cole-son-apply-map.js                 | 36 ++++++++++++++
 scripts/cole-son-price-audit.js               | 72 +++++++++++++++++++++++++++
 scripts/fix-schumacher-double-wallcovering.js | 52 +++++++++++++++++++
 4 files changed, 162 insertions(+), 1 deletion(-)

diff --git a/artmura-site/build-line.js b/artmura-site/build-line.js
index 97cc23f..2af85c0 100644
--- a/artmura-site/build-line.js
+++ b/artmura-site/build-line.js
@@ -73,7 +73,8 @@ async function metafields(pid) {
     const yard = nonSample.sort((a, b) => parseFloat(b.price) - parseFloat(a.price))[0] || p.variants[0];
     const sku = (yard.sku || '').trim().replace(/-sample$/i, '');   // sample-only products: drop the -SAMPLE suffix
     const tags = (p.tags || '').split(',').map(s => s.trim()).filter(Boolean);
-    const baseTitle = p.title.replace(/\s*\|\s*[^|]+$/, '').replace(/\s+Wallcovering$/i, '');
+    // strip the trailing " | Vendor" and any redundant trailing "Wallcovering(s)" (incl. doubled)
+    const baseTitle = p.title.replace(/\s*\|\s*[^|]+$/, '').replace(/(\s+Wallcoverings?){1,2}\s*$/i, '').trim();
     // skip placeholder/junk products: title is just the vendor name (e.g. "Schumacher | Schumacher
     // Wallcovering") or no image — these have no real pattern and pollute the lookbook.
     const cleanBase = baseTitle.replace(/\s+/g, ' ').trim().toLowerCase();
diff --git a/scripts/cole-son-apply-map.js b/scripts/cole-son-apply-map.js
new file mode 100644
index 0000000..fcf225c
--- /dev/null
+++ b/scripts/cole-son-apply-map.js
@@ -0,0 +1,36 @@
+#!/usr/bin/env node
+/* Apply MAP pricing to Cole & Son from the audit (/tmp/cs_audit.json).
+   - fixable: $4.25 sample-only → set yard variant to MAP
+   - offMap:  real price ≠ MAP  → set yard variant to MAP (Kravet-family prices AT MAP)
+   Samples stay $4.25. Yard variant only. DRY RUN by default; --apply writes live. */
+const fs = require('fs');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.T;
+const APPLY = process.argv.includes('--apply');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const a = JSON.parse(fs.readFileSync('/tmp/cs_audit.json', 'utf8'));
+const todo = [...a.fixable, ...a.offMap].filter(x => x.map > 0 && x.vid);
+
+async function api(p, opts = {}, tries = 5) {
+  for (let i = 0; i < tries; i++) {
+    const r = await fetch(`https://${STORE}/admin/api/2024-10${p}`, { ...opts, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(opts.headers || {}) } });
+    if (r.status === 429 || r.status >= 500) { await sleep(1500 * (i + 1)); continue; }
+    await sleep(100); return r;
+  }
+  throw new Error('api fail ' + p);
+}
+
+(async () => {
+  console.log(`Cole & Son MAP apply — ${todo.length} variants (${a.fixable.length} $4.25→MAP + ${a.offMap.length} off-MAP→MAP) | mode: ${APPLY ? 'APPLY' : 'DRY RUN'}\n`);
+  todo.slice(0, 8).forEach(x => console.log(`  ${x.code}  $${x.cur} → $${x.map}  [${x.status}]  ${x.title.slice(0,40)}`));
+  if (!APPLY) { console.log(`\nDRY RUN — re-run with --apply.`); return; }
+  let ok = 0, fail = 0;
+  for (const x of todo) {
+    try {
+      const r = await api(`/variants/${x.vid}.json`, { method: 'PUT', body: JSON.stringify({ variant: { id: x.vid, price: String(x.map) } }) });
+      if (r.ok) { ok++; if (ok % 50 === 0) console.log(`  …${ok}/${todo.length}`); }
+      else { fail++; console.log(`  FAIL ${x.code} ${r.status}`); }
+    } catch (e) { fail++; console.log(`  ERR ${x.code} ${e.message}`); }
+  }
+  console.log(`\nDONE — ${ok} repriced, ${fail} failed.`);
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/cole-son-price-audit.js b/scripts/cole-son-price-audit.js
new file mode 100644
index 0000000..48022e3
--- /dev/null
+++ b/scripts/cole-son-price-audit.js
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+/* Read-only Cole & Son pricing audit: join every live product to MAP (kravet_master_price,
+   MAP = wholesale x 1.5) via the custom.manufacturer_sku metafield. Classifies:
+   - sample-only $4.25 with a MAP available  → fixable (set yard variant to MAP)
+   - real price that differs from MAP         → off-MAP (over/under)
+   - no MAP match                             → can't auto-fix
+   Writes /tmp/cs_audit.json with the full fix plan. NO writes to Shopify. */
+const fs = require('fs');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.T;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+const MAP = {};
+fs.readFileSync('/tmp/cs_map.txt', 'utf8').trim().split('\n').forEach(l => {
+  const [sku, m] = l.split('|'); if (sku) MAP[sku.trim().toUpperCase()] = parseFloat(m);
+});
+
+async function api(p, tries = 5) {
+  for (let i = 0; i < tries; i++) {
+    const r = await fetch(`https://${STORE}/admin/api/2024-10${p}`, { headers: { 'X-Shopify-Access-Token': TOKEN } });
+    if (r.status === 429 || r.status >= 500) { await sleep(1500 * (i + 1)); continue; }
+    await sleep(90); return r;
+  }
+  throw new Error('api fail ' + p);
+}
+async function getAll() {
+  let url = `/products.json?vendor=Cole%20%26%20Son&limit=250&fields=id,title,status,variants`;
+  const out = [];
+  while (url) {
+    const r = await api(url); const link = r.headers.get('Link') || '';
+    out.push(...((await r.json()).products || []));
+    const m = link.split(',').find(s => s.includes('rel="next"'));
+    url = m ? m.slice(m.indexOf('<') + 1, m.indexOf('>')).replace(/^https:\/\/[^/]+\/admin\/api\/[^/]+/, '') : null;
+  }
+  return out;
+}
+const isSample = v => (v.option1 || '').toLowerCase() === 'sample' || /-sample$/i.test(v.sku || '');
+
+(async () => {
+  const prods = await getAll();
+  console.log(`auditing ${prods.length} Cole & Son products...`);
+  const r = { fixable: [], offMap: [], noMap: [], ok: [] };
+  let i = 0;
+  for (const p of prods) {
+    if (++i % 200 === 0) console.log(`  …${i}/${prods.length}`);
+    const mf = await (await api(`/products/${p.id}/metafields.json`)).json();
+    const msku = (mf.metafields || []).find(m => m.namespace === 'custom' && m.key === 'manufacturer_sku');
+    const code = msku ? String(msku.value).trim().toUpperCase() : null;
+    const map = code ? MAP[code] : null;
+    const yard = p.variants.filter(v => !isSample(v)).sort((a, b) => parseFloat(b.price) - parseFloat(a.price))[0] || p.variants[0];
+    const yardPrice = parseFloat(yard.price);
+    const rec = { id: p.id, vid: yard.id, title: p.title, status: p.status, code, map, cur: yardPrice };
+    if (!map) { r.noMap.push(rec); continue; }
+    if (yardPrice <= 4.25) r.fixable.push(rec);                         // sample-only, MAP available
+    else if (Math.abs(yardPrice - map) > 0.5) r.offMap.push(rec);       // real price ≠ MAP
+    else r.ok.push(rec);                                                 // at MAP
+  }
+  fs.writeFileSync('/tmp/cs_audit.json', JSON.stringify(r, null, 0));
+  const sum = k => r[k].length;
+  const active = a => a.filter(x => x.status === 'active').length;
+  console.log('\n=== COLE & SON PRICING AUDIT ===');
+  console.log(`at MAP (correct):        ${sum('ok')}  (active ${active(r.ok)})`);
+  console.log(`FIXABLE ($4.25 → MAP):   ${sum('fixable')}  (active+live ${active(r.fixable)})`);
+  console.log(`OFF-MAP (real ≠ MAP):    ${sum('offMap')}  (active ${active(r.offMap)})`);
+  console.log(`no MAP match:            ${sum('noMap')}  (active ${active(r.noMap)})`);
+  const overMap = r.offMap.filter(x => x.cur > x.map);
+  console.log(`  of off-MAP, OVER map:  ${overMap.length}  (total $${overMap.reduce((s,x)=>s+(x.cur-x.map),0).toFixed(0)} above MAP)`);
+  console.log('\nsample FIXABLE (now $4.25 → should be MAP):');
+  r.fixable.slice(0, 6).forEach(x => console.log(`  ${x.code}  $4.25 → $${x.map}   ${x.title.slice(0,45)}`));
+  console.log('\nsample OFF-MAP:');
+  r.offMap.slice(0, 6).forEach(x => console.log(`  ${x.code}  $${x.cur} → MAP $${x.map}  (${x.cur>x.map?'+':''}${(x.cur-x.map).toFixed(0)})  ${x.title.slice(0,40)}`));
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/fix-schumacher-double-wallcovering.js b/scripts/fix-schumacher-double-wallcovering.js
new file mode 100644
index 0000000..f684584
--- /dev/null
+++ b/scripts/fix-schumacher-double-wallcovering.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/* Fix Schumacher product titles with a redundant doubled "Wallcoverings Wallcovering(s)".
+   "{name} Wallcoverings Wallcovering | Schumacher" → "{name} Wallcovering | Schumacher".
+   DRY RUN by default; --apply writes live (PUT title only). Read-modify is title-only, idempotent. */
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.T;
+const APPLY = process.argv.includes('--apply');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// collapse any run of Wallcovering(s) Wallcovering(s)[ ...] → single "Wallcovering"
+function fixTitle(t) {
+  let f = t.replace(/\bWallcoverings?\s+Wallcoverings?\b/gi, 'Wallcovering');
+  f = f.replace(/\s{2,}/g, ' ').replace(/\s+\|/g, ' |').trim();
+  return f;
+}
+
+async function api(p, opts = {}, tries = 5) {
+  for (let i = 0; i < tries; i++) {
+    const r = await fetch(`https://${STORE}/admin/api/2024-10${p}`, { ...opts, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(opts.headers || {}) } });
+    if (r.status === 429 || r.status >= 500) { await sleep(1500 * (i + 1)); continue; }
+    await sleep(90); return r;
+  }
+  throw new Error('api fail ' + p);
+}
+async function getAll() {
+  let url = `/products.json?vendor=Schumacher&limit=250&fields=id,title,status`;
+  const out = [];
+  while (url) {
+    const r = await api(url); const link = r.headers.get('Link') || '';
+    out.push(...((await r.json()).products || []));
+    const m = link.split(',').find(s => s.includes('rel="next"'));
+    url = m ? m.slice(m.indexOf('<') + 1, m.indexOf('>')).replace(/^https:\/\/[^/]+\/admin\/api\/[^/]+/, '') : null;
+  }
+  return out;
+}
+
+(async () => {
+  const all = await getAll();
+  const todo = all.map(p => ({ ...p, fixed: fixTitle(p.title) })).filter(p => p.fixed !== p.title);
+  console.log(`Schumacher: ${all.length} | titles to fix: ${todo.length} | mode: ${APPLY ? 'APPLY' : 'DRY RUN'}\n`);
+  todo.slice(0, 10).forEach(p => console.log(`  ${p.title}\n   → ${p.fixed}\n`));
+  if (!APPLY) { console.log(`DRY RUN — re-run with --apply to write ${todo.length} titles.`); return; }
+  let ok = 0, fail = 0;
+  for (const p of todo) {
+    try {
+      const r = await api(`/products/${p.id}.json`, { method: 'PUT', body: JSON.stringify({ product: { id: p.id, title: p.fixed } }) });
+      if (r.ok) { ok++; if (ok % 50 === 0) console.log(`  …${ok}/${todo.length}`); }
+      else { fail++; console.log(`  FAIL ${p.id} ${r.status}`); }
+    } catch (e) { fail++; console.log(`  ERR ${p.id} ${e.message}`); }
+  }
+  console.log(`\nDONE — ${ok} fixed, ${fail} failed.`);
+})().catch(e => { console.error(e); process.exit(1); });

← ee9be2e Brands page redesign: pill-tile logo grid for /pages/brands  ·  back to Dw Yolo Loop  ·  Brands page: 62 real logos (vendor-logos CDN set + 14 dig-an 783ea1b →