← 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
M modules/vendors/index.jsA scripts/import-linkedin-follows.mjs
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 →