← back to Marketing Command Center
scripts/import-linkedin-follows.mjs
98 lines
// 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).');