← back to Marketing Command Center
vendors: strict LinkedIn-connection x vendor_registry contact-sheet generator (who I know inside each DW vendor); PII outputs gitignored
2e93a4c55d0c285b37c8aa823454dc08234ab618 · 2026-08-31 13:25:03 -0700 · Steve Abrams
Files touched
M .gitignoreA scripts/build-vendor-contact-sheet.mjs
Diff
commit 2e93a4c55d0c285b37c8aa823454dc08234ab618
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 31 13:25:03 2026 -0700
vendors: strict LinkedIn-connection x vendor_registry contact-sheet generator (who I know inside each DW vendor); PII outputs gitignored
---
.gitignore | 2 +
scripts/build-vendor-contact-sheet.mjs | 81 ++++++++++++++++++++++++++++++++++
2 files changed, 83 insertions(+)
diff --git a/.gitignore b/.gitignore
index 905bc74..76d3d4e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,3 +84,5 @@ demo/out/
data/linkedin-connections.json
data/linkedin-network-firms.json
data/vendor-linkedin-follows.json
+data/vendor-contacts.json
+data/vendor-contacts.html
diff --git a/scripts/build-vendor-contact-sheet.mjs b/scripts/build-vendor-contact-sheet.mjs
new file mode 100644
index 0000000..74fb575
--- /dev/null
+++ b/scripts/build-vendor-contact-sheet.mjs
@@ -0,0 +1,81 @@
+// "Who I know inside each DW vendor" — matches Steve's LinkedIn connections
+// (data/linkedin-connections.json) to the dw_unified vendor_registry with a STRICT
+// matcher (no loose substring; kills the RH/AT&T/On false positives), and emits:
+// data/vendor-contacts.json — vendor -> [{name,title,profileUrl,connectedOn}]
+// data/vendor-contacts.html — a readable sheet (open in a browser)
+// INTERNAL/PII — never expose publicly. For purchasing / samples / escalations.
+// node scripts/build-vendor-contact-sheet.mjs
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+
+const DATA = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'data');
+const people = JSON.parse(fs.readFileSync(path.join(DATA, 'linkedin-connections.json'), 'utf8')).people;
+
+// vendor names from the local dw_unified mirror; fallback to vendor-instagram.json
+let vendors = [];
+try {
+ const out = execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-Atc',
+ 'SELECT DISTINCT vendor_name FROM vendor_registry WHERE vendor_name IS NOT NULL'], { encoding: 'utf8', timeout: 20000 });
+ vendors = out.split('\n').map(s => s.trim()).filter(Boolean);
+} catch { vendors = JSON.parse(fs.readFileSync(path.join(DATA, 'vendor-instagram.json'), 'utf8')).map(x => x.brand).filter(Boolean); }
+
+const norm = s => (s || '').toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]/g, '');
+// Generic words that must NEVER drive a fuzzy match (only an EXACT match counts) —
+// otherwise a vendor literally named "Custom"/"Wallpaper" swallows unrelated firms.
+const GENERIC = new Set(['custom', 'wallpaper', 'wallpapernyc', 'home', 'studio', 'design', 'designs',
+ 'wallcovering', 'wallcoverings', 'fabric', 'fabrics', 'textiles', 'textile', 'interiors', 'interior',
+ 'group', 'company', 'wallpapers', 'walls', 'surface', 'surfaces']);
+// STRICT match: exact normalized, OR one is a length>=6 whole-prefix of the other AND
+// the shorter (matching) side is not a generic word — so "wallquestinc"~"wallquest",
+// "phillipjeffriesltd"~"phillipjeffries", but NOT "rh"~"linherr" or "custom…"~"Custom".
+function vendorFor(company) {
+ const c = norm(company);
+ if (c.length < 5) return null;
+ for (const v of vendors) {
+ const n = norm(v);
+ if (n.length < 5) continue;
+ if (c === n) return v; // exact always wins
+ const shorter = c.length <= n.length ? c : n;
+ if (shorter.length < 6 || GENERIC.has(shorter)) continue;
+ if (c.startsWith(n) || n.startsWith(c)) return v;
+ }
+ return null;
+}
+
+const byVendor = new Map();
+for (const p of people) {
+ const v = vendorFor(p.company);
+ if (!v) continue;
+ if (!byVendor.has(v)) byVendor.set(v, []);
+ byVendor.get(v).push({ name: p.name, title: p.title, connectedOn: p.connectedOn, profileUrl: p.profileUrl, srcCompany: p.company });
+}
+const rows = [...byVendor.entries()].map(([vendor, contacts]) => ({ vendor, count: contacts.length, contacts }))
+ .sort((a, b) => b.count - a.count);
+
+fs.writeFileSync(path.join(DATA, 'vendor-contacts.json'),
+ JSON.stringify({ note: 'Who Steve knows inside each DW vendor (LinkedIn connections × vendor_registry, strict match). INTERNAL/PII.', vendors: rows.length, totalContacts: rows.reduce((s, r) => s + r.count, 0), rows }, null, 2));
+
+// readable HTML sheet
+const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+const html = `<!doctype html><meta charset=utf-8><title>DW Vendor Contact Sheet</title>
+<style>body{font:14px/1.5 -apple-system,Segoe UI,sans-serif;margin:32px;color:#111;max-width:900px}
+h1{font-size:22px;margin:0 0 4px}.sub{color:#666;margin:0 0 24px}
+.v{margin:0 0 18px;border:1px solid #e5e5e5;border-radius:10px;overflow:hidden}
+.vh{background:#111;color:#fff;padding:10px 14px;font-weight:600;display:flex;justify-content:space-between}
+.vh b{background:#c9a227;color:#111;border-radius:20px;padding:1px 10px;font-size:12px}
+table{width:100%;border-collapse:collapse}td{padding:7px 14px;border-top:1px solid #f0f0f0;vertical-align:top}
+.nm{font-weight:600}.ti{color:#555}a{color:#0a66c2;text-decoration:none}.src{color:#999;font-size:12px}</style>
+<h1>Who I know inside each DW vendor</h1>
+<p class=sub>${rows.length} vendors · ${rows.reduce((s, r) => s + r.count, 0)} contacts · from LinkedIn connections × vendor_registry (strict match) · INTERNAL</p>
+${rows.map(r => `<div class=v><div class=vh><span>${esc(r.vendor)}</span><b>${r.count}</b></div><table>${
+ r.contacts.map(c => `<tr><td class=nm>${esc(c.name)}${c.profileUrl ? ` <a href="${esc(c.profileUrl)}" target=_blank rel=noopener>↗</a>` : ''}</td>`
+ + `<td class=ti>${esc(c.title)}${norm(c.srcCompany) !== norm(r.vendor) ? ` <span class=src>(${esc(c.srcCompany)})</span>` : ''}</td>`
+ + `<td class=src>${esc(c.connectedOn || '')}</td></tr>`).join('')
+}</table></div>`).join('')}`;
+fs.writeFileSync(path.join(DATA, 'vendor-contacts.html'), html);
+
+console.log(`Vendor contact sheet: ${rows.length} vendors, ${rows.reduce((s, r) => s + r.count, 0)} contacts`);
+console.log('Top vendors by # of your contacts:');
+rows.slice(0, 20).forEach(r => console.log(` ${String(r.count).padStart(3)} ${r.vendor}`));
+console.log(`\nSheet: data/vendor-contacts.html Data: data/vendor-contacts.json`);
← 8f46fdc vendors: LinkedIn Connections.csv importer + network-firms r
·
back to Marketing Command Center
·
vendors panel: '👥 My vendor contacts (N)' button + /contact f5354e1 →