Initial commit: Screen Leads app

Screen-capture lead tool: capture agent, Claude vision extractor with
pluggable site recipes (LinkedIn), canonical funnel model, SQLite storage,
FastAPI backend, dashboard, and setup/run scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 07:43:33 +05:30
commit c1eef4c6aa
32 changed files with 1688 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Screen Leads</title>
<style>
:root {
--bg: #0f1115; --panel: #171a21; --border: #262b36; --fg: #e6e9ef;
--muted: #8b93a3; --accent: #4f8cff; --ok: #34c759; --warn: #ffb020; --danger: #ff5c5c;
}
@media (prefers-color-scheme: light) {
:root { --bg:#f5f6f8; --panel:#fff; --border:#e2e5ea; --fg:#1b1f27; --muted:#6b7280; }
}
* { box-sizing: border-box; }
body { margin:0; font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
background:var(--bg); color:var(--fg); }
header { display:flex; align-items:center; gap:12px; padding:14px 20px;
border-bottom:1px solid var(--border); background:var(--panel); position:sticky; top:0; }
header h1 { font-size:16px; margin:0; font-weight:600; }
header a { color:var(--accent); text-decoration:none; font-size:13px; font-weight:600; }
header a:hover { text-decoration:underline; }
.pill { padding:2px 10px; border-radius:999px; font-size:12px; font-weight:600; border:1px solid var(--border); }
.s-IDLE{color:var(--muted)} .s-RUNNING{color:var(--ok);border-color:var(--ok)}
.s-PAUSED{color:var(--warn);border-color:var(--warn)} .s-STOPPED{color:var(--danger);border-color:var(--danger)}
main { padding:20px; max-width:1200px; margin:0 auto; }
.controls { display:flex; gap:8px; flex-wrap:wrap; margin-left:auto; }
button { font:inherit; font-weight:600; padding:7px 14px; border-radius:8px; cursor:pointer;
border:1px solid var(--border); background:var(--panel); color:var(--fg); }
button:hover { border-color:var(--accent); }
button:disabled { opacity:.4; cursor:not-allowed; }
button.primary { background:var(--accent); border-color:var(--accent); color:#fff; }
.event { color:var(--muted); font-size:12px; padding:8px 20px; border-bottom:1px solid var(--border);
background:var(--panel); }
.stages { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px; }
.stage { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:10px 14px; min-width:120px; }
.stage .n { font-size:22px; font-weight:700; }
.stage .l { font-size:11px; color:var(--muted); letter-spacing:.04em; }
table { width:100%; border-collapse:collapse; background:var(--panel);
border:1px solid var(--border); border-radius:10px; overflow:hidden; }
th,td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--border); vertical-align:top; }
th { font-size:11px; letter-spacing:.05em; color:var(--muted); text-transform:uppercase; }
tr:last-child td { border-bottom:0; }
.tag { font-size:11px; padding:1px 8px; border-radius:6px; border:1px solid var(--border); white-space:nowrap; }
.tag-NEW{color:var(--muted)} .tag-ENRICHED{color:var(--accent);border-color:var(--accent)}
.tag-CONTACT_FOUND{color:var(--ok);border-color:var(--ok)} .tag-EXPORTED{color:var(--warn);border-color:var(--warn)}
a { color:var(--accent); text-decoration:none; }
.muted { color:var(--muted); }
.empty { text-align:center; color:var(--muted); padding:40px; }
</style>
</head>
<body>
<header>
<h1>Screen&nbsp;Leads</h1>
<span id="state" class="pill s-IDLE">IDLE</span>
<div class="controls">
<button id="btn-start" class="primary" onclick="act('start')">Start</button>
<button id="btn-pause" onclick="act('pause')">Pause</button>
<button id="btn-resume" onclick="act('resume')">Resume</button>
<button id="btn-stop" onclick="act('stop')">Stop</button>
<a href="/support">Help</a>
</div>
</header>
<div id="event" class="event"></div>
<main>
<div id="stages" class="stages"></div>
<table>
<thead><tr>
<th>Name</th><th>Title / Company</th><th>Location</th><th>Contact</th>
<th>Stage</th><th>Conf.</th><th>Captured</th><th>Source</th>
</tr></thead>
<tbody id="rows"><tr><td colspan="8" class="empty">No leads yet.</td></tr></tbody>
</table>
</main>
<script>
const STAGES = ["NEW","ENRICHED","CONTACT_FOUND","EXPORTED"];
async function api(path, method="GET"){ const r=await fetch(path,{method}); return r.json(); }
async function act(name){ await api("/api/"+name,"POST"); refresh(); }
function esc(s){ return (s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c])); }
function fmtDate(ts){ if(!ts) return '<span class="muted">—</span>';
const d=new Date(ts*1000);
return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})
+'<br><span class="muted">'+d.toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit'})+'</span>'; }
async function refresh(){
const st = await api("/api/status");
const el = document.getElementById("state");
el.textContent = st.state; el.className = "pill s-"+st.state;
document.getElementById("event").textContent =
(st.last_event||"—") + (st.reason && st.state==="STOPPED" ? " · "+st.reason : "");
document.getElementById("btn-start").disabled = st.state==="RUNNING"||st.state==="PAUSED";
document.getElementById("btn-pause").disabled = st.state!=="RUNNING";
document.getElementById("btn-resume").disabled = st.state!=="PAUSED";
document.getElementById("btn-stop").disabled = st.state!=="RUNNING"&&st.state!=="PAUSED";
const counts = st.stage_counts||{};
document.getElementById("stages").innerHTML = STAGES.map(s=>
`<div class="stage"><div class="n">${counts[s]||0}</div><div class="l">${s.replace("_"," ")}</div></div>`).join("");
const leads = await api("/api/leads");
const rows = document.getElementById("rows");
if(!leads.length){ rows.innerHTML='<tr><td colspan="7" class="empty">No leads yet.</td></tr>'; return; }
rows.innerHTML = leads.map(l=>{
const contact=[l.email,l.phone,l.website].filter(Boolean).map(esc).join("<br>")||'<span class="muted">—</span>';
const src = l.source_url?`<a href="${esc(l.source_url)}" target="_blank" rel="noopener">${esc(l.source_site||"link")}</a>`:esc(l.source_site);
return `<tr>
<td><strong>${esc(l.full_name)||'<span class="muted">?</span>'}</strong><br><span class="muted">${esc(l.headline)}</span></td>
<td>${esc(l.title)}${l.company?' · '+esc(l.company):''}</td>
<td>${esc(l.location)}</td>
<td>${contact}</td>
<td><span class="tag tag-${l.stage}">${l.stage.replace("_"," ")}</span></td>
<td>${l.confidence?Math.round(l.confidence*100)+'%':'—'}</td>
<td>${fmtDate(l.captured_at)}</td>
<td>${src}</td>
</tr>`;
}).join("");
}
refresh(); setInterval(refresh, 2000);
</script>
</body>
</html>
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Screen Leads · Support</title>
<style>
:root {
--bg: #0f1115; --panel: #171a21; --border: #262b36; --fg: #e6e9ef;
--muted: #8b93a3; --accent: #4f8cff; --ok: #34c759; --warn: #ffb020; --danger: #ff5c5c;
}
@media (prefers-color-scheme: light) {
:root { --bg:#f5f6f8; --panel:#fff; --border:#e2e5ea; --fg:#1b1f27; --muted:#6b7280; }
}
* { box-sizing: border-box; }
body { margin:0; font:15px/1.65 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
background:var(--bg); color:var(--fg); }
header { display:flex; align-items:center; gap:12px; padding:14px 20px;
border-bottom:1px solid var(--border); background:var(--panel); position:sticky; top:0; }
header h1 { font-size:16px; margin:0; font-weight:600; }
header a { margin-left:auto; }
a { color:var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
main { padding:24px 20px 60px; max-width:820px; margin:0 auto; }
h2 { font-size:18px; margin:34px 0 10px; padding-bottom:6px; border-bottom:1px solid var(--border); }
h3 { font-size:15px; margin:20px 0 6px; }
p, li { color:var(--fg); }
.muted { color:var(--muted); }
code, pre { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; }
code { background:var(--panel); border:1px solid var(--border); border-radius:5px; padding:1px 6px; }
pre { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px 16px;
overflow-x:auto; }
pre code { border:0; background:none; padding:0; }
table { width:100%; border-collapse:collapse; margin:12px 0; background:var(--panel);
border:1px solid var(--border); border-radius:10px; overflow:hidden; }
th,td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--border); vertical-align:top; }
th { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; }
tr:last-child td { border-bottom:0; }
.toc { display:flex; flex-wrap:wrap; gap:8px 16px; margin:6px 0 10px; }
.note { border-left:3px solid var(--accent); background:var(--panel); border-radius:0 8px 8px 0;
padding:10px 14px; margin:14px 0; }
.warn { border-left-color:var(--warn); }
</style>
</head>
<body>
<header>
<h1>Screen&nbsp;Leads · Support</h1>
<a href="/">← Back to dashboard</a>
</header>
<main>
<p class="muted">Everything you need to install, run, and troubleshoot the tool.</p>
<div class="toc">
<a href="#what">What it does</a>
<a href="#start">Quick start</a>
<a href="#perms">Permissions</a>
<a href="#use">Using it</a>
<a href="#controls">Controls</a>
<a href="#trouble">Troubleshooting</a>
<a href="#config">Configuration</a>
<a href="#sites">Adding a site</a>
<a href="#compliance">Compliance</a>
</div>
<h2 id="what">What it does</h2>
<p>Screen Leads turns a browser tab you have open on a profile into a structured
lead. It screenshots your screen, scrolls, runs the images through Claude vision,
and saves normalised leads to a local database you browse from the dashboard.</p>
<p>It is <strong>screen-capture only</strong> — it reads pixels already on your
screen and never contacts the target site's servers. LinkedIn profile pages are
the first supported site.</p>
<h2 id="start">Quick start</h2>
<p>Run it on your own machine, in a terminal:</p>
<pre><code>cd path/to/screen-leads
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
export ANTHROPIC_API_KEY=sk-ant-...
python main.py</code></pre>
<p>Then open <a href="http://127.0.0.1:8000">http://127.0.0.1:8000</a>. Next time,
skip the install steps — just activate the venv, set the key, and run
<code>python main.py</code>.</p>
<div class="note">Needs <code>ANTHROPIC_API_KEY</code> in the environment (or an
<code>ant auth login</code> profile). The key is only used to call Claude for extraction.</div>
<h2 id="perms">Permissions by OS</h2>
<table>
<tr><th>OS</th><th>Grant this</th></tr>
<tr><td>macOS</td><td>System Settings → Privacy &amp; Security → <strong>Screen Recording</strong>
and <strong>Accessibility</strong> for your terminal/app. Quit and reopen the terminal after
granting. Automation permission for the browser enables exact URL detection.</td></tr>
<tr><td>Windows</td><td>Usually none. <code>pip install uiautomation</code> enables native URL detection.</td></tr>
<tr><td>Linux</td><td>Use an <strong>X11</strong> session. Wayland can't capture or scroll via
<code>mss</code>/<code>pyautogui</code> — switch to X11 or use the desktop screenshot portal.</td></tr>
</table>
<h2 id="use">Using it</h2>
<ol>
<li>Open a LinkedIn profile in your normal browser. To capture email/phone, open
the <strong>Contact info</strong> panel first — those only appear there, and often
not at all.</li>
<li>Click <strong>Start</strong>. A short countdown appears so you can focus the browser window.</li>
<li>The tool scrolls, screenshots, extracts, and saves the lead, then waits for you
to open the next profile.</li>
</ol>
<div class="note warn">The tool captures the <strong>frontmost window</strong>. Keep the profile
tab focused and on top while it works — clicking back into the dashboard mid-capture points the
screenshots at the wrong window.</div>
<h2 id="controls">Dashboard controls</h2>
<table>
<tr><th>Control</th><th>Effect</th></tr>
<tr><td>Start</td><td>Begins a run: countdown → detect front tab → capture loop.</td></tr>
<tr><td>Pause</td><td>Freezes the loop between ticks; leads already saved stay.</td></tr>
<tr><td>Resume</td><td>Continues a paused run.</td></tr>
<tr><td>Stop</td><td>Ends the run and closes the run record.</td></tr>
</table>
<p>The event line under the header shows live status; the cards show funnel-stage
counts (<code>NEW → ENRICHED → CONTACT_FOUND → EXPORTED</code>).</p>
<h2 id="trouble">Troubleshooting</h2>
<h3>Run stops immediately with "not a supported site / not a profile page"</h3>
<p>The front tab wasn't a LinkedIn <code>/in/…</code> profile. Open a profile and Start again.
To keep polling instead of stopping, set <code>SCREEN_LEADS_ON_INVALID_PAGE=wait</code>.</p>
<h3>"Screen capture failed" or blank/black screenshots</h3>
<p>macOS Screen Recording permission isn't granted (or the terminal wasn't restarted after
granting). On Linux, you're likely on Wayland — switch to an X11 session.</p>
<h3>Page doesn't scroll during capture</h3>
<p>macOS Accessibility permission isn't granted, or the profile window isn't focused. Capture
still runs; you just get fewer distinct sections.</p>
<h3>Authentication / API key errors</h3>
<p><code>ANTHROPIC_API_KEY</code> isn't set in the same terminal running <code>python main.py</code>.
Re-run the <code>export</code> line, or use <code>ant auth login</code>.</p>
<h3>Name captured but no email/phone</h3>
<p>Expected — contact details live behind LinkedIn's <strong>Contact info</strong> panel and are
often not shown. Open that panel before capture; the lead stays at <code>ENRICHED</code> until
contact data is found.</p>
<h3>Wrong window captured</h3>
<p>Keep the profile tab frontmost. Increase <code>SCREEN_LEADS_START_DELAY</code> to give yourself
more time to switch after Start.</p>
<h2 id="config">Configuration</h2>
<p>All optional; set as environment variables (or in a <code>.env</code> file — see
<code>.env.example</code>).</p>
<table>
<tr><th>Variable</th><th>Default</th><th>Purpose</th></tr>
<tr><td><code>ANTHROPIC_API_KEY</code></td><td></td><td>Required. Claude API key.</td></tr>
<tr><td><code>SCREEN_LEADS_MODEL</code></td><td>claude-opus-4-8</td><td>Vision model (use claude-sonnet-5 to cut cost).</td></tr>
<tr><td><code>SCREEN_LEADS_START_DELAY</code></td><td>5</td><td>Grace seconds before first capture (0 = off).</td></tr>
<tr><td><code>SCREEN_LEADS_SCROLL_STEPS</code></td><td>6</td><td>Screenshots per profile.</td></tr>
<tr><td><code>SCREEN_LEADS_SCROLL_AMOUNT</code></td><td>800</td><td>Scroll distance per step.</td></tr>
<tr><td><code>SCREEN_LEADS_SCROLL_PAUSE</code></td><td>0.8</td><td>Settle time (s) after each scroll.</td></tr>
<tr><td><code>SCREEN_LEADS_WATCH_INTERVAL</code></td><td>2.0</td><td>Loop tick (s).</td></tr>
<tr><td><code>SCREEN_LEADS_ON_INVALID_PAGE</code></td><td>stop</td><td><code>stop</code> or <code>wait</code> on a non-target page.</td></tr>
<tr><td><code>SCREEN_LEADS_HOST</code> / <code>_PORT</code></td><td>127.0.0.1 / 8000</td><td>Server bind address.</td></tr>
</table>
<h2 id="sites">Adding a site</h2>
<ol>
<li>Create <code>ai/recipes/&lt;site&gt;.py</code> implementing <code>SiteRecipe</code>
(<code>matches</code>, <code>extraction_schema</code>, <code>extraction_prompt</code>,
<code>to_canonical</code>).</li>
<li>Register it in <code>ai/recipes/__init__.py</code>.</li>
</ol>
<p>The capture loop and guard are recipe-driven, so nothing else changes.</p>
<h2 id="compliance">Compliance</h2>
<p>Automated <em>scraping</em> of LinkedIn violates its Terms of Service. This tool is built for
low-volume, human-in-the-loop use on profiles you manually open and are allowed to view. Keep
pacing conservative and use it accordingly.</p>
</main>
</body>
</html>