← back to Marketing Command Center
Vendor IG roster: add last-10-posts grid per vendor via Instagram Business Discovery (server-side, cached, graceful degrade on expired token)
96afa0ac84b74b1502dda193f855e291892afd5b · 2026-06-16 05:39:41 -0700 · Steve Abrams
Files touched
M modules/vendors/index.jsM public/panels/vendors.jsA scripts/probe-business-discovery.js
Diff
commit 96afa0ac84b74b1502dda193f855e291892afd5b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 16 05:39:41 2026 -0700
Vendor IG roster: add last-10-posts grid per vendor via Instagram Business Discovery (server-side, cached, graceful degrade on expired token)
---
modules/vendors/index.js | 102 +++++++++++++++++++++++++++++++-----
public/panels/vendors.js | 88 +++++++++++++++++++++++++++----
scripts/probe-business-discovery.js | 37 +++++++++++++
3 files changed, 203 insertions(+), 24 deletions(-)
diff --git a/modules/vendors/index.js b/modules/vendors/index.js
index b3f1c2a..d097399 100644
--- a/modules/vendors/index.js
+++ b/modules/vendors/index.js
@@ -1,12 +1,20 @@
// Vendor IG Reporting — directory of DW's + all vendor Instagram accounts, for
// competitive/social reporting. Reads data/vendor-instagram.json (curated; refresh
-// via research). Read-only. Pairs with the advertising-signals tooling for deeper
-// per-vendor ad/social intel later.
+// via research). Now also fetches each account's LAST 10 POSTS via Instagram
+// Business Discovery (DW's own IG business account can read any public IG business
+// account's recent media). Posts are cached to data/vendor-posts-cache.json; the
+// grid renders from cache, and POST /posts/refresh re-crawls. Read-only re: writes.
const fs = require('fs');
const path = require('path');
+const GRAPH = 'https://graph.facebook.com/v23.0';
const DATA = path.join(__dirname, '..', '..', 'data', 'vendor-instagram.json');
+const CACHE = path.join(__dirname, '..', '..', 'data', 'vendor-posts-cache.json');
+
function load() { try { return JSON.parse(fs.readFileSync(DATA, 'utf8')); } catch { return []; } }
+function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch { return {}; } }
+function saveCache(c) { fs.writeFileSync(CACHE, JSON.stringify(c, null, 2)); }
+function env(k) { try { return (fs.readFileSync(path.join(__dirname, '..', '..', '.env'), 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1]; } catch { return null; } }
function followersToNum(f) {
if (!f) return 0;
const m = String(f).trim().match(/^([\d.]+)\s*([kKmM]?)/);
@@ -14,6 +22,39 @@ function followersToNum(f) {
const n = parseFloat(m[1]); const u = m[2].toLowerCase();
return Math.round(n * (u === 'm' ? 1e6 : u === 'k' ? 1e3 : 1));
}
+const handleOf = r => String(r.handle || '').replace(/^@/, '').trim();
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// Find a DW-owned IG business account id to use as the business_discovery node.
+let _igNode = null;
+async function discoveringIg(token) {
+ if (_igNode) return _igNode;
+ const r = await fetch(`${GRAPH}/me/accounts?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(token)}`);
+ const j = await r.json();
+ if (j.error) throw new Error(j.error.message);
+ const p = (j.data || []).find(p => p.instagram_business_account);
+ if (!p) throw new Error('no IG business account linked to any Page');
+ _igNode = { id: p.instagram_business_account.id, username: p.instagram_business_account.username };
+ return _igNode;
+}
+
+async function fetchPosts(igId, token, username) {
+ const fields = `business_discovery.username(${username}){username,followers_count,media_count,media.limit(10){id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count}}`;
+ const r = await fetch(`${GRAPH}/${igId}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(token)}`);
+ const j = await r.json();
+ if (j.error) throw new Error(j.error.message);
+ const bd = j.business_discovery || {};
+ return {
+ followers_count: bd.followers_count ?? null,
+ media_count: bd.media_count ?? null,
+ posts: (bd.media?.data || []).map(m => ({
+ id: m.id, caption: (m.caption || '').slice(0, 280), media_type: m.media_type,
+ image: m.media_type === 'VIDEO' ? (m.thumbnail_url || m.media_url) : m.media_url,
+ permalink: m.permalink, timestamp: m.timestamp,
+ likes: m.like_count ?? null, comments: m.comments_count ?? null,
+ })),
+ };
+}
module.exports = {
id: 'vendors',
@@ -21,25 +62,60 @@ module.exports = {
icon: '📷',
mount(router) {
router.get('/accounts', (_req, res) => {
- const rows = load().map(r => ({
- ...r,
- followersNum: followersToNum(r.followersVerified || r.followers),
- followersDisplay: r.followersVerified || r.followers,
- verifiedLive: r.live === true && !!r.followersVerified,
- hasIG: r.handle && r.handle !== 'none found',
- }));
+ const cache = loadCache();
+ const rows = load().map(r => {
+ const h = handleOf(r);
+ const c = cache[h] || null;
+ return {
+ ...r,
+ followersNum: followersToNum(r.followersVerified || r.followers),
+ followersDisplay: r.followersVerified || r.followers,
+ verifiedLive: r.live === true && !!r.followersVerified,
+ hasIG: r.handle && r.handle !== 'none found',
+ posts: c?.posts || [],
+ postsFetchedAt: c?.fetchedAt || null,
+ postsError: c?.error || null,
+ };
+ });
const withIG = rows.filter(r => r.hasIG);
const reach = withIG.reduce((s, r) => s + r.followersNum, 0);
+ const withPosts = rows.filter(r => r.posts && r.posts.length).length;
res.json({
accounts: rows,
stats: {
- total: rows.length,
- withIG: withIG.length,
- missing: rows.length - withIG.length,
- totalReach: reach,
+ total: rows.length, withIG: withIG.length, missing: rows.length - withIG.length,
+ totalReach: reach, withPosts,
+ postsFetchedAt: Object.values(cache).map(c => c.fetchedAt).filter(Boolean).sort().pop() || null,
dw: rows.find(r => r.vendorCode === 'dw') || null,
},
});
});
+
+ // Re-crawl last-10 posts for all (or ?handle=) vendors via business_discovery.
+ router.post('/posts/refresh', async (req, res) => {
+ const token = env('META_ACCESS_TOKEN');
+ if (!token) return res.json({ ok: false, error: 'META_ACCESS_TOKEN not set in .env' });
+ let ig;
+ try { ig = await discoveringIg(token); }
+ catch (e) { return res.json({ ok: false, error: 'token/IG check failed: ' + e.message + (/expired|session/i.test(e.message) ? ' — paste a fresh long-lived META_ACCESS_TOKEN' : '') }); }
+ const only = (req.query.handle || '').replace(/^@/, '').trim();
+ const cache = loadCache();
+ const targets = load().filter(r => { const h = handleOf(r); return h && r.handle !== 'none found' && (!only || h === only); });
+ let ok = 0, failed = 0;
+ for (const r of targets) {
+ const h = handleOf(r);
+ try {
+ const data = await fetchPosts(ig.id, token, h);
+ cache[h] = { ...data, fetchedAt: new Date().toISOString(), error: null };
+ ok++;
+ } catch (e) {
+ cache[h] = { ...(cache[h] || {}), error: e.message, fetchedAt: new Date().toISOString() };
+ failed++;
+ }
+ saveCache(cache);
+ await sleep(600); // be gentle on Graph rate limits
+ }
+ res.json({ ok: true, discoveringIg: '@' + ig.username, refreshed: ok, failed, total: targets.length });
+ });
},
};
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index d52b858..9900769 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -3,13 +3,37 @@ window.MCC_PANELS['vendors'] = {
async init(root) {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const fmt = n => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'K' : (n || '—');
+ const ago = iso => { if (!iso) return ''; const d = (Date.now() - new Date(iso)) / 36e5; return d < 1 ? Math.round(d * 60) + 'm ago' : d < 24 ? Math.round(d) + 'h ago' : Math.round(d / 24) + 'd ago'; };
let data;
- try { data = await (await fetch('/api/vendors/accounts')).json(); } catch { root.querySelector('#vend-rows').innerHTML = '<div class="muted">Failed to load.</div>'; return; }
- const s = data.stats || {};
- root.querySelector('#vs-total').textContent = s.total || 0;
- root.querySelector('#vs-ig').textContent = s.withIG || 0;
- root.querySelector('#vs-reach').textContent = fmt(s.totalReach || 0);
- root.querySelector('#vs-missing').textContent = s.missing || 0;
+ const loadData = async () => { data = await (await fetch('/api/vendors/accounts')).json(); };
+ try { await loadData(); } catch { root.querySelector('#vend-rows').innerHTML = '<div class="muted">Failed to load.</div>'; return; }
+
+ const setStats = () => {
+ const s = data.stats || {};
+ root.querySelector('#vs-total').textContent = s.total || 0;
+ root.querySelector('#vs-ig').textContent = s.withIG || 0;
+ root.querySelector('#vs-reach').textContent = fmt(s.totalReach || 0);
+ root.querySelector('#vs-missing').textContent = s.missing || 0;
+ };
+ setStats();
+
+ const postGrid = (r) => {
+ if (!r.hasIG) return '';
+ if (r.posts && r.posts.length) {
+ const cells = r.posts.map(p => {
+ const cap = esc((p.caption || '').replace(/\s+/g, ' ').slice(0, 140));
+ const meta = `♥ ${fmt(p.likes)} · 💬 ${fmt(p.comments)}`;
+ const vid = p.media_type === 'VIDEO' ? '<span class="ig-vid">▶</span>' : '';
+ return `<a class="ig-cell" href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer" title="${esc(p.timestamp ? p.timestamp.slice(0,10) : '')} ${meta}\n${cap}">
+ <img loading="lazy" src="${esc(p.image || '')}" alt="" onerror="this.parentNode.classList.add('ig-broken')">
+ ${vid}<span class="ig-meta">${meta}</span></a>`;
+ }).join('');
+ return `<div class="ig-grid">${cells}</div>`;
+ }
+ const why = r.postsError ? `posts unavailable — ${esc(r.postsError).slice(0, 90)}` : 'no posts cached yet — click “Refresh posts”';
+ return `<div class="muted" style="font-size:11px;padding:4px 2px 8px">${why}</div>`;
+ };
+
const render = (sort) => {
let rows = [...data.accounts];
if (sort === 'brand') rows.sort((a, b) => a.brand.localeCompare(b.brand));
@@ -20,16 +44,58 @@ window.MCC_PANELS['vendors'] = {
const handle = r.hasIG
? `<a class="lnk" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${esc(r.handle)} ↗</a>`
: `<span class="muted">no IG</span>`;
- return `<div class="row" style="justify-content:space-between;border-bottom:1px solid var(--line);padding:9px 2px;align-items:center${dw ? ';background:#faf6ee' : ''}">
- <div style="min-width:200px"><b>${esc(r.brand)}</b>${dw ? ' <span class="pill">OURS</span>' : ''}${r.note && !dw ? ` <span class="muted" style="font-size:11px">${esc(r.note)}</span>` : ''}</div>
- <div style="flex:1">${handle}</div>
- <div style="min-width:70px;text-align:right;font-variant-numeric:tabular-nums">${r.hasIG ? fmt(r.followersNum) : ''}</div>
+ return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px${dw ? ';background:#faf6ee' : ''}">
+ <div class="row" style="justify-content:space-between;align-items:center">
+ <div style="min-width:200px"><b>${esc(r.brand)}</b>${dw ? ' <span class="pill">OURS</span>' : ''}${r.note && !dw ? ` <span class="muted" style="font-size:11px">${esc(r.note)}</span>` : ''}</div>
+ <div style="flex:1">${handle}</div>
+ <div style="min-width:70px;text-align:right;font-variant-numeric:tabular-nums">${r.hasIG ? fmt(r.followersNum) : ''}</div>
+ </div>
+ ${postGrid(r)}
</div>`;
}).join('');
};
render('followers');
root.querySelector('#vs-sort').onchange = e => render(e.target.value);
+
+ // Refresh-posts control + last-fetched stamp, injected into the stats bar.
+ const bar = root.querySelector('#vend-stats');
+ if (bar && !bar.querySelector('#vp-refresh')) {
+ const wrap = document.createElement('div');
+ wrap.style.cssText = 'display:flex;flex-direction:column;align-items:flex-end;gap:2px';
+ wrap.innerHTML = `<button id="vp-refresh" class="btn">↻ Refresh posts</button>
+ <small class="muted" id="vp-when">${data.stats.postsFetchedAt ? 'posts updated ' + ago(data.stats.postsFetchedAt) : 'posts not fetched yet'} · ${data.stats.withPosts || 0}/${data.stats.withIG || 0} with posts</small>`;
+ bar.appendChild(wrap);
+ wrap.querySelector('#vp-refresh').onclick = async (e) => {
+ const btn = e.target; btn.disabled = true; btn.textContent = '↻ Fetching last 10 posts…';
+ try {
+ const res = await (await fetch('/api/vendors/posts/refresh', { method: 'POST' })).json();
+ if (!res.ok) { btn.textContent = '⚠ ' + (res.error || 'refresh failed'); btn.disabled = false; return; }
+ await loadData(); setStats();
+ render(root.querySelector('#vs-sort').value);
+ btn.textContent = `✓ ${res.refreshed} ok, ${res.failed} failed (via ${res.discoveringIg})`;
+ root.querySelector('#vp-when').textContent = 'posts updated just now · ' + (data.stats.withPosts || 0) + '/' + (data.stats.withIG || 0) + ' with posts';
+ setTimeout(() => { btn.textContent = '↻ Refresh posts'; btn.disabled = false; }, 4000);
+ } catch (err) { btn.textContent = '⚠ ' + err.message; btn.disabled = false; }
+ };
+ }
+
+ // Grid styling (idempotent)
+ if (!document.getElementById('vend-ig-css')) {
+ const st = document.createElement('style'); st.id = 'vend-ig-css';
+ st.textContent = `
+ .ig-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px;margin-top:9px}
+ .ig-cell{position:relative;aspect-ratio:1/1;border-radius:7px;overflow:hidden;background:#ece7dd;display:block;border:1px solid var(--line)}
+ .ig-cell img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .25s}
+ .ig-cell:hover img{transform:scale(1.06)}
+ .ig-cell.ig-broken{background:#ddd6c8}
+ .ig-cell .ig-vid{position:absolute;top:4px;right:5px;color:#fff;font-size:11px;text-shadow:0 1px 3px #000}
+ .ig-cell .ig-meta{position:absolute;left:0;right:0;bottom:0;padding:3px 5px;font-size:9.5px;color:#fff;background:linear-gradient(transparent,rgba(0,0,0,.72));opacity:0;transition:opacity .2s;font-variant-numeric:tabular-nums;white-space:nowrap}
+ .ig-cell:hover .ig-meta{opacity:1}`;
+ document.head.appendChild(st);
+ }
+
+ const s = data.stats || {};
root.querySelector('#vend-banner').innerHTML = s.missing
- ? `<div class="muted-banner">${s.withIG} of ${s.total} brands have an official Instagram — ${s.missing} have none found (Atomic50, Bespoke, Folia, Naturale 54, Schumacher). Combined vendor reach ≈ ${fmt(s.totalReach)} followers.</div>` : '';
+ ? `<div class="muted-banner">${s.withIG} of ${s.total} brands have an official Instagram — ${s.missing} have none found (Atomic50, Bespoke, Folia, Naturale 54, Schumacher). Combined vendor reach ≈ ${fmt(s.totalReach)} followers. Last-10 posts via Instagram Business Discovery (click Refresh).</div>` : '';
},
};
diff --git a/scripts/probe-business-discovery.js b/scripts/probe-business-discovery.js
new file mode 100644
index 0000000..d7ee84a
--- /dev/null
+++ b/scripts/probe-business-discovery.js
@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+// READ-ONLY probe: can META_ACCESS_TOKEN do Instagram business_discovery?
+// 1) find a DW-owned IG business account id via /me/accounts
+// 2) try business_discovery for one vendor handle -> last 3 posts
+const fs = require('fs');
+const path = require('path');
+const GRAPH = 'https://graph.facebook.com/v23.0';
+const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
+const TOKEN = (env.match(/^META_ACCESS_TOKEN=(.+)$/m) || [])[1];
+if (!TOKEN) { console.error('no META_ACCESS_TOKEN'); process.exit(1); }
+const TEST_HANDLE = process.argv[2] || '1838_wallcoverings';
+
+(async () => {
+ // 1) pages + IG business accounts
+ let r = await fetch(`${GRAPH}/me/accounts?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(TOKEN)}`);
+ let j = await r.json();
+ if (j.error) { console.error('me/accounts error:', JSON.stringify(j.error)); process.exit(1); }
+ const pages = (j.data || []);
+ const withIG = pages.filter(p => p.instagram_business_account);
+ console.log(`Pages: ${pages.length} | with IG business account: ${withIG.length}`);
+ if (!withIG.length) { console.error('NO IG business account linked — business_discovery impossible with this token.'); process.exit(2); }
+ const ig = withIG[0].instagram_business_account;
+ console.log(`Using discovering IG: @${ig.username} (id ${ig.id}) via Page "${withIG[0].name}"`);
+
+ // 2) business_discovery for the test vendor
+ const fields = `business_discovery.username(${TEST_HANDLE}){username,followers_count,media_count,media.limit(3){id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count}}`;
+ r = await fetch(`${GRAPH}/${ig.id}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(TOKEN)}`);
+ j = await r.json();
+ if (j.error) { console.error(`business_discovery error for ${TEST_HANDLE}:`, JSON.stringify(j.error)); process.exit(3); }
+ const bd = j.business_discovery;
+ console.log(`\n✅ business_discovery WORKS for @${bd.username}: ${bd.followers_count} followers, ${bd.media_count} posts`);
+ console.log('Last 3 media:');
+ for (const m of (bd.media?.data || [])) {
+ console.log(` ${m.timestamp?.slice(0,10)} ${m.media_type} ♥${m.like_count ?? '?'} 💬${m.comments_count ?? '?'} ${m.permalink}`);
+ console.log(` img: ${(m.media_url || m.thumbnail_url || '').slice(0,80)}`);
+ }
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
← 8e94008 Channels: add Bluesky (live, app-password) + Threads (review
·
back to Marketing Command Center
·
Vendor IG roster: add web-confirmed @schumacher1889 (was 'no 78312e8 →