← 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
A public/admin/pr-intelligence/linkedin.htmlM public/admin/pr-intelligence/pr-shared.js
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 & 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 →