[object Object]

← back to Marketing Command Center

vendors: LinkedIn 'Company Follows' CSV importer + auto-merge into the LinkedIn amplify harvester (firms Steve follows)

0d655276514a99808d4e72e8e3aec3aea04e383e · 2026-08-31 12:40:30 -0700 · Steve Abrams

Files touched

Diff

commit 0d655276514a99808d4e72e8e3aec3aea04e383e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 12:40:30 2026 -0700

    vendors: LinkedIn 'Company Follows' CSV importer + auto-merge into the LinkedIn amplify harvester (firms Steve follows)
---
 modules/vendors/index.js            | 16 +++++-
 scripts/import-linkedin-follows.mjs | 97 +++++++++++++++++++++++++++++++++++++
 2 files changed, 112 insertions(+), 1 deletion(-)

diff --git a/modules/vendors/index.js b/modules/vendors/index.js
index 000e1df..18a984a 100644
--- a/modules/vendors/index.js
+++ b/modules/vendors/index.js
@@ -20,7 +20,21 @@ const LI_CACHE = path.join(__dirname, '..', '..', 'data', 'vendor-linkedin-cache
 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: [] }; } }
+const LI_FOLLOWS = path.join(__dirname, '..', '..', 'data', 'vendor-linkedin-follows.json');
+function loadLi() {
+  let base; try { base = JSON.parse(fs.readFileSync(LI_DATA, 'utf8')); } catch { base = { accounts: [] }; }
+  base.accounts = base.accounts || [];
+  // Merge the "firms Steve follows" export (import-linkedin-follows.mjs) if present.
+  // Curated vendor map wins on a slug collision; new followed firms are appended.
+  try {
+    const foll = JSON.parse(fs.readFileSync(LI_FOLLOWS, 'utf8'));
+    const have = new Set(base.accounts.filter(a => a.slug).map(a => a.slug.toLowerCase()));
+    for (const a of (foll.accounts || [])) {
+      if (a.slug && !have.has(a.slug.toLowerCase())) { base.accounts.push(a); have.add(a.slug.toLowerCase()); }
+    }
+  } catch { /* no follows file yet — fine */ }
+  return base;
+}
 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; } }
diff --git a/scripts/import-linkedin-follows.mjs b/scripts/import-linkedin-follows.mjs
new file mode 100644
index 0000000..1b71681
--- /dev/null
+++ b/scripts/import-linkedin-follows.mjs
@@ -0,0 +1,97 @@
+// Import a LinkedIn "Company Follows.csv" (Settings → Data Privacy → Get a copy of
+// your data → Company Follows) into data/vendor-linkedin-follows.json, in the same
+// accounts[] shape as data/vendor-linkedin.json. modules/vendors/loadLi() merges this
+// file automatically, so the LinkedIn amplify panel + harvester then cover every firm
+// Steve follows (reference/attribution-amplify only — no image download-to-assets).
+//
+//   node scripts/import-linkedin-follows.mjs ["~/Downloads/Company Follows.csv"]
+//
+// The export CSV columns vary a little by locale but are always:
+//   "Organization"  — either a company name OR a linkedin.com/company/<slug>/ URL
+//   "Followed On"    — a date (optional)
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+
+const IN = (process.argv[2] || path.join(os.homedir(), 'Downloads', 'Company Follows.csv')).replace(/^~/, os.homedir());
+const OUT = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'data', 'vendor-linkedin-follows.json');
+
+if (!fs.existsSync(IN)) {
+  console.error(`No CSV at ${IN}\nExport it: LinkedIn → Settings → Data Privacy → Get a copy of your data → "Company Follows".`);
+  process.exit(1);
+}
+
+// Minimal RFC-4180-ish CSV parse (handles quoted fields + embedded commas/quotes).
+function parseCsv(text) {
+  const rows = []; let row = [], field = '', q = false;
+  for (let i = 0; i < text.length; i++) {
+    const c = text[i];
+    if (q) {
+      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
+      else if (c === '"') q = false;
+      else field += c;
+    } else if (c === '"') q = true;
+    else if (c === ',') { row.push(field); field = ''; }
+    else if (c === '\n' || c === '\r') {
+      if (c === '\r' && text[i + 1] === '\n') i++;
+      row.push(field); field = '';
+      if (row.some(x => x !== '')) rows.push(row);
+      row = [];
+    } else field += c;
+  }
+  if (field !== '' || row.length) { row.push(field); if (row.some(x => x !== '')) rows.push(row); }
+  return rows;
+}
+
+const slugify = (name) => name.toLowerCase().trim()
+  .replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
+
+// slug from a linkedin company URL, else null
+function slugFromUrl(v) {
+  const m = String(v).match(/linkedin\.com\/(?:company|showcase)\/([^\/\?#]+)/i);
+  return m ? decodeURIComponent(m[1]).toLowerCase() : null;
+}
+
+const rows = parseCsv(fs.readFileSync(IN, 'utf8'));
+if (!rows.length) { console.error('Empty CSV.'); process.exit(1); }
+
+// Find the header row (LinkedIn prepends a couple of "Notes" lines in some exports).
+let hIdx = rows.findIndex(r => r.some(c => /organization/i.test(c)));
+if (hIdx < 0) hIdx = 0;
+const header = rows[hIdx].map(h => h.trim().toLowerCase());
+const orgCol = header.findIndex(h => h.includes('organization') || h.includes('company'));
+const dateCol = header.findIndex(h => h.includes('followed'));
+if (orgCol < 0) { console.error('No "Organization" column found. Header was: ' + header.join(' | ')); process.exit(1); }
+
+const seen = new Set();
+const accounts = [];
+for (const r of rows.slice(hIdx + 1)) {
+  const org = (r[orgCol] || '').trim();
+  if (!org) continue;
+  const isUrl = /linkedin\.com\//i.test(org);
+  const slug = isUrl ? slugFromUrl(org) : slugify(org);
+  if (!slug || seen.has(slug)) continue;
+  seen.add(slug);
+  const brand = isUrl ? (slugFromUrl(org) || org).replace(/-/g, ' ').replace(/\b\w/g, m => m.toUpperCase()) : org;
+  accounts.push({
+    vendorCode: 'follow',
+    brand,
+    slug,
+    verified: isUrl,               // URL-form slugs are exact; name-derived slugs need OG confirm
+    source: 'follows-export',
+    followedOn: dateCol >= 0 ? (r[dateCol] || '').trim() || null : null,
+    note: isUrl ? 'from LinkedIn Company-Follows export (URL slug)' : 'from LinkedIn Company-Follows export (name→slug, verify via harvest OG)',
+  });
+}
+
+const out = {
+  note: 'Firms Steve follows on LinkedIn — imported from the Company-Follows data export. Reference/attribution-amplify only.',
+  importedAt: new Date().toISOString(),
+  source: path.basename(IN),
+  count: accounts.length,
+  accounts,
+};
+fs.writeFileSync(OUT, JSON.stringify(out, null, 2));
+console.log(`Imported ${accounts.length} followed firms → ${OUT}`);
+console.log(`  URL-slug (exact): ${accounts.filter(a => a.verified).length}   name-derived (verify via harvest): ${accounts.filter(a => !a.verified).length}`);
+console.log('Next: POST /api/vendors/linkedin/harvest to pull each one\'s public OG (thumbnail + text).');

← 4bbd244 auto-data-snapshot: 2026-08-31T12:34:16 (10 data files) — da  ·  back to Marketing Command Center  ·  auto-data-snapshot: 2026-08-31T13:13:14 (1 data files) — pub 0cc700b →