← back to Dw Yolo Loop
Showroom Lines: scan + bulk tag/metafield scripts for single-variant $4.25 products (sample-only → showroom category)
ae2c5c86d55485602829aae1b9aed2600c61e904 · 2026-06-16 15:41:08 -0700 · Steve Abrams
Files touched
M .gitignoreA showroom-lines-scan.jsA showroom-lines-tag.js
Diff
commit ae2c5c86d55485602829aae1b9aed2600c61e904
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 16 15:41:08 2026 -0700
Showroom Lines: scan + bulk tag/metafield scripts for single-variant $4.25 products (sample-only → showroom category)
---
.gitignore | 5 +++
showroom-lines-scan.js | 102 +++++++++++++++++++++++++++++++++++++++++++++++++
showroom-lines-tag.js | 88 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 195 insertions(+)
diff --git a/.gitignore b/.gitignore
index 7c8e470..967e93f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,8 @@ data/price-sheets/*-plan.json
gmc-425-result.json
# nested pilot repo (has own .git; flagged for consolidate/submodule decision)
scripts/sample-split-pilot/
+
+# showroom-lines runtime artifacts
+showroom-lines-done.txt
+showroom-lines-worklist.json
+showroom-lines-report.json
diff --git a/showroom-lines-scan.js b/showroom-lines-scan.js
new file mode 100644
index 0000000..beb5051
--- /dev/null
+++ b/showroom-lines-scan.js
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * Showroom Lines scan (READ-ONLY, $0).
+ * Steve's directive 2026-06-16: products whose ONLY variant is the $4.25 memo
+ * sample are a deliberate category — "SHOWROOM LINES" — kept live, $4.25 is the
+ * accurate price. This identifies that exact set and characterizes it so we can
+ * tag + metafield them and surface a Showroom section.
+ *
+ * Showroom-line predicate: status:active AND exactly 1 variant AND that variant
+ * price <= $4.255 (the $4.25 sample floor, with epsilon).
+ *
+ * Writes a worklist + a characterization report. Zero writes to Shopify.
+ */
+const fs = require('fs');
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VERSION = '2024-10';
+const ENV = fs.readFileSync('/Users/stevestudio2/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const ENDPOINT = `https://${SHOP}/admin/api/${VERSION}/graphql.json`;
+const SAMPLE = 4.255;
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+ for (let attempt = 0; attempt < 7; attempt++) {
+ let res;
+ try {
+ res = await fetch(ENDPOINT, { method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }) });
+ } catch (e) { await sleep(2000 * (attempt + 1)); continue; }
+ if (res.status === 429) { await sleep(2000 * (attempt + 1)); continue; }
+ const json = await res.json();
+ if (json.errors) {
+ if (JSON.stringify(json.errors).includes('THROTTLED')) { await sleep(2000 * (attempt + 1)); continue; }
+ throw new Error(JSON.stringify(json.errors));
+ }
+ const avail = json.extensions?.cost?.throttleStatus?.currentlyAvailable ?? 4000;
+ if (avail < 400) await sleep(1500);
+ return json.data;
+ }
+ throw new Error('exhausted retries');
+}
+
+const QUERY = `
+query($cursor: String) {
+ products(first: 100, after: $cursor, query: "status:active") {
+ pageInfo { hasNextPage endCursor }
+ nodes {
+ id title vendor handle productType
+ tags
+ variants(first: 10) { nodes { title price } }
+ }
+ }
+}`;
+
+// junk heuristics — things we'd want to eyeball before auto-classing as showroom
+const JUNK_RE = /\b(test|placeholder|do not use|sample card|deleted)\b/i;
+
+(async () => {
+ let cursor = null, pages = 0, total = 0;
+ const showroom = [];
+ const vendors = {};
+ let junk = [], priceOther = 0;
+ const t0 = Date.now();
+ do {
+ const d = await gql(QUERY, { cursor });
+ const conn = d.products;
+ for (const p of conn.nodes) {
+ total++;
+ const vs = p.variants.nodes;
+ if (vs.length !== 1) continue; // must be single-variant
+ const price = parseFloat(vs[0].price);
+ if (!(price <= SAMPLE)) { priceOther++; continue; } // single variant but not $4.25
+ const rec = { id: p.id, title: p.title, vendor: p.vendor || '(none)', handle: p.handle,
+ price, alreadyTagged: (p.tags || []).includes('Showroom Line') };
+ showroom.push(rec);
+ vendors[rec.vendor] = (vendors[rec.vendor] || 0) + 1;
+ if (JUNK_RE.test(p.title)) junk.push(rec);
+ }
+ cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
+ if (++pages % 25 === 0) process.stderr.write(` ...${pages} pages, ${total} active, showroom=${showroom.length}\n`);
+ } while (cursor);
+
+ const topVendors = Object.entries(vendors).sort((a, b) => b[1] - a[1]).slice(0, 20);
+ const alreadyTagged = showroom.filter(r => r.alreadyTagged).length;
+ const report = {
+ scannedActive: total,
+ showroomCount: showroom.length,
+ singleVariantNon425: priceOther,
+ alreadyTagged,
+ needTag: showroom.length - alreadyTagged,
+ junkSuspects: junk.length,
+ topVendors,
+ elapsedSec: Math.round((Date.now() - t0) / 1000),
+ };
+ fs.writeFileSync(__dirname + '/showroom-lines-worklist.json',
+ JSON.stringify({ generatedAt: new Date().toISOString(), predicate: 'active & 1 variant & price<=4.255', report, ids: showroom.map(r => r.id) }, null, 2));
+ fs.writeFileSync(__dirname + '/showroom-lines-report.json', JSON.stringify({ report, junkSuspects: junk.slice(0, 50), sample: showroom.slice(0, 25) }, null, 2));
+ console.log(JSON.stringify(report, null, 2));
+ console.log('\nWorklist → showroom-lines-worklist.json Report → showroom-lines-report.json');
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
diff --git a/showroom-lines-tag.js b/showroom-lines-tag.js
new file mode 100644
index 0000000..3159149
--- /dev/null
+++ b/showroom-lines-tag.js
@@ -0,0 +1,88 @@
+'use strict';
+/**
+ * Showroom Lines — bulk tag + metafield (LIVE write, Steve-authorized 2026-06-16).
+ * Tags each showroom-line product "Showroom Line" and sets custom.showroom_line=true.
+ * - Reads IDs from showroom-lines-worklist.json (the read-only scan output).
+ * - Idempotent + RESUMABLE: completed IDs appended to showroom-lines-done.txt;
+ * a restart skips them. Safe to Ctrl-C and rerun.
+ * - Reversible (remove the tag / metafield).
+ * - $0: Shopify Admin API, no per-call charge.
+ */
+const fs = require('fs');
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VERSION = '2024-10';
+const ENV = fs.readFileSync('/Users/stevestudio2/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const ENDPOINT = `https://${SHOP}/admin/api/${VERSION}/graphql.json`;
+const DONE_FILE = __dirname + '/showroom-lines-done.txt';
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+ for (let attempt = 0; attempt < 8; attempt++) {
+ let res;
+ try {
+ res = await fetch(ENDPOINT, { method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }) });
+ } catch (e) { await sleep(2000 * (attempt + 1)); continue; }
+ if (res.status === 429) { await sleep(2000 * (attempt + 1)); continue; }
+ const json = await res.json();
+ if (json.errors) {
+ if (JSON.stringify(json.errors).includes('THROTTLED')) { await sleep(2000 * (attempt + 1)); continue; }
+ throw new Error(JSON.stringify(json.errors));
+ }
+ const avail = json.extensions?.cost?.throttleStatus?.currentlyAvailable ?? 4000;
+ if (avail < 500) await sleep(1200);
+ return json.data;
+ }
+ throw new Error('exhausted retries');
+}
+
+const DEF = `mutation { metafieldDefinitionCreate(definition: {
+ name: "Showroom Line", namespace: "custom", key: "showroom_line",
+ type: "boolean", ownerType: PRODUCT,
+ description: "Single-variant $4.25 memo-sample product sold as a showroom line."
+}) { createdDefinition { id } userErrors { code message } } }`;
+
+const MUT = `mutation($id: ID!) {
+ t: tagsAdd(id: $id, tags: ["Showroom Line"]) { userErrors { message } }
+ m: metafieldsSet(metafields: [{ ownerId: $id, namespace: "custom", key: "showroom_line", type: "boolean", value: "true" }]) { userErrors { message } }
+}`;
+
+(async () => {
+ // 1) ensure the metafield definition exists (idempotent — ignore "taken")
+ try {
+ const d = await gql(DEF, {});
+ const errs = d.metafieldDefinitionCreate.userErrors || [];
+ if (errs.length && !/taken|exists/i.test(JSON.stringify(errs))) console.warn('def warn:', JSON.stringify(errs));
+ else console.log('metafield definition custom.showroom_line ready');
+ } catch (e) { console.warn('def step:', e.message); }
+
+ // 2) load worklist + resume set
+ const wl = JSON.parse(fs.readFileSync(__dirname + '/showroom-lines-worklist.json', 'utf8'));
+ const allIds = wl.ids;
+ const done = new Set(fs.existsSync(DONE_FILE) ? fs.readFileSync(DONE_FILE, 'utf8').split('\n').filter(Boolean) : []);
+ const todo = allIds.filter(id => !done.has(id));
+ console.log(`worklist=${allIds.length} alreadyDone=${done.size} todo=${todo.length}`);
+
+ const doneStream = fs.createWriteStream(DONE_FILE, { flags: 'a' });
+ let ok = 0, fail = 0;
+ const t0 = Date.now();
+ for (let i = 0; i < todo.length; i++) {
+ const id = todo[i];
+ try {
+ const d = await gql(MUT, { id });
+ const e = [...(d.t?.userErrors || []), ...(d.m?.userErrors || [])];
+ if (e.length) { fail++; if (fail <= 20) console.warn('userErr', id, JSON.stringify(e)); }
+ else { ok++; doneStream.write(id + '\n'); }
+ } catch (e) { fail++; if (fail <= 20) console.warn('fail', id, e.message); }
+ if ((i + 1) % 200 === 0) {
+ const rate = (i + 1) / ((Date.now() - t0) / 1000);
+ const eta = Math.round((todo.length - i - 1) / rate / 60);
+ console.log(` ${i + 1}/${todo.length} ok=${ok} fail=${fail} ${rate.toFixed(1)}/s ~${eta}min left`);
+ }
+ }
+ doneStream.end();
+ console.log(`\nDone: ${ok} tagged, ${fail} failed, ${done.size} pre-done. Total showroom-tagged ≈ ${ok + done.size}.`);
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
← 8ff645a harden add_roll_variants: price floor (>=$10, block $4.25 sa
·
back to Dw Yolo Loop
·
add_roll_variants: commit the 305-target inputs into repo + e3607af →