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,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)
|
||||
Reference in New Issue
Block a user