[object Object]

← back to Marketing Command Center

vendors: add LinkedIn vendor→company mapping + public OG harvester (reference/attribution-amplify, no API/auth)

6ccae80ad5f3fc73c87b2f5f1c30a7995f06e97e · 2026-08-31 12:18:48 -0700 · Steve Abrams

Files touched

Diff

commit 6ccae80ad5f3fc73c87b2f5f1c30a7995f06e97e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 12:18:48 2026 -0700

    vendors: add LinkedIn vendor→company mapping + public OG harvester (reference/attribution-amplify, no API/auth)
---
 data/vendor-linkedin.json | 44 ++++++++++++++++++++++
 modules/vendors/index.js  | 96 +++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 140 insertions(+)

diff --git a/data/vendor-linkedin.json b/data/vendor-linkedin.json
new file mode 100644
index 0000000..ae2acb2
--- /dev/null
+++ b/data/vendor-linkedin.json
@@ -0,0 +1,44 @@
+{
+  "note": "Vendor → LinkedIn COMPANY page mapping for the reference/attribution-amplify workflow. slug = the linkedin.com/company/<slug> path. verified=true means the slug was EMPIRICALLY confirmed (public Open-Graph GET returned 200 with an og:title/description matching the correct DW-vendor brand); verified=false is a best-guess candidate a human must confirm before it's trusted (LinkedIn rate-limits/999-walls some OG probes, so 'unconfirmed' does not always mean 'wrong'). companyUrl is derived from slug. Harvest is a public Open-Graph pull of the PUBLIC company page only — NO LinkedIn API, NO auth, NO login-scraping. Reference + attribution-amplify ONLY: browse, copy caption text, share WITH attribution back to the vendor. Never download their image into DW's own asset library or repost their image as DW's own.",
+  "verifiedAt": "2026-08-31",
+  "accounts": [
+    { "vendorCode": "dw", "brand": "Designer Wallcoverings", "slug": "designerwallcoverings", "verified": true, "note": "our own company page (OG confirmed)" },
+    { "vendorCode": "1838", "brand": "1838 Wallcoverings", "slug": "1838-wallcoverings", "verified": true },
+    { "vendorCode": "arteriors", "brand": "Arteriors Home", "slug": "arteriors-home", "verified": true, "note": "ARTERIORS Home luxury lighting/furniture — NOT the Boston faux-painting 'Arteriors'" },
+    { "vendorCode": "atomic50", "brand": "Atomic50 Ceilings", "slug": null, "verified": false, "note": "no LinkedIn company page found" },
+    { "vendorCode": "bespoke", "brand": "Bespoke Wallcoverings", "slug": null, "verified": false, "note": "no LinkedIn company page found" },
+    { "vendorCode": "bnwalls", "brand": "BN Walls", "slug": "bnwallcoverings", "verified": true, "note": "Bnwallcoverings (BN Walls / Vescom group) — OG confirmed 200" },
+    { "vendorCode": "brewster", "brand": "Brewster Home Fashions", "slug": "brewster-home-fashions", "verified": true },
+    { "vendorCode": "colouranddesign", "brand": "Colour & Design", "slug": "colour-&-design", "verified": true, "note": "Koroseal family — OG confirmed (slug URL-encodes the &)" },
+    { "vendorCode": "contrado", "brand": "Contrado", "slug": "contrado-imaging-ltd", "verified": false, "note": "candidate 404'd on probe — needs a human to find the live slug" },
+    { "vendorCode": "dedar", "brand": "Dedar", "slug": "dedar", "verified": true, "note": "DEDAR MILANO — OG confirmed" },
+    { "vendorCode": "denovowall", "brand": "DeNovo Wall", "slug": null, "verified": false, "note": "no LinkedIn company page found (LBIBoyd rep line)" },
+    { "vendorCode": "designtex", "brand": "Designtex", "slug": "designtex", "verified": true },
+    { "vendorCode": "fabricut", "brand": "Fabricut", "slug": "fabricut", "verified": true },
+    { "vendorCode": "folia", "brand": "Folia Fabrics", "slug": null, "verified": false, "note": "no LinkedIn company page found (source retired)" },
+    { "vendorCode": "hygge", "brand": "Hygge & West", "slug": "hygge-&-west", "verified": false, "note": "candidate 404'd on probe — needs a human to find the live slug" },
+    { "vendorCode": "justindavid", "brand": "Justin David Textiles", "slug": null, "verified": false, "note": "private-label upstream — do NOT surface customer-facing; confirm before use" },
+    { "vendorCode": "knoll", "brand": "Knoll Textiles", "slug": "knoll", "verified": true, "note": "Knoll (MillerKnoll) parent; Knoll Textiles is a division" },
+    { "vendorCode": "maya-romanoff", "brand": "Maya Romanoff", "slug": "maya-romanoff", "verified": true },
+    { "vendorCode": "mindthegap", "brand": "MINDTHEGAP", "slug": "mindtheg", "verified": true, "note": "MINDTHEGAP International — OG confirmed" },
+    { "vendorCode": "muralsource", "brand": "Mural Source", "slug": null, "verified": false, "note": "no LinkedIn company page found" },
+    { "vendorCode": "naturale54", "brand": "Naturale 54", "slug": null, "verified": false, "note": "no LinkedIn company page found (LBIBoyd rep line)" },
+    { "vendorCode": "osborne", "brand": "Osborne & Little", "slug": "osborne-&-little", "verified": false, "note": "candidate 404'd on probe — needs a human to find the live slug" },
+    { "vendorCode": "rebel-walls", "brand": "Rebel Walls", "slug": "rebel-walls", "verified": true, "note": "OG resolves to 'Gimmersta Wallpaper' — Rebel Walls' parent (Gimmersta Wallpaper AB); correct entity" },
+    { "vendorCode": "romo", "brand": "Romo", "slug": "romo-group", "verified": true, "note": "ROMO GROUP parent" },
+    { "vendorCode": "stout", "brand": "Stout Textiles", "slug": "stout-inc", "verified": true, "note": "Stout Inc — OG confirmed" },
+    { "vendorCode": "timorous", "brand": "Timorous Beasties", "slug": "timorousbeasties", "verified": true, "note": "OG confirmed (no hyphens in the slug)" },
+    { "vendorCode": "wallquest", "brand": "WallQuest", "slug": null, "verified": false, "note": "private-label upstream (Malibu) — no confirmed slug; do NOT surface customer-facing" },
+    { "vendorCode": "york", "brand": "York Wallcoverings", "slug": "york-wallcoverings", "verified": true },
+    { "vendorCode": "schumacher", "brand": "F. Schumacher", "slug": "f-schumacher-&-co", "verified": false, "note": "brand policy: INTERNAL-ONLY, must not be amplified customer-facing; slug unconfirmed" },
+    { "vendorCode": "thibaut", "brand": "Thibaut", "slug": "thibaut-inc", "verified": true, "note": "Thibaut Inc — OG confirmed" },
+    { "vendorCode": "arte", "brand": "Arte International", "slug": "arte", "verified": true, "note": "ARTE (Belgium) — OG confirmed" },
+    { "vendorCode": "koroseal", "brand": "Koroseal", "slug": "koroseal", "verified": true, "note": "Koroseal Interior Products — OG confirmed" },
+    { "vendorCode": "wolf-gordon", "brand": "Wolf Gordon", "slug": "wolf-gordon", "verified": true, "note": "Wolf-Gordon — OG confirmed" },
+    { "vendorCode": "carnegie", "brand": "Carnegie Fabrics", "slug": "carnegie", "verified": true, "note": "Carnegie — OG confirmed" },
+    { "vendorCode": "scalamandre", "brand": "Scalamandré", "slug": "scalamandre", "verified": true, "note": "Scalamandré Luxury Fabrics & Wallcoverings — OG confirmed" },
+    { "vendorCode": "sister-parish", "brand": "Sister Parish Design", "slug": "sisterparishdesign", "verified": true, "note": "Sister Parish Design — OG confirmed (no hyphens)" },
+    { "vendorCode": "cole-son", "brand": "Cole & Son", "slug": "cole-&-son", "verified": true, "note": "Cole & Son — OG confirmed (slug URL-encodes the &)" },
+    { "vendorCode": "phillip-jeffries", "brand": "Phillip Jeffries", "slug": "phillip-jeffries-ltd", "verified": false, "note": "candidate 404'd on probe — needs a human to find the live slug" }
+  ]
+}
diff --git a/modules/vendors/index.js b/modules/vendors/index.js
index 89848c7..7a586da 100644
--- a/modules/vendors/index.js
+++ b/modules/vendors/index.js
@@ -10,10 +10,19 @@ 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');
+// LinkedIn vendor→company mapping + public-OG post cache (reference/attribution-
+// amplify only — NO LinkedIn API, NO auth, NO scraping-around-login; a read-only
+// Open-Graph GET of the PUBLIC company/post page, exactly like the linkedin
+// module's fetchOg helper).
+const LI_DATA = path.join(__dirname, '..', '..', 'data', 'vendor-linkedin.json');
+const LI_CACHE = path.join(__dirname, '..', '..', 'data', 'vendor-linkedin-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 loadLi() { try { return JSON.parse(fs.readFileSync(LI_DATA, 'utf8')); } catch { return { accounts: [] }; } }
+function loadLiCache() { try { return JSON.parse(fs.readFileSync(LI_CACHE, 'utf8')); } catch { return {}; } }
+function saveLiCache(c) { try { fs.mkdirSync(path.dirname(LI_CACHE), { recursive: true }); fs.writeFileSync(LI_CACHE, JSON.stringify(c, null, 2)); } catch { /* non-fatal */ } }
 function envFrom(file, k) { try { return (fs.readFileSync(file, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1]; } catch { return null; } }
 // Resolve a usable Meta token — prefer a never-expiring PAGE token. Checks
 // META_ACCESS_TOKEN then IG_ACCESS_TOKEN, in MCC .env then the secrets master.
@@ -74,6 +83,39 @@ async function fetchPosts(igId, token, username) {
   };
 }
 
+// ── LinkedIn public Open-Graph harvest (reference/attribution-amplify) ────────
+// LinkedIn has NO public feed-read API and its User Agreement bans scraping-
+// around-login, so this is a best-effort READ-ONLY Open-Graph GET of the PUBLIC
+// company page (linkedin.com/company/<slug>) — same technique as the linkedin
+// module's fetchOg. It returns the company page's og:title/og:image/og:description
+// (a thumbnail + text + the permalink). It NEVER logs in, never hits an API,
+// never downloads the image into DW's assets — the amplify UX copies the text and
+// shares a link WITH attribution back to the vendor.
+const LI_UA = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
+async function fetchLiOg(url) {
+  const ctrl = new AbortController();
+  const timer = setTimeout(() => ctrl.abort(), 8000);
+  try {
+    const r = await fetch(url, { headers: { 'User-Agent': LI_UA, 'Accept-Language': 'en-US,en;q=0.9' }, redirect: 'follow', signal: ctrl.signal });
+    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(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"') : ''; };
+    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 };
+  } finally { clearTimeout(timer); }
+}
+// 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.
+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 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 };
+}
+
 module.exports = {
   id: 'vendors',
   title: 'Vendor IG Reporting',
@@ -181,5 +223,59 @@ module.exports = {
         res.json({ ok: false, handle, error: e.message + (/rate|limit|#4|#17/i.test(e.message) ? ' — rate-limited, try again shortly' : '') });
       }
     });
+
+    // ── LinkedIn (reference/attribution-amplify) ─────────────────────────────
+    // GET the vendor→LinkedIn-company roster merged with the cached OG pull +
+    // a ready-to-copy attribution-amplify kit per vendor. READ-ONLY; no API.
+    router.get('/linkedin/accounts', (_req, res) => {
+      const cfg = loadLi();
+      const cache = loadLiCache();
+      const accounts = (cfg.accounts || []).map(a => {
+        const companyUrl = a.slug ? `https://www.linkedin.com/company/${a.slug}/` : null;
+        const c = a.slug ? (cache[a.slug] || null) : null;
+        const og = c && c.og ? c.og : null;
+        return {
+          vendorCode: a.vendorCode, brand: a.brand, slug: a.slug || null,
+          verified: a.verified === true, note: a.note || '',
+          hasLinkedIn: !!a.slug, companyUrl,
+          og, fetchedAt: c ? c.fetchedAt : null, error: c ? c.error : null,
+          amplify: (companyUrl && og) ? liAmplifyKit(a.brand, companyUrl, og) : null,
+        };
+      });
+      const withLi = accounts.filter(a => a.hasLinkedIn);
+      res.json({
+        accounts,
+        stats: {
+          total: accounts.length,
+          withLinkedIn: withLi.length,
+          verified: withLi.filter(a => a.verified).length,
+          unverified: withLi.filter(a => !a.verified).length,
+          missing: accounts.length - withLi.length,
+          harvested: accounts.filter(a => a.og).length,
+          harvestedAt: Object.values(cache).map(c => c.fetchedAt).filter(Boolean).sort().pop() || null,
+        },
+      });
+    });
+
+    // POST /linkedin/harvest — pull the PUBLIC OG (thumbnail + text) for every
+    // mapped vendor company page (or ?vendorCode= for one). Read-only GETs; no
+    // login, no API. Rate-gentle. Honest per-vendor error surfacing (LinkedIn may
+    // auth-wall / rate-limit a given fetch — the embed/link still works).
+    router.post('/linkedin/harvest', async (req, res) => {
+      const only = String((req.body && req.body.vendorCode) || (req.query && req.query.vendorCode) || '').trim();
+      const cfg = loadLi();
+      const cache = loadLiCache();
+      const targets = (cfg.accounts || []).filter(a => a.slug && (!only || a.vendorCode === only));
+      let ok = 0, failed = 0;
+      for (const a of targets) {
+        const companyUrl = `https://www.linkedin.com/company/${a.slug}/`;
+        const og = await fetchLiOg(companyUrl);
+        if (og.ok) { cache[a.slug] = { og: { title: og.title, thumb: og.thumb, description: og.description }, status: og.status, fetchedAt: new Date().toISOString(), error: null }; ok++; }
+        else { cache[a.slug] = { ...(cache[a.slug] || {}), status: og.status || null, fetchedAt: new Date().toISOString(), error: og.error || 'fetch failed' }; failed++; }
+        saveLiCache(cache);
+        await sleep(800); // be gentle — LinkedIn rate-limits aggressive OG pulls
+      }
+      res.json({ ok: true, harvested: ok, failed, total: targets.length });
+    });
   },
 };

← d56a62c TK-10519: safe no-publish TikTok integration demo video (rec  ·  back to Marketing Command Center  ·  vendors panel: add 'Pull ALL vendor accounts' + IG coverage b8fe12e →