← back to Li Followhub

server.js

253 lines

#!/usr/bin/env node
/**
 * LinkedIn Follow Hub — a LOCAL, resumable click-list for manually following
 * LinkedIn leads/designers/reps. It NEVER clicks Follow for you and never
 * drives LinkedIn — it opens each person's profile in your normal Chrome so
 * YOU click Follow (LinkedIn TOS = manual + tooling only, see the standing
 * dw-linkedin rule + the "automation-flagged, use click-list" memo).
 *
 * Data is read live from the ~/li-*.json files the earlier session produced.
 * Your progress (who you've followed) is persisted server-side to
 * data/progress.json so it survives reloads AND reboots — the one thing the
 * old static li-clicklist.html could not do.
 *
 * Zero dependencies (http + fs only). Cost: $0 (local).
 */
const http = require('http');
const fs = require('fs');
const path = require('path');

const HOME = process.env.HOME;
const PORT = process.env.PORT || 9819;
const HOST = '127.0.0.1';
const DATA_DIR = path.join(__dirname, 'data');
const PROGRESS_FILE = path.join(DATA_DIR, 'progress.json');
const MANUAL_FILE = path.join(DATA_DIR, 'manual-leads.json');

// ---- helpers ---------------------------------------------------------------
function readJSON(file, fallback) {
  try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
  catch (e) { return fallback; }
}
function loadProgress() {
  const p = readJSON(PROGRESS_FILE, null);
  if (p && p.followed) return p;
  return { followed: {}, dailyTarget: 20, updatedAt: null };
}
function saveProgress(p) {
  p.updatedAt = new Date().toISOString();
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(PROGRESS_FILE, JSON.stringify(p, null, 2));
}
// Strip LinkedIn profile URLs down to a stable base (no /overlay, /recent-activity…)
function baseProfile(u) {
  if (!u) return u;
  const m = String(u).match(/(https?:\/\/[^\/]*linkedin\.com\/in\/[^\/?#]+)/i);
  return m ? m[1] + '/' : u;
}

// ---- build the working set from the ~/li-*.json files ----------------------
function buildLists() {
  const leads = readJSON(`${HOME}/li-leads.json`, []);
  const profileUrls = readJSON(`${HOME}/li-profile-urls.json`, {});
  const reps = readJSON(`${HOME}/li-hospitality-reps.json`, []);
  const designerQueue = readJSON(`${HOME}/li-designer-queue.json`, []);
  const designersFollowed = readJSON(`${HOME}/li-designers-followed.json`, []);
  const manualLeads = readJSON(MANUAL_FILE, []); // hub-owned, grown via the Refresh/Add box

  // Set of profiles the earlier collector recorded as follow-events (soft hint only)
  const softFollowed = new Set(
    (Array.isArray(designersFollowed) ? designersFollowed : [])
      .map(baseProfile)
  );

  const newLeads = leads.filter(l => l.list === 'new-leads');
  const warmLeads = leads.filter(l => l.list === 'warm-leads');

  const leadItem = (l, kind) => {
    const direct = profileUrls[l.href] && profileUrls[l.href].url;
    const url = direct
      ? baseProfile(direct)
      : `https://www.linkedin.com${l.href}`;
    return {
      id: l.href,                       // stable id = the SalesNav lead href
      name: l.name,
      url,
      resolved: !!direct,               // true = direct /in/ profile, false = SalesNav fallback
      kind,
    };
  };

  const lists = [
    {
      key: 'reps',
      label: 'Hospitality / FF&E reps',
      note: 'Curated real people. Open → Follow on their profile.',
      items: (Array.isArray(reps) ? reps : []).map(r => ({
        id: baseProfile(r.url), name: r.name, url: baseProfile(r.url),
        resolved: true, kind: 'profile',
        softDone: softFollowed.has(baseProfile(r.url)),
      })),
    },
    {
      key: 'new',
      label: 'New leads (Sales Navigator)',
      note: 'Direct profile link where resolved, else SalesNav → View profile → Follow.',
      items: newLeads.map(l => ({ ...leadItem(l, 'lead'), softDone: false })),
    },
    {
      key: 'designers',
      label: 'Interior designers & architects',
      note: 'Large queue harvested from LinkedIn people-search. Follow at a human pace — mind the daily cap.',
      items: (Array.isArray(designerQueue) ? designerQueue : []).map(u => {
        const b = baseProfile(u);
        return { id: b, name: b.replace(/^https?:\/\/(www\.)?linkedin\.com\/in\//, '').replace(/\/$/, ''),
                 url: b, resolved: true, kind: 'profile', softDone: softFollowed.has(b) };
      }),
    },
    {
      key: 'warm',
      label: 'Warm leads (1st-degree — mostly auto-followed)',
      note: 'Connecting auto-follows, so these are largely done already. Confirm-only.',
      items: warmLeads.map(l => ({ ...leadItem(l, 'warm'), softDone: true })),
    },
    {
      key: 'manual',
      label: 'Added by me',
      note: 'Leads you pasted in from a normal Sales Nav / search session. Grow this instead of auto-scraping.',
      items: (Array.isArray(manualLeads) ? manualLeads : []).map(m => ({
        id: baseProfile(m.url), name: m.name || baseProfile(m.url).replace(/^https?:\/\/(www\.)?linkedin\.com\/in\//, '').replace(/\/$/, ''),
        url: baseProfile(m.url), resolved: true, kind: 'profile',
        softDone: softFollowed.has(baseProfile(m.url)),
      })),
    },
  ];

  const searches = {
    designers: [
      ['Interior designers — Los Angeles', 'https://www.linkedin.com/search/results/people/?keywords=interior%20designer%20Los%20Angeles'],
      ['Interior designers — United States', 'https://www.linkedin.com/search/results/people/?keywords=interior%20designer&geoUrn=%5B%22103644278%22%5D'],
      ['Interior designers — your network (2nd)', 'https://www.linkedin.com/search/results/people/?keywords=interior%20designer&network=%5B%22S%22%5D'],
      ['Architects (interior)', 'https://www.linkedin.com/search/results/people/?keywords=architect%20interior'],
    ],
    hospitality: [
      ['Hospitality FF&E reps', 'https://www.linkedin.com/search/results/people/?keywords=hospitality%20FF%26E%20sales%20representative'],
      ['Hospitality design reps', 'https://www.linkedin.com/search/results/people/?keywords=hospitality%20design%20representative'],
      ['Contract furniture / hospitality reps', 'https://www.linkedin.com/search/results/people/?keywords=contract%20furniture%20hospitality%20rep'],
    ],
  };

  return { lists, searches };
}

// ---- request handling ------------------------------------------------------
function sendJSON(res, code, obj) {
  const body = JSON.stringify(obj);
  res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
  res.end(body);
}
function readBody(req) {
  return new Promise((resolve) => {
    let b = '';
    req.on('data', c => { b += c; if (b.length > 1e6) req.destroy(); });
    req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } });
  });
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://${HOST}:${PORT}`);

  // --- API ---
  if (url.pathname === '/api/data') {
    const { lists, searches } = buildLists();
    const progress = loadProgress();
    // today's follow count (in local time)
    const today = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD local
    const todayCount = Object.values(progress.followed)
      .filter(f => f.ts && new Date(f.ts).toLocaleDateString('en-CA') === today).length;
    return sendJSON(res, 200, {
      lists, searches,
      followed: progress.followed,
      dailyTarget: progress.dailyTarget || 20,
      todayCount,
      totalFollowed: Object.keys(progress.followed).length,
      updatedAt: progress.updatedAt,
    });
  }

  if (url.pathname === '/api/toggle' && req.method === 'POST') {
    const body = await readBody(req);
    const { id, done } = body;
    if (!id) return sendJSON(res, 400, { error: 'missing id' });
    const p = loadProgress();
    if (done) p.followed[id] = { ts: new Date().toISOString() };
    else delete p.followed[id];
    saveProgress(p);
    return sendJSON(res, 200, { ok: true, id, done: !!done, totalFollowed: Object.keys(p.followed).length });
  }

  if (url.pathname === '/api/bulk' && req.method === 'POST') {
    const body = await readBody(req);
    const ids = Array.isArray(body.ids) ? body.ids : [];
    const done = !!body.done;
    const p = loadProgress();
    const ts = new Date().toISOString();
    ids.forEach(id => { if (done) p.followed[id] = { ts }; else delete p.followed[id]; });
    saveProgress(p);
    return sendJSON(res, 200, { ok: true, count: ids.length, totalFollowed: Object.keys(p.followed).length });
  }

  if (url.pathname === '/api/add-leads' && req.method === 'POST') {
    const body = await readBody(req);
    const text = String(body.text || '');
    // known ids already in the hub (all source lists) so we never add a dup
    const { lists } = buildLists();
    const known = new Set();
    lists.forEach(l => l.items.forEach(i => { known.add(i.id); if (i.url) known.add(baseProfile(i.url)); }));
    // parse each line: find a linkedin /in/ URL; name = text before it (if any)
    const added = [];
    const seen = new Set();
    text.split(/[\r\n]+/).forEach(line => {
      const m = line.match(/https?:\/\/[^\s,]*linkedin\.com\/in\/[^\s,)"']+/i);
      if (!m) return;
      const profile = baseProfile(m[0]);
      if (known.has(profile) || seen.has(profile)) return;
      seen.add(profile);
      // name = the chunk before the URL, trimmed of separators
      let name = line.slice(0, m.index).replace(/[\s,;|\-–—]+$/, '').replace(/^[\s,;|]+/, '').trim();
      added.push({ name: name || null, url: profile, list: 'manual', addedAt: new Date().toISOString() });
    });
    const existing = readJSON(MANUAL_FILE, []);
    const merged = (Array.isArray(existing) ? existing : []).concat(added);
    fs.mkdirSync(DATA_DIR, { recursive: true });
    fs.writeFileSync(MANUAL_FILE, JSON.stringify(merged, null, 2));
    return sendJSON(res, 200, { ok: true, added: added.length, totalManual: merged.length,
      names: added.map(a => a.name || a.url).slice(0, 8) });
  }

  if (url.pathname === '/api/config' && req.method === 'POST') {
    const body = await readBody(req);
    const p = loadProgress();
    if (Number.isFinite(body.dailyTarget)) p.dailyTarget = Math.max(1, Math.min(200, body.dailyTarget | 0));
    saveProgress(p);
    return sendJSON(res, 200, { ok: true, dailyTarget: p.dailyTarget });
  }

  // --- static ---
  let file = url.pathname === '/' ? '/index.html' : url.pathname;
  file = path.join(__dirname, 'public', path.normalize(file).replace(/^(\.\.[\/\\])+/, ''));
  fs.readFile(file, (err, data) => {
    if (err) { res.writeHead(404); return res.end('not found'); }
    const ext = path.extname(file);
    const type = ext === '.html' ? 'text/html' : ext === '.js' ? 'text/javascript'
      : ext === '.css' ? 'text/css' : 'application/octet-stream';
    res.writeHead(200, { 'Content-Type': type });
    res.end(data);
  });
});

server.listen(PORT, HOST, () => {
  console.log(`LinkedIn Follow Hub → http://${HOST}:${PORT}`);
  console.log(`progress persisted to ${PROGRESS_FILE}`);
});