← back to Gmc Titlefix
capture-mdc-preimages.mjs
135 lines
#!/usr/bin/env node
/**
* capture-mdc-preimages.mjs — READ-ONLY per-offer PREIMAGE capture for the TK-11307 MDC
* Google sample-title push. (TK-11037 REVISE path, prerequisite #3.)
*
* WHY: push-mdc-sample-titles.mjs overwrites each offer's Google title via the supplemental
* source but NEVER captures the prior value — so the "rollback" it references does not exist,
* and the write to Google (an external, irreversible channel) has no restore. This tool GETs,
* per offer, the CURRENT PROCESSED Google title (what Google shows today) and records a
* restoration mapping, so GATED-4 becomes reversible before it is ever considered for firing.
*
* SEMANTICS: the current processed title IS the meaningful preimage. Restoration = re-insert
* that captured title through the SAME supplemental mechanism → reproduces the exact prior
* visible state for every offer, with NO per-input delete needed (avoids the deletion-authority
* caveat in Memo A #3/#4). We still flag `overrideLikelyPresent` so a future executor may
* choose delete-vs-reinsert.
*
* OFFER IDENTITY: MDC offers live on the LEGACY BARE-VARIANT feed — product resource name is
* `accounts/<mid>/products/en~US~<offerId>` (NO `online~` prefix). Each override-list row already
* carries the correct `gmcName`; we use it verbatim. (Do NOT route through _mc-read-v1 getProduct,
* which prepends `online~` and would 404 every MDC offer.)
*
* READ-ONLY: only GMC products.get GETs + one local JSON written. Nothing pushed to Google or
* Shopify. Free Merchant API reads → $0.
*
* INPUT: data/mdc-sample-title-overrides.json (the EXACT set the pusher writes — 1:1 alignment)
* OUTPUT: data/mdc-sample-title-preimages-<ISO>.json (per-offer preimage + restore map + manifest hash)
* USAGE: node capture-mdc-preimages.mjs [--limit=N] (--limit for a canary sample)
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
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 LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
const BASE = 'https://merchantapi.googleapis.com';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
const LIST_PATH = path.join(__dirname, 'data', 'mdc-sample-title-overrides.json');
const listRaw = fs.readFileSync(LIST_PATH, 'utf8');
const list = JSON.parse(listRaw).slice(0, LIMIT);
const listHash = sha(listRaw);
// A processed title that already reads like our sample transform => an override is probably already live.
const looksLikeOverride = t => /^Sample\b/.test(String(t || ''));
(async () => {
let tok = await token(), tokAt = Date.now();
console.log(`capture-mdc-preimages — READ-ONLY. offers: ${list.length}${LIMIT !== Infinity ? ` (--limit ${LIMIT})` : ''}`);
console.log(`source list: ${LIST_PATH} sha256=${listHash.slice(0, 16)}…\n`);
const out = [];
let ok = 0, missing = 0, err = 0, overridePresent = 0, alreadyMatches = 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 = `${BASE}/products/v1/${row.gmcName}`; // gmcName is en~US~<offerId> (bare feed) — use verbatim
let rec = { offerId: row.offerId, gmcName: row.gmcName, sku: row.sku, primaryTitle: row.currentTitle, proposedTitle: row.proposedTitle };
try {
const r = await fetch(url, { headers: { Authorization: 'Bearer ' + tok } });
if (r.status === 429) { await sleep(3000); i--; continue; }
const j = await r.json();
if (r.status === 404) {
rec = { ...rec, exists: false, processedTitlePreimage: null, overrideLikelyPresent: false, restoreAction: 'none (offer absent on GMC)', capturedAt: new Date().toISOString() };
missing++;
} else if (!r.ok) {
rec = { ...rec, exists: null, error: `HTTP ${r.status} ${JSON.stringify(j.error || j).slice(0, 120)}`, capturedAt: new Date().toISOString() };
err++;
if (err <= 8) console.error('ERR', row.offerId, rec.error);
} else {
const title = (j.productAttributes || {}).title ?? null;
const isOv = looksLikeOverride(title);
const already = title === row.proposedTitle; // live already == what a push would write
if (isOv) overridePresent++;
if (already) alreadyMatches++;
// The pre-push Google state for EVERY offer is the PRIMARY (Shopify) feed title. When no
// supplemental override is live, processed == primary. When one IS live (this offer was
// already pushed), processed shows the override, so the true revert target is the primary.
const restoreTarget = row.currentTitle; // primary Shopify title = universal pre-push Google title
rec = {
...rec,
exists: true,
processedTitleLive: title, // the CURRENT live Google title (post any prior override)
overrideLikelyPresent: isOv, // heuristic: live title already reads like our sample transform
alreadyMatchesProposed: already, // TRUE => GATED-4 already applied to this offer
restoreTarget, // set supplemental title to this (or delete input) to revert
restoreAction: isOv
? `OVERRIDE ALREADY LIVE — to revert: set supplemental title = ${JSON.stringify(restoreTarget)} (or delete the input)`
: `no override live yet — processed==primary; to revert a future push: set title = ${JSON.stringify(restoreTarget)} (or delete input)`,
capturedAt: new Date().toISOString(),
};
ok++;
}
} catch (e) {
rec = { ...rec, exists: null, error: String(e).slice(0, 120), capturedAt: new Date().toISOString() };
err++;
if (err <= 8) console.error('ERR', row.offerId, rec.error);
}
out.push(rec);
if (i % 100 === 0) console.log(` ${i}/${list.length} | ok ${ok} missing ${missing} err ${err}`);
await sleep(140); // ~7 req/s, gentle on the free read API
}
const capturedAt = new Date().toISOString();
const payloadRows = JSON.stringify(out);
const manifest = {
tool: 'capture-mdc-preimages.mjs',
ticket: 'TK-11037 / TK-11307',
merchant: MERCHANT,
capturedAt,
sourceList: 'data/mdc-sample-title-overrides.json',
sourceListSha256: listHash,
offerCount: out.length,
exists: ok, missing, errors: err, overrideLikelyPresent: overridePresent,
alreadyMatchesProposed: alreadyMatches,
preimagesSha256: sha(payloadRows),
restoreMethod: 'reinsert captured processedTitlePreimage via the supplemental source (no per-input delete required); overrideLikelyPresent flags where a supplemental override already exists',
readOnly: true,
note: 'Preimages align 1:1 with the CURRENT override list. If the override list is rebuilt fresh (after GATED-0 ShowroomOnly applier), RE-RUN this capture so the map matches the exact write set.',
};
const outPath = path.join(__dirname, 'data', `mdc-sample-title-preimages-${capturedAt.replace(/[:.]/g, '-')}.json`);
fs.writeFileSync(outPath, JSON.stringify({ manifest, preimages: out }, null, 1));
console.log(`\nDONE (READ-ONLY). captured ${out.length} | live ${ok} | absent ${missing} | err ${err}`);
console.log(` override-already-present ${overridePresent} | ALREADY-MATCHES-PROPOSED ${alreadyMatches} (=> GATED-4 already applied to these offers)`);
console.log(`manifest sha256(preimages)=${manifest.preimagesSha256.slice(0, 16)}…`);
console.log(`wrote: ${outPath}`);
})();