[object Object]

← 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

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 &amp; 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 →