← back to Gmc Titlefix
gmc-titlefix: add Merchant API v1 read shim + migrate verify-canary & lever2-add-recheck off sunset v2.1
3628b9f4288d780c5cba5d913f76dd0091a972fe · 2026-09-10 00:09:31 -0700 · Steve
_mc-read-v1.js returns v2.1-compatible shapes (name ~ not :, destinationStatuses country-arrays -> approved/disapproved, price Money -> {value}). Proven vs known approved+disapproved offers; verify-canary 400-offer scan clean (12 genuine 404s = removed offers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQ7X5JUdRkJVZ7HbNtzw4e
Files touched
A _mc-read-v1.jsM lever2-add-recheck.mjsM verify-canary.mjs
Diff
commit 3628b9f4288d780c5cba5d913f76dd0091a972fe
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 10 00:09:31 2026 -0700
gmc-titlefix: add Merchant API v1 read shim + migrate verify-canary & lever2-add-recheck off sunset v2.1
_mc-read-v1.js returns v2.1-compatible shapes (name ~ not :, destinationStatuses country-arrays -> approved/disapproved, price Money -> {value}). Proven vs known approved+disapproved offers; verify-canary 400-offer scan clean (12 genuine 404s = removed offers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQ7X5JUdRkJVZ7HbNtzw4e
---
_mc-read-v1.js | 104 +++++++++++++++++++++++++++++++++++++++++++++++++
lever2-add-recheck.mjs | 10 ++---
verify-canary.mjs | 9 ++---
3 files changed, 113 insertions(+), 10 deletions(-)
diff --git a/_mc-read-v1.js b/_mc-read-v1.js
new file mode 100644
index 0000000..6142def
--- /dev/null
+++ b/_mc-read-v1.js
@@ -0,0 +1,104 @@
+// Shared Merchant API v1 READ shim (Content API v2.1 sunset 2026-08-18; v1beta discontinued
+// 2026-02-28). Returns v2.1-COMPATIBLE shapes so callers migrate with a one-line swap and the
+// field-mapping risk lives in ONE audited place, not smeared across every tool. (TK10993, 2026-09-10)
+//
+// v1 realities proven live before writing this shim:
+// • product name uses '~' not ':' (online~en~US~<offerId>) — colon → HTTP 400
+// • products.get returns BOTH productStatus AND productAttributes (one call covers the old
+// v2.1 products.get AND productstatuses.get)
+// • productStatus.destinationStatuses = [{reportingContext, approvedCountries[],
+// pendingCountries[], disapprovedCountries[]}] — NOT a single {destination,status} string
+// • price attribute is Money {amountMicros, currencyCode}
+// • account issues: accounts/v1/accounts/{mid}/issues → {accountIssues:[{severity,title,...}]}
+// • aggregate: issueresolution/v1/accounts/{mid}/aggregateProductStatuses
+const { token, MERCHANT } = require('./_auth.js');
+const BASE = 'https://merchantapi.googleapis.com';
+
+async function H() { return { Authorization: 'Bearer ' + (await token()) }; }
+const toV1Name = rid => 'online~' + String(rid).replace(/^online:/, '').replace(/:/g, '~'); // 'online:en:US:x' → 'online~en~US~x'; bare offerId also ok
+const moneyToNum = m => (m && m.amountMicros != null) ? Number(m.amountMicros) / 1e6 : (m && m.value != null ? Number(m.value) : null);
+
+// Map v1 productStatus.destinationStatuses (country arrays) → a v2.1-style status string for a country.
+function destStatus(productStatus, country = 'US', ctx = 'SHOPPING_ADS') {
+ const d = (productStatus && productStatus.destinationStatuses || []).find(x => x.reportingContext === ctx)
+ || (productStatus && productStatus.destinationStatuses || [])[0];
+ if (!d) return 'other';
+ if ((d.disapprovedCountries || []).includes(country)) return 'disapproved';
+ if ((d.pendingCountries || []).includes(country)) return 'pending';
+ if ((d.approvedCountries || []).includes(country)) return 'approved';
+ return 'other';
+}
+
+// GET one processed product by v2.1-style rid ('online:en:US:<offerId>') or bare offerId.
+// Returns a v2.1-COMPATIBLE object: { id, price:{value,currency}, title, destinationStatuses:[{destination,status}],
+// itemLevelIssues:[{code,servability,attributeName}], _v1 } — plus _v1 for callers that want the raw v1 body.
+async function getProduct(rid, { country = 'US' } = {}) {
+ const name = `accounts/${MERCHANT}/products/${toV1Name(rid)}`;
+ const r = await fetch(`${BASE}/products/v1/${name}`, { headers: await H() });
+ const j = await r.json();
+ if (!r.ok) { const e = new Error(`v1 products.get HTTP ${r.status} ${JSON.stringify(j.error || j).slice(0, 160)}`); e.status = r.status; throw e; }
+ const attr = j.productAttributes || {};
+ const ps = j.productStatus || {};
+ const price = moneyToNum(attr.price);
+ return {
+ id: rid,
+ price: price != null ? { value: String(price), currency: attr.price && attr.price.currencyCode } : undefined,
+ title: attr.title,
+ destinationStatuses: [{ destination: 'Shopping', status: destStatus(ps, country) }],
+ itemLevelIssues: (ps.itemLevelIssues || []).map(i => ({
+ code: i.code,
+ servability: (i.severity === 'DISAPPROVED') ? 'disapproved' : (i.severity || '').toLowerCase(),
+ attributeName: i.attribute,
+ description: i.description,
+ })),
+ _v1: j,
+ };
+}
+
+// List processed products (paginated). cb(product_v1_compat) per item; returns total count.
+async function listProducts(cb, { pageSize = 250 } = {}) {
+ let page = '', n = 0;
+ do {
+ const r = await fetch(`${BASE}/products/v1/accounts/${MERCHANT}/products?pageSize=${pageSize}` + (page ? `&pageToken=${encodeURIComponent(page)}` : ''), { headers: await H() });
+ const j = await r.json();
+ if (!r.ok) { const e = new Error(`v1 products.list HTTP ${r.status} ${JSON.stringify(j.error || j).slice(0, 160)}`); e.status = r.status; throw e; }
+ for (const p of (j.products || [])) {
+ const attr = p.productAttributes || {}, ps = p.productStatus || {}, price = moneyToNum(attr.price);
+ await cb({ id: (p.offerId ? `online:${p.contentLanguage}:${p.feedLabel}:${p.offerId}` : p.name), offerId: p.offerId, price: price != null ? { value: String(price), currency: attr.price && attr.price.currencyCode } : undefined, title: attr.title, destinationStatuses: [{ destination: 'Shopping', status: destStatus(ps) }], itemLevelIssues: (ps.itemLevelIssues || []).map(i => ({ code: i.code, servability: (i.severity === 'DISAPPROVED') ? 'disapproved' : (i.severity || '').toLowerCase(), attributeName: i.attribute })), _v1: p });
+ n++;
+ }
+ page = j.nextPageToken || '';
+ } while (page);
+ return n;
+}
+
+// Aggregate product statuses (SHOPPING_ADS) → { active, disapproved, pending, scanned, disapproval_pct, top_reasons, by_country }
+async function aggregateStatuses() {
+ let active = 0, disc = 0, pending = 0, page = ''; const codes = {}, byCountry = {}, seen = new Set();
+ do {
+ const r = await fetch(`${BASE}/issueresolution/v1/accounts/${MERCHANT}/aggregateProductStatuses?pageSize=100` + (page ? `&pageToken=${encodeURIComponent(page)}` : ''), { headers: await H() });
+ const j = await r.json();
+ if (!r.ok) throw new Error(`aggregate HTTP ${r.status} ${JSON.stringify(j.error || j).slice(0, 160)}`);
+ for (const a of (j.aggregateProductStatuses || [])) {
+ if (a.reportingContext !== 'SHOPPING_ADS') continue;
+ const s = a.stats || {};
+ active += Number(s.activeCount || 0); disc += Number(s.disapprovedCount || 0); pending += Number(s.pendingCount || 0);
+ if (a.country) byCountry[a.country] = (byCountry[a.country] || 0) + Number(s.disapprovedCount || 0);
+ for (const i of (a.itemLevelIssues || [])) if (i.severity === 'DISAPPROVED') codes[i.code] = (codes[i.code] || 0) + Number(i.productCount || 0);
+ }
+ page = j.nextPageToken || ''; if (page && seen.has(page)) break; seen.add(page);
+ } while (page);
+ const scanned = active + disc + pending;
+ return { active, disapproved: disc, pending, scanned, disapproval_pct: scanned ? +(disc / scanned * 100).toFixed(1) : null, by_country: byCountry, top_reasons: Object.entries(codes).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([code, n]) => ({ code, n })) };
+}
+
+// Account-level issues → { reachable, accountIssues:[{sev,id,title,dest}], suspended }
+async function accountIssues() {
+ const r = await fetch(`${BASE}/accounts/v1/accounts/${MERCHANT}/issues`, { headers: await H() });
+ const j = await r.json();
+ if (!r.ok) throw new Error(`accounts/issues HTTP ${r.status} ${JSON.stringify(j.error || j).slice(0, 160)}`);
+ const issues = (j.accountIssues || []).map(i => ({ sev: (i.severity || '').toLowerCase(), id: i.name || i.id, title: i.title, dest: i.impactedDestinations }));
+ return { reachable: true, accountIssues: issues, suspended: issues.some(i => i.sev === 'critical') };
+}
+
+module.exports = { getProduct, listProducts, aggregateStatuses, accountIssues, destStatus, toV1Name, MERCHANT };
diff --git a/lever2-add-recheck.mjs b/lever2-add-recheck.mjs
index 3f853a3..004b2ae 100644
--- a/lever2-add-recheck.mjs
+++ b/lever2-add-recheck.mjs
@@ -21,6 +21,7 @@ import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
const { token, MERCHANT } = require('/Users/macstudio3/Projects/gmc-titlefix/_auth.js');
+const mc = require('/Users/macstudio3/Projects/gmc-titlefix/_mc-read-v1.js'); // Merchant API v1 read shim (v2.1 sunset 2026-08-18)
const HOME = os.homedir();
const LIST = path.join(HOME, '.claude/yolo-queue/gmc-price-ADD-us-pilot.json');
@@ -42,13 +43,12 @@ const tok = await token();
const list = JSON.parse(fs.readFileSync(LIST,'utf8')).overrides;
let priced = 0; const rows = [];
for (const o of list){
- const nm = `accounts/${MERCHANT}/products/online~en~US~${o.offerId}`;
let price=null, cleared=false;
try {
- const pj = await (await fetch(`https://merchantapi.googleapis.com/products/v1/${nm}`,{headers:{Authorization:'Bearer '+tok}})).json();
- price = pj.productAttributes?.price?.amountMicros ? pj.productAttributes.price.amountMicros/1e6 : null;
- const sj = await (await fetch(`https://shoppingcontent.googleapis.com/content/v2.1/${MERCHANT}/productstatuses/${encodeURIComponent('online:en:US:'+o.offerId)}`,{headers:{Authorization:'Bearer '+tok}})).json();
- cleared = !(sj.itemLevelIssues||[]).some(i=>i.code==='item_missing_required_attribute'&&i.attributeName==='price');
+ // v1 shim: one products.get returns processed price + item issues (v2.1-compat shape).
+ const p = await mc.getProduct('online:en:US:'+o.offerId);
+ price = p.price?.value != null ? Number(p.price.value) : null;
+ cleared = !(p.itemLevelIssues||[]).some(i=>i.code==='item_missing_required_attribute'&&i.attributeName==='price');
} catch(e){}
const ok = price!=null && cleared;
if (ok) priced++;
diff --git a/verify-canary.mjs b/verify-canary.mjs
index 7cceea1..eb2dda9 100644
--- a/verify-canary.mjs
+++ b/verify-canary.mjs
@@ -5,6 +5,7 @@ import { createRequire } from 'module';
import { evaluateCanary } from './track-c-safety.mjs';
const require = createRequire(import.meta.url);
const { token, MERCHANT } = require('./_auth.js');
+const mc = require('./_mc-read-v1.js'); // Merchant API v1 read shim (v2.1 sunset 2026-08-18)
const CANARY = '/Users/macstudio3/.claude/yolo-queue/gmc-fresh-override-canary.json';
const BASELINE = new URL('./data/track-c-canary-baseline.json', import.meta.url);
const BASELINE_ATTEMPT = new URL('./data/track-c-canary-baseline-attempt.json', import.meta.url);
@@ -27,14 +28,12 @@ async function scan() {
for (let i = 0; i < canary.length; i++) {
const row = canary[i], rid = `online:${row.contentLanguage}:${row.feedLabel}:${row.offerId}`;
try {
- const [status, product] = await Promise.all([
- getJson(`https://shoppingcontent.googleapis.com/content/v2.1/${MERCHANT}/productstatuses/${encodeURIComponent(rid)}`, headers),
- getJson(`https://shoppingcontent.googleapis.com/content/v2.1/${MERCHANT}/products/${encodeURIComponent(rid)}`, headers),
- ]);
+ // v1 shim: one products.get returns both served price and status (v2.1-compat shape).
+ const product = await mc.getProduct(rid);
const price = Number(product.price?.value);
if (!Number.isFinite(price)) throw new Error('missing/non-numeric served price');
if (price > 4.26) out.flipped++; else out.still425++;
- const destinations = status.destinationStatuses || [];
+ const destinations = product.destinationStatuses || [];
const state = (destinations.find(d => /Shopping/i.test(d.destination)) || destinations[0] || {}).status;
if (state === 'approved') out.approved++;
else if (state === 'disapproved') out.disapproved++;
← 8bc8356 auto-data-snapshot: 2026-09-09T08:59:31 (1 data files) — dat
·
back to Gmc Titlefix
·
gmc-titlefix: migrate remaining v2.1 read tools to _mc-read- f78c7a1 →