← back to Gmc Titlefix
gmc: MDC Google sample-title override (DTD verdict B, TK-11307)
37f1e1773bf96226bc10c2f5d44fde6c859c1449 · 2026-09-09 07:45:30 -0700 · steve
Builds + pushes a GMC supplemental-datasource title override so each MDC
(tag:Showroom) offer's GOOGLE title begins with 'Sample — <pattern> — Memo Swatch'
(Google sample policy) while the offer stays a compliant $4.25 sample. Changes ONLY
the Google-facing title via productInputs:insert — never the Shopify PDP/customer
title. Strictly scoped to tag:Showroom (hasShowroomTag re-affirm); sellable PR
(Spazzolato) is never in the list. Pusher is DRY-RUN by default, HARD-guards that
every proposed title begins with 'Sample', requires --apply --i-am-steve. 11/11 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDeaFL4eeREBZX79tc4cnr
Files touched
A build-mdc-sample-titles.mjsA push-mdc-sample-titles.mjsA test-mdc-sample-titles.mjs
Diff
commit 37f1e1773bf96226bc10c2f5d44fde6c859c1449
Author: steve <steve@designerwallcoverings.com>
Date: Wed Sep 9 07:45:30 2026 -0700
gmc: MDC Google sample-title override (DTD verdict B, TK-11307)
Builds + pushes a GMC supplemental-datasource title override so each MDC
(tag:Showroom) offer's GOOGLE title begins with 'Sample — <pattern> — Memo Swatch'
(Google sample policy) while the offer stays a compliant $4.25 sample. Changes ONLY
the Google-facing title via productInputs:insert — never the Shopify PDP/customer
title. Strictly scoped to tag:Showroom (hasShowroomTag re-affirm); sellable PR
(Spazzolato) is never in the list. Pusher is DRY-RUN by default, HARD-guards that
every proposed title begins with 'Sample', requires --apply --i-am-steve. 11/11 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDeaFL4eeREBZX79tc4cnr
---
build-mdc-sample-titles.mjs | 122 ++++++++++++++++++++++++++++++++++++++++++++
push-mdc-sample-titles.mjs | 66 ++++++++++++++++++++++++
test-mdc-sample-titles.mjs | 40 +++++++++++++++
3 files changed, 228 insertions(+)
diff --git a/build-mdc-sample-titles.mjs b/build-mdc-sample-titles.mjs
new file mode 100644
index 0000000..f112490
--- /dev/null
+++ b/build-mdc-sample-titles.mjs
@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+/**
+ * build-mdc-sample-titles.mjs — build the GOOGLE-facing "Sample —" title override list
+ * for the MDC showroom line (tag:Showroom). (TK-11307, DTD verdict B)
+ *
+ * WHY: MDC products carry a single $4.25 memo-sample variant (no roll). On Google the
+ * OFFER PRICE is a compliant $4.25, but the TITLE reads "<Pattern> Wallcovering | Phillipe
+ * Romano" — so Google sees a $4.25-priced wallcovering (a ~$300 item at $4.25) and flags a
+ * price-mismatch. Per Google's sample policy the title must BEGIN with "Sample". This tool
+ * builds a supplemental-feed title-override list (offerId -> "Sample — …") that changes ONLY
+ * the GOOGLE-facing title via a GMC supplemental data source — the on-site Shopify PDP title
+ * is NEVER touched (shoppers still see the wallcovering name).
+ *
+ * SCOPE — STRICTLY tag:Showroom, ACTIVE, actually published to the Google & YouTube channel.
+ * Belt-and-suspenders: re-affirms hasShowroomTag per product, so a sellable Phillipe Romano
+ * product (Spazzolato etc., no Showroom tag) is NEVER in the list and its title is unchanged.
+ *
+ * READ-ONLY against Shopify (query only). Writes one local JSON. Nothing is pushed to GMC
+ * here — push-mdc-sample-titles.mjs (Steve-gated) does that.
+ *
+ * OUTPUT: data/mdc-sample-title-overrides.json (rows: offerId, gmcId, productId, currentTitle, proposedTitle)
+ * USAGE: node build-mdc-sample-titles.mjs
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+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 __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VER = '2024-10';
+const GOOGLE_PUBLICATION = 'gid://shopify/Publication/29646651457'; // Google & YouTube
+const GTITLE_MAX = 150; // Google title max length
+
+/**
+ * The Google-facing sample title transform (exported for tests). Strips a trailing
+ * "| Phillipe Romano" / "- Phillipe Romano" vendor suffix, wraps as a memo sample, and
+ * ALWAYS begins with "Sample". Truncation is end-only so the "Sample" prefix survives.
+ */
+export function mdcSampleTitle(title) {
+ const clean = String(title == null ? '' : title)
+ .replace(/\s*[|\-–—]\s*Phillipe\s+Romano\s*$/i, '')
+ .trim();
+ let t = `Sample — ${clean} — Memo Swatch`;
+ if (t.length > GTITLE_MAX) t = t.slice(0, GTITLE_MAX - 1).trimEnd() + '…';
+ return t;
+}
+
+/** Pick the memo-sample variant (sku ends -Sample), else the single/first variant. */
+export function sampleVariant(variants) {
+ if (!variants || !variants.length) return null;
+ return variants.find(v => /-sample$/i.test(v.sku || '')) || variants[0];
+}
+
+// main (skipped when imported for tests)
+const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+if (isMain) {
+ 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));
+ const gql = async (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 { 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 Q = `query($cursor:String){
+ products(first:100, after:$cursor, query:"status:active tag:'Showroom'"){
+ pageInfo{ hasNextPage endCursor }
+ nodes{ legacyResourceId title tags
+ onGoogle:publishedOnPublication(publicationId:"${GOOGLE_PUBLICATION}")
+ variants(first:10){ nodes{ legacyResourceId sku } } }
+ }
+ }`;
+
+ (async () => {
+ const rows = []; let cursor = null, has = true, seen = 0, offCh = 0, notag = 0;
+ while (has) {
+ const d = await gql(Q, { cursor });
+ for (const p of d.products.nodes) {
+ seen++;
+ if (!hasShowroomTag(p.tags)) { notag++; continue; } // re-affirm the tag — never trust the query
+ if (!p.onGoogle) { offCh++; continue; } // only offers actually on Google
+ const v = sampleVariant(p.variants.nodes);
+ if (!v) continue;
+ const offerId = `shopify_US_${p.legacyResourceId}_${v.legacyResourceId}`;
+ rows.push({
+ offerId,
+ gmcId: `online:en:US:${offerId}`,
+ productId: String(p.legacyResourceId),
+ sku: v.sku,
+ currentTitle: p.title,
+ proposedTitle: mdcSampleTitle(p.title),
+ });
+ }
+ has = d.products.pageInfo.hasNextPage; cursor = d.products.pageInfo.endCursor;
+ }
+ const DIR = path.join(__dirname, 'data'); fs.mkdirSync(DIR, { recursive: true });
+ const out = path.join(DIR, 'mdc-sample-title-overrides.json');
+ fs.writeFileSync(out, JSON.stringify(rows, null, 1));
+ console.log('MDC (tag:Showroom) Google sample-title override list');
+ console.log(` active tag:'Showroom' seen: ${seen} | skipped(no real tag): ${notag} | skipped(off Google): ${offCh}`);
+ console.log(` overrides written: ${rows.length}`);
+ if (rows[0]) { console.log(' sample row:'); console.log(' ', rows[0].offerId); console.log(' ', rows[0].currentTitle, '->', rows[0].proposedTitle); }
+ console.log(' wrote:', out);
+ console.log('\n Fire (Steve only): node push-mdc-sample-titles.mjs --apply --i-am-steve <dataSourceName>');
+ })();
+}
diff --git a/push-mdc-sample-titles.mjs b/push-mdc-sample-titles.mjs
new file mode 100644
index 0000000..394de81
--- /dev/null
+++ b/push-mdc-sample-titles.mjs
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+/**
+ * push-mdc-sample-titles.mjs — GATED WRITE. Push the MDC "Sample —" Google-title overrides
+ * to a GMC supplemental data source (Merchant API v1), keyed by offerId. (TK-11307 verdict B)
+ *
+ * ⚠️ STEVE-GATED, customer-facing (Google listing). DEFAULT = DRY-RUN (no writes).
+ * A real run requires BOTH: --apply AND --i-am-steve AND a <dataSourceName>.
+ * It overrides ONLY the GOOGLE title via the supplemental source — it NEVER touches
+ * Shopify, price, availability, or the on-site PDP. Fully reversible: delete the
+ * supplemental data source (removes every override at once) — see the memo.
+ *
+ * Reads: data/mdc-sample-title-overrides.json (from build-mdc-sample-titles.mjs).
+ * GUARD: HARD-ABORTS unless EVERY row's proposedTitle begins with "Sample" (the whole point).
+ *
+ * Create the data source first (dedicated, so rollback = delete just this source):
+ * node step-a-create-datasource.js # (rename displayName to 'DW MDC Sample Title Overrides' first)
+ * then: node push-mdc-sample-titles.mjs --apply --i-am-steve accounts/146735262/dataSources/<id>
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const { token, MERCHANT } = require('./_auth');
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v === undefined ? true : v]; }));
+const DS = process.argv.slice(2).find(a => a.startsWith('accounts/'));
+const APPLY = args.apply === true && args['i-am-steve'] === true;
+
+const LIST_PATH = path.join(__dirname, 'data', 'mdc-sample-title-overrides.json');
+const list = JSON.parse(fs.readFileSync(LIST_PATH, 'utf8'));
+
+// HARD GUARD — every proposed Google title MUST begin with "Sample".
+const bad = list.filter(r => !/^Sample\b/.test(String(r.proposedTitle || '')));
+if (bad.length) {
+ console.error(`⛔ ABORT: ${bad.length}/${list.length} proposed titles do NOT begin with "Sample". Rebuild the list.`);
+ console.error(` e.g. ${bad.slice(0, 3).map(r => r.offerId + ': ' + r.proposedTitle).join(' | ')}`);
+ process.exit(1);
+}
+
+console.log(`push-mdc-sample-titles — mode: ${APPLY ? '⚠️ LIVE APPLY' : 'DRY-RUN (no writes)'}`);
+console.log(`overrides: ${list.length} (all begin with "Sample" ✓)`);
+if (!APPLY || !DS) {
+ console.log('\nDRY-RUN or no data source. To execute (Steve only):');
+ console.log(' 1) node step-a-create-datasource.js # create the supplemental source, copy its name');
+ console.log(' 2) node push-mdc-sample-titles.mjs --apply --i-am-steve accounts/146735262/dataSources/<id>');
+ if (list[0]) console.log(`\n first override: ${list[0].offerId}\n ${list[0].currentTitle}\n -> ${list[0].proposedTitle}`);
+ process.exit(0);
+}
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+(async () => {
+ let tok = await token(), tokAt = Date.now(), ok = 0, fail = 0;
+ for (let i = 0; i < list.length; i++) {
+ if (Date.now() - tokAt > 50 * 60 * 1000) { tok = await token(); tokAt = Date.now(); }
+ const row = list[i];
+ const url = `https://merchantapi.googleapis.com/products/v1/accounts/${MERCHANT}/productInputs:insert?dataSource=${encodeURIComponent(DS)}`;
+ const body = { offerId: row.offerId, contentLanguage: 'en', feedLabel: 'US', productAttributes: { title: row.proposedTitle } };
+ const r = await fetch(url, { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ if (r.ok) ok++; else { fail++; if (fail <= 10) console.error('FAIL', row.offerId, r.status, (await r.text()).slice(0, 120)); }
+ if (i % 500 === 0) console.log(` ${i}/${list.length} | ok ${ok} fail ${fail}`);
+ if (r.status === 429) await sleep(3000);
+ }
+ console.log(`DONE: ok ${ok} fail ${fail} of ${list.length}`);
+})();
diff --git a/test-mdc-sample-titles.mjs b/test-mdc-sample-titles.mjs
new file mode 100644
index 0000000..21a1520
--- /dev/null
+++ b/test-mdc-sample-titles.mjs
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/**
+ * test-mdc-sample-titles.mjs — assert the Google title for an MDC product begins with
+ * "Sample" while a sellable Phillipe Romano product's title is unchanged. (TK-11307 verdict B)
+ */
+import { createRequire } from 'node:module';
+import { mdcSampleTitle, sampleVariant } from './build-mdc-sample-titles.mjs';
+const require = createRequire(import.meta.url);
+const { hasShowroomTag } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+
+// The builder emits an override row ONLY for a tag:Showroom product; a sellable PR product
+// (no Showroom tag) is skipped, so its Google title stays the Shopify title unchanged.
+function wouldOverride(product) { return hasShowroomTag(product.tags); }
+
+const MDC = { title: 'London All About Big Geo Platinum White Wallcovering | Phillipe Romano', tags: ['Type II', 'quotes', 'Showroom'], variants: [{ legacyResourceId: '44594816745523', sku: 'DWPP-205000-Sample' }] };
+const SELL = { title: 'Spazzolato Bronze Wallcovering | Phillipe Romano', tags: ['Type II', 'Vinyl'], variants: [{ legacyResourceId: '1', sku: 'DWPX-500001-Yard' }, { legacyResourceId: '2', sku: 'DWPX-500001-Sample' }] };
+const PJ = { title: 'Untethered Wallcovering by Phillip Jeffries', tags: ['Grasscloth', 'Showroom'], variants: [{ legacyResourceId: '9', sku: 'PJ-Sample' }] };
+
+const cases = [
+ ['MDC Google title BEGINS with "Sample"', mdcSampleTitle(MDC.title).startsWith('Sample'), true],
+ ['MDC Google title drops the "| Phillipe Romano" suffix', /Phillipe Romano/.test(mdcSampleTitle(MDC.title)), false],
+ ['MDC Google title is a memo swatch', /Memo Swatch$/.test(mdcSampleTitle(MDC.title)), true],
+ ['MDC IS in the override set (tag:Showroom)', wouldOverride(MDC), true],
+ ['Sellable PR (Spazzolato) title UNCHANGED — not in override set', wouldOverride(SELL), false],
+ ['MDC offerId uses the -Sample variant', sampleVariant(MDC.variants).sku, 'DWPP-205000-Sample'],
+ ['Sellable PR sample-variant would be the -Sample one IF it were scoped', sampleVariant(SELL.variants).sku, 'DWPX-500001-Sample'],
+ ['PJ (showroom) Google title also begins with "Sample" when tagged', mdcSampleTitle(PJ.title).startsWith('Sample'), true],
+ ['override title <= 150 chars', mdcSampleTitle('X'.repeat(400)).length <= 150, true],
+ ['override title stays "Sample"-prefixed after truncation', mdcSampleTitle('X'.repeat(400)).startsWith('Sample'), true],
+ ['empty title still safe', typeof mdcSampleTitle('') === 'string', true],
+];
+
+let ok = 0;
+for (const [name, got, want] of cases) {
+ const pass = got === want; if (pass) ok++;
+ console.log((pass ? 'PASS' : 'FAIL') + ' | ' + name + ' => ' + JSON.stringify(got) + (pass ? '' : ' (want ' + JSON.stringify(want) + ')'));
+}
+console.log(`\n${ok}/${cases.length} passed`);
+console.log('example:', JSON.stringify(mdcSampleTitle(MDC.title)));
+process.exit(ok === cases.length ? 0 : 1);
← 105c2ff auto-data-snapshot: 2026-09-05T08:46:42 (7 data files) — dat
·
back to Gmc Titlefix
·
gmc mdc-title: fix 404 — bare-variant offerId, en~US~ name, 8f147e6 →