← back to Rentv 2026
public/admin/pr-intelligence/linkedin.html
236 lines
<!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 Boom’s own LinkedIn connections — the TOS-safe way. Boom 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 Boom’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 Boom ~2 minutes and LinkedIn emails him the file.</p>
<ol class="ln-steps">
<li>Signed in as Steve Boom, 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 Boom 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 Boom 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>