← 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();
});