← back to Designerwallcoverings
feat(gmc): exclude quote_only class from Google feed (Fentucci/Tokiwa 504, RIGO/CMO precedent)
81226bfee4d3800b4bdd29ed2b8c93af44cbc074 · 2026-09-01 10:55:07 -0700 · Steve Abrams
Layer-1 of TK-11061: add custom.price_mode metafield to feed-eligibility.mjs
GraphQL query + a hard-gate exclusion for price_mode='quote_only' (vendor
'Fentucci Naturals' backstop). Dry-run verified: exactly 504 excluded
(all Fentucci Naturals), 0 DWFN in feed-clean.tsv, priced DWFE 'Fentucci'
line untouched. Also adds tk11061-channel.mjs (Layer-2 restore-map/apply/restore).
Files touched
M scripts/google-feed/feed-eligibility.mjsA scripts/google-feed/tk11061-channel.mjs
Diff
commit 81226bfee4d3800b4bdd29ed2b8c93af44cbc074
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 1 10:55:07 2026 -0700
feat(gmc): exclude quote_only class from Google feed (Fentucci/Tokiwa 504, RIGO/CMO precedent)
Layer-1 of TK-11061: add custom.price_mode metafield to feed-eligibility.mjs
GraphQL query + a hard-gate exclusion for price_mode='quote_only' (vendor
'Fentucci Naturals' backstop). Dry-run verified: exactly 504 excluded
(all Fentucci Naturals), 0 DWFN in feed-clean.tsv, priced DWFE 'Fentucci'
line untouched. Also adds tk11061-channel.mjs (Layer-2 restore-map/apply/restore).
---
scripts/google-feed/feed-eligibility.mjs | 9 +++
scripts/google-feed/tk11061-channel.mjs | 95 ++++++++++++++++++++++++++++++++
2 files changed, 104 insertions(+)
diff --git a/scripts/google-feed/feed-eligibility.mjs b/scripts/google-feed/feed-eligibility.mjs
index 22cc9be..7804a77 100644
--- a/scripts/google-feed/feed-eligibility.mjs
+++ b/scripts/google-feed/feed-eligibility.mjs
@@ -104,6 +104,7 @@ query($cursor:String){
widthGlobal: metafield(namespace:"global", key:"width"){ value }
widthCustom: metafield(namespace:"custom", key:"width"){ value }
widthDwc: metafield(namespace:"dwc", key:"width"){ value }
+ priceMode: metafield(namespace:"custom", key:"price_mode"){ value }
}
}
}`;
@@ -127,6 +128,14 @@ function evaluate(p) {
const width = (p.widthGlobal?.value || p.widthCustom?.value || p.widthDwc?.value || '').trim();
// ---- hard gates (exclude) ----
+ // Quote-only lines (Fentucci Naturals / Tokiwa PL, and any future quote-on-request
+ // line — RIGO/CMO precedent) advertise no price by design. They must never reach
+ // the Google feed (no price = guaranteed price-mismatch disapproval). Keyed on the
+ // price-policy metafield so it auto-covers future quote-only vendors. Vendor exact-
+ // match is a belt-and-suspenders backstop if a row is ever missing the metafield. TK-11061.
+ const priceMode = (p.priceMode?.value || '').trim().toLowerCase();
+ if (priceMode === 'quote_only' || (p.vendor || '').trim() === 'Fentucci Naturals')
+ reasons.push('quote_only_no_advertised_price');
if (!hasImg) reasons.push('no_image');
if (rollPrice == null) reasons.push('no_roll_variant_price');
else if (rollPrice < FLOOR) reasons.push(`roll_price_below_floor_${rollPrice}`);
diff --git a/scripts/google-feed/tk11061-channel.mjs b/scripts/google-feed/tk11061-channel.mjs
new file mode 100644
index 0000000..07b9b27
--- /dev/null
+++ b/scripts/google-feed/tk11061-channel.mjs
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+/**
+ * tk11061-channel.mjs — Layer-2 channel op for TK-11061 (Fentucci quote-only feed exclusion).
+ *
+ * Unpublishes the 504 quote-only Fentucci Naturals (Tokiwa PL) products from the
+ * "Google & YouTube" sales channel (publication 29646651457) ONLY — never touches
+ * Online Store, any of the other 12 channels, price, status, or inventory.
+ *
+ * MODES:
+ * map (default, read-only) — build the RESTORE-MAP: for each of the 504 GIDs,
+ * record whether it is currently published to the Google publication.
+ * Writes data/google-feed/tk11061/restore-map.json. NO writes to Shopify.
+ * apply --i-am-steve — unpublish from Google publication ONLY the GIDs the restore-map
+ * recorded as on_google=true. Requires restore-map.json to exist first
+ * (reversibility precondition). ≥90s is not needed (single channel, one op
+ * per product); throttle-paced.
+ * restore --i-am-steve — re-publish to the Google publication exactly the GIDs the
+ * restore-map recorded as on_google=true (the recorded undo).
+ *
+ * Reads GID list from: data/google-feed/tk11061/target-504-gids.txt
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+const GOOGLE_PUBLICATION = 'gid://shopify/Publication/29646651457'; // Google & YouTube
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+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 MODE = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : 'map';
+const CONFIRM = args['i-am-steve'] === true;
+
+const DIR = path.join(process.cwd(),'data','google-feed','tk11061');
+const GIDS_FILE = path.join(DIR,'target-504-gids.txt');
+const MAP_FILE = path.join(DIR,'restore-map.json');
+
+const TOKEN = (fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1]?.trim();
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+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'); }
+
+const gids = fs.readFileSync(GIDS_FILE,'utf8').trim().split('\n').map(s=>s.trim()).filter(Boolean);
+if (gids.length !== 504) { console.error(`REFUSING: expected 504 GIDs, got ${gids.length}`); process.exit(1); }
+
+// Query publish state to the Google publication for a product
+const STATE_Q = `query($id:ID!){ product(id:$id){ id handle publishedOnPublication(publicationId:"${GOOGLE_PUBLICATION}") onlineStorePublished: publishedOnPublication(publicationId:"gid://shopify/Publication/22208643184") } }`;
+
+async function buildMap(){
+ const map = { ticket:'TK-11061', publication:GOOGLE_PUBLICATION, built_at:new Date().toISOString(), total:gids.length, entries:[] };
+ let onG=0, offG=0, i=0;
+ for (const gid of gids) {
+ i++;
+ const d = await gql(STATE_Q, { id: gid });
+ const p = d.product;
+ if (!p) { map.entries.push({ gid, missing:true }); continue; }
+ const on = !!p.publishedOnPublication;
+ map.entries.push({ gid, handle:p.handle, on_google:on });
+ on ? onG++ : offG++;
+ if (i % 100 === 0) process.stderr.write(` state ${i}/${gids.length} | on_google ${onG} off ${offG}\n`);
+ }
+ map.on_google_count = onG; map.off_google_count = offG;
+ fs.mkdirSync(DIR,{recursive:true});
+ fs.writeFileSync(MAP_FILE, JSON.stringify(map,null,2));
+ console.log(JSON.stringify({ mode:'map', total:map.total, on_google:onG, off_google:offG, wrote:MAP_FILE }, null, 2));
+}
+
+const UNPUB = `mutation($id:ID!,$pubs:[PublicationInput!]!){ publishableUnpublish(id:$id, input:$pubs){ userErrors{ field message } } }`;
+const PUB = `mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{ field message } } }`;
+
+async function runMutation(kind){
+ if (!fs.existsSync(MAP_FILE)) { console.error('REFUSING: restore-map.json missing — run `map` first (reversibility precondition)'); process.exit(1); }
+ const map = JSON.parse(fs.readFileSync(MAP_FILE,'utf8'));
+ const targets = map.entries.filter(e => e.on_google === true).map(e => e.gid);
+ const M = kind==='apply' ? UNPUB : PUB;
+ const verb = kind==='apply' ? 'unpublish' : 're-publish';
+ console.log(`${kind.toUpperCase()} — ${verb} ${targets.length} products (on_google=true) ${CONFIRM?'⚠️ LIVE':'DRY-RUN'} on ${GOOGLE_PUBLICATION}`);
+ if (!CONFIRM) { console.log('DRY-RUN: add --i-am-steve to execute'); return; }
+ let done=0, errs=0;
+ for (const gid of targets) {
+ try {
+ const d = await gql(M, { id: gid, pubs:[{ publicationId: GOOGLE_PUBLICATION }] });
+ const ue = (kind==='apply'? d.publishableUnpublish : d.publishablePublish)?.userErrors || [];
+ if (ue.length) { errs++; if (errs<=10) console.log(' err', gid, JSON.stringify(ue)); } else done++;
+ } catch(e){ errs++; if (errs<=10) console.log(' EX', gid, e.message); }
+ if ((done+errs) % 100 === 0) console.log(` progress: ${done} ${verb}ed, ${errs} err`);
+ }
+ console.log(`DONE — ${verb}ed ${done}, errors ${errs} (of ${targets.length})`);
+}
+
+(async()=>{
+ if (MODE==='map') await buildMap();
+ else if (MODE==='apply') await runMutation('apply');
+ else if (MODE==='restore') await runMutation('restore');
+ else { console.error('unknown mode', MODE); process.exit(1); }
+})();
← d3e1577 Sanderson TK-11070 batch-remediation + rollback scripts
·
back to Designerwallcoverings
·
auto-data-snapshot: 2026-09-01T10:56:56 (8 data files) — scr 9b75f40 →