← 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)