← 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}`));