← back to Marketing Command Center
scripts/build-vendor-contact-sheet.mjs
82 lines
// "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`);