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