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