← back to Gmc Viewer
server.js
108 lines
// gmc-viewer — local web viewer of the LIVE Google Merchant Center catalog for DW
// (merchant 146735262). Pulls products (title/price/image/link) + productstatuses
// (approval status) via the Content API, caches to data/, serves a filterable grid.
// READ-ONLY: never writes to Merchant Center. Basic auth admin/DW2024!. $0.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { token, MERCHANT } = require('./_auth.js');
const PORT = process.env.PORT || 9972; // reassigned from 9971 2026-09-10 (TK-11375): 9971 collided
// with adsense-manager (pm2), which documents :9971 as its canonical dashboard port. gmc-viewer's
// launchd KeepAlive job (com.steve.gmc-viewer) always wins a respawn fight, so moving THIS port
// (not adsense-manager's) is the durable fix. Undo: revert to 9971 (git revert this commit).
const DATA = path.join(__dirname, 'data', 'catalog.json');
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
let CATALOG = { generated_at: null, items: [], summary: {} };
let refreshing = false;
if (fs.existsSync(DATA)) { try { CATALOG = JSON.parse(fs.readFileSync(DATA, 'utf8')); console.log(`loaded ${CATALOG.items.length} cached offers`); } catch (e) {} }
// Content API status values are 'approved' / 'disapproved' / 'pending' (NOT 'active').
function computeSummary(items) {
return {
total: items.length,
approved: items.filter(i => i.status === 'approved').length,
disapproved: items.filter(i => i.status === 'disapproved').length,
pending: items.filter(i => i.status === 'pending').length,
at_425: items.filter(i => Math.abs(i.price - 4.25) < 0.01).length,
real_price: items.filter(i => i.price > 4.26).length,
by_country: items.reduce((a, i) => (a[i.country] = (a[i.country] || 0) + 1, a), {}),
};
}
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 Merchant API v1...');
try {
const tok = await token(); const H = { Authorization: 'Bearer ' + tok };
const items = []; let page = null;
do {
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 = j.nextPageToken;
if (items.length % 10000 < 1000) console.log(` ${items.length} products`);
} while (page);
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 (cache kept):', e.message, e.cause ? (e.cause.code || e.cause.message) : ''); }
refreshing = false;
}
const send = (res, code, body, type = 'application/json') => { res.writeHead(code, { 'Content-Type': type }); res.end(body); };
const server = http.createServer((req, res) => {
if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="gmc"' }); return res.end('auth'); }
const u = new URL(req.url, 'http://x');
if (u.pathname === '/api/summary') return send(res, 200, JSON.stringify({ generated_at: CATALOG.generated_at, summary: computeSummary(CATALOG.items), refreshing }));
if (u.pathname === '/api/refresh') { pull(); return send(res, 200, JSON.stringify({ started: true })); }
if (u.pathname === '/api/products') {
const f = u.searchParams.get('filter') || 'all';
const q = (u.searchParams.get('q') || '').toLowerCase();
const limit = Math.min(2000, parseInt(u.searchParams.get('limit') || '500', 10));
let items = CATALOG.items;
if (f === 'approved') items = items.filter(i => i.status === 'approved');
else if (f === 'disapproved') items = items.filter(i => i.status === 'disapproved');
else if (f === 'pending') items = items.filter(i => i.status === 'pending');
else if (f === 'at425') items = items.filter(i => Math.abs(i.price - 4.25) < 0.01);
else if (f === 'real') items = items.filter(i => i.price > 4.26);
if (q) items = items.filter(i => i.title.toLowerCase().includes(q) || i.brand.toLowerCase().includes(q));
return send(res, 200, JSON.stringify({ count: items.length, items: items.slice(0, limit) }));
}
const file = u.pathname === '/' ? '/index.html' : u.pathname;
const fp = path.join(__dirname, 'public', file);
if (fp.startsWith(path.join(__dirname, 'public')) && fs.existsSync(fp)) {
const t = file.endsWith('.html') ? 'text/html' : 'text/plain';
return send(res, 200, fs.readFileSync(fp), t);
}
send(res, 404, 'not found', 'text/plain');
});
server.listen(PORT, () => {
console.log(`gmc-viewer on http://127.0.0.1:${PORT} (admin/DW2024!)`);
if (!CATALOG.items.length) pull();
});