commit c1eef4c6aa6951a99b85fb375465ec600f2280e1 Author: Bhushan C Thakkar Date: Mon Jul 27 07:43:33 2026 +0530 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c234456 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8234288 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.env +data/ +.venv/ +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2052dab --- /dev/null +++ b/README.md @@ -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**. + +
Manual steps (no scripts) + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +export ANTHROPIC_API_KEY=sk-ant-... +python main.py +``` +
+ +### 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/.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. diff --git a/agent/__init__.py b/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/detector.py b/agent/detector.py new file mode 100644 index 0000000..6a75ad7 --- /dev/null +++ b/agent/detector.py @@ -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, + ) diff --git a/agent/platform/__init__.py b/agent/platform/__init__.py new file mode 100644 index 0000000..51476af --- /dev/null +++ b/agent/platform/__init__.py @@ -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 diff --git a/agent/platform/linux.py b/agent/platform/linux.py new file mode 100644 index 0000000..504c3a4 --- /dev/null +++ b/agent/platform/linux.py @@ -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 diff --git a/agent/platform/macos.py b/agent/platform/macos.py new file mode 100644 index 0000000..1e5e16e --- /dev/null +++ b/agent/platform/macos.py @@ -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 diff --git a/agent/platform/windows.py b/agent/platform/windows.py new file mode 100644 index 0000000..cb5dd3c --- /dev/null +++ b/agent/platform/windows.py @@ -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 diff --git a/agent/screenshot.py b/agent/screenshot.py new file mode 100644 index 0000000..1907051 --- /dev/null +++ b/agent/screenshot.py @@ -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) diff --git a/agent/scroller.py b/agent/scroller.py new file mode 100644 index 0000000..75931d2 --- /dev/null +++ b/agent/scroller.py @@ -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) diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/extractor.py b/ai/extractor.py new file mode 100644 index 0000000..09d5342 --- /dev/null +++ b/ai/extractor.py @@ -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) diff --git a/ai/recipes/__init__.py b/ai/recipes/__init__.py new file mode 100644 index 0000000..b5f44f4 --- /dev/null +++ b/ai/recipes/__init__.py @@ -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 diff --git a/ai/recipes/base.py b/ai/recipes/base.py new file mode 100644 index 0000000..722206a --- /dev/null +++ b/ai/recipes/base.py @@ -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.""" diff --git a/ai/recipes/linkedin.py b/ai/recipes/linkedin.py new file mode 100644 index 0000000..b5d5c42 --- /dev/null +++ b/ai/recipes/linkedin.py @@ -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), + ) diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..3621ed8 --- /dev/null +++ b/api/app.py @@ -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} diff --git a/config.py b/config.py new file mode 100644 index 0000000..b19bdc1 --- /dev/null +++ b/config.py @@ -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")) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/controller.py b/core/controller.py new file mode 100644 index 0000000..5eb7ed2 --- /dev/null +++ b/core/controller.py @@ -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() diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..57f3708 --- /dev/null +++ b/core/models.py @@ -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) diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..05ab8aa --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,121 @@ + + + + + +Screen Leads + + + +
+

Screen Leads

+ IDLE +
+ + + + + Help +
+
+
+
+
+ + + + + + +
NameTitle / CompanyLocationContactStageConf.CapturedSource
No leads yet.
+
+ + + diff --git a/dashboard/support.html b/dashboard/support.html new file mode 100644 index 0000000..2b99839 --- /dev/null +++ b/dashboard/support.html @@ -0,0 +1,173 @@ + + + + + +Screen Leads · Support + + + +
+

Screen Leads · Support

+ ← Back to dashboard +
+
+

Everything you need to install, run, and troubleshoot the tool.

+ + +

What it does

+

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.

+

It is screen-capture only — it reads pixels already on your + screen and never contacts the target site's servers. LinkedIn profile pages are + the first supported site.

+ +

Quick start

+

Run it on your own machine, in a terminal:

+
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
+

Then open http://127.0.0.1:8000. Next time, + skip the install steps — just activate the venv, set the key, and run + python main.py.

+
Needs ANTHROPIC_API_KEY in the environment (or an + ant auth login profile). The key is only used to call Claude for extraction.
+ +

Permissions by OS

+ + + + + +
OSGrant this
macOSSystem Settings → Privacy & Security → Screen Recording + and Accessibility for your terminal/app. Quit and reopen the terminal after + granting. Automation permission for the browser enables exact URL detection.
WindowsUsually none. pip install uiautomation enables native URL detection.
LinuxUse an X11 session. Wayland can't capture or scroll via + mss/pyautogui — switch to X11 or use the desktop screenshot portal.
+ +

Using it

+
    +
  1. Open a LinkedIn profile in your normal browser. To capture email/phone, open + the Contact info panel first — those only appear there, and often + not at all.
  2. +
  3. Click Start. A short countdown appears so you can focus the browser window.
  4. +
  5. The tool scrolls, screenshots, extracts, and saves the lead, then waits for you + to open the next profile.
  6. +
+
The tool captures the frontmost window. 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.
+ +

Dashboard controls

+ + + + + + +
ControlEffect
StartBegins a run: countdown → detect front tab → capture loop.
PauseFreezes the loop between ticks; leads already saved stay.
ResumeContinues a paused run.
StopEnds the run and closes the run record.
+

The event line under the header shows live status; the cards show funnel-stage + counts (NEW → ENRICHED → CONTACT_FOUND → EXPORTED).

+ +

Troubleshooting

+

Run stops immediately with "not a supported site / not a profile page"

+

The front tab wasn't a LinkedIn /in/… profile. Open a profile and Start again. + To keep polling instead of stopping, set SCREEN_LEADS_ON_INVALID_PAGE=wait.

+

"Screen capture failed" or blank/black screenshots

+

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.

+

Page doesn't scroll during capture

+

macOS Accessibility permission isn't granted, or the profile window isn't focused. Capture + still runs; you just get fewer distinct sections.

+

Authentication / API key errors

+

ANTHROPIC_API_KEY isn't set in the same terminal running python main.py. + Re-run the export line, or use ant auth login.

+

Name captured but no email/phone

+

Expected — contact details live behind LinkedIn's Contact info panel and are + often not shown. Open that panel before capture; the lead stays at ENRICHED until + contact data is found.

+

Wrong window captured

+

Keep the profile tab frontmost. Increase SCREEN_LEADS_START_DELAY to give yourself + more time to switch after Start.

+ +

Configuration

+

All optional; set as environment variables (or in a .env file — see + .env.example).

+ + + + + + + + + + + +
VariableDefaultPurpose
ANTHROPIC_API_KEYRequired. Claude API key.
SCREEN_LEADS_MODELclaude-opus-4-8Vision model (use claude-sonnet-5 to cut cost).
SCREEN_LEADS_START_DELAY5Grace seconds before first capture (0 = off).
SCREEN_LEADS_SCROLL_STEPS6Screenshots per profile.
SCREEN_LEADS_SCROLL_AMOUNT800Scroll distance per step.
SCREEN_LEADS_SCROLL_PAUSE0.8Settle time (s) after each scroll.
SCREEN_LEADS_WATCH_INTERVAL2.0Loop tick (s).
SCREEN_LEADS_ON_INVALID_PAGEstopstop or wait on a non-target page.
SCREEN_LEADS_HOST / _PORT127.0.0.1 / 8000Server bind address.
+ +

Adding a site

+
    +
  1. Create ai/recipes/<site>.py implementing SiteRecipe + (matches, extraction_schema, extraction_prompt, + to_canonical).
  2. +
  3. Register it in ai/recipes/__init__.py.
  4. +
+

The capture loop and guard are recipe-driven, so nothing else changes.

+ +

Compliance

+

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.

+
+ + diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/database.py b/db/database.py new file mode 100644 index 0000000..323e986 --- /dev/null +++ b/db/database.py @@ -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() diff --git a/main.py b/main.py new file mode 100644 index 0000000..467f2b6 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c50957 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..071c407 --- /dev/null +++ b/run.bat @@ -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 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..8b6a26c --- /dev/null +++ b/run.sh @@ -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 diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..7201768 --- /dev/null +++ b/setup.bat @@ -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 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..c69849d --- /dev/null +++ b/setup.sh @@ -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"