← back to Li Followhub
LinkedIn Follow Hub — resumable, cadence-guarded manual click-list (:9819)
b722503b271acfc8b1bd84968f81ae604b266108 · 2026-07-12 08:13:07 -0700 · Steve
Files touched
A .gitignoreA README.mdA public/index.htmlA server.jsA server.out
Diff
commit b722503b271acfc8b1bd84968f81ae604b266108
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jul 12 08:13:07 2026 -0700
LinkedIn Follow Hub — resumable, cadence-guarded manual click-list (:9819)
---
.gitignore | 9 ++
README.md | 46 ++++++++++
public/index.html | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 212 +++++++++++++++++++++++++++++++++++++++++++
server.out | 20 +++++
5 files changed, 550 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6a8c28e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/progress.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cf2ba00
--- /dev/null
+++ b/README.md
@@ -0,0 +1,46 @@
+# LinkedIn Follow Hub
+
+A **local, resumable, cadence-guarded click-list** for manually following LinkedIn
+leads / interior designers / hospitality reps.
+
+**http://127.0.0.1:9819**
+
+## Why it exists
+Driving Steve's real LinkedIn account to bulk-Follow trips active bot-detection
+(PerimeterX `uc=scraping`, invisible reCAPTCHA, click-swallowing). Standing rule:
+**LinkedIn = manual + tooling only, never auto-act (TOS).** So this hub does the
+*tooling* half — it surfaces every person to follow and remembers your progress —
+while **you** do the clicking in your normal Chrome. It never drives LinkedIn.
+
+## What it fixes vs. the old `~/li-clicklist.html`
+The old page was static HTML with no memory — reload and you lost your place.
+This one persists every follow to `data/progress.json`, so it **survives reloads
+and reboots**, shows a live daily count, and warns when you hit a human-paced cap.
+
+## Data sources (read live each request, never written)
+- `~/li-hospitality-reps.json` — curated reps
+- `~/li-leads.json` — 167 Sales Navigator leads (new + warm)
+- `~/li-profile-urls.json` — resolved direct `/in/` profile links
+- `~/li-designer-queue.json` — ~1,827 interior designer / architect profiles
+- `~/li-designers-followed.json` — prior follow-events (soft "likely already" hint)
+
+## Your progress
+- `data/progress.json` — `{ followed: { <id>: {ts} }, dailyTarget }` (git-ignored; personal state)
+
+## Run
+```sh
+node ~/Projects/li-followhub/server.js # foreground
+# or keep it always-on:
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.li-followhub.plist
+```
+
+## Cadence
+Default cap 20/day (editable in the header). Bunched follows are what re-trip the
+scraping flag — the ring + red banner exist to keep you under the radar. The cap
+is advisory; the buttons still work — it's your call, it just warns.
+
+## Hard rules honored
+- Never clicks Follow, never drives LinkedIn, never touches openclaw.
+- Never sends **Connect** (that's an invite) — only surfaces the profile so you Follow.
+- Does not auto-rescrape Sales Navigator (that scrape is what gets flagged) — a fresh
+ lead pull must come from you in a normal SalesNav session.
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..84c71a0
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,263 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>LinkedIn Follow Hub</title>
+<style>
+ :root{
+ --li:#0a66c2; --li-d:#004182; --ink:#1d2226; --mut:#66707a;
+ --line:#e3e7ea; --bg:#f4f2ee; --card:#fff; --good:#057642; --warn:#915907; --bad:#b24020;
+ --cols:4;
+ }
+ *{box-sizing:border-box}
+ body{font:15px/1.5 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ margin:0;background:var(--bg);color:var(--ink)}
+ header{background:var(--card);border-bottom:1px solid var(--line);padding:16px 22px;position:sticky;top:0;z-index:20}
+ h1{margin:0;font-size:20px;display:flex;align-items:center;gap:10px}
+ h1 .in{background:var(--li);color:#fff;border-radius:5px;padding:1px 7px;font-weight:800}
+ .wrap{max-width:1280px;margin:0 auto;padding:0 22px}
+ .disclaim{background:#fff4e5;border-left:4px solid var(--warn);border-radius:6px;
+ padding:10px 14px;margin:14px 0;font-size:13.5px;color:#5a4300}
+ .disclaim b{color:var(--bad)}
+ /* cadence */
+ .cadence{display:flex;flex-wrap:wrap;align-items:center;gap:18px;margin:14px 0}
+ .ring{--p:0;width:64px;height:64px;border-radius:50%;flex:0 0 auto;
+ background:conic-gradient(var(--li) calc(var(--p)*1%), var(--line) 0);
+ display:grid;place-items:center;position:relative}
+ .ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--card)}
+ .ring b{position:relative;font-size:15px}
+ .cad-txt small{color:var(--mut)}
+ .cad-txt .big{font-size:22px;font-weight:800}
+ .cap-warn{background:#fde7e3;border:1px solid var(--bad);color:var(--bad);
+ border-radius:8px;padding:10px 14px;font-weight:600;margin:6px 0;display:none}
+ .cap-warn.show{display:block}
+ .stat{display:flex;gap:22px;flex-wrap:wrap;margin-left:auto}
+ .stat div{text-align:center}
+ .stat .n{font-size:20px;font-weight:800;color:var(--li-d)}
+ .stat .l{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
+ input[type=number]{width:56px;padding:3px 6px;border:1px solid var(--line);border-radius:6px;font:inherit}
+ /* tabs */
+ .tabs{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 2px}
+ .tab{border:1px solid var(--line);background:var(--card);border-radius:20px;padding:6px 14px;
+ cursor:pointer;font-weight:600;font-size:13.5px;color:var(--mut)}
+ .tab.on{background:var(--li);border-color:var(--li);color:#fff}
+ .tab .c{opacity:.7;font-weight:700;margin-left:5px}
+ /* controls */
+ .controls{display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin:14px 0}
+ .controls input[type=search]{flex:1;min-width:200px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;font:inherit}
+ .controls select{padding:8px 10px;border:1px solid var(--line);border-radius:8px;font:inherit;background:var(--card)}
+ .controls label{font-size:12.5px;color:var(--mut);display:flex;align-items:center;gap:7px}
+ .listnote{color:var(--mut);font-size:13px;margin:2px 0 10px}
+ .bulk{font-size:12.5px;color:var(--li);cursor:pointer;text-decoration:underline;background:none;border:0;padding:0}
+ /* grid */
+ .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:10px;padding-bottom:60px}
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 12px;
+ display:flex;flex-direction:column;gap:7px;break-inside:avoid}
+ .card.done{background:#f0f7f2;border-color:#bfe3cd}
+ .card .nm{font-weight:600;word-break:break-word;line-height:1.25}
+ .card .row{display:flex;align-items:center;gap:8px;margin-top:auto}
+ .open{flex:1;text-align:center;background:var(--li);color:#fff;border-radius:20px;
+ padding:6px 10px;font-weight:600;font-size:13px;text-decoration:none}
+ .open:hover{background:var(--li-d)}
+ .open.salesnav{background:#5a4300;}
+ .chk{display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--good);cursor:pointer;user-select:none;white-space:nowrap}
+ .chk input{width:17px;height:17px;accent-color:var(--good)}
+ .tag{font-size:10.5px;padding:1px 6px;border-radius:10px;font-weight:700;align-self:flex-start}
+ .tag.sn{background:#fff4e5;color:var(--warn)}
+ .tag.soft{background:#eef3f8;color:var(--li-d)}
+ .when{font-size:11px;color:var(--good);display:flex;align-items:center;gap:4px}
+ .chips a{display:inline-block;background:var(--li);color:#fff;padding:7px 13px;border-radius:20px;
+ margin:5px 8px 5px 0;font-weight:600;font-size:13px;text-decoration:none}
+ .chips a:hover{background:var(--li-d)}
+ .empty{color:var(--mut);padding:30px;text-align:center}
+ .foot{color:var(--mut);font-size:12px;margin:20px 0}
+ h2.sec{font-size:15px;color:var(--li-d);margin:26px 0 4px;border-top:1px solid var(--line);padding-top:18px}
+</style>
+</head>
+<body>
+<header>
+ <div class="wrap">
+ <h1><span class="in">in</span> Follow Hub <span style="font-weight:400;color:var(--mut);font-size:14px">— manual, resumable, cadence-guarded</span></h1>
+ <div class="disclaim">
+ <b>You</b> click Follow — this page never does. Open each profile in your <b>normal Chrome</b>
+ (logged into LinkedIn), <b>not</b> the orange openclaw window. On the profile hit <b>Follow</b>
+ (or <b>More → Follow</b>) — never <b>Connect</b> (that sends an invite). Then tick the box here so it's remembered.
+ </div>
+
+ <div class="cadence">
+ <div class="ring" id="ring"><b id="ringtxt">0</b></div>
+ <div class="cad-txt">
+ <div><span class="big" id="todayN">0</span> <small>followed today</small></div>
+ <small>Daily cap <input type="number" id="target" min="1" max="200" value="20"> — keep it human-paced</small>
+ </div>
+ <div class="stat">
+ <div><div class="n" id="totN">0</div><div class="l">Total done</div></div>
+ <div><div class="n" id="remN">0</div><div class="l">Remaining</div></div>
+ </div>
+ </div>
+ <div class="cap-warn" id="capwarn">⚠️ Daily cap reached. Stop for today — bunched follows are what re-trips LinkedIn's scraping flag.</div>
+
+ <div class="tabs" id="tabs"></div>
+ </div>
+</header>
+
+<div class="wrap">
+ <div class="controls">
+ <input type="search" id="q" placeholder="Search name…">
+ <select id="sort">
+ <option value="undone">Not-followed first</option>
+ <option value="name">Name A→Z</option>
+ <option value="done">Followed first</option>
+ </select>
+ <label><input type="checkbox" id="hideDone"> Hide followed</label>
+ <label>Density <input type="range" id="density" min="2" max="7" value="4"></label>
+ <button class="bulk" id="markVisible">✓ Mark all visible as followed</button>
+ <button class="bulk" id="clearVisible" style="color:var(--bad)">↺ Unmark all visible</button>
+ </div>
+ <div class="listnote" id="listnote"></div>
+ <div class="grid" id="grid"></div>
+
+ <div id="searches"></div>
+ <div class="foot" id="foot"></div>
+</div>
+
+<script>
+const $ = s => document.querySelector(s);
+let STATE = { lists:[], searches:{}, followed:{}, dailyTarget:20, todayCount:0, totalFollowed:0 };
+let activeTab = localStorage.getItem('lfh.tab') || 'reps';
+const prefs = {
+ sort: localStorage.getItem('lfh.sort') || 'undone',
+ hideDone: localStorage.getItem('lfh.hideDone') === '1',
+ density: +(localStorage.getItem('lfh.density') || 4),
+ q: '',
+};
+
+async function load(){
+ const r = await fetch('/api/data'); STATE = await r.json();
+ $('#target').value = STATE.dailyTarget;
+ applyPrefs(); renderTabs(); render(); renderSearches(); renderFoot();
+}
+function applyPrefs(){
+ $('#sort').value = prefs.sort;
+ $('#hideDone').checked = prefs.hideDone;
+ $('#density').value = prefs.density;
+ document.documentElement.style.setProperty('--cols', prefs.density);
+}
+function totalItems(){ return STATE.lists.reduce((n,l)=>n+l.items.length,0); }
+function updateCadence(){
+ const cap = STATE.dailyTarget||20;
+ const p = Math.min(100, Math.round(STATE.todayCount/cap*100));
+ $('#ring').style.setProperty('--p', p);
+ $('#ringtxt').textContent = STATE.todayCount;
+ $('#todayN').textContent = STATE.todayCount;
+ $('#totN').textContent = STATE.totalFollowed;
+ $('#remN').textContent = Math.max(0, totalItems() - STATE.totalFollowed);
+ $('#capwarn').classList.toggle('show', STATE.todayCount >= cap);
+}
+function renderTabs(){
+ $('#tabs').innerHTML = STATE.lists.map(l=>{
+ const done = l.items.filter(i=>STATE.followed[i.id]).length;
+ return `<div class="tab ${l.key===activeTab?'on':''}" data-k="${l.key}">${l.label}
+ <span class="c">${done}/${l.items.length}</span></div>`;
+ }).join('');
+ $('#tabs').querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
+ activeTab=t.dataset.k; localStorage.setItem('lfh.tab',activeTab); renderTabs(); render();
+ });
+}
+function currentList(){ return STATE.lists.find(l=>l.key===activeTab) || STATE.lists[0]; }
+function visibleItems(){
+ const l = currentList(); if(!l) return [];
+ let items = l.items.slice();
+ const q = prefs.q.trim().toLowerCase();
+ if(q) items = items.filter(i=>(i.name||'').toLowerCase().includes(q));
+ if(prefs.hideDone) items = items.filter(i=>!STATE.followed[i.id]);
+ const done = i => STATE.followed[i.id]?1:0;
+ if(prefs.sort==='name') items.sort((a,b)=>(a.name||'').localeCompare(b.name||''));
+ else if(prefs.sort==='done') items.sort((a,b)=>done(b)-done(a)||(a.name||'').localeCompare(b.name||''));
+ else items.sort((a,b)=>done(a)-done(b)||(a.name||'').localeCompare(b.name||'')); // undone first
+ return items;
+}
+function fmtWhen(ts){
+ try{ return new Date(ts).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
+ catch{ return ''; }
+}
+function render(){
+ const l = currentList();
+ $('#listnote').textContent = l ? l.note : '';
+ const items = visibleItems();
+ updateCadence();
+ if(!items.length){ $('#grid').innerHTML = `<div class="empty">Nothing here — try clearing search / “Hide followed”.</div>`; return; }
+ $('#grid').innerHTML = items.map(i=>{
+ const f = STATE.followed[i.id];
+ const sn = i.resolved===false;
+ return `<div class="card ${f?'done':''}" data-id="${encodeURIComponent(i.id)}">
+ ${sn?'<span class="tag sn">Sales Nav</span>':(i.softDone&&!f?'<span class="tag soft">likely already</span>':'')}
+ <div class="nm">${escapeHtml(i.name||'(profile)')}</div>
+ ${f?`<div class="when">🕓 followed ${fmtWhen(f.ts)}</div>`:''}
+ <div class="row">
+ <a class="open ${sn?'salesnav':''}" target="_blank" rel="noopener noreferrer" href="${i.url}">${sn?'SalesNav ↗':'Open ↗'}</a>
+ <label class="chk"><input type="checkbox" ${f?'checked':''}> done</label>
+ </div>
+ </div>`;
+ }).join('');
+ $('#grid').querySelectorAll('.card').forEach(c=>{
+ const id = decodeURIComponent(c.dataset.id);
+ c.querySelector('input').onchange = e => toggle(id, e.target.checked);
+ });
+}
+function escapeHtml(s){return String(s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m]));}
+
+async function toggle(id, done){
+ // optimistic
+ if(done) STATE.followed[id]={ts:new Date().toISOString()}; else delete STATE.followed[id];
+ recount(); renderTabs(); render();
+ await fetch('/api/toggle',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({id,done})});
+}
+function recount(){
+ STATE.totalFollowed = Object.keys(STATE.followed).length;
+ const today = new Date().toLocaleDateString('en-CA');
+ STATE.todayCount = Object.values(STATE.followed)
+ .filter(f=>f.ts && new Date(f.ts).toLocaleDateString('en-CA')===today).length;
+}
+async function bulk(ids, done){
+ const ts=new Date().toISOString();
+ ids.forEach(id=>{ if(done) STATE.followed[id]={ts}; else delete STATE.followed[id]; });
+ recount(); renderTabs(); render();
+ await fetch('/api/bulk',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({ids,done})});
+}
+function renderSearches(){
+ const s = STATE.searches||{};
+ const chip = a => (a||[]).map(([t,u])=>`<a target="_blank" rel="noopener noreferrer" href="${u}">${t}</a>`).join('');
+ $('#searches').innerHTML = `
+ <h2 class="sec">Find more — designers & architects</h2>
+ <div class="chips">${chip(s.designers)}</div>
+ <h2 class="sec">Find more — hospitality reps</h2>
+ <div class="chips">${chip(s.hospitality)}</div>
+ <p class="listnote">Open a search → open each person's profile → Follow → paste them into your queue next time you refresh leads.</p>`;
+}
+function renderFoot(){
+ $('#foot').innerHTML = `Progress saved to <code>data/progress.json</code> — survives reload & reboot ·
+ reads live from <code>~/li-*.json</code> · last server write ${STATE.updatedAt?fmtWhen(STATE.updatedAt):'—'} ·
+ <b>$0 (local)</b>. LinkedIn = manual + tooling only (TOS).`;
+}
+
+// controls
+$('#q').oninput = e => { prefs.q=e.target.value; render(); };
+$('#sort').onchange = e => { prefs.sort=e.target.value; localStorage.setItem('lfh.sort',prefs.sort); render(); };
+$('#hideDone').onchange = e => { prefs.hideDone=e.target.checked; localStorage.setItem('lfh.hideDone',prefs.hideDone?'1':'0'); render(); };
+$('#density').oninput = e => { prefs.density=+e.target.value; localStorage.setItem('lfh.density',prefs.density);
+ document.documentElement.style.setProperty('--cols',prefs.density); };
+$('#target').onchange = async e => {
+ const v=Math.max(1,Math.min(200,+e.target.value||20)); STATE.dailyTarget=v; updateCadence();
+ await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dailyTarget:v})});
+};
+$('#markVisible').onclick = () => { if(confirm('Mark every card currently shown as followed?')) bulk(visibleItems().map(i=>i.id), true); };
+$('#clearVisible').onclick = () => { if(confirm('Unmark every card currently shown?')) bulk(visibleItems().map(i=>i.id), false); };
+
+load();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..49049cc
--- /dev/null
+++ b/server.js
@@ -0,0 +1,212 @@
+#!/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');
+
+// ---- 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`, []);
+
+ // 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 })),
+ },
+ ];
+
+ 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/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}`);
+});
diff --git a/server.out b/server.out
new file mode 100644
index 0000000..54a10ba
--- /dev/null
+++ b/server.out
@@ -0,0 +1,20 @@
+node:events:487
+ throw er; // Unhandled 'error' event
+ ^
+
+Error: listen EADDRINUSE: address already in use 127.0.0.1:9819
+ at Server.setupListenHandle [as _listen2] (node:net:2167:16)
+ at listenInCluster (node:net:2224:12)
+ at node:net:2448:7
+ at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
+Emitted 'error' event on Server instance at:
+ at emitErrorNT (node:net:2203:8)
+ at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
+ code: 'EADDRINUSE',
+ errno: -48,
+ syscall: 'listen',
+ address: '127.0.0.1',
+ port: 9819
+}
+
+Node.js v26.4.0
(oldest)
·
back to Li Followhub
·
gitignore runtime server logs 235e720 →