← back to Marketing Command Center
vendors panel: add 'Pull ALL vendor accounts' + IG coverage note + LinkedIn amplify section (reference/attribution-amplify, no <a>-wrapped cards); licdn img-proxy allowlist
b8fe12e59e8b5ef99136aaae23f75217d1359c2b · 2026-08-31 12:24:38 -0700 · Steve Abrams
Files touched
M modules/vendors/index.jsM public/panels/vendors.htmlM public/panels/vendors.jsM server.js
Diff
commit b8fe12e59e8b5ef99136aaae23f75217d1359c2b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 31 12:24:38 2026 -0700
vendors panel: add 'Pull ALL vendor accounts' + IG coverage note + LinkedIn amplify section (reference/attribution-amplify, no <a>-wrapped cards); licdn img-proxy allowlist
---
modules/vendors/index.js | 9 ++-
public/panels/vendors.html | 23 +++++++-
public/panels/vendors.js | 143 +++++++++++++++++++++++++++++++++++++++++++++
server.js | 2 +-
4 files changed, 172 insertions(+), 5 deletions(-)
diff --git a/modules/vendors/index.js b/modules/vendors/index.js
index 7a586da..bf39bab 100644
--- a/modules/vendors/index.js
+++ b/modules/vendors/index.js
@@ -100,7 +100,9 @@ async function fetchLiOg(url) {
const status = r.status;
if (!r.ok) return { ok: false, status, error: 'HTTP ' + status + (status === 999 || status === 429 ? ' (LinkedIn rate-limited / auth-walled this fetch)' : '') };
const html = (await r.text()).slice(0, 400000);
- const og = k => { const m = html.match(new RegExp('<meta[^>]+(?:property|name)=["\\\']og:' + k + '["\\\'][^>]+content=["\\\']([^"\\\']+)', 'i')); return m ? m[1].replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"') : ''; };
+ // Decode HTML entities (LinkedIn double-encodes &amp; → run &→& twice).
+ const dec = s => String(s || '').replace(/&/g, '&').replace(/&/g, '&').replace(/'/g, "'").replace(/'/g, "'").replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
+ const og = k => { const m = html.match(new RegExp('<meta[^>]+(?:property|name)=["\\\']og:' + k + '["\\\'][^>]+content=["\\\']([^"\\\']+)', 'i')); return m ? dec(m[1]) : ''; };
return { ok: true, status, title: og('title'), thumb: og('image'), description: og('description') };
} catch (e) {
return { ok: false, error: /abort/i.test(e.message) ? 'timeout' : e.message };
@@ -109,9 +111,12 @@ async function fetchLiOg(url) {
// Reference/attribution-amplify kit for a LinkedIn company: copyable text that
// CREDITS the vendor + links back to their public page, plus a DW-visit CTA. No
// image is ever pulled into DW assets — attribution + link-share only.
+// DW standing rule — "Wallpaper" is banned in DW's own output; "Wallcovering(s)".
+// The amplify CAPTION is DW copy (even though it quotes the vendor), so de-wallpaper it.
+const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
function liAmplifyKit(brand, companyUrl, og) {
const DWUTM = 'https://designerwallcoverings.com/?utm_source=linkedin&utm_medium=social&utm_campaign=vendor-amplify';
- const snippet = String(og && og.description || og && og.title || '').replace(/\s+/g, ' ').trim().slice(0, 220);
+ const snippet = deWallpaper(String(og && og.description || og && og.title || '').replace(/\s+/g, ' ').trim().slice(0, 220));
const text = `${brand}${snippet ? ' — ' + snippet : ''}\n\n🔗 via ${brand} on LinkedIn: ${companyUrl}\n🛍️ Explore the line at Designer Wallcoverings: ${DWUTM}\n\n#DesignerWallcoverings #wallcoverings #interiordesign`;
return { text, dwLink: DWUTM };
}
diff --git a/public/panels/vendors.html b/public/panels/vendors.html
index 9d9668e..47b6f5b 100644
--- a/public/panels/vendors.html
+++ b/public/panels/vendors.html
@@ -22,7 +22,26 @@
<div id="vend-owned-rows"><div class="muted">Loading owned accounts…</div></div>
</div>
<div class="card">
- <h2>Vendor brands</h2>
- <div class="muted" style="margin-bottom:10px">External vendor / brand accounts (suppliers & competitors — NOT owned). Click any handle to open the account. Use this roster for competitive/social reporting.</div>
+ <div class="row" style="justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
+ <h2 style="margin:0">Vendor brands · Instagram</h2>
+ <div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
+ <button id="vp-pull-all" class="btn amp-strong" title="Pull the last 10 posts for EVERY vendor Instagram account via Business Discovery (reference/attribution-amplify)">⬇ Pull ALL vendor accounts</button>
+ <small class="muted" id="vp-pull-when"></small>
+ </div>
+ </div>
+ <div class="muted" style="margin:8px 0 4px">External vendor / brand accounts (suppliers & competitors — NOT owned). Click any handle to open the account. Use this roster for competitive/social reporting. <b>Reference + attribution-amplify only</b> — copy the caption text and share with credit back to the vendor.</div>
+ <div id="vend-ig-coverage" class="muted" style="font-size:11.5px;margin-bottom:8px"></div>
<div id="vend-rows"></div>
</div>
+<div class="card" id="vend-li-card">
+ <div class="row" style="justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
+ <h2 style="margin:0">Vendor brands · LinkedIn</h2>
+ <div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
+ <button id="vp-li-harvest" class="btn amp-strong" title="Public Open-Graph pull (thumbnail + text) of every mapped vendor LinkedIn company page — no API, no login">⬇ Harvest ALL LinkedIn pages</button>
+ <small class="muted" id="vp-li-when"></small>
+ </div>
+ </div>
+ <div class="muted" style="margin:8px 0 4px">Vendor LinkedIn <b>company pages</b> (B2B/trade). Public Open-Graph pull only — <b>no LinkedIn API, no login</b>. <b>Reference + attribution-amplify only</b>: browse, copy the text, share with a link back to the vendor. <span style="color:#8a6d2f">✓ verified</span> = slug confirmed against the real company page; <span style="color:#a1341f">? unverified</span> = best guess, confirm before trusting.</div>
+ <div id="vend-li-summary" class="muted" style="font-size:11.5px;margin-bottom:8px"></div>
+ <div id="vend-li-rows"><div class="muted">Loading LinkedIn roster…</div></div>
+</div>
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index de6a067..c1721ea 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -1204,6 +1204,149 @@ window.MCC_PANELS['vendors'] = {
document.head.appendChild(st);
}
+ // ── Part A · "Pull ALL vendor accounts" + IG coverage note ────────────────
+ // One-click pull of the last-10 posts for EVERY vendor IG account via
+ // Business Discovery (reference/attribution-amplify — never downloads their
+ // image into DW assets). Surfaces the accounts that CAN'T be covered honestly:
+ // an account with a handle but no cached posts, and the "none found" brands
+ // (no BD-readable Business/Creator IG account — Graph 110/2207013).
+ const renderCoverage = () => {
+ const el = root.querySelector('#vend-ig-coverage');
+ if (!el) return;
+ const accts = data.accounts || [];
+ const withIG = accts.filter(a => a.hasIG && a.vendorCode !== 'dw');
+ const covered = withIG.filter(a => a.posts && a.posts.length);
+ const noHandle = accts.filter(a => !a.hasIG && a.vendorCode !== 'dw');
+ const errored = withIG.filter(a => (!a.posts || !a.posts.length));
+ const parts = [`<b>${covered.length}/${withIG.length}</b> vendor IG accounts have posts pulled`];
+ if (errored.length) parts.push(`<span style="color:#a1341f">${errored.length} with a handle but no posts yet</span> (${esc(errored.map(a => a.brand).slice(0, 6).join(', '))}${errored.length > 6 ? '…' : ''})`);
+ if (noHandle.length) parts.push(`<span title="No public Business/Creator IG account is readable via Instagram Business Discovery (Graph error 110/2207013). These can't be amplified via the official API — confirmed 2026-08-31, not faked.">${noHandle.length} have no BD-readable IG</span> (${esc(noHandle.map(a => a.brand).join(', '))})`);
+ el.innerHTML = parts.join(' · ');
+ };
+ renderCoverage();
+
+ const pullBtn = root.querySelector('#vp-pull-all');
+ const pullWhen = root.querySelector('#vp-pull-when');
+ if (pullWhen) pullWhen.textContent = data.stats.postsFetchedAt ? 'last pulled ' + ago(data.stats.postsFetchedAt) : 'not pulled yet';
+ if (pullBtn && !pullBtn.dataset.wired) {
+ pullBtn.dataset.wired = '1';
+ pullBtn.onclick = async () => {
+ const label = pullBtn.textContent; pullBtn.disabled = true; pullBtn.textContent = '⬇ Pulling all accounts…';
+ try {
+ const res = await (await fetch(location.origin + '/api/vendors/posts/refresh', { method: 'POST', credentials: 'same-origin' })).json();
+ if (!res.ok) { pullBtn.textContent = '⚠ ' + (res.error || 'pull failed'); pullBtn.disabled = false; return; }
+ await loadData(); setStats(); renderCoverage();
+ render(root.querySelector('#vs-sort').value);
+ pullBtn.textContent = `✓ ${res.refreshed} pulled, ${res.failed} failed`;
+ if (pullWhen) pullWhen.textContent = 'last pulled just now · via ' + (res.discoveringIg || '@DW');
+ setTimeout(() => { pullBtn.textContent = label; pullBtn.disabled = false; }, 4000);
+ } catch (err) { pullBtn.textContent = '⚠ ' + err.message; pullBtn.disabled = false; }
+ };
+ }
+
+ // ── Part B · Vendor LinkedIn section (reference/attribution-amplify) ───────
+ // Mirrors the IG amplify-kit UX: browse each vendor's public LinkedIn company
+ // page (thumbnail + text via a public Open-Graph pull — NO API, NO login),
+ // copy an attribution-amplify caption, and open the page to share WITH credit.
+ // NO image is ever downloaded into DW assets; NO card is <a>-wrapped.
+ const liRoot = root.querySelector('#vend-li-rows');
+ if (liRoot && !liRoot.dataset.wired) {
+ liRoot.dataset.wired = '1';
+ // Registry of copyable LinkedIn amplify text, keyed by a stable id, so the
+ // delegated handler pastes clean human text (never DOM-scraped HTML).
+ const liKits = {};
+ let liData = null;
+ const loadLi = async () => { liData = await (await fetch(location.origin + '/api/vendors/linkedin/accounts', { credentials: 'same-origin' })).json(); };
+
+ const renderLi = () => {
+ const summary = root.querySelector('#vend-li-summary');
+ const st = (liData && liData.stats) || {};
+ if (summary) summary.innerHTML = `<b>${st.withLinkedIn || 0}</b>/${st.total || 0} mapped to a LinkedIn company · <span style="color:#8a6d2f">${st.verified || 0} verified</span> · <span style="color:#a1341f">${st.unverified || 0} unverified</span> · ${st.missing || 0} no page · <b>${st.harvested || 0}</b> harvested${st.harvestedAt ? ' · ' + ago(st.harvestedAt) : ''}`;
+ const accts = (liData && liData.accounts) || [];
+ // vendors WITHOUT a LinkedIn page sink to the bottom; verified first among the rest.
+ const rows = accts.slice().sort((a, b) => (b.hasLinkedIn - a.hasLinkedIn) || (b.verified - a.verified) || String(a.brand).localeCompare(String(b.brand)));
+ liRoot.innerHTML = rows.map(a => {
+ const restricted = /\bschumacher\b/i.test(a.brand || '');
+ if (!a.hasLinkedIn) {
+ return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:8px 2px;opacity:.6">
+ <div class="row" style="justify-content:space-between;align-items:center"><div style="min-width:200px"><b>${esc(a.brand)}</b></div>
+ <div class="muted" style="font-size:11px">${esc(a.note || 'no LinkedIn company page')}</div></div></div>`;
+ }
+ const badge = a.verified
+ ? `<span class="pill" style="background:#f4ecd6;color:#6b531f" title="Slug confirmed against the real company page">✓ verified</span>`
+ : `<span class="pill" style="background:#fbf1ee;color:#a1341f" title="Best-guess slug — confirm before trusting${a.note ? ': ' + a.note : ''}">? unverified</span>`;
+ const og = a.og;
+ const thumb = og && og.thumb
+ ? `<img class="ig-li-thumb" loading="lazy" src="${esc(location.origin + '/api/img-proxy?u=' + encodeURIComponent(og.thumb))}" alt="" onerror="this.style.visibility='hidden'">`
+ : `<span class="ig-li-thumb"></span>`;
+ const body = og
+ ? `<span class="ig-li-cap">${esc(og.title || a.brand)}</span><span class="ig-li-meta">${esc((og.description || '').replace(/\s+/g, ' ').slice(0, 140))}</span>`
+ : `<span class="ig-li-cap">${esc(a.brand)}</span><span class="ig-li-meta">${a.error ? esc('LinkedIn blocked this pull — ' + a.error) : 'not harvested yet — click ⬇ Harvest'}</span>`;
+ // The "card" is a plain div (NOT <a>-wrapped, per standing UI rule). The
+ // permalink is a normal link + the amplify controls are siblings.
+ let ampRow;
+ if (restricted) {
+ ampRow = `<div class="amp-row amp-restricted"><span class="amp-note" title="Brand policy: this line must not appear on any customer-facing / marketing / share surface. Internal reporting only.">🔒 Internal only — do not amplify</span></div>`;
+ } else {
+ const id = 'li' + Math.random().toString(36).slice(2, 8);
+ if (a.amplify) liKits[id] = a.amplify.text;
+ ampRow = `<div class="amp-row">
+ <a class="amp-btn" href="${esc(a.companyUrl)}" target="_blank" rel="noopener noreferrer" title="Open the vendor's LinkedIn company page">in Open ↗</a>
+ <button type="button" class="amp-btn" data-li="harvest" data-vc="${esc(a.vendorCode)}" title="Pull this page's public thumbnail + text (Open Graph)">↻ Harvest</button>
+ ${a.amplify ? `<button type="button" class="amp-btn" data-li="copy" data-li-id="${id}" title="Copy an attribution-amplify caption (credits the vendor + links to DW)">⧉ Copy amplify text</button>` : ''}
+ </div>`;
+ }
+ return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:9px 2px">
+ <div class="row" style="justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
+ <div style="min-width:200px"><b>${esc(a.brand)}</b> ${badge}</div>
+ <div style="flex:1"><a class="lnk" href="${esc(a.companyUrl)}" target="_blank" rel="noopener noreferrer">/company/${esc(a.slug)} ↗</a></div>
+ </div>
+ <div class="ig-list"><div class="ig-li" style="cursor:default">${thumb}<span class="ig-li-body">${body}</span></div></div>
+ ${ampRow}</div>`;
+ }).join('');
+ };
+
+ // One delegated listener on the LinkedIn rows host (survives re-render).
+ liRoot.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-li]');
+ if (!btn) return;
+ if (btn.dataset.li === 'copy') {
+ const text = liKits[btn.dataset.liId] || '';
+ const label = btn.textContent;
+ const ok = await copyText(text);
+ btn.textContent = ok ? '✓ Copied' : '⚠ Copy failed';
+ setTimeout(() => { btn.textContent = label; }, 2000);
+ return;
+ }
+ if (btn.dataset.li === 'harvest') {
+ const label = btn.textContent; btn.disabled = true; btn.textContent = '↻ Harvesting…';
+ try {
+ const res = await (await fetch(location.origin + '/api/vendors/linkedin/harvest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ vendorCode: btn.dataset.vc }) })).json();
+ await loadLi(); renderLi();
+ if (!res.ok) { btn.textContent = '⚠ failed'; setTimeout(() => { btn.disabled = false; btn.textContent = label; }, 2500); }
+ } catch (err) { btn.textContent = '⚠ ' + err.message; btn.disabled = false; }
+ return;
+ }
+ });
+
+ const liHarvestAll = root.querySelector('#vp-li-harvest');
+ if (liHarvestAll && !liHarvestAll.dataset.wired) {
+ liHarvestAll.dataset.wired = '1';
+ liHarvestAll.onclick = async () => {
+ const label = liHarvestAll.textContent; liHarvestAll.disabled = true; liHarvestAll.textContent = '⬇ Harvesting all pages…';
+ try {
+ const res = await (await fetch(location.origin + '/api/vendors/linkedin/harvest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({}) })).json();
+ await loadLi(); renderLi();
+ liHarvestAll.textContent = res.ok ? `✓ ${res.harvested} harvested, ${res.failed} blocked` : '⚠ failed';
+ const when = root.querySelector('#vp-li-when'); if (when) when.textContent = 'harvested just now';
+ setTimeout(() => { liHarvestAll.textContent = label; liHarvestAll.disabled = false; }, 4000);
+ } catch (err) { liHarvestAll.textContent = '⚠ ' + err.message; liHarvestAll.disabled = false; }
+ };
+ }
+
+ loadLi().then(renderLi).catch(() => { liRoot.innerHTML = '<div class="muted">Failed to load LinkedIn roster.</div>'; });
+ }
+
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). Combined vendor reach ≈ ${fmt(s.totalReach)} followers. Last 3 posts shown inline per vendor via Instagram Business Discovery (click Refresh).</div>` : '';
diff --git a/server.js b/server.js
index a106846..011f849 100644
--- a/server.js
+++ b/server.js
@@ -374,7 +374,7 @@ app.get('/api/vendor-amplify-assets', (_req, res) => {
// cross-origin and would otherwise block toDataURL). Read-only, image-only,
// size-capped, allowlisted to the known IG/FB CDN + product-image hosts. Not a
// general proxy — refuses anything that isn't an image from an allowed host.
-const IMG_PROXY_HOSTS = /(\.cdninstagram\.com|\.fbcdn\.net|instagram\.com|designerwallcoverings\.com|\.shopify\.com|cdn\.shopify\.com)$/i;
+const IMG_PROXY_HOSTS = /(\.cdninstagram\.com|\.fbcdn\.net|instagram\.com|designerwallcoverings\.com|\.shopify\.com|cdn\.shopify\.com|\.licdn\.com)$/i;
app.get('/api/img-proxy', async (req, res) => {
const raw = String(req.query.u || '');
let u;
← 6ccae80 vendors: add LinkedIn vendor→company mapping + public OG har
·
back to Marketing Command Center
·
vendors: harden fetchLiOg — linkedin-host guard, redirect:ma be836bb →