← back to Designer Wallcoverings
activated-viewer: essentials audit + width/tag cross-check + remediation worklist generator
c1e4c9dbeb428f11960f03876ac0a689b5745e84 · 2026-06-20 10:36:00 -0700 · Steve
Files touched
M shopify/activated-viewer/.gitignoreM shopify/activated-viewer/audit.js
Diff
commit c1e4c9dbeb428f11960f03876ac0a689b5745e84
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jun 20 10:36:00 2026 -0700
activated-viewer: essentials audit + width/tag cross-check + remediation worklist generator
---
shopify/activated-viewer/.gitignore | 3 ++
shopify/activated-viewer/audit.js | 86 +++++++++++++++++++------------------
2 files changed, 47 insertions(+), 42 deletions(-)
diff --git a/shopify/activated-viewer/.gitignore b/shopify/activated-viewer/.gitignore
index 2e8157a9..43bdbf29 100644
--- a/shopify/activated-viewer/.gitignore
+++ b/shopify/activated-viewer/.gitignore
@@ -1,3 +1,6 @@
node_modules/
.env
*.log
+worklist.json
+worklist.csv
+audit-result.json
diff --git a/shopify/activated-viewer/audit.js b/shopify/activated-viewer/audit.js
index 688026cf..40211519 100644
--- a/shopify/activated-viewer/audit.js
+++ b/shopify/activated-viewer/audit.js
@@ -1,65 +1,67 @@
'use strict';
-// Audit recently-ACTIVATED products against the 3 essentials: specs-in-metafields,
-// image (factory), priced (has a real roll/bolt, not sample-only). Read-only GraphQL.
+// Audit recently-ACTIVATED products vs the 3 essentials (priced / image / specs / width),
+// cross-check width+image against the catalog's own Needs-Width / Needs-Image tags, and
+// emit a remediation worklist grouped by gap type. Read-only GraphQL.
const fs = require('fs'), path = require('path');
for (const p of [path.join(__dirname, '..', '.env'), path.join(__dirname, '.env')])
if (fs.existsSync(p)) for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); }
const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN, VER = '2024-10';
-const MAX_PAGES = Number(process.env.MAX_PAGES || 5); // 5×250 = up to 1250
+const MAX_PAGES = Number(process.env.MAX_PAGES || 5);
const Q = `query($cur:String){ products(first:250, query:"status:active", sortKey:CREATED_AT, reverse:true, after:$cur){
pageInfo{ hasNextPage endCursor }
- nodes{ id title handle tags featuredImage{ url } mediaCount{ count }
- variants(first:15){ nodes{ price sku title } }
+ nodes{ id title handle tags publishedAt createdAt featuredImage{ url } mediaCount{ count }
+ variants(first:15){ nodes{ price sku } }
metafields(first:60){ nodes{ namespace key value } } } } }`;
async function gql(cur) {
const r = await fetch(`https://${STORE}/admin/api/${VER}/graphql.json`, {
- method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
- body: JSON.stringify({ query: Q, variables: { cur } })
- });
- const j = await r.json();
- if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 200));
+ method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: Q, variables: { cur } }) });
+ const j = await r.json(); if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 200));
return j.data.products;
}
(async () => {
let cur = null, all = [];
- for (let p = 0; p < MAX_PAGES; p++) {
- const pg = await gql(cur); all.push(...pg.nodes);
- if (!pg.pageInfo.hasNextPage) break; cur = pg.pageInfo.endCursor;
- await new Promise(r => setTimeout(r, 350)); // gentle on the API
- }
- const fail = { priced: [], image: [], specs: [], width: [] };
+ for (let p = 0; p < MAX_PAGES; p++) { const pg = await gql(cur); all.push(...pg.nodes); if (!pg.pageInfo.hasNextPage) break; cur = pg.pageInfo.endCursor; await new Promise(r => setTimeout(r, 320)); }
+
+ const rows = [], x = { priced: 0, image: 0, specs: 0, width: 0, needsWidthTag: 0, needsImageTag: 0, widthMetaMissing: 0, imageMissing: 0, widthAgree: 0, widthOnlyTag: 0, widthOnlyMeta: 0 };
for (const p of all) {
const sku = (p.variants.nodes[0] && p.variants.nodes[0].sku) || p.handle;
+ const tags = (p.tags || []).map(t => t.toLowerCase());
+ const mf = p.metafields.nodes, keys = mf.map(m => `${m.namespace}.${m.key}`);
const priced = p.variants.nodes.some(v => parseFloat(v.price) > 4.25);
- const hasImage = !!(p.featuredImage && p.featuredImage.url) && (p.mediaCount ? p.mediaCount.count > 0 : true);
- const mfKeys = p.metafields.nodes.map(m => `${m.namespace}.${m.key}`);
- const hasSpecs = p.metafields.nodes.some(m => m.namespace === 'specs') ||
- mfKeys.includes('custom.ai_enriched_at') ||
- p.metafields.nodes.filter(m => m.namespace === 'custom').length >= 5;
- const hasWidth = mfKeys.some(k => /\.width$|\.roll_width$|\.product_width$/.test(k)) ||
- p.metafields.nodes.some(m => m.namespace === 'custom' && /width/i.test(m.key) && m.value);
- if (!priced) fail.priced.push(sku);
- if (!hasImage) fail.image.push(sku);
- if (!hasSpecs) fail.specs.push(sku);
- if (!hasWidth) fail.width.push(sku);
+ const hasImage = !!(p.featuredImage && p.featuredImage.url) && (!p.mediaCount || p.mediaCount.count > 0);
+ const hasSpecs = mf.some(m => m.namespace === 'specs') || keys.includes('custom.ai_enriched_at') || mf.filter(m => m.namespace === 'custom').length >= 5;
+ const hasWidth = mf.some(m => /width/i.test(m.key) && m.value && String(m.value).trim() !== '');
+ const needsWidthTag = tags.includes('needs-width'), needsImageTag = tags.includes('needs-image');
+ if (priced) x.priced++; if (hasImage) x.image++; if (hasSpecs) x.specs++; if (hasWidth) x.width++;
+ if (needsWidthTag) x.needsWidthTag++; if (needsImageTag) x.needsImageTag++;
+ if (!hasWidth) x.widthMetaMissing++; if (!hasImage) x.imageMissing++;
+ // width cross-check (tag vs metafield)
+ if (needsWidthTag && !hasWidth) x.widthAgree++; else if (needsWidthTag && hasWidth) x.widthOnlyTag++; else if (!needsWidthTag && !hasWidth) x.widthOnlyMeta++;
+ const gaps = []; if (!priced) gaps.push('price'); if (!hasImage) gaps.push('image'); if (!hasSpecs) gaps.push('specs'); if (!hasWidth) gaps.push('width');
+ if (gaps.length) rows.push({ sku, handle: p.handle, id: String(p.id).split('/').pop(), title: p.title, price: p.variants.nodes.reduce((m, v) => Math.max(m, parseFloat(v.price) || 0), 0), activated: (p.publishedAt || p.createdAt || '').slice(0, 10), gaps: gaps.join('+') });
}
- const n = all.length, pct = x => ((n - x) / n * 100).toFixed(1);
- const show = a => a.slice(0, 12).join(', ') + (a.length > 12 ? ` …+${a.length - 12}` : '');
- console.log(`\n=== ACTIVATED-ITEM ESSENTIALS AUDIT — ${n} most-recent active products ===\n`);
- console.log(`PRICED (roll/bolt > $4.25) : ${n - fail.priced.length}/${n} (${pct(fail.priced.length)}%) — ${fail.priced.length} sample-only/unpriced`);
- console.log(`HAS IMAGE : ${n - fail.image.length}/${n} (${pct(fail.image.length)}%) — ${fail.image.length} missing`);
- console.log(`SPECS IN METAFIELDS : ${n - fail.specs.length}/${n} (${pct(fail.specs.length)}%) — ${fail.specs.length} missing specs`);
- console.log(`WIDTH METAFIELD (hard gate) : ${n - fail.width.length}/${n} (${pct(fail.width.length)}%) — ${fail.width.length} missing width`);
- console.log('');
- if (fail.priced.length) console.log(` ✗ not priced : ${show(fail.priced)}`);
- if (fail.image.length) console.log(` ✗ no image : ${show(fail.image)}`);
- if (fail.specs.length) console.log(` ✗ no specs : ${show(fail.specs)}`);
- if (fail.width.length) console.log(` ✗ no width : ${show(fail.width)}`);
- // persist the fail lists for follow-up
- fs.writeFileSync(path.join(__dirname, 'audit-result.json'), JSON.stringify({ n, fail, at: new Date().toISOString() }, null, 1));
- console.log(`\nfull fail-lists → audit-result.json`);
+ const n = all.length, pct = c => (c / n * 100).toFixed(1);
+ console.log(`\n=== ESSENTIALS AUDIT + WIDTH CROSS-CHECK — ${n} most-recent active ===\n`);
+ console.log(`priced ${pct(x.priced)}% image ${pct(x.image)}% specs ${pct(x.specs)}% width ${pct(x.width)}%`);
+ console.log(`\n-- WIDTH cross-check (metafield vs Needs-Width tag) --`);
+ console.log(` missing width metafield : ${x.widthMetaMissing}`);
+ console.log(` carry 'Needs-Width' tag : ${x.needsWidthTag}`);
+ console.log(` agree (tag AND no meta) : ${x.widthAgree}`);
+ console.log(` tag set but meta present: ${x.widthOnlyTag} (tag is stale)`);
+ console.log(` no meta but no tag : ${x.widthOnlyMeta} (gap NOT flagged by tag)`);
+ console.log(`\n-- IMAGE cross-check --`);
+ console.log(` missing image : ${x.imageMissing}`);
+ console.log(` carry 'Needs-Image' tag : ${x.needsImageTag}`);
+ // worklist outputs
+ const byGap = {}; rows.forEach(r => { byGap[r.gaps] = (byGap[r.gaps] || 0) + 1; });
+ fs.writeFileSync(path.join(__dirname, 'worklist.json'), JSON.stringify({ n, generatedAt: new Date().toISOString(), counts: byGap, total_gap_items: rows.length, rows }, null, 1));
+ const csv = ['sku,handle,id,gaps,price,activated,title', ...rows.map(r => `${r.sku},${r.handle},${r.id},${r.gaps},${r.price},${r.activated},"${(r.title || '').replace(/"/g, "'")}"`)].join('\n');
+ fs.writeFileSync(path.join(__dirname, 'worklist.csv'), csv);
+ console.log(`\n-- REMEDIATION WORKLIST (${rows.length} items with ≥1 gap) --`);
+ Object.entries(byGap).sort((a, b) => b[1] - a[1]).forEach(([g, c]) => console.log(` ${String(c).padStart(4)} ${g}`));
+ console.log(`\nwritten → worklist.json worklist.csv`);
})().catch(e => { console.error('AUDIT FAIL:', e.message); process.exit(1); });
← cb819a0a add inventory-set-2026-newest.js — ensure newest N products'
·
back to Designer Wallcoverings
·
run inventory-set-2026 sweep after each cadence run c48c81f4 →