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,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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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."""
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
+72
@@ -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}
|
||||||
@@ -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"))
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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>
|
||||||
+162
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user