← back to Ventura Corridor
public/linkedin.html
246 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>LinkedIn agent · DW</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
<link rel="stylesheet" href="/theme.css">
<script src="/theme.js" defer></script>
<style>
:root {
--noir: #0a0a0c; --noir-rise: #131316; --ink: #f0ece2;
--ink-mute: #888475; --metal: #b89968; --metal-glow: #d4b683;
--rule: #2a2622;
--serif: 'Cormorant Garamond', 'Times New Roman', serif;
--sans: 'Inter', -apple-system, system-ui, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--li: #0a66c2;
}
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Inter:wght@200;300;400;500;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
* { box-sizing: border-box; }
header { position: sticky; top: 0; z-index: 10;
background: linear-gradient(180deg, rgba(10,10,12,.97) 70%, rgba(10,10,12,0));
padding: 18px 32px 14px; border-bottom: 1px solid var(--rule);
display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap;
}
header h1 { font-family: var(--serif); font-weight: 400; font-size: 22px; margin: 0; }
header h1 em { font-style: italic; color: var(--metal); }
.sub { font-size: 10px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
nav.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
nav.tabs a { font-size: 9px; letter-spacing: .26em; text-transform: uppercase;
color: var(--ink-mute); text-decoration: none; padding: 5px 10px; border: 1px solid var(--rule); }
nav.tabs a.active, nav.tabs a:hover { color: var(--metal); border-color: var(--metal); }
.stack { padding: 24px 32px; max-width: 1200px; }
section { margin-bottom: 32px; padding: 18px 22px; border: 1px solid var(--rule); background: var(--noir-rise); }
section h2 { font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 18px; margin: 0 0 10px; color: var(--ink); }
section h2 em { color: var(--metal); font-style: normal; font-size: 10px; letter-spacing: .26em; text-transform: uppercase; vertical-align: middle; margin-left: 8px; }
section p { color: var(--ink-mute); font-size: 12px; line-height: 1.55; }
textarea, input[type=text] { width: 100%; background: var(--noir); border: 1px solid var(--rule); color: var(--ink); padding: 8px 10px; font-family: var(--mono); font-size: 11px; }
button { background: transparent; border: 1px solid var(--metal); color: var(--metal); padding: 6px 14px; cursor: pointer; font-size: 9px; letter-spacing: .26em; text-transform: uppercase; font-family: inherit; }
button:hover { background: rgba(184,153,104,0.06); }
.stats { display: flex; gap: 24px; flex-wrap: wrap; font-size: 10px; letter-spacing: .15em; color: var(--ink-mute); text-transform: uppercase; margin-top: 12px; }
.stats b { color: var(--metal); font-family: var(--serif); font-style: italic; font-size: 18px; margin-right: 6px; font-variant-numeric: tabular-nums; }
table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 10px; }
th, td { padding: 6px 8px; text-align: left; border-bottom: 1px solid var(--rule); }
th { font-size: 9px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); }
.tag { font-size: 8px; letter-spacing: .22em; text-transform: uppercase; padding: 2px 5px; border: 1px solid var(--rule); color: var(--ink-mute); }
.tag.match { color: var(--li); border-color: var(--li); }
.tag.no-match { color: var(--ink-mute); }
a.profile { color: var(--li); font-family: var(--mono); font-size: 10px; text-decoration: none; }
a.profile:hover { text-decoration: underline; }
pre { background: rgba(0,0,0,0.3); padding: 10px 12px; border: 1px solid var(--rule); white-space: pre-wrap; font-family: var(--mono); font-size: 10px; line-height: 1.5; color: var(--ink); }
.warn { background: rgba(196,125,90,0.06); border-color: #c47d5a; }
.warn h2 em { color: #c47d5a; }
.copy-btn { font-size: 8px; padding: 3px 6px; border-color: var(--rule); color: var(--ink-mute); margin-left: 6px; }
.copy-btn:hover { color: var(--li); border-color: var(--li); }
</style>
</head>
<body>
<header>
<div>
<h1>LinkedIn <em>agent</em> · pull, draft, queue</h1>
<div class="sub">connection import · per-lead DM · daily engagement plan · post drafts</div>
</div>
<nav class="tabs">
<a href="/">map</a>
<a href="/atlas.html">atlas</a>
<a href="/pitches.html">pitches</a>
<a href="/walk-route.html">walk route</a>
<a href="/responses.html">responses</a>
<a href="/postcards.html">postcards</a>
<a href="/buildings.html">buildings</a>
<a href="/crawl-derby.html">🏇 derby</a>
<a href="/linkedin.html" class="active">linkedin</a>
<span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
</nav>
</header>
<div class="stack">
<section class="warn">
<h2><em>Compliance note</em></h2>
<p>LinkedIn's TOS prohibit programmatic bulk messaging from your personal account. Account restrictions are common with 3rd-party automation tools (Phantombuster, Dripify, Expandi). This agent is built for <strong>manual + tooling</strong>: it generates drafts, ranks targets, tracks status — you copy/paste each message yourself. For automation that doesn't risk your account: <strong>LinkedIn Sales Navigator + Sales Insights API</strong> ($80-130/mo) is the only TOS-compliant route.</p>
</section>
<section>
<h2>Step 1 · Import your connections<em>one-time CSV upload</em></h2>
<p>On LinkedIn: <span style="font-family:var(--mono);font-size:10px;color:var(--metal)">Settings → Privacy → Get a copy of your data → Connections only → Request</span>. You'll receive a `Connections.csv` by email within ~10 min. Paste its contents below.</p>
<textarea id="csv-paste" rows="6" placeholder="Paste Connections.csv contents here…"></textarea>
<div style="margin-top:8px"><button id="csv-import">Import connections</button></div>
<div class="stats" id="import-stats" style="display:none">
<span><b id="s-total">—</b> connections in DB</span>
<span><b id="s-matched">—</b> matched to corridor pitches</span>
</div>
</section>
<section>
<h2>Step 2 · Leads<em>your network × corridor pipeline</em></h2>
<p>Connections that match a corridor pitch — you're already 1st-degree. Highest-conversion outreach.</p>
<table id="leads-tbl">
<thead><tr><th>Match</th><th>Connection</th><th>Company</th><th>Pitch</th><th>DM draft</th><th></th></tr></thead>
<tbody id="leads-rows"><tr><td colspan="6" style="color:var(--ink-mute)">Import connections first.</td></tr></tbody>
</table>
</section>
<section>
<h2>Step 3 · Daily DM cadence<em>recommended pace, manual sending</em></h2>
<p>LinkedIn's invitation-and-DM safe rate for established accounts is roughly <strong>20-25 invites/day</strong> and <strong>50-100 DMs/day</strong> to existing connections. The agent ranks candidates daily; you click profile → paste DM → send. Track via "DM sent" button.</p>
<table id="cadence-tbl">
<thead><tr><th>Today's queue</th><th>Vertical</th><th>Status</th><th></th></tr></thead>
<tbody id="cadence-rows"></tbody>
</table>
</section>
<section>
<h2>Step 4 · Post drafts<em>weekly content scaffold</em></h2>
<p>Draft posts you can publish manually. Suggested cadence: 2-3 posts/week, mixing corridor commentary, project reveals, behind-the-scenes.</p>
<div id="post-list"></div>
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
<button onclick="seedPosts()">+ Seed 5 starter post drafts</button>
</div>
</section>
<section>
<h2>Agent skills<em>what an autonomous agent can do for you</em></h2>
<table>
<thead><tr><th>Skill</th><th>Mode</th><th>Risk</th></tr></thead>
<tbody>
<tr><td>Generate vertical-specific DM drafts</td><td>auto, persisted</td><td style="color:#6a9b73">none</td></tr>
<tr><td>Match Connections.csv to corridor pitches</td><td>auto on import</td><td style="color:#6a9b73">none</td></tr>
<tr><td>Rank daily DM queue by priority + recency</td><td>auto</td><td style="color:#6a9b73">none</td></tr>
<tr><td>Schedule weekly post drafts (3 topics)</td><td>auto, you publish</td><td style="color:#6a9b73">none</td></tr>
<tr><td>Generate per-lead "warm comment" suggestions on their recent posts</td><td>auto</td><td style="color:#c4a05c">low — manual paste</td></tr>
<tr><td>Browser automation: send invites, DMs, comments</td><td>NOT BUILT</td><td style="color:#b66565">high — TOS violation, account ban risk</td></tr>
<tr><td>Sales Navigator + InMail via paid API</td><td>requires Steve to subscribe</td><td style="color:#6a9b73">none — TOS-compliant</td></tr>
</tbody>
</table>
</section>
</div>
<script>
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
document.getElementById('csv-import').addEventListener('click', async () => {
const csv = document.getElementById('csv-paste').value;
if (csv.length < 50) { alert('paste your Connections.csv first'); return; }
const r = await fetch('/api/linkedin/import-connections', {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: csv,
}).then(r => r.json());
if (r.error) { alert('Error: ' + r.error); return; }
document.getElementById('s-total').textContent = r.total;
document.getElementById('s-matched').textContent = r.matched;
document.getElementById('import-stats').style.display = '';
loadLeads();
});
async function loadLeads() {
const r = await fetch('/api/linkedin/leads').then(r => r.json());
const matched = (r.rows || []).filter(x => x.pitch_id);
if (!matched.length) { document.getElementById('leads-rows').innerHTML = `<tr><td colspan="6" style="color:var(--ink-mute)">No matched leads yet. Import connections.</td></tr>`; return; }
document.getElementById('leads-rows').innerHTML = matched.slice(0, 50).map(l => `
<tr>
<td><span class="tag match">${(l.match_confidence*100).toFixed(0)}%</span></td>
<td>${escapeHtml(l.full_name || '')}<br><a class="profile" href="${l.profile_url}" target="_blank">${escapeHtml((l.profile_url || '').replace('https://www.linkedin.com/in/',''))}</a></td>
<td>${escapeHtml(l.company || '')}<br><span style="color:var(--ink-mute);font-size:10px">${escapeHtml(l.position || '')}</span></td>
<td><span class="tag">${l.pitch_type || ''}</span> ${l.dw_proximity ? '<span class="tag" style="color:var(--metal-glow)">'+l.dw_proximity+'</span>' : ''}</td>
<td><pre>${escapeHtml(l.li_message || '')}</pre></td>
<td><button class="copy-btn" onclick="copyDM(${l.pitch_id}, this)">Copy DM</button></td>
</tr>
`).join('');
}
function copyDM(pitchId, btn) {
const pre = btn.closest('tr').querySelector('pre');
navigator.clipboard.writeText(pre.textContent).then(() => {
btn.textContent = '✓ copied';
setTimeout(() => btn.textContent = 'Copy DM', 2000);
// Mark as DM sent
fetch('/api/pitches/' + pitchId, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'sent', notes: 'Sent via LinkedIn DM' })
});
});
}
async function seedPosts() {
const seeds = [
{topic:'corridor-insight', hook:'Did you know Ventura Boulevard has 9 different parking-operator brands occupying 48 lots? More than any retail chain.', body:'Looked at LA City Business Tax Registrants for the Ventura Blvd corridor — 17,238 active licenses. The biggest single-brand presence isn''t coffee, it isn''t food, it''s parking. KeyMe (key kiosks) at 13 locations leads the corridor. We''re a wallcovering shop tucked between the lots, but the data tells a real story about how this street works.', hashtags:['LosAngeles','SmallBusiness','VenturaBoulevard','SmallBusinessTuesday']},
{topic:'product-feature', hook:'A single accent wall in a powder room or office reception is the highest-leverage interior detail nobody talks about.', body:'Designers know this; clients usually don''t. We just installed Phillip Jeffries grass cloth in a Sherman Oaks home office — 11 yards, one corner of one room, and the whole space reads three rungs higher on perceived budget. The full price was less than swapping out a sofa.', hashtags:['InteriorDesign','LosAngelesDesign','Wallcovering','PhillipJeffries']},
{topic:'project-reveal', hook:'Behind-the-scenes: Mind the Gap installation we wrapped this week in Encino.', body:'Client came in last month asking for "something playful, but not too much." Three samples later we landed on Mind the Gap''s "Polynesia" in the dining room. Posting the install photos with permission. Wallcovering: ~$420 retail per roll, total install: ~$2,800. The room reads like a $30K renovation.', hashtags:['MindTheGap','BeforeAndAfter','EncinoDesign','InteriorDesignerLife']},
{topic:'corridor-insight', hook:'Of the 5,386 corridor businesses we cataloged, only 3 H1 headlines made it past our "exceptional voice" filter.', body:'We classified every Ventura Blvd business website''s primary headline into TEMPLATE/FUNCTIONAL/SHARP/EXCEPTIONAL. 53% are still on default WordPress copy. 3 wrote something memorable: Mel''s Drive-In ("America''s Most Iconic Diner"), My Botox LA ("Stay Beautiful"), and Universal Law ("injured? Call now…"). The rest of the corridor is hiding behind beige.', hashtags:['Branding','SmallBusiness','VenturaBoulevard','CopyMatters']},
{topic:'product-feature', hook:'Designers ask why we''re still independent. Here''s the math.', body:'Trade-only showrooms in West Hollywood charge designers $8/sample, 3-week lead times, no installation referrals. We''re on Ventura Blvd, samples are $0, you have them tomorrow, and we''ll connect you to the installer who did 200+ DW jobs this year. Same lines, lower friction. That''s the whole pitch.', hashtags:['Wallcovering','InteriorDesign','LosAngeles','Trade']}
];
for (const s of seeds) {
await fetch('/api/linkedin/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(s) });
}
loadPosts();
}
async function loadPosts() {
const r = await fetch('/api/linkedin/posts').then(r => r.json());
const list = document.getElementById('post-list');
if (!(r.rows || []).length) { list.innerHTML = '<p style="color:var(--ink-mute)">No drafts yet. Click below to seed 5 starters.</p>'; return; }
list.innerHTML = r.rows.map(p => `
<div style="padding:12px 14px;border:1px solid var(--rule);margin-bottom:8px;background:var(--noir)">
<div style="font-size:9px;letter-spacing:.26em;text-transform:uppercase;color:var(--metal);margin-bottom:6px">${p.topic} · ${p.status}</div>
<div style="font-family:var(--serif);font-style:italic;font-size:15px;color:var(--ink);margin-bottom:6px">${escapeHtml(p.hook || '')}</div>
<div style="font-size:11px;color:var(--ink-mute);line-height:1.55;white-space:pre-wrap">${escapeHtml(p.body || '')}</div>
<div style="margin-top:6px;font-family:var(--mono);font-size:9px;color:var(--metal)">${(p.hashtags || []).map(h => '#'+h).join(' ')}</div>
</div>
`).join('');
}
async function loadCadence() {
const r = await fetch('/api/linkedin/queue').then(r => r.json());
const rows = (r.rows || []).slice(0, 20);
if (!rows.length) {
document.getElementById('cadence-rows').innerHTML = `<tr><td colspan="4" style="color:var(--ink-mute)">No queue items yet. Will populate from matched leads when you mark them for engagement.</td></tr>`;
return;
}
document.getElementById('cadence-rows').innerHTML = rows.map(q => `
<tr><td>${escapeHtml(q.business_name || '')} · ${escapeHtml(q.action)}</td>
<td>${escapeHtml(q.pitch_type || '')}</td>
<td>${q.scheduled_for || ''}</td>
<td><button class="copy-btn">✓ done</button></td></tr>
`).join('');
}
loadLeads();
loadPosts();
loadCadence();
</script>
</body>
</html>