← back to Marketing Command Center
scripts/build-followlist.js
101 lines
#!/usr/bin/env node
/*
* build-followlist.js — regenerate public/data/linkedin-followlist.json
* from the live source files when present, so the LinkedIn panel's Follow-list
* stays current. Sources (all optional, overridable via env):
*
* LI_CLICKLIST (default ~/li-clicklist.html) — designer / hospitality / warm sections + saved searches
* CRCP_BROKERS (default ~/Projects/commercialrealestate/data/brokers-snapshot.json) — CRE broker section
*
* Falls back to the committed JSON for any section whose source is missing
* (e.g. on Kamatera, where neither source exists — run this on the Mac before
* deploy). Idempotent; safe to run repeatedly.
*
* npm run build:followlist
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const HOME = os.homedir();
const CLICKLIST = process.env.LI_CLICKLIST || path.join(HOME, 'li-clicklist.html');
const BROKERS = process.env.CRCP_BROKERS || path.join(HOME, 'Projects', 'commercialrealestate', 'data', 'brokers-snapshot.json');
const OUT = path.join(__dirname, '..', 'public', 'data', 'linkedin-followlist.json');
const prev = fs.existsSync(OUT) ? JSON.parse(fs.readFileSync(OUT, 'utf8')) : { sections: [], searches: [] };
// ── 1. Designer / hospitality / warm sections + saved searches (from li-clicklist.html) ──
function parseClicklist(src) {
const ols = [...src.matchAll(/<ol>([\s\S]*?)<\/ol>/g)].map(m => m[1]);
const people = ol => [...ol.matchAll(/<a[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map(m => {
let href = m[1].trim();
if (href.startsWith('/')) href = 'https://www.linkedin.com' + href;
return { name: m[2].trim(), href, kind: /\/sales\/lead\//.test(href) ? 'salesnav' : 'profile' };
});
const sections = [
{ id: 'hospitality', title: 'Hospitality reps — follow these', note: 'Real hospitality / FF&E people. Each → their profile → Follow.', people: people(ols[0] || '') },
{ id: 'new-leads', title: 'New leads to follow', note: 'Open each profile and click Follow. Sales-Nav rows: View profile → Follow.', people: people(ols[1] || '') },
{ id: 'warm-leads', title: 'Warm leads — already 1st-degree', note: 'Connecting auto-follows, so these are largely done. Skim only to confirm.', people: people(ols[2] || '') },
].filter(s => s.people.length);
const bigs = [...src.matchAll(/<div class=big>([\s\S]*?)<\/div>/g)].map(m => m[1]);
const links = b => [...b.matchAll(/<a[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map(m => ({ label: m[2].trim(), href: m[1].trim() }));
const searches = [];
if (bigs[0]) searches.push({ group: 'Interior designers & architects', links: links(bigs[0]) });
if (bigs[1]) searches.push({ group: 'Hospitality reps', links: links(bigs[1]) });
const upd = (src.match(/Updated\s+([0-9]{4}-[0-9]{2}-[0-9]{2})/) || [])[1] || null;
return { sections, searches, updated: upd };
}
let clicklist = null;
if (fs.existsSync(CLICKLIST)) {
clicklist = parseClicklist(fs.readFileSync(CLICKLIST, 'utf8'));
console.log(`✓ clicklist: ${CLICKLIST} → ${clicklist.sections.length} sections, ${clicklist.sections.reduce((n, s) => n + s.people.length, 0)} people`);
} else {
console.log(`• clicklist source missing (${CLICKLIST}) — preserving existing non-broker sections`);
}
// ── 2. CRE broker section (from CRCP snapshot) ──
function buildBrokerSection(rows) {
const seen = new Set();
const people = [];
for (const b of rows) {
const name = (b.name || '').trim();
if (!name) continue;
const firm = (b.firm || '').trim();
const key = (name + '|' + firm).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
// Scraped `linkedin` is unreliable (often a domain-derived company handle),
// so drive a name-search that lands on the real person to Follow.
const q = encodeURIComponent([name, firm].filter(Boolean).join(' '));
people.push({ name, sub: firm || undefined, href: `https://www.linkedin.com/search/results/people/?keywords=${q}`, kind: 'search' });
}
return { id: 'cre-brokers', title: 'Commercial real estate brokers (CRCP)', note: 'CRE brokers from the CRCP directory. Each link runs a LinkedIn people-search on the broker + firm — open the right person and Follow.', people };
}
let brokerSection = (prev.sections || []).find(s => s.id === 'cre-brokers') || null;
if (fs.existsSync(BROKERS)) {
const raw = JSON.parse(fs.readFileSync(BROKERS, 'utf8'));
const rows = Array.isArray(raw) ? raw : (raw.brokers || raw.rows || raw.data || []);
brokerSection = buildBrokerSection(rows);
console.log(`✓ brokers: ${BROKERS} → ${brokerSection.people.length} brokers (deduped)`);
} else if (brokerSection) {
console.log(`• broker source missing (${BROKERS}) — preserving existing ${brokerSection.people.length} brokers`);
}
// ── 3. Merge + write ──
const baseSections = clicklist ? clicklist.sections : (prev.sections || []).filter(s => s.id !== 'cre-brokers');
const sections = brokerSection ? [...baseSections, brokerSection] : baseSections;
const out = {
source: 'li-clicklist.html + CRCP brokers-snapshot.json',
updated: (clicklist && clicklist.updated) || prev.updated || new Date().toISOString().slice(0, 10),
guidance: "Open each person's LinkedIn PROFILE in your normal Chrome (logged into LinkedIn) and click Follow (or More → Follow). Sales-Nav / name-search rows: open, find the right person, Follow. Check them off as you go — progress is saved on this device.",
sections,
searches: clicklist ? clicklist.searches : (prev.searches || []),
};
fs.writeFileSync(OUT, JSON.stringify(out, null, 2));
const tot = sections.reduce((n, s) => n + s.people.length, 0);
console.log(`\nwrote ${path.relative(process.cwd(), OUT)} — ${sections.length} sections, ${tot} people, ${out.searches.reduce((n, s) => n + s.links.length, 0)} saved-searches`);
sections.forEach(s => console.log(` ${s.id}: ${s.people.length}`));