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