[object Object]

← back to Rentv

CRM: add LinkedIn Network page — TOS-safe import of Bloom's self-exported connections into People

f8bdede1035939da8ab07a0c253cfdf6c6d74ba6 · 2026-08-04 14:30:58 -0700 · steve

Files touched

Diff

commit f8bdede1035939da8ab07a0c253cfdf6c6d74ba6
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Aug 4 14:30:58 2026 -0700

    CRM: add LinkedIn Network page — TOS-safe import of Bloom's self-exported connections into People
---
 public/admin/pr-intelligence/linkedin.html | 235 +++++++++++++++++++++++++++++
 public/admin/pr-intelligence/pr-shared.js  |   1 +
 2 files changed, 236 insertions(+)

diff --git a/public/admin/pr-intelligence/linkedin.html b/public/admin/pr-intelligence/linkedin.html
new file mode 100644
index 00000000..a71b4088
--- /dev/null
+++ b/public/admin/pr-intelligence/linkedin.html
@@ -0,0 +1,235 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — LinkedIn Network</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+<style>
+  .ln-steps{counter-reset:s;margin:0 0 18px;padding:0;list-style:none}
+  .ln-steps li{position:relative;padding:10px 0 10px 40px;border-bottom:1px solid var(--line,#e4e7ea);font-size:13.5px;line-height:1.5}
+  .ln-steps li:last-child{border-bottom:0}
+  .ln-steps li::before{counter-increment:s;content:counter(s);position:absolute;left:0;top:9px;width:26px;height:26px;
+    display:flex;align-items:center;justify-content:center;border-radius:999px;background:#0a6cff;color:#fff;font-weight:700;font-size:12px}
+  .ln-steps a{font-weight:600}
+  .ln-card{border:1px solid var(--line,#e4e7ea);border-radius:10px;padding:18px 20px;margin:0 0 18px;background:var(--card,#fff)}
+  .ln-card h3{margin:0 0 6px;font-size:15px}
+  .ln-note{font-size:12.5px;color:var(--muted,#5b636b);margin:8px 0 0}
+  .ln-safe{display:inline-block;background:#e7f6ec;color:#1c7a3e;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700;letter-spacing:.03em}
+  .ln-drop{border:2px dashed #b9c4d0;border-radius:10px;padding:26px;text-align:center;color:var(--muted,#5b636b);cursor:pointer;transition:border-color .15s,background .15s}
+  .ln-drop.hot{border-color:#0a6cff;background:#f3f8ff}
+  .ln-grid{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:12px}
+  .ln-grid th,.ln-grid td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line,#eee);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px}
+  .ln-grid th{color:var(--muted,#5b636b);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
+  .ln-stat{display:inline-block;margin-right:16px;font-size:13px}
+  .ln-stat b{font-size:18px;display:block}
+  .warn{color:#b23a1e}
+</style>
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('linkedin', 'LinkedIn Network',
+    'Grow RENTV by importing Steve Bloom’s own LinkedIn connections — the TOS-safe way. Bloom exports his data from LinkedIn (no password ever leaves his account), you drop the CSV here, and every contact lands in People for ranked outreach.');
+
+  // ── How-to (self-export) ──────────────────────────────────────────────────
+  const how = document.createElement('div'); how.className = 'ln-card';
+  how.innerHTML = `
+    <h3>Get Steve Bloom’s connections <span class="ln-safe">TOS-SAFE · NO PASSWORD</span></h3>
+    <p class="ln-note">LinkedIn does not expose a follower/connection list to any API or scraper — the only complete, compliant source is the account owner’s own data export. It takes Bloom ~2 minutes and LinkedIn emails him the file.</p>
+    <ol class="ln-steps">
+      <li>Signed in as Steve Bloom, open <a href="https://www.linkedin.com/mypreferences/d/download-my-data" target="_blank" rel="noopener noreferrer">Settings → Data Privacy → Get a copy of your data</a>.</li>
+      <li>Choose <b>“Want something in particular?”</b> and tick <b>Connections</b> (not the full archive — it’s faster).</li>
+      <li>Click <b>Request archive</b> and confirm the LinkedIn password prompt. LinkedIn emails a download link, usually within a few minutes.</li>
+      <li>Download <code>Connections.csv</code> from that email and drop it below. That’s the complete list of his 1st-degree network.</li>
+    </ol>
+    <p class="ln-note"><b>Followers vs. connections:</b> the export covers 1st-degree <i>connections</i> (mutual). Pure one-way <i>followers</i> aren’t individually listed by LinkedIn for anyone — if RENTV has a LinkedIn <b>Company Page</b>, its admin can see follower <i>analytics/demographics</i> under Page → Analytics → Followers, but not a per-person CSV. Connections are the actionable list.</p>`;
+  c.appendChild(how);
+
+  // ── Drop / upload ─────────────────────────────────────────────────────────
+  const up = document.createElement('div'); up.className = 'ln-card';
+  up.innerHTML = `
+    <h3>Import the export</h3>
+    <div class="ln-drop" id="drop">
+      <div><b>Drop <code>Connections.csv</code> here</b> or click to choose</div>
+      <div class="ln-note" id="fname">The LinkedIn “Notes:” preamble and First/Last split are handled automatically.</div>
+    </div>
+    <input type="file" id="file" accept=".csv,text/csv" hidden>
+    <div id="parsed"></div>`;
+  c.appendChild(up);
+
+  const runCard = document.createElement('div'); runCard.className = 'ln-card'; runCard.style.display = 'none';
+  runCard.innerHTML = `
+    <h3>Preview &amp; import</h3>
+    <div id="preview"></div>
+    <div style="margin-top:14px">
+      <button class="btn" id="btn-preview">🔍 Preview (dry run)</button>
+      <button class="btn" id="btn-import" disabled>⬇ Import into People</button>
+      <span class="ln-note" id="run-note" style="margin-left:10px"></span>
+    </div>`;
+  c.appendChild(runCard);
+
+  // ── Recent batches ────────────────────────────────────────────────────────
+  const batchCard = document.createElement('div'); batchCard.className = 'ln-card';
+  batchCard.innerHTML = `<h3>Recent imports</h3><div id="batches"></div>`;
+  c.appendChild(batchCard);
+
+  // ── CSV helpers (client-side) ─────────────────────────────────────────────
+  // Minimal RFC-4180-ish parser: quoted fields, embedded commas/newlines/quotes.
+  function parseCSV(text) {
+    const rows = []; let row = [], field = '', i = 0, q = false;
+    text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+    while (i < text.length) {
+      const ch = text[i];
+      if (q) {
+        if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; continue; } q = false; i++; continue; }
+        field += ch; i++; continue;
+      }
+      if (ch === '"') { q = true; i++; continue; }
+      if (ch === ',') { row.push(field); field = ''; i++; continue; }
+      if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
+      field += ch; i++;
+    }
+    if (field.length || row.length) { row.push(field); rows.push(row); }
+    return rows.filter((r) => r.length && !(r.length === 1 && r[0].trim() === ''));
+  }
+  function csvField(v) { v = String(v == null ? '' : v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; }
+  function toCSV(rows, headers) { return [headers.join(',')].concat(rows.map((r) => headers.map((h) => csvField(r[h])).join(','))).join('\n'); }
+
+  const TARGET = ['full_name', 'linkedin_url', 'organization_name', 'exact_title', 'public_work_email'];
+  const COLMAP = Object.fromEntries(TARGET.map((t) => [t, t]));
+
+  // Turn a raw LinkedIn Connections.csv into a normalized people_csv string.
+  function normalizeLinkedIn(raw) {
+    const grid = parseCSV(raw);
+    // LinkedIn prepends a "Notes:" preamble; the real header is the first row that
+    // contains a name column. Find it and slice from there.
+    const headerIdx = grid.findIndex((r) => {
+      const low = r.map((x) => x.trim().toLowerCase());
+      return low.includes('first name') || low.includes('full name') || (low.includes('url') && low.includes('company'));
+    });
+    if (headerIdx < 0) return { error: 'Could not find a LinkedIn header row (expected columns like First Name, Last Name, URL, Company, Position). Is this the Connections.csv from the LinkedIn data export?' };
+    const H = grid[headerIdx].map((x) => x.trim());
+    const col = (name) => H.findIndex((h) => h.toLowerCase() === name);
+    const iFirst = col('first name'), iLast = col('last name'), iFull = col('full name'),
+          iUrl = col('url'), iEmail = col('email address'), iCompany = col('company'), iPos = col('position');
+    const out = [];
+    for (const r of grid.slice(headerIdx + 1)) {
+      const full = iFull >= 0 ? (r[iFull] || '').trim()
+        : [iFirst >= 0 ? r[iFirst] : '', iLast >= 0 ? r[iLast] : ''].map((s) => (s || '').trim()).filter(Boolean).join(' ');
+      if (!full) continue;
+      out.push({
+        full_name: full,
+        linkedin_url: iUrl >= 0 ? (r[iUrl] || '').trim() : '',
+        organization_name: iCompany >= 0 ? (r[iCompany] || '').trim() : '',
+        exact_title: iPos >= 0 ? (r[iPos] || '').trim() : '',
+        public_work_email: iEmail >= 0 ? (r[iEmail] || '').trim() : '',
+      });
+    }
+    if (!out.length) return { error: 'Header found but no contact rows parsed.' };
+    return { rows: out, csv: toCSV(out, TARGET) };
+  }
+
+  // ── Wire upload ───────────────────────────────────────────────────────────
+  let normCsv = null, normRows = null, filename = null;
+  const drop = document.getElementById('drop'), fileInput = document.getElementById('file');
+  const parsedBox = document.getElementById('parsed'), previewBox = document.getElementById('preview');
+  const btnPreview = document.getElementById('btn-preview'), btnImport = document.getElementById('btn-import');
+
+  function handleFile(f) {
+    filename = f.name;
+    const rd = new FileReader();
+    rd.onload = () => {
+      const res = normalizeLinkedIn(String(rd.result || ''));
+      if (res.error) { normCsv = null; parsedBox.innerHTML = `<p class="warn">⚠ ${PR.esc(res.error)}</p>`; runCard.style.display = 'none'; return; }
+      normCsv = res.csv; normRows = res.rows;
+      document.getElementById('fname').innerHTML = `📄 <b>${PR.esc(f.name)}</b> — ${res.rows.length.toLocaleString()} connections parsed`;
+      const sample = res.rows.slice(0, 8);
+      parsedBox.innerHTML = `
+        <table class="ln-grid"><thead><tr><th>Name</th><th>Company</th><th>Title</th><th>Email</th><th>LinkedIn</th></tr></thead>
+        <tbody>${sample.map((r) => `<tr>
+          <td>${PR.esc(r.full_name)}</td><td>${PR.esc(r.organization_name)}</td><td>${PR.esc(r.exact_title)}</td>
+          <td>${PR.esc(r.public_work_email) || '—'}</td>
+          <td>${r.linkedin_url ? `<a href="${PR.esc(r.linkedin_url)}" target="_blank" rel="noopener noreferrer">profile</a>` : '—'}</td>
+        </tr>`).join('')}</tbody></table>
+        <p class="ln-note">Showing ${sample.length} of ${res.rows.length.toLocaleString()}. Emails only appear for connections who chose to share them.</p>`;
+      runCard.style.display = ''; btnImport.disabled = true;
+      previewBox.innerHTML = ''; document.getElementById('run-note').textContent = '';
+    };
+    rd.readAsText(f);
+  }
+
+  drop.addEventListener('click', () => fileInput.click());
+  fileInput.addEventListener('change', () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); });
+  ['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('hot'); }));
+  ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('hot'); }));
+  drop.addEventListener('drop', (e) => { const f = e.dataTransfer.files[0]; if (f) handleFile(f); });
+
+  // ── Preview (dry run through the standard importer) ───────────────────────
+  btnPreview.addEventListener('click', () => PR.guard(async () => {
+    if (!normCsv) { PR.toast('Upload a Connections.csv first', true); return; }
+    btnPreview.disabled = true; document.getElementById('run-note').textContent = 'Running dry-run…';
+    try {
+      const p = await PR.api('/import/run', { method: 'POST', body: {
+        kind: 'people_csv', csv: normCsv, columnMap: COLMAP, dry_run: true, filename,
+        source_note: 'Steve Bloom LinkedIn network (self-exported connections)',
+      } });
+      const r = p.report;
+      previewBox.innerHTML = `
+        <div style="margin:8px 0">
+          <span class="ln-stat"><b>${(r.created.length).toLocaleString()}</b>would be added</span>
+          <span class="ln-stat"><b>${(r.duplicates.length).toLocaleString()}</b>already in CRM</span>
+          <span class="ln-stat"><b>${(r.skipped.length + r.errors.length).toLocaleString()}</b>skipped</span>
+        </div>
+        <p class="ln-note">Dry run only — nothing was written (batch #${p.batch_id} logged as a dry run). Click <b>Import</b> to write the ${(r.created.length).toLocaleString()} new contacts.</p>`;
+      btnImport.disabled = r.created.length === 0;
+      document.getElementById('run-note').textContent = '';
+    } finally { btnPreview.disabled = false; }
+  }));
+
+  // ── Import (real write) ───────────────────────────────────────────────────
+  btnImport.addEventListener('click', () => PR.guard(async () => {
+    if (!normCsv) return;
+    if (!confirm('Import these connections into People? New organizations will be created for unknown companies. This is reversible from Recent imports.')) return;
+    btnImport.disabled = true; document.getElementById('run-note').textContent = 'Importing…';
+    try {
+      const p = await PR.api('/import/run', { method: 'POST', body: {
+        kind: 'people_csv', csv: normCsv, columnMap: COLMAP, dry_run: false, filename,
+        source_note: 'Steve Bloom LinkedIn network (self-exported connections)',
+      } });
+      const r = p.report;
+      PR.toast(`Imported ${r.created.length} contacts (batch #${p.batch_id})`);
+      previewBox.innerHTML = `<p>✅ Added <b>${r.created.length.toLocaleString()}</b> · duplicates ${r.duplicates.length.toLocaleString()} · skipped ${(r.skipped.length + r.errors.length).toLocaleString()}.
+        <a href="/admin/pr-intelligence/people">Open People →</a></p>`;
+      loadBatches();
+    } finally { btnImport.disabled = false; document.getElementById('run-note').textContent = ''; }
+  }, null));
+
+  // ── Recent import batches (with reverse) ──────────────────────────────────
+  async function loadBatches() {
+    try {
+      const j = await PR.api('/import/batches');
+      const rows = (j.rows || []).filter((b) => (b.kind === 'people_csv' || b.kind === 'contact_list'));
+      const box = document.getElementById('batches');
+      if (!rows.length) { box.innerHTML = '<div class="empty">No imports yet</div>'; return; }
+      box.innerHTML = `<table class="ln-grid"><thead><tr>
+        <th>#</th><th>File</th><th>When</th><th>Added</th><th>Dupes</th><th>Dry?</th><th></th></tr></thead><tbody>
+        ${rows.map((b) => `<tr>
+          <td>${b.id}</td><td>${PR.esc(b.filename || '—')}</td><td>${PR.date(b.created_at)}</td>
+          <td>${(b.created_count || 0).toLocaleString()}</td><td>${(b.duplicate_count || 0).toLocaleString()}</td>
+          <td>${b.dry_run ? 'dry' : '—'}</td>
+          <td>${(!b.dry_run && (b.created_count || 0) > 0) ? `<a href="#" data-reverse="${b.id}">reverse</a>` : ''}</td>
+        </tr>`).join('')}</tbody></table>`;
+      box.querySelectorAll('[data-reverse]').forEach((a) => a.addEventListener('click', (e) => {
+        e.preventDefault();
+        const id = a.getAttribute('data-reverse');
+        if (!confirm(`Reverse import batch #${id}? This archives the contacts it created.`)) return;
+        PR.guard(async () => { await PR.api('/import/' + id + '/reverse', { method: 'POST' }); loadBatches(); }, 'Batch reversed');
+      }));
+    } catch (e) { document.getElementById('batches').innerHTML = `<p class="warn">${PR.esc(e.message)}</p>`; }
+  }
+  loadBatches();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/pr-shared.js b/public/admin/pr-intelligence/pr-shared.js
index adae3e40..a10cfab1 100644
--- a/public/admin/pr-intelligence/pr-shared.js
+++ b/public/admin/pr-intelligence/pr-shared.js
@@ -7,6 +7,7 @@
     ['research', '🔎', 'Research'],
     ['organizations', '🏢', 'Organizations'],
     ['people', '👤', 'People'],
+    ['linkedin', '🔗', 'LinkedIn Network'],
     ['review', '✅', 'Review Queue'],
     ['__sep1', '', 'Outreach'],
     ['campaigns', '📮', 'Campaigns'],

← e32c9d29 auto-save: 2026-08-04T14:04:01 (3 files) — data/markets.json  ·  back to Rentv  ·  auto-save: 2026-08-04T14:34:11 (3 files) — data/markets.json 3e8842dc →