[object Object]

← back to Rentv

crm: live LinkedIn refresh — incremental scrape of recent connections + message threads unioned into a live-delta the server reads per request (no full re-export needed)

c0a1f7cc4c9aa105a024daa1264b844d9046195b · 2026-08-12 18:30:58 -0700 · Steve Abrams

Files touched

Diff

commit c0a1f7cc4c9aa105a024daa1264b844d9046195b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 18:30:58 2026 -0700

    crm: live LinkedIn refresh — incremental scrape of recent connections + message threads unioned into a live-delta the server reads per request (no full re-export needed)
---
 .gitignore            |  1 +
 scripts/li-refresh.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js             |  8 ++++++--
 3 files changed, 57 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index 289394a9..f32e0553 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,4 @@ data/linkedin-connections.csv
 data/linkedin-follows.json
 data/linkedin-export/
 data/linkedin-graph.json
+data/linkedin-graph-live.json
diff --git a/scripts/li-refresh.js b/scripts/li-refresh.js
new file mode 100644
index 00000000..4cfde97f
--- /dev/null
+++ b/scripts/li-refresh.js
@@ -0,0 +1,50 @@
+// Incremental LinkedIn refresh — keeps the CRM green-highlight current without a full re-export.
+// Scrapes only your RECENT connections (top of the list) + RECENT message threads (cheap, no
+// full-list crash) and unions any NEW names into data/linkedin-graph-live.json. The server reads
+// graph.json + graph-live.json on every request, so new connects/messages show live after each run.
+const puppeteer = require(process.env.HOME + '/.npm-global/lib/node_modules/puppeteer');
+const fs = require('fs');
+const LIVE = process.env.HOME + '/Projects/rentv/data/linkedin-graph-live.json';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const load = () => { try { return JSON.parse(fs.readFileSync(LIVE, 'utf8')); } catch { return { connected: [], messaged: [] }; } };
+(async () => {
+  let b; try { b = await puppeteer.connect({ browserURL: 'http://127.0.0.1:18800', defaultViewport: null }); }
+  catch (e) { console.log('NO_BROWSER ' + e.message); return; }
+  const pg = (await b.pages()).find(p => true);
+  const live = load();
+  const connected = new Set(live.connected || []), messaged = new Set(live.messaged || []);
+  const before = connected.size + messaged.size;
+
+  // 1) recent connections — default sort is "Recently added"; grab the top ~3 pages only
+  try {
+    await pg.goto('https://www.linkedin.com/mynetwork/invite-connect/connections/', { waitUntil: 'domcontentloaded', timeout: 45000 });
+    await sleep(3500);
+    for (let i = 0; i < 3; i++) {
+      const ns = await pg.evaluate(() => [...document.querySelectorAll('a[href*="/in/"]')].map(a => { const t = (a.innerText || '').trim(); return t ? t.split('\n')[0].trim() : ''; }).filter(Boolean));
+      ns.forEach(n => { if (n && n.length > 1) connected.add(n); });
+      await pg.evaluate(() => { const btn = [...document.querySelectorAll('button')].find(x => /^load more$/i.test((x.innerText || '').trim())); btn && btn.click(); });
+      await sleep(1400);
+    }
+  } catch (e) { console.log('conn-scrape ' + e.message); }
+
+  // 2) recent message threads — participant names from the conversation list (best-effort)
+  try {
+    await pg.goto('https://www.linkedin.com/messaging/', { waitUntil: 'domcontentloaded', timeout: 45000 });
+    await sleep(4000);
+    const names = await pg.evaluate(() => {
+      const out = [];
+      document.querySelectorAll('.msg-conversation-listitem, li[class*="conversation"], a[href*="/messaging/thread/"]').forEach(el => {
+        const h = el.querySelector('h3, .msg-conversation-listitem__participant-names, [class*="participant"]');
+        const t = ((h && h.innerText) || '').trim().split('\n')[0].trim();
+        if (t && t.length > 1 && !/^you\b/i.test(t)) out.push(t);
+      });
+      return out;
+    });
+    names.forEach(n => { if (n && !/designer wallcoverings|steve abrams/i.test(n)) messaged.add(n); });
+  } catch (e) { console.log('msg-scrape ' + e.message); }
+
+  fs.writeFileSync(LIVE, JSON.stringify({ connected: [...connected], messaged: [...messaged] }));
+  const after = connected.size + messaged.size;
+  console.log(`REFRESHED live-delta: connected ${connected.size}, messaged ${messaged.size} (+${after - before} new)`);
+  await b.disconnect();
+})().catch(e => { console.error('ERR ' + e.message); process.exit(0); });
diff --git a/server.js b/server.js
index 312cb3fe..4087f7ec 100644
--- a/server.js
+++ b/server.js
@@ -2402,8 +2402,12 @@ function loadLinkedInFollows() {
 function liBuildPersonSets(arr){ const full = new Set(), fl = new Set(); for (const n of (arr || [])) { const nn = liNorm(n); if (nn) full.add(nn); const f = liFirstLast(n); if (f) fl.add(f); } return { full, fl }; }
 function liFirmNorm(s){ return String(s || '').toLowerCase().replace(/\b(inc|llc|llp|lp|ltd|co|corp|corporation|company|group|the|usa|us|and|of|realty|real|estate|properties|partners|associates|brokerage|commercial|residential|holdings|international|worldwide)\b/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim(); }
 function loadLinkedInGraph(){
-  let g = null;
-  try { g = JSON.parse(fs.readFileSync(path.join(DATA, 'linkedin-graph.json'), 'utf8')); } catch { /* none */ }
+  // Union the export-built baseline (linkedin-graph.json) with the live-delta from the
+  // incremental refresh scraper (linkedin-graph-live.json) so new connects/messages show live.
+  const g = { connected: [], firms: [], messaged: [], invited: [], endorsed: [] };
+  for (const fn of ['linkedin-graph.json', 'linkedin-graph-live.json']) {
+    try { const j = JSON.parse(fs.readFileSync(path.join(DATA, fn), 'utf8')); for (const k of Object.keys(g)) if (Array.isArray(j[k])) g[k] = g[k].concat(j[k]); } catch { /* none */ }
+  }
   const csvConn = loadLinkedInFollows();                       // connections.csv / follows.json (fallback + union)
   const connected = liBuildPersonSets(g && g.connected);
   for (const x of csvConn.full) connected.full.add(x); for (const x of csvConn.fl) connected.fl.add(x);

← f7b12994 crm: full LinkedIn relationship layer from the whole export  ·  back to Rentv  ·  crm: launchd template for durable 30-min LinkedIn live-refre e84d52d4 →