[object Object]

← back to Designerwallcoverings

google-feed A2: showroom-scoped unpublish generator + hard --showroom-only guard on apply-unpublish (TK-11193)

4b3a65ef91105bf3969748156a69e631d09485c6 · 2026-09-03 12:48:32 -0700 · showroom-line-steward

prep-showroom-unpublish.mjs queries the live channel for showroom-vendor products
only (config-driven via showroom-vendor.cjs, never hardcoded) -> unpublish-list-showroom.csv.
apply-unpublish.mjs gains --list= + a hard --showroom-only guard that ABORTS if any
row is a non-showroom vendor, so the broad 25,644-row feed-hygiene list can never be
mis-fired as the showroom fix (the over-scope caught in dry-run, TK-11193).

Files touched

Diff

commit 4b3a65ef91105bf3969748156a69e631d09485c6
Author: showroom-line-steward <steve@designerwallcoverings.com>
Date:   Thu Sep 3 12:48:32 2026 -0700

    google-feed A2: showroom-scoped unpublish generator + hard --showroom-only guard on apply-unpublish (TK-11193)
    
    prep-showroom-unpublish.mjs queries the live channel for showroom-vendor products
    only (config-driven via showroom-vendor.cjs, never hardcoded) -> unpublish-list-showroom.csv.
    apply-unpublish.mjs gains --list= + a hard --showroom-only guard that ABORTS if any
    row is a non-showroom vendor, so the broad 25,644-row feed-hygiene list can never be
    mis-fired as the showroom fix (the over-scope caught in dry-run, TK-11193).
---
 scripts/google-feed/apply-unpublish.mjs         |  48 ++++++++--
 scripts/google-feed/prep-showroom-unpublish.mjs | 114 ++++++++++++++++++++++++
 2 files changed, 155 insertions(+), 7 deletions(-)

diff --git a/scripts/google-feed/apply-unpublish.mjs b/scripts/google-feed/apply-unpublish.mjs
index 0db23b8..877f23c 100644
--- a/scripts/google-feed/apply-unpublish.mjs
+++ b/scripts/google-feed/apply-unpublish.mjs
@@ -9,14 +9,22 @@
  *     touches Online Store or any other channel, never deletes/archives, never
  *     changes prices. Fully reversible (re-publish to re-list).
  *
- * Reads:  data/google-feed/unpublish-list.csv  (from prep-actions.mjs)
+ * Reads:  data/google-feed/<list>  — default unpublish-list.csv (from prep-actions.mjs),
+ *         or --list=unpublish-list-showroom.csv (from prep-showroom-unpublish.mjs).
  * Filter: --reason=<class>  to stage a subset (e.g. --reason=roll_price_is to start
  *         with just the $4.25-roll bugs). Omit to target the whole excluded set.
+ * GUARD:  --showroom-only  — HARD guard. Requires EVERY row's vendor to be a showroom
+ *         vendor (showroom-vendors.json via isShowroomVendor); ABORTS if any row is not.
+ *         This makes the showroom fix impossible to mis-fire against the broad
+ *         25,644-row feed-hygiene list (the TK-11193 over-scope that dry-run caught).
  * Safety: idempotent — checks each product's Google-channel publish state and skips
  *         ones already unpublished; batches of 50 with throttle pacing.
  */
 import fs from 'node:fs';
 import path from 'node:path';
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const { isShowroomVendor } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
 
 const GOOGLE_PUBLICATION = 'gid://shopify/Publication/29646651457'; // Google & YouTube
 const SHOP = 'designer-laboratory-sandbox.myshopify.com';
@@ -24,6 +32,8 @@ const VER = '2024-10';
 const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
 const APPLY = args.apply === true && args['i-am-steve'] === true;
 const REASON = args.reason || null;
+const LIST = typeof args.list === 'string' ? args.list : 'unpublish-list.csv';
+const SHOWROOM_ONLY = args['showroom-only'] === true;
 const LIMIT = args.limit ? parseInt(args.limit,10) : Infinity;
 
 const TOKEN = (fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1]?.trim();
@@ -32,18 +42,42 @@ const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
 const sleep = ms => new Promise(r=>setTimeout(r,ms));
 async function gql(query, variables){ for(let a=0;a<8;a++){ let j; try{ const r=await fetch(URL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query,variables})}); j=await r.json(); }catch(e){ await sleep(1500*(a+1)); continue; } if(j.errors){ if(JSON.stringify(j.errors).includes('THROTTLED')){ await sleep(2000*(a+1)); continue; } throw new Error(JSON.stringify(j.errors)); } const t=j.extensions?.cost?.throttleStatus; if(t&&t.currentlyAvailable<400) await sleep(1200); return j.data; } throw new Error('retries'); }
 
-// load list
-const rows = fs.readFileSync(path.join(process.cwd(),'data','google-feed','unpublish-list.csv'),'utf8').trim().split('\n').slice(1)
-  .map(l => { const m=l.match(/^(\d+),/); return { id: m&&m[1], reasonClass: l.split(',')[3] }; })
-  .filter(x => x.id && (!REASON || x.reasonClass === REASON))
+// load list — proper CSV field parse (handle/vendor are quoted, may contain commas)
+function parseCsvLine(line) {
+  const out = []; let cur = '', q = false;
+  for (let i = 0; i < line.length; i++) {
+    const c = line[i];
+    if (q) { if (c === '"') { if (line[i+1] === '"') { cur += '"'; i++; } else q = false; } else cur += c; }
+    else { if (c === '"') q = true; else if (c === ',') { out.push(cur); cur = ''; } else cur += c; }
+  }
+  out.push(cur); return out;
+}
+const LIST_PATH = path.join(process.cwd(), 'data', 'google-feed', LIST);
+const allRows = fs.readFileSync(LIST_PATH, 'utf8').trim().split('\n').slice(1)
+  .map(l => { const f = parseCsvLine(l); return { id: (f[0]||'').trim(), vendor: f[2]||'', reasonClass: f[3]||'' }; })
+  .filter(x => /^\d+$/.test(x.id));
+
+// HARD SHOWROOM GUARD — refuse to fire against anything but showroom-vendor rows.
+if (SHOWROOM_ONLY) {
+  const nonShowroom = allRows.filter(x => !isShowroomVendor(x.vendor));
+  if (nonShowroom.length) {
+    console.error(`\n⛔ ABORT: --showroom-only was set but ${nonShowroom.length}/${allRows.length} rows in ${LIST} are NOT showroom vendors.`);
+    console.error(`   e.g. ${nonShowroom.slice(0,3).map(r=>`${r.id}(${r.vendor||'?'})`).join(', ')}`);
+    console.error(`   This guard prevents unpublishing the broad feed-hygiene set. Point --list at a showroom-scoped file (prep-showroom-unpublish.mjs).`);
+    process.exit(1);
+  }
+}
+const rows = allRows
+  .filter(x => !REASON || x.reasonClass === REASON)
   .slice(0, LIMIT);
 
 console.log(`apply-unpublish — mode: ${APPLY ? '⚠️  LIVE APPLY' : 'DRY-RUN (no writes)'}`);
+console.log(`list: ${LIST}${SHOWROOM_ONLY ? ' (--showroom-only guard PASSED — all rows are showroom vendors)' : ''}`);
 console.log(`target publication: Google & YouTube (${GOOGLE_PUBLICATION})`);
-console.log(`candidates: ${rows.length}${REASON ? ` (reason=${REASON})` : ' (entire excluded set)'}`);
+console.log(`candidates: ${rows.length}${REASON ? ` (reason=${REASON})` : (SHOWROOM_ONLY ? ' (showroom-scoped set)' : ' (entire excluded set)')}`);
 if (!APPLY) {
   console.log('\nDRY-RUN: nothing will be unpublished. To execute (Steve only):');
-  console.log(`  node apply-unpublish.mjs --apply --i-am-steve${REASON?` --reason=${REASON}`:''}`);
+  console.log(`  node apply-unpublish.mjs --apply --i-am-steve --list=${LIST}${SHOWROOM_ONLY?' --showroom-only':''}${REASON?` --reason=${REASON}`:''}`);
   process.exit(0);
 }
 
diff --git a/scripts/google-feed/prep-showroom-unpublish.mjs b/scripts/google-feed/prep-showroom-unpublish.mjs
new file mode 100644
index 0000000..5e7baee
--- /dev/null
+++ b/scripts/google-feed/prep-showroom-unpublish.mjs
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+/**
+ * prep-showroom-unpublish.mjs — build a SHOWROOM-VENDOR-SCOPED unpublish list for
+ * the Google & YouTube sales channel. (TK-11186 / TK-11193)
+ *
+ * WHY THIS EXISTS: prep-actions.mjs stages the ENTIRE feed-exclusion set (~25,644
+ * products: no_roll_variant_price, below_floor, quote_only, leaks, …) into
+ * unpublish-list.csv, and apply-unpublish.mjs consumes that whole file with NO
+ * vendor filter. Firing it for the showroom fix would unpublish 25,644 products
+ * from the paid Google channel, not the ~275 Phillip-Jeffries showroom leak. That
+ * over-scope was caught in dry-run (TK-11193). This tool produces a list that is
+ * PROVABLY only showroom-vendor products actually published to the Google channel.
+ *
+ * Source of truth for WHICH vendors are showroom-only: showroom-vendors.json via the
+ * shared isShowroomVendor()/showroomVendors() primitive. NEVER hardcode a vendor name.
+ *
+ * READ-ONLY against Shopify (query only). Writes one local CSV. Nothing is
+ * unpublished here — apply-unpublish.mjs (Steve-gated) does that, and only when
+ * pointed at this file with --showroom-only.
+ *
+ * OUTPUT: data/google-feed/unpublish-list-showroom.csv
+ *   header identical to unpublish-list.csv so apply-unpublish.mjs can consume it:
+ *   id,handle,vendor,reason_class,reasons,admin_link
+ *
+ * USAGE: node prep-showroom-unpublish.mjs
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const { isShowroomVendor, showroomVendors } =
+  require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VER = '2024-10';
+const STORE = 'designer-laboratory-sandbox';
+const GOOGLE_PUBLICATION = 'gid://shopify/Publication/29646651457'; // Google & YouTube
+const REASON_CLASS = 'showroom_only_addressable_not_discoverable';
+const adminLink = id => `https://admin.shopify.com/store/${STORE}/products/${id}`;
+
+const TOKEN = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+  .match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1]?.trim();
+if (!TOKEN) { console.error('no token'); process.exit(1); }
+const ENDPOINT = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function gql(query, variables) {
+  for (let a = 0; a < 8; a++) {
+    let j;
+    try {
+      const r = await fetch(ENDPOINT, { method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables }) });
+      j = await r.json();
+    } catch (e) { await sleep(1500 * (a + 1)); continue; }
+    if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; } throw new Error(JSON.stringify(j.errors)); }
+    const t = j.extensions?.cost?.throttleStatus;
+    if (t && t.currentlyAvailable < 400) await sleep(1200);
+    return j.data;
+  }
+  throw new Error('retries');
+}
+
+const csvEsc = s => `"${String(s == null ? '' : s).replace(/"/g, '""')}"`;
+
+const Q = `query($cursor:String,$q:String!){
+  products(first:100, after:$cursor, query:$q){
+    pageInfo{ hasNextPage endCursor }
+    nodes{ id handle vendor onGoogle:publishedOnPublication(publicationId:"${GOOGLE_PUBLICATION}") }
+  }
+}`;
+
+(async () => {
+  const vendors = showroomVendors();
+  if (!vendors.length) { console.error('showroom-vendors.json is empty — nothing to do'); process.exit(1); }
+  const DIR = path.join(process.cwd(), 'data', 'google-feed');
+  fs.mkdirSync(DIR, { recursive: true });
+
+  const rows = [];
+  const perVendor = {};
+  for (const vendor of vendors) {
+    // status:active — a showroom line stays ACTIVE (addressable); we only pull it off the channel.
+    const q = `status:active vendor:'${vendor.replace(/'/g, "\\'")}'`;
+    let cursor = null, has = true, seen = 0, onCh = 0;
+    while (has) {
+      const d = await gql(Q, { cursor, q });
+      for (const p of d.products.nodes) {
+        seen++;
+        // Belt-and-suspenders: re-affirm via the shared primitive, never trust the query alone.
+        if (!isShowroomVendor(p.vendor)) continue;
+        if (!p.onGoogle) continue; // only products actually on the paid channel
+        onCh++;
+        const id = p.id.split('/').pop();
+        rows.push([id, csvEsc(p.handle), csvEsc(p.vendor), REASON_CLASS,
+          csvEsc(REASON_CLASS), adminLink(id)].join(','));
+      }
+      has = d.products.pageInfo.hasNextPage;
+      cursor = d.products.pageInfo.endCursor;
+    }
+    perVendor[vendor] = { active: seen, on_google_channel: onCh };
+  }
+
+  const header = 'id,handle,vendor,reason_class,reasons,admin_link';
+  const out = path.join(DIR, 'unpublish-list-showroom.csv');
+  fs.writeFileSync(out, header + '\n' + rows.join('\n') + (rows.length ? '\n' : ''));
+
+  console.log('SHOWROOM-SCOPED unpublish list (Google & YouTube channel only)');
+  console.log('  showroom vendors:', JSON.stringify(vendors));
+  for (const [v, c] of Object.entries(perVendor))
+    console.log(`  ${v}: ${c.active} active, ${c.on_google_channel} on channel`);
+  console.log('  TOTAL to unpublish:', rows.length);
+  console.log('  wrote:', out);
+  console.log('\n  Fire (Steve only):');
+  console.log('    node apply-unpublish.mjs --apply --i-am-steve --list=unpublish-list-showroom.csv --showroom-only');
+})();

← a4b3584 TK-11046 WS-C: GMC price resolver must not read a tombstone  ·  back to Designerwallcoverings  ·  TK-11186: A2 residual-drain utility + canonical 2293 publica d5f04b6 →