← back to Gmc Viewer
server.js
102 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 || 9971;
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() {
if (refreshing) return; refreshing = true;
console.log('refreshing from Content API...');
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;
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: [] });
}
page = r.nextPageToken;
if (n % 5000 === 0) console.log(` ${n} 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) };
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); }
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();
});