[object Object]

← 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

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 →