← back to Gmc Titlefix

gmc-weight-override.js

145 lines

// GMC SHIPPING-WEIGHT OVERRIDE — set shipping_weight = 1 lb on Google offers that are
// disapproved for missing_shipping_weight. FEED-ONLY: this is a Google Merchant feed
// attribute, entirely separate from the Shopify variant weight that drives real checkout.
// Setting it here does NOT change what a customer pays at checkout (Shopify owns that).
//
// Targets come from the gmc-viewer cache (data/catalog.json) — offers whose issues[]
// include 'missing_shipping_weight'. Optionally also arm $4.25 sample offers preemptively.
//
//   node gmc-weight-override.js                 # DRY-RUN: build target list from cache, no writes
//   node gmc-weight-override.js --include-samples# also arm every $4.25 sample offer
//   node gmc-weight-override.js --create-ds       # GATED: create the supplemental datasource
//   node gmc-weight-override.js --apply <DS> [--canary N]  # GATED: push weight overrides
//
// Cost: $0 (Merchant API + local cache read; no per-call charge).
const { token, MERCHANT } = require('./_auth.js');
const fs = require('fs');
const CACHE = '/Users/macstudio3/Projects/gmc-viewer/data/catalog.json';
const OUT = '/Users/macstudio3/.claude/yolo-queue/gmc-weight-override-list.json';
// Tiered feed weights (Steve 2026-07-09): real products 1 lb, $4.25 samples 0.35 lb.
const WEIGHT_REAL = parseFloat(process.env.WEIGHT_REAL || '1');
const WEIGHT_SAMPLE = parseFloat(process.env.WEIGHT_SAMPLE || '0.35');
const args = process.argv.slice(2);
const INCLUDE_SAMPLES = args.includes('--include-samples');
const SAMPLES_ONLY = args.includes('--samples-only');  // Steve: rolls are already correct — fix ONLY samples @ 0.35lb
const CREATE_DS = args.includes('--create-ds');
const APPLY = args.includes('--apply');
// Default to the already-linked weight datasource so no long arg is needed (terminal-wrap-safe).
const DEFAULT_DS = 'accounts/146735262/dataSources/10683334493';
const _dsArg = APPLY ? args[args.indexOf('--apply') + 1] : null;
const DS = APPLY ? ((_dsArg && !_dsArg.startsWith('--')) ? _dsArg : DEFAULT_DS) : null;
const LIST_DS = args.includes('--list-ds');
const VERIFY_LINK = args.includes('--verify-link');
const VL_DS = VERIFY_LINK ? args[args.indexOf('--verify-link') + 1] : null;
const CANARY = args.includes('--canary') ? parseInt(args[args.indexOf('--canary') + 1] || '10', 10) : 0;
const sleep = ms => new Promise(r => setTimeout(r, ms));

function buildList() {
  const cat = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
  const targets = [];
  for (const i of cat.items) {
    const needsWeight = (i.issues || []).includes('missing_shipping_weight');
    const isSample = Math.abs(i.price - 4.25) < 0.01;
    if (SAMPLES_ONLY) { if (!isSample) continue; }   // Steve: rolls are already correct — fix ONLY samples
    if (SAMPLES_ONLY ? isSample : (needsWeight || (INCLUDE_SAMPLES && isSample))) {
      targets.push({ offerId: i.offerId, contentLanguage: (i.id || '').split(':')[1] || 'en', feedLabel: i.country || 'US', weight: isSample ? WEIGHT_SAMPLE : WEIGHT_REAL, reason: needsWeight ? 'missing_shipping_weight' : 'sample-preemptive', title: i.title });
    }
  }
  return { generated_at: new Date().toISOString(), weight_real_lb: WEIGHT_REAL, weight_sample_lb: WEIGHT_SAMPLE, include_samples: INCLUDE_SAMPLES, count: targets.length, targets };
}

async function createDataSource() {
  const tok = await token();
  const url = `https://merchantapi.googleapis.com/datasources/v1/accounts/${MERCHANT}/dataSources`;
  for (const supp of [{}, { contentLanguage: 'en' }, { contentLanguage: 'en', feedLabel: 'US' }]) {
    const r = await fetch(url, { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify({ displayName: 'DW Shipping Weight Overrides', supplementalProductDataSource: supp }) });
    const j = await r.json();
    if (r.status >= 200 && r.status < 300 && j.name) { console.log('DATASOURCE CREATED:\n' + j.name); return; }
    console.error('  shape', JSON.stringify(supp), '->', r.status, (j.error?.message || '').slice(0, 120));
  }
  console.error('all datasource shapes failed');
}

// Verify the supplemental datasource is LINKED to a primary feed — else inserts 200 but do nothing.
async function verifyLink(ds) {
  const tok = await token();
  const suppId = (ds.match(/(\d+)$/) || [])[1];
  // AUTHORITATIVE: check every primary's defaultRule.takeFromDataSources for this supplemental
  // (the supplemental's own referencingPrimaryDataSources reverse-pointer lags / only reflects UI links).
  const all = await (await fetch(`https://merchantapi.googleapis.com/datasources/v1/accounts/${MERCHANT}/dataSources?pageSize=100`, { headers: { Authorization: 'Bearer ' + tok } })).json();
  const linkedTo = [];
  for (const d of (all.dataSources || [])) {
    const tf = d.primaryProductDataSource?.defaultRule?.takeFromDataSources || [];
    if (tf.some(x => (x.supplementalDataSourceName || '').includes(suppId))) linkedTo.push(d.name);
  }
  console.log(linkedTo.length ? `✓ LINKED — referenced by primary rule(s): ${linkedTo.join(', ')}` : '⚠️ NOT LINKED — no primary rule references it; inserts would 200 but NOT propagate.');
}
// Link our supplemental into the primary feed's takeFromDataSources (before {self:true}).
// DRY-RUN by default; add --yes to PATCH the primary. This is a PRIMARY-FEED config write.
async function linkSupplemental() {
  const PRIMARY = '180695450';
  const SUPP = 'accounts/146735262/dataSources/10683334493';
  const tok = await token();
  const base = `https://merchantapi.googleapis.com/datasources/v1/accounts/${MERCHANT}/dataSources/${PRIMARY}`;
  const cur = await (await fetch(base, { headers: { Authorization: 'Bearer ' + tok } })).json();
  const pds = cur.primaryProductDataSource;
  const tf = (pds.defaultRule && pds.defaultRule.takeFromDataSources) || [{ self: true }];
  if (tf.some(x => x.supplementalDataSourceName === SUPP)) { console.log('✓ already linked — nothing to do'); return; }
  const idx = tf.findIndex(x => x.self);
  const newTf = [...tf]; newTf.splice(idx < 0 ? tf.length : idx, 0, { supplementalDataSourceName: SUPP });
  const newPds = { ...pds, defaultRule: { ...pds.defaultRule, takeFromDataSources: newTf } };
  console.log('CURRENT takeFromDataSources:', JSON.stringify(tf));
  console.log('NEW     takeFromDataSources:', JSON.stringify(newTf));
  if (!args.includes('--yes')) { console.log('\nDRY-RUN — re-run with --yes to apply the link (PATCHes the primary feed).'); return; }
  const r = await fetch(base + '?updateMask=primaryProductDataSource', { method: 'PATCH', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify({ primaryProductDataSource: newPds }) });
  const txt = await r.text();
  console.log(r.ok ? '✓ LINKED (HTTP ' + r.status + ')' : '⚠️ PATCH failed ' + r.status + ' ' + txt.slice(0, 300));
}
async function listDs() {
  const tok = await token();
  const r = await (await fetch(`https://merchantapi.googleapis.com/datasources/v1/accounts/${MERCHANT}/dataSources?pageSize=100`, { headers: { Authorization: 'Bearer ' + tok } })).json();
  for (const d of (r.dataSources || [])) console.log(`${d.name} | ${d.displayName || ''} | ${d.primaryProductDataSource ? 'PRIMARY' : d.supplementalProductDataSource ? 'supplemental' : '?'}`);
}
// Stratified canary: pull N/2 from each offerId format so BOTH shapes are tested.
function stratify(list, n) {
  const shopifyFmt = list.filter(t => /^shopify_/.test(t.offerId));
  const bareFmt = list.filter(t => !/^shopify_/.test(t.offerId));
  const half = Math.ceil(n / 2);
  return [...shopifyFmt.slice(0, half), ...bareFmt.slice(0, n - Math.min(half, shopifyFmt.length))];
}
async function apply(ds) {
  const list = JSON.parse(fs.readFileSync(OUT, 'utf8')).targets;
  const slice = CANARY ? stratify(list, CANARY) : list;
  let tok = await token(), tokAt = Date.now(), ok = 0, fail = 0;
  console.log(`Pushing tiered shipping_weight (real ${WEIGHT_REAL}lb / sample ${WEIGHT_SAMPLE}lb) for ${slice.length} offers → ${ds}`);
  if (CANARY) console.log(`  canary formats: ${slice.map(t => /^shopify_/.test(t.offerId) ? 'S' : 'B').join('')} (S=shopify_ B=bare)`);
  for (let i = 0; i < slice.length; i++) {
    if (Date.now() - tokAt > 50 * 60 * 1000) { tok = await token(); tokAt = Date.now(); }
    const row = slice[i];
    const url = `https://merchantapi.googleapis.com/products/v1/accounts/${MERCHANT}/productInputs:insert?dataSource=${encodeURIComponent(ds)}`;
    const body = { offerId: row.offerId, contentLanguage: row.contentLanguage, feedLabel: row.feedLabel, productAttributes: { shippingWeight: { value: row.weight, unit: 'lb' } } };
    const r = await fetch(url, { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    const txt = await r.text(); let j = {}; try { j = JSON.parse(txt); } catch (e) {}
    if (r.ok && !j.error) ok++; else { fail++; if (fail <= 10) console.error('FAIL', row.offerId, r.status, txt.slice(0, 160)); }
    if (i % 500 === 0) console.log(`  ${i}/${slice.length} | ok ${ok} fail ${fail}`);
    if (r.status === 429) await sleep(3000);
  }
  console.log(`DONE: ok ${ok} fail ${fail} of ${slice.length}`);
  if (CANARY) console.log(`\n⚠️ Canary HTTP-200 only proves delivery, NOT propagation. Confirm the DS is LINKED (node gmc-weight-override.js --verify-link "${ds}") AND check GMC in 24h that these cleared before scaling.`);
}

(async () => {
  if (LIST_DS) return listDs();
  if (args.includes('--link')) return linkSupplemental();
  if (VERIFY_LINK) { if (!VL_DS) { console.error('need datasource: --verify-link <DS>'); process.exit(1); } return verifyLink(VL_DS); }
  if (CREATE_DS) return createDataSource();
  if (APPLY) { if (!DS) { console.error('need datasource: --apply <DS>'); process.exit(1); } return apply(DS); }
  const out = buildList();
  fs.writeFileSync(OUT, JSON.stringify(out, null, 2));
  const byReason = out.targets.reduce((a, t) => (a[t.reason] = (a[t.reason] || 0) + 1, a), {});
  console.log(`Shipping-weight override targets: ${out.count} (real ${WEIGHT_REAL}lb / samples ${WEIGHT_SAMPLE}lb — feed-only, checkout unaffected)`);
  console.log('  by reason:', JSON.stringify(byReason));
  console.log(`Wrote ${OUT}`);
  out.targets.slice(0, 6).forEach(t => console.log(`  ${t.offerId}  ${t.reason}  ${(t.title || '').slice(0, 40)}`));
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });