[object Object]

← back to Gmc Viewer

gmc-viewer: migrate refresh to Merchant API v1, fail-closed cache write (TK-12036)

828b20f7b14bd4b8c6f430c7b429239d25c0880a · 2026-09-24 23:08:40 -0700 · Steve Abrams

Content API v2.1 410s mid-pagination (sunset); old pull() wrote the partial
result as the catalog then crashed on undefined summary. Now: merchantapi
products/v1, overwrite cache only after a complete error-free walk.
Verified: 61,659 offers refreshed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RPYSiFdzmd1W1zPngeHTy

Files touched

Diff

commit 828b20f7b14bd4b8c6f430c7b429239d25c0880a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 23:08:40 2026 -0700

    gmc-viewer: migrate refresh to Merchant API v1, fail-closed cache write (TK-12036)
    
    Content API v2.1 410s mid-pagination (sunset); old pull() wrote the partial
    result as the catalog then crashed on undefined summary. Now: merchantapi
    products/v1, overwrite cache only after a complete error-free walk.
    Verified: 61,659 offers refreshed.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014RPYSiFdzmd1W1zPngeHTy
---
 server.js | 55 +++++++++++++++++++++++++++++--------------------------
 1 file changed, 29 insertions(+), 26 deletions(-)

diff --git a/server.js b/server.js
index 6aff703..378e334 100644
--- a/server.js
+++ b/server.js
@@ -32,40 +32,43 @@ function computeSummary(items) {
 }
 
 async function pull() {
+  // TK-12036: migrated off Content API v2.1 (sunset — 410s mid-pagination since 2026-08-18) to
+  // Merchant API products/v1, which returns attributes + productStatus in one listing. FAIL-CLOSED:
+  // the cache is only overwritten after a COMPLETE, error-free walk, so a partial pull can never be
+  // served as if it were the whole catalog (TK-11431 rule 1). Undo: git revert this commit.
   if (refreshing) return; refreshing = true;
-  console.log('refreshing from Content API...');
+  console.log('refreshing from Merchant API v1...');
   try {
     const tok = await token(); const H = { Authorization: 'Bearer ' + tok };
-    // 1) products (title/price/image/link/brand)
-    const byId = new Map(); let page = null, n = 0;
+    const items = []; let page = null;
     do {
-      const r = await (await fetch(`https://shoppingcontent.googleapis.com/content/v2.1/${MERCHANT}/products?maxResults=250` + (page ? `&pageToken=${page}` : ''), { headers: H })).json();
-      if (r.error) { console.error('products err', JSON.stringify(r.error).slice(0, 150)); break; }
-      for (const p of (r.resources || [])) {
-        n++;
-        byId.set(p.id, { id: p.id, offerId: p.offerId, title: p.title || '', brand: p.brand || '', price: parseFloat(p.price?.value || '0'), image: p.imageLink || '', link: p.link || '', country: (p.id || '').split(':')[2] || '', status: 'unknown', issues: [] });
+      const r = await fetch(`https://merchantapi.googleapis.com/products/v1/accounts/${MERCHANT}/products?pageSize=1000` + (page ? `&pageToken=${encodeURIComponent(page)}` : ''), { headers: H });
+      const j = await r.json();
+      if (!r.ok || j.error) throw new Error(`products HTTP ${r.status} ${JSON.stringify(j.error || {}).slice(0, 150)}`);
+      for (const p of (j.products || [])) {
+        const a = p.productAttributes || {}, st = p.productStatus || {};
+        const ds = st.destinationStatuses || [];
+        const d = ds.find(x => x.reportingContext === 'SHOPPING_ADS') || ds[0] || {};
+        const status = (d.disapprovedCountries || []).length ? 'disapproved'
+          : (d.approvedCountries || []).length ? 'approved'
+          : (d.pendingCountries || []).length ? 'pending' : 'unknown';
+        items.push({
+          id: `online:${p.contentLanguage}:${p.feedLabel}:${p.offerId}`, offerId: p.offerId,
+          title: a.title || '', brand: a.brand || '',
+          price: a.price ? Number(a.price.amountMicros || 0) / 1e6 : 0,
+          image: a.imageLink || '', link: a.link || '', country: p.feedLabel || '', status,
+          issues: [...new Set((st.itemLevelIssues || []).filter(i => i.severity === 'DISAPPROVED').map(i => i.code))].slice(0, 4),
+        });
       }
-      page = r.nextPageToken;
-      if (n % 5000 === 0) console.log(`  ${n} products`);
+      page = j.nextPageToken;
+      if (items.length % 10000 < 1000) console.log(`  ${items.length} products`);
     } while (page);
-    // 2) statuses
-    page = null; let sn = 0;
-    do {
-      const r = await (await fetch(`https://shoppingcontent.googleapis.com/content/v2.1/${MERCHANT}/productstatuses?maxResults=250` + (page ? `&pageToken=${page}` : ''), { headers: H })).json();
-      if (r.error) { console.error('statuses err', JSON.stringify(r.error).slice(0, 150)); break; }
-      for (const p of (r.resources || [])) {
-        const it = byId.get(p.productId); if (!it) continue;
-        const ds = (p.destinationStatuses || []); it.status = (ds.find(d => /Shopping/i.test(d.destination)) || ds[0] || {}).status || 'unknown';
-        it.issues = [...new Set((p.itemLevelIssues || []).filter(i => i.servability === 'disapproved').map(i => i.code))].slice(0, 4);
-        sn++;
-      }
-      page = r.nextPageToken;
-    } while (page);
-    const items = [...byId.values()];
-    CATALOG = { generated_at: new Date().toISOString(), items, summary: computeSummary(items) };
+    if (!items.length) throw new Error('zero products returned — refusing to overwrite cache');
+    const summary = computeSummary(items);
+    CATALOG = { generated_at: new Date().toISOString(), source: 'merchantapi/products/v1', items, summary };
     fs.writeFileSync(DATA, JSON.stringify(CATALOG));
     console.log(`refreshed: ${items.length} offers, ${summary.approved} approved, ${summary.disapproved} disapproved`);
-  } catch (e) { console.error('pull failed', e.message); }
+  } catch (e) { console.error('pull failed (cache kept):', e.message, e.cause ? (e.cause.code || e.cause.message) : ''); }
   refreshing = false;
 }
 

← 05df51e reassign default port 9971->9972 to resolve collision with a  ·  back to Gmc Viewer  ·  (newest)