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
+20
View File
@@ -0,0 +1,20 @@
# Copy to .env and fill in. Only ANTHROPIC_API_KEY is required.
ANTHROPIC_API_KEY=sk-ant-...
# Vision model (default is the most capable; sonnet is cheaper for high volume)
# SCREEN_LEADS_MODEL=claude-opus-4-8
# Capture tuning
# SCREEN_LEADS_START_DELAY=5 # grace period to focus the browser (0 = off)
# SCREEN_LEADS_SCROLL_STEPS=6
# SCREEN_LEADS_SCROLL_AMOUNT=800
# SCREEN_LEADS_SCROLL_PAUSE=0.8
# SCREEN_LEADS_WATCH_INTERVAL=2.0
# "stop" (default) ends the run when the front tab is not a LinkedIn profile;
# "wait" keeps polling instead.
# SCREEN_LEADS_ON_INVALID_PAGE=stop
# Server
# SCREEN_LEADS_HOST=127.0.0.1
# SCREEN_LEADS_PORT=8000
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.env
data/
.venv/
venv/
+101
View File
@@ -0,0 +1,101 @@
# Screen Leads
Turn a browser tab you have open on a profile into a structured lead. The tool
screenshots the screen, scrolls, runs the images through Claude vision, and
saves normalised leads to a database you browse and control from a dashboard.
Screen-capture only: it reads pixels a human already chose to display and never
touches the target site's servers or DOM. LinkedIn is the first supported site;
new sites drop in as recipes.
## How it works
```
Start (dashboard) → detect front tab → [stop unless it's an enabled
site's target page, e.g. a LinkedIn profile] → scroll + screenshot loop →
Claude vision extraction → normalise to canonical Lead → SQLite → dashboard
```
- **Guard** — runs only when the front tab is an enabled recipe's *target* page
(a LinkedIn `/in/` profile). On anything else it stops (set
`SCREEN_LEADS_ON_INVALID_PAGE=wait` to poll instead).
- **Detection** — reads your own browser's URL where possible (macOS reliably),
otherwise the AI reads the address bar from the screenshot. Both are passive.
- **Contact info** — name/headline/company come from the profile page. Email &
phone live behind LinkedIn's **Contact info** panel: open it yourself before
capture and the tool will read whatever is shown. Often it simply isn't there.
- **Funnel** — every lead is normalised to one schema and advances
`NEW → ENRICHED → CONTACT_FOUND → EXPORTED` as data completeness grows.
## Setup
```bash
# 1. Get the code
git clone https://git.thecaoffice.com/OpenSource/screen-leads-app.git
cd screen-leads-app
# 2. One-time setup — venv, dependencies, and prompts for your API key
./setup.sh # Windows: setup.bat
# 3. Run it
./run.sh # Windows: run.bat
```
`setup.sh` creates the virtualenv, installs dependencies, and writes your
`ANTHROPIC_API_KEY` to `.env`. `run.sh` launches the dashboard — open
http://127.0.0.1:8000 and use **Start / Pause / Stop**.
<details><summary>Manual steps (no scripts)</summary>
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export ANTHROPIC_API_KEY=sk-ant-...
python main.py
```
</details>
### OS permissions (grant once)
| OS | Needs |
|----|-------|
| macOS | System Settings → Privacy & Security → **Screen Recording** and **Accessibility** for your terminal/app. Automation permission for the browser enables exact URL detection. |
| Windows | Usually none. `pip install uiautomation` enables native URL detection. |
| Linux | Use an **X11** session. Wayland can't capture/scroll via `mss`/`pyautogui` — switch to X11 or use the desktop screenshot portal. |
## Usage
1. Open a LinkedIn profile in your normal browser (optionally open **Contact info**).
2. Click **Start** on the dashboard.
3. The tool scrolls, captures, extracts, and saves the lead; it then waits for
you to open the next profile. Navigating away from a profile stops the run
(default) so it never runs on non-target pages.
## Project layout
```
screen-leads/
agent/ capture loop pieces: screenshot, scroll, detection
platform/ native URL helpers (mac / windows / linux)
ai/ Claude vision extraction + per-site recipes
recipes/ base.py, linkedin.py, registry (__init__.py)
core/ canonical models + capture controller (state machine)
db/ SQLite persistence (leads + runs)
api/ FastAPI: /api/start /pause /resume /stop /status /leads
dashboard/ single-file web UI
config.py main.py
```
## Adding a new site
1. Add `ai/recipes/<site>.py` implementing `SiteRecipe` (`matches`,
`extraction_schema`, `extraction_prompt`, `to_canonical`).
2. Register it in `ai/recipes/__init__.py`.
Nothing in the capture loop changes — the guard and pipeline are recipe-driven.
## Compliance note
Automated *scraping* 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.
View File
+70
View File
@@ -0,0 +1,70 @@
"""Front-tab detection: decide whether the current page is an enabled site's
capturable target (a LinkedIn profile, to start).
Strategy:
1. Try to read the browser URL natively (fast, exact; macOS reliably, others
best-effort).
2. If no URL, capture one screenshot and let the AI read the address bar /
recognise the site visually (portable, screen-only).
3. Ask the recipe registry whether any enabled recipe matches.
"""
from __future__ import annotations
from typing import Optional
from agent import screenshot
from agent.platform import get_active_tab_url
from ai import recipes
from core.models import DetectionResult, PageContext
def detect(use_ai_fallback: bool = True) -> DetectionResult:
url = get_active_tab_url()
ctx = PageContext(url=url)
method = "url" if url else ""
hit = recipes.match(ctx)
if hit is None and url is None and use_ai_fallback:
# No native URL and nothing matched — read the screen visually.
try:
shot = screenshot.capture(prefix="detect")
from ai import extractor
info = extractor.classify_page(shot)
ctx.ai_site = info.get("site")
ctx.ai_page_type = info.get("page_type")
ctx.ai_url_text = info.get("address_bar_url")
method = "visual"
hit = recipes.match(ctx)
except Exception as e:
return DetectionResult(ok=False, reason=f"detection failed: {e}")
if hit is None:
where = ctx.best_url or "the current tab"
return DetectionResult(
ok=False,
reason=f"Front tab is not a supported site ({where}).",
url=ctx.best_url,
method=method,
)
recipe, pm = hit
if not pm.is_target:
return DetectionResult(
ok=False,
reason=(f"Front tab is {recipe.display_name} but not a "
f"{recipe.target_page_type} page (got '{pm.page_type}')."),
recipe_id=recipe.site_id,
page_type=pm.page_type,
url=ctx.best_url,
method=method,
)
return DetectionResult(
ok=True,
recipe_id=recipe.site_id,
page_type=pm.page_type,
url=ctx.best_url,
method=method,
)
+30
View File
@@ -0,0 +1,30 @@
"""Cross-platform helper for reading the front browser tab's URL.
This is the one genuinely OS-specific piece. Reading *your own* browser's
current URL is a passive, local operation (no request to the target site), so
it stays within the screen-capture / compliance posture. When it can't return
a URL (common on Windows/Linux), the detector falls back to reading the address
bar visually from a screenshot via the AI.
"""
import sys
from typing import Optional
def get_active_tab_url() -> Optional[str]:
"""Best-effort URL of the frontmost browser tab, or None if unavailable."""
try:
if sys.platform == "darwin":
from . import macos
return macos.get_active_tab_url()
if sys.platform.startswith("linux"):
from . import linux
return linux.get_active_tab_url()
if sys.platform in ("win32", "cygwin"):
from . import windows
return windows.get_active_tab_url()
except Exception:
return None
return None
+11
View File
@@ -0,0 +1,11 @@
"""Linux (X11): the active window *title* is reachable via xdotool, but the URL
is not exposed without a browser extension or the accessibility bus. We return
None so the detector falls back to reading the address bar visually from the
screenshot, which is the portable path. (Wayland exposes neither; the app warns
about that separately.)
"""
from typing import Optional
def get_active_tab_url() -> Optional[str]:
return None
+39
View File
@@ -0,0 +1,39 @@
"""macOS: read the frontmost browser tab URL via AppleScript.
Reads local browser state only (no network call to the target site). Requires
the terminal/app running this to have Automation permission for the browser.
"""
import subprocess
from typing import Optional
# Browsers that expose `URL of active tab of front window` (Chromium family)
# or `URL of front document` (Safari).
_CHROMIUM = ["Google Chrome", "Brave Browser", "Microsoft Edge", "Arc", "Vivaldi", "Chromium"]
def _frontmost_app() -> Optional[str]:
script = 'tell application "System Events" to get name of first process whose frontmost is true'
return _osascript(script)
def _osascript(script: str) -> Optional[str]:
try:
out = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, timeout=5,
)
val = out.stdout.strip()
return val or None
except Exception:
return None
def get_active_tab_url() -> Optional[str]:
app = _frontmost_app()
if not app:
return None
if app in _CHROMIUM:
return _osascript(f'tell application "{app}" to get URL of active tab of front window')
if app == "Safari":
return _osascript('tell application "Safari" to get URL of front document')
return None
+24
View File
@@ -0,0 +1,24 @@
"""Windows: reliably reading a browser's address-bar URL needs UI Automation
(pywinauto/uiautomation). We attempt a lightweight UIA read if available and
otherwise return None, letting the detector fall back to visual address-bar
reading from the screenshot.
"""
from typing import Optional
def get_active_tab_url() -> Optional[str]:
try:
import uiautomation as auto # optional dependency
except Exception:
return None
try:
window = auto.GetForegroundControl()
# Chromium/Firefox expose the address bar as an Edit control named
# "Address and search bar" / "Search or enter address".
edit = window.EditControl(searchDepth=12)
if edit.Exists(0.3):
val = (edit.GetValuePattern().Value or "").strip()
return val or None
except Exception:
return None
return None
+35
View File
@@ -0,0 +1,35 @@
"""Cross-platform full-screen capture via `mss` (Windows, macOS, Linux/X11)."""
from __future__ import annotations
import time
from pathlib import Path
from typing import Optional
from config import SCREENSHOT_DIR
class CaptureError(RuntimeError):
pass
def capture(prefix: str = "shot") -> str:
"""Grab the primary monitor to a PNG and return its path."""
try:
import mss
import mss.tools
except Exception as e: # pragma: no cover
raise CaptureError(f"mss not available: {e}")
ts = int(time.time() * 1000)
path = SCREENSHOT_DIR / f"{prefix}_{ts}.png"
try:
with mss.mss() as sct:
monitor = sct.monitors[1] # [0] is the virtual "all monitors" rect
img = sct.grab(monitor)
mss.tools.to_png(img.rgb, img.size, output=str(path))
except Exception as e:
raise CaptureError(
f"Screen capture failed ({e}). On macOS grant Screen Recording; "
f"on Linux use an X11 session (Wayland needs the screenshot portal)."
)
return str(path)
+42
View File
@@ -0,0 +1,42 @@
"""OS-level scrolling of the focused window via pyautogui.
We drive real scroll input rather than the DOM, so it works over a plain
screenshot pipeline on any OS. Needs Accessibility permission on macOS and an
X11 session on Linux.
"""
from __future__ import annotations
import time
from config import SCROLL_AMOUNT, SCROLL_PAUSE
class ScrollError(RuntimeError):
pass
def _pyautogui():
try:
import pyautogui
pyautogui.FAILSAFE = False
return pyautogui
except Exception as e: # pragma: no cover
raise ScrollError(
f"pyautogui not available ({e}). On macOS grant Accessibility; "
f"on Linux use X11."
)
def scroll_to_top() -> None:
pg = _pyautogui()
# A large upward scroll reliably returns most pages to the top.
for _ in range(10):
pg.scroll(SCROLL_AMOUNT)
time.sleep(SCROLL_PAUSE)
def scroll_down(amount: int = SCROLL_AMOUNT) -> None:
pg = _pyautogui()
pg.scroll(-abs(amount))
time.sleep(SCROLL_PAUSE)
View File
+90
View File
@@ -0,0 +1,90 @@
"""Vision extraction via Claude.
Two calls:
- extract_lead(): screenshots + a recipe's schema -> raw structured JSON.
- classify_page(): one screenshot -> {site, page_type, address bar url}, used
only when no native browser URL is available (visual site detection).
Both use structured outputs (output_config.format) so the response is
schema-validated JSON, not free text.
"""
from __future__ import annotations
import base64
import json
from functools import lru_cache
from typing import Any
from config import ANTHROPIC_MODEL, MAX_IMAGES_PER_CALL
from ai.recipes.base import SiteRecipe
@lru_cache(maxsize=1)
def _client():
import anthropic
# Resolves ANTHROPIC_API_KEY (or an `ant auth login` profile) from the env.
return anthropic.Anthropic()
def _image_block(path: str) -> dict[str, Any]:
with open(path, "rb") as f:
data = base64.standard_b64encode(f.read()).decode("utf-8")
return {
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": data},
}
def _first_json(response) -> dict[str, Any]:
for block in response.content:
if block.type == "text":
return json.loads(block.text)
raise RuntimeError("model returned no text block")
def extract_lead(recipe: SiteRecipe, screenshot_paths: list[str]) -> dict[str, Any]:
paths = screenshot_paths[:MAX_IMAGES_PER_CALL]
content: list[dict[str, Any]] = [_image_block(p) for p in paths]
content.append({"type": "text", "text": recipe.extraction_prompt})
response = _client().messages.create(
model=ANTHROPIC_MODEL,
max_tokens=2048,
messages=[{"role": "user", "content": content}],
output_config={"format": {"type": "json_schema", "schema": recipe.extraction_schema}},
)
return _first_json(response)
_CLASSIFY_SCHEMA = {
"type": "object",
"properties": {
"site": {"type": "string"}, # e.g. "linkedin", "other"
"page_type": {"type": "string"}, # e.g. "profile", "search", "feed", "other"
"address_bar_url": {"type": "string"},
},
"required": ["site", "page_type", "address_bar_url"],
"additionalProperties": False,
}
_CLASSIFY_PROMPT = (
"This is a screenshot of a web browser. Identify the site and page from the "
"browser's address bar and the visible layout. Return: 'site' (lowercase, "
"e.g. 'linkedin', or 'other'); 'page_type' ('profile' for a single person's "
"page, otherwise 'search', 'feed', 'messaging', or 'other'); and "
"'address_bar_url' (the URL text you can read, or \"\")."
)
def classify_page(screenshot_path: str) -> dict[str, Any]:
response = _client().messages.create(
model=ANTHROPIC_MODEL,
max_tokens=512,
messages=[{
"role": "user",
"content": [_image_block(screenshot_path), {"type": "text", "text": _CLASSIFY_PROMPT}],
}],
output_config={"format": {"type": "json_schema", "schema": _CLASSIFY_SCHEMA}},
)
return _first_json(response)
+25
View File
@@ -0,0 +1,25 @@
"""Recipe registry. Register new site recipes here — nothing else changes."""
from __future__ import annotations
from typing import Optional
from core.models import PageContext, PageMatch
from .base import SiteRecipe
from .linkedin import LinkedInRecipe
REGISTRY: list[SiteRecipe] = [
LinkedInRecipe(),
]
def enabled_recipes() -> list[SiteRecipe]:
return [r for r in REGISTRY if r.enabled]
def match(ctx: PageContext) -> Optional[tuple[SiteRecipe, PageMatch]]:
"""Return the first enabled recipe that recognises the page context."""
for recipe in enabled_recipes():
m = recipe.matches(ctx)
if m is not None:
return recipe, m
return None
+36
View File
@@ -0,0 +1,36 @@
"""SiteRecipe interface. Each supported site implements one of these; adding a
new site is a drop-in module registered in ai/recipes/__init__.py — the core
capture loop never changes.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Optional
from core.models import Lead, PageContext, PageMatch
class SiteRecipe(ABC):
site_id: str = ""
display_name: str = ""
enabled: bool = True
# Which extracted page_type is a capturable target (e.g. a person's profile).
target_page_type: str = "profile"
@abstractmethod
def matches(self, ctx: PageContext) -> Optional[PageMatch]:
"""Return a PageMatch if this recipe recognises the page, else None."""
@property
@abstractmethod
def extraction_schema(self) -> dict[str, Any]:
"""JSON schema (structured-output format) for the raw extraction."""
@property
@abstractmethod
def extraction_prompt(self) -> str:
"""Instruction given to the vision model alongside the screenshots."""
@abstractmethod
def to_canonical(self, raw: dict[str, Any]) -> Lead:
"""Map this site's raw extraction to the canonical Lead schema."""
+91
View File
@@ -0,0 +1,91 @@
"""LinkedIn profile recipe — the first supported site.
Target page: a person's profile (linkedin.com/in/...), typically reached from
search. Anything else on LinkedIn (feed, search results, messaging) is not a
target; non-LinkedIn pages don't match at all.
"""
from __future__ import annotations
import re
from typing import Any, Optional
from core.models import Lead, PageContext, PageMatch
from .base import SiteRecipe
_PROFILE_RE = re.compile(r"linkedin\.com/in/[^/?#]+", re.I)
_LINKEDIN_RE = re.compile(r"linkedin\.com", re.I)
class LinkedInRecipe(SiteRecipe):
site_id = "linkedin"
display_name = "LinkedIn"
enabled = True
target_page_type = "profile"
def matches(self, ctx: PageContext) -> Optional[PageMatch]:
url = ctx.best_url
if url:
if _PROFILE_RE.search(url):
return PageMatch(self.site_id, "profile", is_target=True)
if _LINKEDIN_RE.search(url):
return PageMatch(self.site_id, "other", is_target=False)
return None
# No URL available: fall back to the AI's visual read.
if (ctx.ai_site or "").lower() == "linkedin":
page_type = (ctx.ai_page_type or "other").lower()
return PageMatch(self.site_id, page_type, is_target=(page_type == "profile"))
return None
@property
def extraction_schema(self) -> dict[str, Any]:
str_fields = [
"full_name", "headline", "current_title", "current_company",
"location", "about", "email", "phone", "website",
]
props: dict[str, Any] = {f: {"type": "string"} for f in str_fields}
props["other_profiles"] = {"type": "array", "items": {"type": "string"}}
props["confidence"] = {"type": "number"}
return {
"type": "object",
"properties": props,
"required": str_fields + ["other_profiles", "confidence"],
"additionalProperties": False,
}
@property
def extraction_prompt(self) -> str:
return (
"These screenshots are a single LinkedIn profile page, captured top "
"to bottom. Extract the person's details into the required JSON. "
"Rules: use an empty string \"\" for any field not visible; do NOT "
"guess or invent contact details. Email, phone and website usually "
"only appear if the user opened the 'Contact info' panel — extract "
"them only if they are actually shown. 'other_profiles' is any other "
"social/web links visible (Twitter/X, GitHub, personal site). "
"'confidence' is 0-1 for how legible the profile was."
)
def to_canonical(self, raw: dict[str, Any]) -> Lead:
socials: dict[str, str] = {}
for link in raw.get("other_profiles") or []:
low = link.lower()
if "github.com" in low:
socials["github"] = link
elif "twitter.com" in low or "x.com" in low:
socials["twitter"] = link
else:
socials.setdefault("other", link)
return Lead(
full_name=raw.get("full_name", "") or "",
headline=raw.get("headline", "") or "",
title=raw.get("current_title", "") or "",
company=raw.get("current_company", "") or "",
location=raw.get("location", "") or "",
about=raw.get("about", "") or "",
email=raw.get("email", "") or "",
phone=raw.get("phone", "") or "",
website=raw.get("website", "") or "",
social_handles=socials,
source_site=self.site_id,
confidence=float(raw.get("confidence", 0) or 0),
)
View File
+72
View File
@@ -0,0 +1,72 @@
"""FastAPI backend: run controls, status, and lead queries; serves the dashboard."""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
import config
from core.controller import controller
from db import database
app = FastAPI(title="Screen Leads")
_ROOT = Path(__file__).resolve().parent.parent
_DASHBOARD_DIR = _ROOT / "dashboard"
_DASHBOARD = _DASHBOARD_DIR / "index.html"
_SUPPORT = _DASHBOARD_DIR / "support.html"
@app.on_event("startup")
def _startup() -> None:
database.init_db()
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return _DASHBOARD.read_text(encoding="utf-8")
@app.get("/support", response_class=HTMLResponse)
def support() -> str:
return _SUPPORT.read_text(encoding="utf-8")
# --- run controls ------------------------------------------------------------
@app.post("/api/start")
def start():
return controller.start()
@app.post("/api/pause")
def pause():
return controller.pause()
@app.post("/api/resume")
def resume():
return controller.resume()
@app.post("/api/stop")
def stop():
return controller.stop()
@app.get("/api/status")
def status():
return {**controller.status(), "stage_counts": database.stage_counts()}
# --- leads -------------------------------------------------------------------
@app.get("/api/leads")
def leads(stage: Optional[str] = None):
return JSONResponse(database.list_leads(stage))
@app.post("/api/leads/{lead_id}/stage")
def set_stage(lead_id: int, stage: str):
database.set_stage(lead_id, stage)
return {"ok": True}
+55
View File
@@ -0,0 +1,55 @@
"""Central configuration, all overridable via environment variables.
Anything user- or machine-specific lives here so the rest of the code never
reads os.environ directly.
"""
import os
from pathlib import Path
try:
from dotenv import load_dotenv
load_dotenv()
except Exception: # dotenv is optional
pass
def _env(name: str, default: str) -> str:
return os.environ.get(name, default)
BASE_DIR = Path(__file__).resolve().parent
# --- Storage -----------------------------------------------------------------
DATA_DIR = Path(_env("SCREEN_LEADS_DATA", str(BASE_DIR / "data")))
DATA_DIR.mkdir(parents=True, exist_ok=True)
SCREENSHOT_DIR = DATA_DIR / "screenshots"
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = Path(_env("SCREEN_LEADS_DB", str(DATA_DIR / "leads.db")))
# --- AI ----------------------------------------------------------------------
# Vision model used for extraction. Defaults to the most capable Claude model;
# override with SCREEN_LEADS_MODEL=claude-sonnet-5 for a cheaper high-volume run.
ANTHROPIC_MODEL = _env("SCREEN_LEADS_MODEL", "claude-opus-4-8")
MAX_IMAGES_PER_CALL = int(_env("SCREEN_LEADS_MAX_IMAGES", "8"))
# --- Capture -----------------------------------------------------------------
# Grace period after Start before the first capture, so you can focus the
# browser window on the profile. Set to 0 to disable.
START_DELAY = float(_env("SCREEN_LEADS_START_DELAY", "5"))
SCROLL_STEPS = int(_env("SCREEN_LEADS_SCROLL_STEPS", "6")) # screenshots per profile
SCROLL_AMOUNT = int(_env("SCREEN_LEADS_SCROLL_AMOUNT", "800")) # pyautogui scroll clicks
SCROLL_PAUSE = float(_env("SCREEN_LEADS_SCROLL_PAUSE", "0.8")) # settle time (s)
WATCH_INTERVAL = float(_env("SCREEN_LEADS_WATCH_INTERVAL", "2.0")) # loop tick (s)
# What to do when the front tab is not an enabled site's target page.
# "stop" -> end the run (the requested behaviour)
# "wait" -> pause and keep polling until a valid page appears
ON_INVALID_PAGE = _env("SCREEN_LEADS_ON_INVALID_PAGE", "stop").lower()
# --- Server ------------------------------------------------------------------
HOST = _env("SCREEN_LEADS_HOST", "127.0.0.1")
PORT = int(_env("SCREEN_LEADS_PORT", "8000"))
View File
+186
View File
@@ -0,0 +1,186 @@
"""Capture controller: a background state machine driven by the dashboard.
States: IDLE -> RUNNING <-> PAUSED -> STOPPED. The loop detects the front tab,
stops (or waits) on a non-target page per config, and for each new profile URL
runs scroll -> screenshot -> extract -> normalise -> save.
"""
from __future__ import annotations
import threading
import time
import traceback
from enum import Enum
from typing import Any, Optional
import config
from agent import detector, screenshot, scroller
from ai import extractor, recipes
from core.models import Lead
from db import database
class State(str, Enum):
IDLE = "IDLE"
RUNNING = "RUNNING"
PAUSED = "PAUSED"
STOPPED = "STOPPED"
def _recipe_by_id(site_id: str):
for r in recipes.REGISTRY:
if r.site_id == site_id:
return r
return None
class CaptureController:
def __init__(self) -> None:
self._state = State.IDLE
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._pause = threading.Event()
self._lock = threading.Lock()
self.reason = ""
self.last_event = ""
self.captured_urls: set[str] = set()
self.leads_captured = 0
self._run_id: Optional[int] = None
# --- introspection -------------------------------------------------------
def status(self) -> dict[str, Any]:
return {
"state": self._state.value,
"reason": self.reason,
"last_event": self.last_event,
"leads_captured": self.leads_captured,
"captured_urls": len(self.captured_urls),
"on_invalid_page": config.ON_INVALID_PAGE,
}
def _set(self, state: State, event: str = "") -> None:
self._state = state
if event:
self.last_event = event
# --- controls ------------------------------------------------------------
def start(self) -> dict[str, Any]:
with self._lock:
if self._state in (State.RUNNING, State.PAUSED):
return self.status()
self._stop.clear()
self._pause.clear()
self.reason = ""
self.leads_captured = 0
self.captured_urls.clear()
self._run_id = database.start_run()
self._set(State.RUNNING, "started")
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
return self.status()
def pause(self) -> dict[str, Any]:
if self._state == State.RUNNING:
self._pause.set()
self._set(State.PAUSED, "paused")
return self.status()
def resume(self) -> dict[str, Any]:
if self._state == State.PAUSED:
self._pause.clear()
self._set(State.RUNNING, "resumed")
return self.status()
def stop(self) -> dict[str, Any]:
if self._state in (State.RUNNING, State.PAUSED):
self._stop.set()
self._pause.clear()
self.reason = self.reason or "stopped by user"
return self.status()
# --- main loop -----------------------------------------------------------
def _finish(self, status: str, reason: str) -> None:
self.reason = reason
self._set(State.STOPPED, reason)
if self._run_id is not None:
database.finish_run(self._run_id, status, reason, self.leads_captured)
self._run_id = None
def _countdown(self) -> None:
"""Grace period so the user can focus the browser before capture."""
remaining = int(round(config.START_DELAY))
while remaining > 0 and not self._stop.is_set():
self.last_event = f"focus your browser — starting in {remaining}s"
time.sleep(1)
remaining -= 1
def _loop(self) -> None:
try:
self._countdown()
while not self._stop.is_set():
if self._pause.is_set():
time.sleep(0.3)
continue
det = detector.detect()
if not det.ok:
if config.ON_INVALID_PAGE == "wait":
self.last_event = f"waiting: {det.reason}"
time.sleep(config.WATCH_INTERVAL)
continue
self._finish("stopped", det.reason)
return
url = det.url or ""
if url and url in self.captured_urls:
self.last_event = "waiting for a new profile"
time.sleep(config.WATCH_INTERVAL)
continue
recipe = _recipe_by_id(det.recipe_id or "")
if recipe is None:
self._finish("error", f"no recipe for {det.recipe_id}")
return
self.last_event = f"capturing {url or det.recipe_id}"
lead = self._capture(recipe, url)
if lead is not None:
self.leads_captured += 1
if url:
self.captured_urls.add(url)
self.last_event = f"saved: {lead.full_name or url}"
time.sleep(config.WATCH_INTERVAL)
self._finish("stopped", self.reason or "stopped by user")
except Exception as e: # keep the loop crash-visible on the dashboard
self._finish("error", f"{e}\n{traceback.format_exc(limit=2)}")
def _capture(self, recipe, url: str) -> Optional[Lead]:
# scroll + screenshot the full page
try:
scroller.scroll_to_top()
except Exception as e:
self.last_event = f"scroll unavailable: {e}"
shots: list[str] = []
for i in range(config.SCROLL_STEPS):
if self._stop.is_set():
break
shots.append(screenshot.capture(prefix=f"{recipe.site_id}"))
try:
scroller.scroll_down()
except Exception:
break
if not shots:
return None
raw = extractor.extract_lead(recipe, shots)
lead = recipe.to_canonical(raw)
lead.source_url = url
lead.screenshot_refs = shots
database.upsert_lead(lead)
return lead
# module-level singleton shared by the API
controller = CaptureController()
+115
View File
@@ -0,0 +1,115 @@
"""Canonical, site-agnostic data structures.
Every site recipe normalises its raw extraction into `Lead`, so leads from any
source share one funnel table and one dashboard view.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Any, Optional
class FunnelStage(str, Enum):
NEW = "NEW" # name/headline only
ENRICHED = "ENRICHED" # company / title / location / about captured
CONTACT_FOUND = "CONTACT_FOUND" # email / phone / website captured
EXPORTED = "EXPORTED" # pushed downstream (set by the user)
@dataclass
class Lead:
# identity
full_name: str = ""
first_name: str = ""
last_name: str = ""
headline: str = ""
title: str = ""
# organisation
company: str = ""
company_url: str = ""
industry: str = ""
location: str = ""
about: str = ""
# contact (often empty on the first pass)
email: str = ""
phone: str = ""
website: str = ""
social_handles: dict[str, str] = field(default_factory=dict)
# provenance
source_site: str = ""
source_url: str = ""
captured_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
screenshot_refs: list[str] = field(default_factory=list)
# quality
confidence: float = 0.0
fields_found: list[str] = field(default_factory=list)
needs_review: bool = False
# funnel
stage: str = FunnelStage.NEW.value
def compute_stage(self) -> str:
"""Advance the funnel stage from data completeness (never regress EXPORTED)."""
if self.stage == FunnelStage.EXPORTED.value:
return self.stage
if self.email or self.phone or self.website or self.social_handles:
return FunnelStage.CONTACT_FOUND.value
if self.company or self.title or self.location or self.about:
return FunnelStage.ENRICHED.value
return FunnelStage.NEW.value
def finalize(self) -> "Lead":
"""Derive first/last name, fields_found, and funnel stage."""
if self.full_name and not (self.first_name or self.last_name):
parts = self.full_name.split()
if parts:
self.first_name = parts[0]
self.last_name = " ".join(parts[1:])
self.fields_found = [
k for k, v in asdict(self).items()
if k not in ("fields_found", "stage", "needs_review", "captured_at",
"updated_at", "confidence") and v
]
self.stage = self.compute_stage()
return self
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass
class PageMatch:
"""Result of a recipe testing a page context."""
site_id: str
page_type: str # e.g. "profile", "search", "feed", "other"
is_target: bool # True only for a page we should capture
@dataclass
class PageContext:
"""What the detector knows about the current front tab."""
url: Optional[str] = None
ai_site: Optional[str] = None # site guessed visually by the AI
ai_page_type: Optional[str] = None # page type guessed visually
ai_url_text: Optional[str] = None # address-bar text read from a screenshot
@property
def best_url(self) -> str:
return (self.url or self.ai_url_text or "").strip()
@dataclass
class DetectionResult:
ok: bool # a valid, enabled target page is in front
reason: str = "" # human-readable explanation (shown on stop)
recipe_id: Optional[str] = None
page_type: Optional[str] = None
url: str = ""
method: str = "" # "url" | "visual"
def dumps(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False)
+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>
View File
+162
View File
@@ -0,0 +1,162 @@
"""SQLite persistence for leads and capture runs.
Thread-safe: the capture loop runs on a background thread while the API serves
on the main thread, so all access goes through one connection guarded by a lock.
Leads are keyed by source_url; re-capturing the same profile merges new,
non-empty fields into the existing row rather than duplicating it.
"""
from __future__ import annotations
import json
import sqlite3
import threading
import time
from typing import Any, Optional
from config import DB_PATH
from core.models import Lead
_lock = threading.Lock()
_conn: Optional[sqlite3.Connection] = None
_JSON_FIELDS = ("social_handles", "screenshot_refs", "fields_found")
def _connect() -> sqlite3.Connection:
global _conn
if _conn is None:
_conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
_conn.row_factory = sqlite3.Row
return _conn
def init_db() -> None:
with _lock:
conn = _connect()
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
full_name TEXT, first_name TEXT, last_name TEXT,
headline TEXT, title TEXT,
company TEXT, company_url TEXT, industry TEXT, location TEXT, about TEXT,
email TEXT, phone TEXT, website TEXT, social_handles TEXT,
source_site TEXT, source_url TEXT UNIQUE,
captured_at REAL, updated_at REAL, screenshot_refs TEXT,
confidence REAL, fields_found TEXT, needs_review INTEGER,
stage TEXT
);
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT, started_at REAL, stopped_at REAL,
reason TEXT, leads_captured INTEGER DEFAULT 0
);
"""
)
conn.commit()
def _row_to_lead(row: sqlite3.Row) -> dict[str, Any]:
d = dict(row)
for f in _JSON_FIELDS:
try:
d[f] = json.loads(d[f]) if d.get(f) else ([] if f != "social_handles" else {})
except Exception:
d[f] = [] if f != "social_handles" else {}
d["needs_review"] = bool(d.get("needs_review"))
return d
def get_lead_by_url(source_url: str) -> Optional[dict[str, Any]]:
with _lock:
cur = _connect().execute("SELECT * FROM leads WHERE source_url = ?", (source_url,))
row = cur.fetchone()
return _row_to_lead(row) if row else None
def upsert_lead(lead: Lead) -> dict[str, Any]:
"""Insert a new lead, or merge non-empty fields into an existing one."""
lead.finalize()
with _lock:
conn = _connect()
existing = conn.execute(
"SELECT * FROM leads WHERE source_url = ?", (lead.source_url,)
).fetchone()
d = lead.to_dict()
for f in _JSON_FIELDS:
d[f] = json.dumps(d[f], ensure_ascii=False)
d["needs_review"] = 1 if d["needs_review"] else 0
if existing is None:
cols = ", ".join(d.keys())
ph = ", ".join(["?"] * len(d))
conn.execute(f"INSERT INTO leads ({cols}) VALUES ({ph})", tuple(d.values()))
else:
merged = dict(existing)
# prefer new non-empty scalar values; JSON fields already serialised
for k, v in d.items():
if k in ("id", "captured_at", "source_url"):
continue
if k in _JSON_FIELDS:
if v not in ("[]", "{}", "", None):
merged[k] = v
elif v not in ("", None, 0, 0.0):
merged[k] = v
merged["updated_at"] = time.time()
set_clause = ", ".join(f"{k} = ?" for k in merged if k != "id")
vals = [merged[k] for k in merged if k != "id"]
conn.execute(
f"UPDATE leads SET {set_clause} WHERE id = ?", (*vals, merged["id"])
)
conn.commit()
return get_lead_by_url(lead.source_url) or {}
def list_leads(stage: Optional[str] = None) -> list[dict[str, Any]]:
with _lock:
conn = _connect()
if stage:
cur = conn.execute(
"SELECT * FROM leads WHERE stage = ? ORDER BY updated_at DESC", (stage,)
)
else:
cur = conn.execute("SELECT * FROM leads ORDER BY updated_at DESC")
return [_row_to_lead(r) for r in cur.fetchall()]
def stage_counts() -> dict[str, int]:
with _lock:
cur = _connect().execute("SELECT stage, COUNT(*) c FROM leads GROUP BY stage")
return {r["stage"]: r["c"] for r in cur.fetchall()}
def set_stage(lead_id: int, stage: str) -> None:
with _lock:
conn = _connect()
conn.execute(
"UPDATE leads SET stage = ?, updated_at = ? WHERE id = ?",
(stage, time.time(), lead_id),
)
conn.commit()
# --- runs --------------------------------------------------------------------
def start_run() -> int:
with _lock:
conn = _connect()
cur = conn.execute(
"INSERT INTO runs (status, started_at) VALUES ('running', ?)", (time.time(),)
)
conn.commit()
return int(cur.lastrowid)
def finish_run(run_id: int, status: str, reason: str, leads_captured: int) -> None:
with _lock:
conn = _connect()
conn.execute(
"UPDATE runs SET status = ?, stopped_at = ?, reason = ?, leads_captured = ? WHERE id = ?",
(status, time.time(), reason, leads_captured, run_id),
)
conn.commit()
+18
View File
@@ -0,0 +1,18 @@
"""Entry point: launch the dashboard + API.
python main.py
Then open http://127.0.0.1:8000 and use Start / Pause / Stop. Requires
ANTHROPIC_API_KEY in the environment (or an `ant auth login` profile).
"""
import uvicorn
import config
def main() -> None:
uvicorn.run("api.app:app", host=config.HOST, port=config.PORT, reload=False)
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
anthropic>=0.116.0
fastapi>=0.110
uvicorn[standard]>=0.27
mss>=9.0
pyautogui>=0.9.54
python-dotenv>=1.0
+28
View File
@@ -0,0 +1,28 @@
@echo off
REM One-command runner for Screen Leads (Windows).
cd /d "%~dp0"
if not exist ".venv" (
echo Creating virtual environment (.venv)...
python -m venv .venv
)
call .venv\Scripts\activate
if not exist ".venv\.installed" (
echo Installing dependencies...
pip install -q --upgrade pip
pip install -q -r requirements.txt
type nul > .venv\.installed
)
if "%ANTHROPIC_API_KEY%"=="" if not exist ".env" (
echo ANTHROPIC_API_KEY is not set and no .env file was found.
echo set ANTHROPIC_API_KEY=sk-ant-... ^(this terminal^)
echo or copy .env.example to .env and edit it
exit /b 1
)
if "%SCREEN_LEADS_HOST%"=="" set SCREEN_LEADS_HOST=127.0.0.1
if "%SCREEN_LEADS_PORT%"=="" set SCREEN_LEADS_PORT=8000
echo Starting Screen Leads at http://%SCREEN_LEADS_HOST%:%SCREEN_LEADS_PORT% (Ctrl-C to stop)
python main.py
Executable
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# One-command runner for Screen Leads (macOS / Linux).
# Creates the virtualenv, installs deps on first run (or when requirements
# change), checks for the API key, and launches the dashboard.
set -euo pipefail
cd "$(dirname "$0")"
PYTHON="${PYTHON:-python3}"
# 1. Virtual environment
if [ ! -d ".venv" ]; then
echo "→ Creating virtual environment (.venv)…"
"$PYTHON" -m venv .venv
fi
# shellcheck disable=SC1091
source .venv/bin/activate
# 2. Dependencies — (re)install only when requirements.txt is newer than the marker
if [ ! -f ".venv/.installed" ] || [ requirements.txt -nt ".venv/.installed" ]; then
echo "→ Installing dependencies…"
pip install -q --upgrade pip
pip install -q -r requirements.txt
touch ".venv/.installed"
fi
# 3. API key — config.py also loads a .env file, so either is fine
if [ -z "${ANTHROPIC_API_KEY:-}" ] && [ ! -f ".env" ]; then
echo "⚠ ANTHROPIC_API_KEY is not set and no .env file was found."
echo " Fix with one of:"
echo " export ANTHROPIC_API_KEY=sk-ant-... (this terminal)"
echo " cp .env.example .env && edit it (persisted)"
exit 1
fi
# 4. Launch
HOST="${SCREEN_LEADS_HOST:-127.0.0.1}"
PORT="${SCREEN_LEADS_PORT:-8000}"
echo "→ Starting Screen Leads at http://${HOST}:${PORT} (Ctrl-C to stop)"
exec python main.py
+41
View File
@@ -0,0 +1,41 @@
@echo off
REM One-time setup for Screen Leads (Windows):
REM 1. create the virtual environment
REM 2. install dependencies
REM 3. prompt for environment variables and write .env
setlocal enabledelayedexpansion
cd /d "%~dp0"
echo == Screen Leads setup ==
if not exist ".venv" (
echo Creating virtual environment (.venv)...
python -m venv .venv
) else (
echo Virtual environment already exists.
)
call .venv\Scripts\activate
echo Installing dependencies...
pip install -q --upgrade pip
pip install -q -r requirements.txt
type nul > .venv\.installed
if exist ".env" (
echo .env already exists - leaving it untouched.
goto done
)
echo.
echo Set up your environment variables (saved to .env):
set /p API_KEY=" Anthropic API key (sk-ant-...): "
set /p MODEL=" Model [claude-opus-4-8] (Enter to accept): "
if "!MODEL!"=="" set MODEL=claude-opus-4-8
> .env echo ANTHROPIC_API_KEY=!API_KEY!
>> .env echo SCREEN_LEADS_MODEL=!MODEL!
echo Wrote .env
if "!API_KEY!"=="" echo WARNING: no key entered - edit .env before running.
:done
echo.
echo Setup complete. Start the app with: run.bat
endlocal
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# One-time setup for Screen Leads (macOS / Linux):
# 1. create the virtual environment
# 2. install dependencies
# 3. prompt for environment variables and write .env
# After this, start the app with ./run.sh
set -euo pipefail
cd "$(dirname "$0")"
PYTHON="${PYTHON:-python3}"
echo "== Screen Leads setup =="
# 1. Virtual environment
if [ ! -d ".venv" ]; then
echo "→ Creating virtual environment (.venv)…"
"$PYTHON" -m venv .venv
else
echo "→ Virtual environment already exists."
fi
# shellcheck disable=SC1091
source .venv/bin/activate
# 2. Dependencies
echo "→ Installing dependencies…"
pip install -q --upgrade pip
pip install -q -r requirements.txt
touch ".venv/.installed"
# 3. Environment variables → .env
if [ -f ".env" ]; then
echo "→ .env already exists — leaving it untouched."
else
echo
echo "Set up your environment variables (saved to .env):"
printf " Anthropic API key (sk-ant-…): "
read -rs API_KEY; echo
printf " Model [claude-opus-4-8] (Enter to accept): "
read -r MODEL
MODEL="${MODEL:-claude-opus-4-8}"
{
echo "ANTHROPIC_API_KEY=${API_KEY}"
echo "SCREEN_LEADS_MODEL=${MODEL}"
} > .env
chmod 600 .env
echo "→ Wrote .env"
if [ -z "${API_KEY}" ]; then
echo " ⚠ No key entered — edit .env and set ANTHROPIC_API_KEY before running."
fi
fi
echo
echo "✓ Setup complete. Start the app with: ./run.sh"