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