← back to Designerwallcoverings
google-channel unpublish: add tag-scoped path for shared-vendor showroom lines (TK-11307)
01c73ed80df87e56e908e160beb809efa69c62e7 · 2026-09-08 15:46:32 -0700 · steve
New prep-showroom-unpublish-tag.mjs selects status:active tag:'Showroom' products
actually published to the Google & YouTube channel (re-affirmed per-product via
hasShowroomTag), writing the same-format CSV. apply-unpublish.mjs gains a
--showroom-product-only HARD guard that LIVE-fetches each row's vendor+tags and
requires isShowroomProduct before any channel unpublish — the tag-based analogue of
--showroom-only, so the MDC unpublish cannot mis-fire against a sellable product.
Read-only prep verified (0 rows today — tag not yet applied). Nothing fired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDeaFL4eeREBZX79tc4cnr
Files touched
M scripts/google-feed/apply-unpublish.mjsA scripts/google-feed/prep-showroom-unpublish-tag.mjs
Diff
commit 01c73ed80df87e56e908e160beb809efa69c62e7
Author: steve <steve@designerwallcoverings.com>
Date: Tue Sep 8 15:46:32 2026 -0700
google-channel unpublish: add tag-scoped path for shared-vendor showroom lines (TK-11307)
New prep-showroom-unpublish-tag.mjs selects status:active tag:'Showroom' products
actually published to the Google & YouTube channel (re-affirmed per-product via
hasShowroomTag), writing the same-format CSV. apply-unpublish.mjs gains a
--showroom-product-only HARD guard that LIVE-fetches each row's vendor+tags and
requires isShowroomProduct before any channel unpublish — the tag-based analogue of
--showroom-only, so the MDC unpublish cannot mis-fire against a sellable product.
Read-only prep verified (0 rows today — tag not yet applied). Nothing fired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDeaFL4eeREBZX79tc4cnr
---
scripts/google-feed/apply-unpublish.mjs | 31 ++++++-
.../google-feed/prep-showroom-unpublish-tag.mjs | 98 ++++++++++++++++++++++
2 files changed, 127 insertions(+), 2 deletions(-)
diff --git a/scripts/google-feed/apply-unpublish.mjs b/scripts/google-feed/apply-unpublish.mjs
index 877f23c..b754a6c 100644
--- a/scripts/google-feed/apply-unpublish.mjs
+++ b/scripts/google-feed/apply-unpublish.mjs
@@ -24,7 +24,7 @@ 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 { isShowroomVendor, isShowroomProduct } = 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';
@@ -34,6 +34,11 @@ 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;
+// TK-11307: tag-based guard for a SHARED-vendor showroom line (MDC under 'Phillipe Romano').
+// The vendor guard (--showroom-only) can't be used because the vendor is not a showroom
+// vendor. This guard LIVE-fetches each row's vendor+tags and requires isShowroomProduct
+// (vendor-on-list OR carries the 'Showroom' tag) — never trusts the CSV alone.
+const SHOWROOM_PRODUCT_ONLY = args['showroom-product-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();
@@ -67,6 +72,28 @@ if (SHOWROOM_ONLY) {
process.exit(1);
}
}
+// HARD SHOWROOM-PRODUCT GUARD (TK-11307) — live-verify EVERY row is a showroom product
+// (vendor-on-list OR carries the 'Showroom' tag) before allowing a channel unpublish.
+if (SHOWROOM_PRODUCT_ONLY) {
+ const TQ = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id vendor tags } } }`;
+ const bad = [];
+ for (let i = 0; i < allRows.length; i += 100) {
+ const batch = allRows.slice(i, i + 100);
+ const ids = batch.map(r => `gid://shopify/Product/${r.id}`);
+ const d = await gql(TQ, { ids });
+ const byId = new Map((d.nodes || []).filter(Boolean).map(n => [n.id.split('/').pop(), n]));
+ for (const r of batch) {
+ const n = byId.get(r.id);
+ if (!n || !isShowroomProduct({ vendor: n.vendor, tags: n.tags })) bad.push(r.id + (n ? `(${n.vendor})` : '(missing)'));
+ }
+ }
+ if (bad.length) {
+ console.error(`\n⛔ ABORT: --showroom-product-only set but ${bad.length}/${allRows.length} rows are NOT showroom products (no 'Showroom' tag and vendor not on list).`);
+ console.error(` e.g. ${bad.slice(0,3).join(', ')}`);
+ console.error(` This guard prevents unpublishing a sellable product. Re-run prep-showroom-unpublish-tag.mjs to rebuild the list.`);
+ process.exit(1);
+ }
+}
const rows = allRows
.filter(x => !REASON || x.reasonClass === REASON)
.slice(0, LIMIT);
@@ -77,7 +104,7 @@ console.log(`target publication: Google & YouTube (${GOOGLE_PUBLICATION})`);
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 --list=${LIST}${SHOWROOM_ONLY?' --showroom-only':''}${REASON?` --reason=${REASON}`:''}`);
+ console.log(` node apply-unpublish.mjs --apply --i-am-steve --list=${LIST}${SHOWROOM_ONLY?' --showroom-only':''}${SHOWROOM_PRODUCT_ONLY?' --showroom-product-only':''}${REASON?` --reason=${REASON}`:''}`);
process.exit(0);
}
diff --git a/scripts/google-feed/prep-showroom-unpublish-tag.mjs b/scripts/google-feed/prep-showroom-unpublish-tag.mjs
new file mode 100644
index 0000000..dce8b08
--- /dev/null
+++ b/scripts/google-feed/prep-showroom-unpublish-tag.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+/**
+ * prep-showroom-unpublish-tag.mjs — build a TAG-SCOPED unpublish list for the
+ * Google & YouTube sales channel, for a SHARED-vendor showroom line. (TK-11307)
+ *
+ * WHY A SEPARATE TAG PATH: prep-showroom-unpublish.mjs is VENDOR-scoped
+ * (isShowroomVendor + `vendor:'X'` query). MDC lives under the SHARED private label
+ * "Phillipe Romano", which ALSO carries sellable lines — so it can only be selected by
+ * its product-level "Showroom" tag, never by vendor. This tool queries
+ * `status:active AND tag:'Showroom'`, re-affirms each product actually carries the tag
+ * (hasShowroomTag), keeps only those actually published to the Google channel, and
+ * writes a CSV in the SAME format apply-unpublish.mjs consumes.
+ *
+ * READ-ONLY against Shopify (query only). Writes one local CSV. Nothing is unpublished
+ * here — apply-unpublish.mjs (Steve-gated) does that with --showroom-product-only.
+ *
+ * OUTPUT: data/google-feed/unpublish-list-showroom-tag.csv
+ * header: id,handle,vendor,reason_class,reasons,admin_link
+ *
+ * USAGE: node prep-showroom-unpublish-tag.mjs [--tag=Showroom]
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const { hasShowroomTag } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
+const TAG = typeof args.tag === 'string' ? args.tag : 'Showroom';
+
+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 tags onGoogle:publishedOnPublication(publicationId:"${GOOGLE_PUBLICATION}") }
+ }
+}`;
+
+(async () => {
+ const DIR = path.join(process.cwd(), 'data', 'google-feed');
+ fs.mkdirSync(DIR, { recursive: true });
+ const q = `status:active tag:'${String(TAG).replace(/'/g, "\\'")}'`;
+ const rows = []; let cursor = null, has = true, seen = 0, onCh = 0, offCh = 0;
+ while (has) {
+ const d = await gql(Q, { cursor, q });
+ for (const p of d.products.nodes) {
+ seen++;
+ // Belt-and-suspenders: re-affirm the tag from the returned tags array, never trust the query alone.
+ if (!hasShowroomTag(p.tags)) continue;
+ if (!p.onGoogle) { offCh++; 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;
+ }
+ const header = 'id,handle,vendor,reason_class,reasons,admin_link';
+ const out = path.join(DIR, 'unpublish-list-showroom-tag.csv');
+ fs.writeFileSync(out, header + '\n' + rows.join('\n') + (rows.length ? '\n' : ''));
+
+ console.log('TAG-SCOPED showroom unpublish list (Google & YouTube channel only)');
+ console.log(` tag: '${TAG}'`);
+ console.log(` active tag:'${TAG}' seen: ${seen} | on Google channel: ${onCh} | already off channel: ${offCh}`);
+ 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-tag.csv --showroom-product-only');
+})();
← da49390 theme (source): hide showroom-TAGGED products from browse/se
·
back to Designerwallcoverings
·
auto-data-snapshot: 2026-09-08T16:14:09 (2 data files) — scr 42afab5 →