← back to Gmc Titlefix

build-mdc-sample-titles.mjs

146 lines

#!/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 and flags a price-mismatch. Per
 * Google's sample policy the title must BEGIN with "Sample". This builds a supplemental-feed
 * title-override list that changes ONLY the GOOGLE title via a GMC supplemental data source
 * — the on-site Shopify PDP title is NEVER touched.
 *
 * OFFER IDENTITY (fixed 2026-09-09 after a 404 diagnosis): MDC offers live on GMC under the
 * LEGACY BARE-VARIANT-ID feed — offerId = the bare Shopify variant legacyResourceId (e.g.
 * "44595192496179"), product resource name = "en~US~<offerId>" (NO "online~" prefix, NO
 * "shopify_US_" compound). Not every ACTIVE tag:Showroom product is actually a live GMC
 * product, so this builder pulls the REAL set of GMC offerIds and includes ONLY MDC offers
 * that already exist on GMC — so the push can never 404 on a missing product.
 *
 * SCOPE — STRICTLY tag:Showroom, ACTIVE. Belt-and-suspenders: re-affirms hasShowroomTag per
 * product, so a sellable Phillipe Romano product (Spazzolato etc.) is never included.
 *
 * READ-ONLY (Shopify GET + GMC products.list GET). Writes one local JSON. Nothing pushed.
 * OUTPUT: data/mdc-sample-title-overrides.json  (offerId, gmcName, productId, sku, 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);
import { resolveSampleTitleDS } from './resolve-mdc-sample-source.mjs';
export { resolveSampleTitleDS };
const __dirname = path.dirname(fileURLToPath(import.meta.url));

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const VER = '2024-10';
const GTITLE_MAX = 150; // Google title max length

/**
 * The Google-facing sample title transform (exported for tests). Strips a trailing
 * "| Phillipe Romano" vendor suffix, wraps as a memo sample, 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];
}

/**
 * Resolve the EXISTING, PRIMARY-LINKED "DW Sample Title Overrides" supplemental data source
 * (never create a new one — that spawned the empty-duplicate 404 mess). Returns the data
 * source RESOURCE NAME that is actually referenced by a primary feed's takeFromDataSources.
 * (exported so the pusher reuses the identical resolution.)
 */

const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
  const { token, MERCHANT } = require('./_auth');
  const { hasShowroomTag } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
  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 token'); process.exit(1); }
  const SEP = `https://${SHOP}/admin/api/${VER}/graphql.json`;
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const sgql = async (query, variables) => {
    for (let a = 0; a < 8; a++) {
      let j;
      try { const r = await fetch(SEP, { 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('shopify retries');
  };

  (async () => {
    const tok = await token();
    const ds = await resolveSampleTitleDS(tok, { merchant: MERCHANT });
    // 1. Real GMC offerId set (paginate products.list) — so we only override offers that EXIST.
    process.stderr.write('pulling GMC offerId set…\n');
    const gmcOffers = new Set();
    let pt = null, gpages = 0;
    do {
      const url = `https://merchantapi.googleapis.com/products/v1/accounts/${MERCHANT}/products?pageSize=250` + (pt ? `&pageToken=${pt}` : '');
      const r = await fetch(url, { headers: { Authorization: 'Bearer ' + tok } });
      const j = await r.json();
      for (const p of (j.products || [])) if (p.offerId) gmcOffers.add(String(p.offerId));
      pt = j.nextPageToken; gpages++;
    } while (pt && gpages < 400);
    process.stderr.write(`  GMC offers: ${gmcOffers.size} (pages ${gpages})\n`);

    // 2. Enumerate ACTIVE tag:Showroom MDC products; offerId = bare sample-variant legacyId.
    const Q = `query($cursor:String){
      products(first:100, after:$cursor, query:"status:active tag:'Showroom'"){
        pageInfo{ hasNextPage endCursor }
        nodes{ legacyResourceId title tags variants(first:10){ nodes{ legacyResourceId sku } } }
      }
    }`;
    const rows = []; let cursor = null, has = true, seen = 0, notag = 0, notOnGmc = 0;
    while (has) {
      const d = await sgql(Q, { cursor });
      for (const p of d.products.nodes) {
        seen++;
        if (!hasShowroomTag(p.tags)) { notag++; continue; }
        const v = sampleVariant(p.variants.nodes);
        if (!v) continue;
        const offerId = String(v.legacyResourceId); // BARE variant id — the legacy GMC feed identity
        if (!gmcOffers.has(offerId)) { notOnGmc++; continue; } // not a live GMC product → skip (no 404)
        rows.push({
          offerId,
          gmcName: `accounts/${MERCHANT}/products/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(not a live GMC offer): ${notOnGmc}`);
    console.log(`  overrides written: ${rows.length}`);
    if (rows[0]) { console.log('  sample:', rows[0].offerId, '|', rows[0].currentTitle, '->', rows[0].proposedTitle); }
    console.log('  reuse datasource:', ds.name || ds.error, ds.linked ? '(linked to primary ✓)' : '');
    if (ds.duplicates?.length) console.log('  EMPTY DUPLICATE title datasources to delete:', ds.duplicates.join(', '));
    console.log('  wrote:', out);
    console.log('\n  Fire (Steve only): node push-mdc-sample-titles.mjs --apply --i-am-steve');
  })();
}