← 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); });