← back to Designer Wallcoverings
Luxuria retitle dry-run tool — color:tag path proven lossy/colliding
98abb531ef4c8b0623eb7c65a56fa0f163204617 · 2026-07-06 14:37:13 -0700 · Steve
Files touched
A scripts/luxuria_retitle.js
Diff
commit 98abb531ef4c8b0623eb7c65a56fa0f163204617
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 6 14:37:13 2026 -0700
Luxuria retitle dry-run tool — color:tag path proven lossy/colliding
---
scripts/luxuria_retitle.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 55 insertions(+)
diff --git a/scripts/luxuria_retitle.js b/scripts/luxuria_retitle.js
new file mode 100644
index 00000000..10b12103
--- /dev/null
+++ b/scripts/luxuria_retitle.js
@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+/**
+ * Fix Luxuria title-collapse: many active "Luxuria <coarse>" products share a title,
+ * but each carries a more-specific `color:<X>` tag. Propose title = "Luxuria <X>".
+ * DRY-RUN by default (analyze + enumerate collisions, NO writes). --apply to write.
+ */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const SHOP = process.env.SHOPIFY_STORE_DOMAIN;
+const TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN;
+const VER = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-01';
+const GQL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+const REST = `https://${SHOP}/admin/api/${VER}`;
+const APPLY = process.argv.includes('--apply');
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function gql(q) { const r = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q }) }); const j = await r.json(); if (j.errors) throw new Error(JSON.stringify(j.errors)); return j.data; }
+async function rest(m, p, b) { const r = await fetch(`${REST}${p}`, { method: m, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: b ? JSON.stringify(b) : undefined }); if (!r.ok) throw new Error(`${r.status}: ${(await r.text()).slice(0, 150)}`); return r.json(); }
+
+const colorTag = (tags) => { const t = tags.find(x => /^color:/i.test(x)); return t ? t.split(':')[1].trim() : null; };
+const title2 = (t) => t.replace(/\s*\|\s*Phillipe Romano.*$/i, '').trim();
+
+(async () => {
+ const items = []; let cursor = null;
+ for (let pg = 0; pg < 12; pg++) {
+ const after = cursor ? `, after:"${cursor}"` : '';
+ const d = await gql(`{ products(first:100${after}, query:"title:*Luxuria* AND status:active"){ pageInfo{hasNextPage endCursor} edges{ node{ id title tags variants(first:1){edges{node{sku}}} } } } }`);
+ for (const e of d.products.edges) { const n = e.node; const sku = n.variants.edges[0]?.node?.sku || ''; if (sku.endsWith('-Sample')) continue; items.push({ id: n.id.split('/').pop(), title: n.title, ctitle: title2(n.title), tag: colorTag(n.tags) }); }
+ if (!d.products.pageInfo.hasNextPage) break; cursor = d.products.pageInfo.endCursor; await sleep(200);
+ }
+
+ // proposed new title
+ for (const it of items) it.proposed = it.tag ? `Luxuria ${it.tag}` : null;
+ const before = new Set(items.map(i => i.ctitle));
+ const withTag = items.filter(i => i.proposed);
+ const noTag = items.filter(i => !i.proposed);
+ // collisions AFTER: proposed titles that still repeat
+ const propCounts = {}; for (const i of withTag) propCounts[i.proposed] = (propCounts[i.proposed] || 0) + 1;
+ const stillDup = Object.entries(propCounts).filter(([, c]) => c > 1);
+ const changed = withTag.filter(i => i.proposed !== i.ctitle);
+
+ console.log(`${APPLY ? '' : '[DRY-RUN] '}Luxuria retitle from color: tag`);
+ console.log(`Active Luxuria products: ${items.length}`);
+ console.log(` distinct titles NOW: ${before.size}`);
+ console.log(` have a color: tag: ${withTag.length} | NO color: tag (can't retitle): ${noTag.length}`);
+ console.log(` would CHANGE title: ${changed.length}`);
+ console.log(` proposed titles that STILL collide: ${stillDup.length} groups (${stillDup.reduce((s, [, c]) => s + c, 0)} products)`);
+ console.log(`\nSample changes:`); changed.slice(0, 15).forEach(i => console.log(` ${i.id} "${i.ctitle}" → "${i.proposed}"`));
+ if (stillDup.length) { console.log(`\nStill-colliding proposed titles (top 10):`); stillDup.sort((a, b) => b[1] - a[1]).slice(0, 10).forEach(([t, c]) => console.log(` ${c}x ${t}`)); }
+ if (noTag.length) { console.log(`\nNo-tag examples (would be left as-is):`); noTag.slice(0, 6).forEach(i => console.log(` ${i.id} "${i.ctitle}"`)); }
+
+ if (!APPLY) { console.log('\nNO WRITES. Re-run --apply to retitle the changed set.'); return; }
+ console.log(`\nApplying ${changed.length} retitles…`); let ok = 0;
+ for (const i of changed) { try { await rest('PUT', `/products/${i.id}.json`, { product: { id: Number(i.id), title: i.proposed } }); ok++; } catch (e) { console.log(` ✗ ${i.id}: ${e.message.slice(0, 100)}`); } await sleep(300); }
+ console.log(`Retitled ${ok}/${changed.length}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
← cb2e90b9 wallquest write.cjs: also persist Roll Length (per-item unit
·
back to Designer Wallcoverings
·
wallquest crawl: priced-first ordering + full-catalog (every 52637906 →