← back to Gmc Titlefix

_mc-read-v1.js

130 lines

// 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()) }; }
// Normalize any caller-supplied product id/name to the v1 product-name SEGMENT (the part after
// 'accounts/<mid>/products/'). Accepts, in order: a full resource name
// 'accounts/<mid>/products/<name>'; a '~'-delimited v1 name verbatim — the ONLINE feed
// 'online~en~US~<offerId>' OR the LEGACY bare-variant feed 'en~US~<offerId>' (this is the TK-11846
// fix: those legacy names must NOT get an extra 'online~' or they 400 '[name] Invalid'); a
// colon-delimited v2.1 rid 'online:en:US:<offerId>' → 'online~en~US~<offerId>'; or a bare offerId,
// which lacks feed/lang/country so it defaults to the online feed 'online~en~US~<offerId>' (a bare
// id cannot address a LEGACY offer — pass that offer's full gmcName instead).
const toV1Name = rid => {
  let s = String(rid);
  const m = s.match(/\/products\/(.+)$/); if (m) s = m[1]; // strip 'accounts/<mid>/products/' if a full name was passed
  s = s.replace(/^online:/, '');                          // tolerate a legacy 'online:' colon prefix
  if (s.includes('~')) return s;                          // already a v1 name (legacy en~US~x OR online~en~US~x) → verbatim
  if (s.includes(':')) return 'online~' + s.replace(/:/g, '~'); // colon v2.1 rid → online~en~US~x
  return 'online~en~US~' + s;                             // bare offerId → default online feed
};
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;
    // NOT_MEASURED discipline (TK-11846 / CLAUDE.md TK-11431 amendment 1): ONLY a clean 404 is a
    // genuine "product absent". A 400 (malformed name — the legacy-feed bug), 401/403 (auth), 429,
    // or 5xx means we never actually measured this product's state — a caller MUST NOT read it as
    // "no override present" / "not on Google". Callers that gate on presence should treat
    // e.notMeasured === true as UNKNOWN, not as an absence.
    e.notMeasured = r.status !== 404;
    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 };