← back to Li Followhub
auto-save: 2026-07-12T08:47:12 (3 files) — public/index.html server.js data/
295e1559fb8f26f91078321a60660425f8ce50a0 · 2026-07-12 08:47:15 -0700 · Steve Abrams
Files touched
A data/manual-leads.jsonM public/index.htmlM server.js
Diff
commit 295e1559fb8f26f91078321a60660425f8ce50a0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 12 08:47:15 2026 -0700
auto-save: 2026-07-12T08:47:12 (3 files) — public/index.html server.js data/
---
data/manual-leads.json | 1 +
public/index.html | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 40 +++++++++++++++++++++++++++++++++++++
3 files changed, 95 insertions(+)
diff --git a/data/manual-leads.json b/data/manual-leads.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/data/manual-leads.json
@@ -0,0 +1 @@
+[]
diff --git a/public/index.html b/public/index.html
index 84c71a0..f2c8bc9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -73,6 +73,20 @@
.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}
+ /* refresh panel */
+ .refresh-panel{display:none;background:var(--card);border:1px solid var(--li);border-radius:10px;
+ padding:14px 16px;margin:2px 0 10px}
+ .refresh-panel.open{display:block}
+ .rp-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:6px 0}
+ .rp-btn{background:#eef3f8;border:1px solid var(--line);color:var(--li-d);border-radius:8px;
+ padding:7px 14px;font:inherit;font-weight:600;cursor:pointer}
+ .rp-btn.primary{background:var(--li);border-color:var(--li);color:#fff}
+ .rp-btn:hover{filter:brightness(.97)}
+ .rp-hint{font-size:12.5px;color:var(--mut)}
+ .rp-note{font-size:12.5px;color:#5a4300;background:#fff4e5;border-left:3px solid var(--warn);
+ border-radius:5px;padding:8px 11px;margin:8px 0}
+ #pasteBox{width:100%;min-height:76px;border:1px solid var(--line);border-radius:8px;
+ padding:9px 11px;font:13px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
</style>
</head>
<body>
@@ -114,7 +128,23 @@
<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>
+ <button class="bulk" id="refreshBtn" style="font-weight:700">⟳ Refresh leads</button>
</div>
+
+ <div class="refresh-panel" id="refreshPanel">
+ <div class="rp-row">
+ <button class="rp-btn" id="reloadDisk">↻ Reload from disk</button>
+ <span class="rp-hint">Re-reads your <code>~/li-*.json</code> — picks up anything the collector or you added, no restart.</span>
+ </div>
+ <div class="rp-note">Can't auto-scrape Sales Nav (that trips the bot flag). To grow the list: in your <b>normal Chrome</b>, open a
+ <b>Find more</b> search below, copy the profile links, and paste them here — one per line, optionally <code>Name, https://…/in/…</code>.</div>
+ <textarea id="pasteBox" placeholder="Jane Smith, https://www.linkedin.com/in/janesmith/ https://www.linkedin.com/in/another-designer/"></textarea>
+ <div class="rp-row">
+ <button class="rp-btn primary" id="addLeads">+ Add pasted leads</button>
+ <span class="rp-hint" id="rpResult"></span>
+ </div>
+ </div>
+
<div class="listnote" id="listnote"></div>
<div class="grid" id="grid"></div>
@@ -257,6 +287,30 @@ $('#target').onchange = async e => {
$('#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); };
+// --- refresh leads ---
+$('#refreshBtn').onclick = () => $('#refreshPanel').classList.toggle('open');
+if(location.hash === '#refresh') $('#refreshPanel').classList.add('open');
+$('#reloadDisk').onclick = async () => {
+ const before = totalItems();
+ await load();
+ const diff = totalItems() - before;
+ $('#rpResult').textContent = diff > 0 ? `Reloaded — +${diff} new` : 'Reloaded — no change';
+ $('#rpResult').style.color = diff > 0 ? 'var(--good)' : 'var(--mut)';
+};
+$('#addLeads').onclick = async () => {
+ const text = $('#pasteBox').value.trim();
+ if(!text){ $('#rpResult').textContent='Paste some LinkedIn /in/ links first.'; $('#rpResult').style.color='var(--bad)'; return; }
+ $('#addLeads').disabled = true;
+ const r = await fetch('/api/add-leads',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})});
+ const j = await r.json();
+ $('#addLeads').disabled = false;
+ $('#rpResult').style.color = j.added ? 'var(--good)' : 'var(--mut)';
+ $('#rpResult').textContent = j.added
+ ? `Added ${j.added} → "Added by me" tab` + (j.added>j.names.length?'':` (${j.names.join(', ')})`)
+ : 'Nothing new — those links are dups or not /in/ profiles.';
+ if(j.added){ $('#pasteBox').value=''; activeTab='manual'; localStorage.setItem('lfh.tab','manual'); await load(); }
+};
+
load();
</script>
</body>
diff --git a/server.js b/server.js
index 49049cc..40ec43b 100644
--- a/server.js
+++ b/server.js
@@ -22,6 +22,7 @@ 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) {
@@ -52,6 +53,7 @@ function buildLists() {
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(
@@ -109,6 +111,16 @@ function buildLists() {
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 = {
@@ -185,6 +197,34 @@ const server = http.createServer(async (req, res) => {
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();
← 2480865 auto-save: 2026-07-12T08:17:05 (1 files) — preview.png
·
back to Li Followhub
·
(newest)