← 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
M scripts/google-feed/apply-unpublish.mjsA scripts/google-feed/prep-showroom-unpublish.mjs
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 →