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:
@@ -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 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=>({"&":"&","<":"<",">":">"}[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>
|
||||
@@ -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 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 & 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/<site>.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>
|
||||
Reference in New Issue
Block a user