[object Object]

← 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

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 →