Spaces:
Sleeping
Sleeping
Anuragh Claude Sonnet 4.6 commited on
Commit Β·
ec592cf
1
Parent(s): b2df971
sync: Replace Composio with Nango for LinkedIn integration
Browse filesCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- backend/main.py +12 -12
- backend/requirements.txt +0 -1
- backend/services/linkedin_composio.py +0 -230
- backend/services/linkedin_nango.py +143 -0
backend/main.py
CHANGED
|
@@ -108,7 +108,7 @@ from services import embedding_matcher
|
|
| 108 |
from services import quality_gate
|
| 109 |
from services import scoring_engine
|
| 110 |
from services import latex_resume
|
| 111 |
-
from services import
|
| 112 |
from core import auth as auth_module
|
| 113 |
|
| 114 |
logger = logging.getLogger(__name__)
|
|
@@ -656,11 +656,11 @@ def linkedin_auth_url(
|
|
| 656 |
user: dict = Depends(get_current_user),
|
| 657 |
):
|
| 658 |
"""Return a Composio OAuth URL for the user to connect their LinkedIn account."""
|
| 659 |
-
if not
|
| 660 |
-
raise HTTPException(status_code=503, detail="
|
| 661 |
entity_id = user.get("clerk_id") or user.get("id") or "default"
|
| 662 |
try:
|
| 663 |
-
url =
|
| 664 |
return {"ok": True, "url": url}
|
| 665 |
except Exception as e:
|
| 666 |
logger.error("LinkedIn auth URL error: %s", e)
|
|
@@ -670,21 +670,21 @@ def linkedin_auth_url(
|
|
| 670 |
@app.get("/api/linkedin/status")
|
| 671 |
def linkedin_status(user: dict = Depends(get_current_user)):
|
| 672 |
"""Return whether the user has LinkedIn connected via Composio."""
|
| 673 |
-
if not
|
| 674 |
-
return {"ok": True, "connected": False, "reason": "
|
| 675 |
entity_id = user.get("clerk_id") or user.get("id") or "default"
|
| 676 |
-
connected =
|
| 677 |
return {"ok": True, "connected": connected}
|
| 678 |
|
| 679 |
|
| 680 |
@app.post("/api/linkedin/import")
|
| 681 |
def linkedin_import(user: dict = Depends(get_current_user)):
|
| 682 |
"""Import user's LinkedIn profile as a parsed resume object."""
|
| 683 |
-
if not
|
| 684 |
-
raise HTTPException(status_code=503, detail="
|
| 685 |
entity_id = user.get("clerk_id") or user.get("id") or "default"
|
| 686 |
try:
|
| 687 |
-
data =
|
| 688 |
return {"ok": True, "data": data}
|
| 689 |
except Exception as e:
|
| 690 |
logger.error("LinkedIn import error: %s", e)
|
|
@@ -694,10 +694,10 @@ def linkedin_import(user: dict = Depends(get_current_user)):
|
|
| 694 |
@app.post("/api/linkedin/enrich-companies")
|
| 695 |
def linkedin_enrich_companies(req: LinkedInEnrichRequest, user: dict = Depends(get_current_user)):
|
| 696 |
"""Enrich a list of job dicts with LinkedIn company_info. Skips silently on failure."""
|
| 697 |
-
if not
|
| 698 |
return {"ok": True, "jobs": req.jobs}
|
| 699 |
entity_id = req.entity_id or user.get("clerk_id") or user.get("id") or "default"
|
| 700 |
-
enriched =
|
| 701 |
return {"ok": True, "jobs": enriched}
|
| 702 |
|
| 703 |
|
|
|
|
| 108 |
from services import quality_gate
|
| 109 |
from services import scoring_engine
|
| 110 |
from services import latex_resume
|
| 111 |
+
from services import linkedin_nango
|
| 112 |
from core import auth as auth_module
|
| 113 |
|
| 114 |
logger = logging.getLogger(__name__)
|
|
|
|
| 656 |
user: dict = Depends(get_current_user),
|
| 657 |
):
|
| 658 |
"""Return a Composio OAuth URL for the user to connect their LinkedIn account."""
|
| 659 |
+
if not linkedin_nango.NANGO_SECRET_KEY:
|
| 660 |
+
raise HTTPException(status_code=503, detail="NANGO_SECRET_KEY not configured")
|
| 661 |
entity_id = user.get("clerk_id") or user.get("id") or "default"
|
| 662 |
try:
|
| 663 |
+
url = linkedin_nango.get_connect_url(user_id=entity_id, redirect_url=redirect)
|
| 664 |
return {"ok": True, "url": url}
|
| 665 |
except Exception as e:
|
| 666 |
logger.error("LinkedIn auth URL error: %s", e)
|
|
|
|
| 670 |
@app.get("/api/linkedin/status")
|
| 671 |
def linkedin_status(user: dict = Depends(get_current_user)):
|
| 672 |
"""Return whether the user has LinkedIn connected via Composio."""
|
| 673 |
+
if not linkedin_nango.NANGO_SECRET_KEY:
|
| 674 |
+
return {"ok": True, "connected": False, "reason": "NANGO_SECRET_KEY not configured"}
|
| 675 |
entity_id = user.get("clerk_id") or user.get("id") or "default"
|
| 676 |
+
connected = linkedin_nango.is_connected(entity_id)
|
| 677 |
return {"ok": True, "connected": connected}
|
| 678 |
|
| 679 |
|
| 680 |
@app.post("/api/linkedin/import")
|
| 681 |
def linkedin_import(user: dict = Depends(get_current_user)):
|
| 682 |
"""Import user's LinkedIn profile as a parsed resume object."""
|
| 683 |
+
if not linkedin_nango.NANGO_SECRET_KEY:
|
| 684 |
+
raise HTTPException(status_code=503, detail="NANGO_SECRET_KEY not configured")
|
| 685 |
entity_id = user.get("clerk_id") or user.get("id") or "default"
|
| 686 |
try:
|
| 687 |
+
data = linkedin_nango.import_profile(entity_id)
|
| 688 |
return {"ok": True, "data": data}
|
| 689 |
except Exception as e:
|
| 690 |
logger.error("LinkedIn import error: %s", e)
|
|
|
|
| 694 |
@app.post("/api/linkedin/enrich-companies")
|
| 695 |
def linkedin_enrich_companies(req: LinkedInEnrichRequest, user: dict = Depends(get_current_user)):
|
| 696 |
"""Enrich a list of job dicts with LinkedIn company_info. Skips silently on failure."""
|
| 697 |
+
if not linkedin_nango.NANGO_SECRET_KEY:
|
| 698 |
return {"ok": True, "jobs": req.jobs}
|
| 699 |
entity_id = req.entity_id or user.get("clerk_id") or user.get("id") or "default"
|
| 700 |
+
enriched = linkedin_nango.enrich_companies(req.jobs, entity_id)
|
| 701 |
return {"ok": True, "jobs": enriched}
|
| 702 |
|
| 703 |
|
backend/requirements.txt
CHANGED
|
@@ -17,4 +17,3 @@ stripe>=8.0.0
|
|
| 17 |
python-dotenv>=1.0.0
|
| 18 |
# Deploy trigger v4.9.0
|
| 19 |
python-jobspy>=1.1.80
|
| 20 |
-
composio-core>=0.7.21
|
|
|
|
| 17 |
python-dotenv>=1.0.0
|
| 18 |
# Deploy trigger v4.9.0
|
| 19 |
python-jobspy>=1.1.80
|
|
|
backend/services/linkedin_composio.py
DELETED
|
@@ -1,230 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
LinkedIn integration via Composio.
|
| 3 |
-
Provides profile import and company info enrichment.
|
| 4 |
-
|
| 5 |
-
Actions used:
|
| 6 |
-
LINKEDIN_GET_MY_INFO β fetch authenticated user's profile
|
| 7 |
-
LINKEDIN_GET_COMPANY_INFO β fetch company details by name/vanity
|
| 8 |
-
|
| 9 |
-
Requires:
|
| 10 |
-
COMPOSIO_API_KEY in environment
|
| 11 |
-
User must have connected their LinkedIn account via /api/linkedin/auth-url
|
| 12 |
-
"""
|
| 13 |
-
import os
|
| 14 |
-
import logging
|
| 15 |
-
from typing import Dict, List, Optional
|
| 16 |
-
|
| 17 |
-
logger = logging.getLogger(__name__)
|
| 18 |
-
|
| 19 |
-
COMPOSIO_API_KEY = os.environ.get("COMPOSIO_API_KEY", "")
|
| 20 |
-
|
| 21 |
-
_company_cache: Dict[str, dict] = {}
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def _toolset(entity_id: str):
|
| 25 |
-
from composio import ComposioToolSet
|
| 26 |
-
return ComposioToolSet(api_key=COMPOSIO_API_KEY, entity_id=entity_id)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def get_oauth_url(entity_id: str, redirect_url: str) -> str:
|
| 30 |
-
"""Return LinkedIn OAuth URL for the given user entity."""
|
| 31 |
-
from composio import Composio, App
|
| 32 |
-
client = Composio(api_key=COMPOSIO_API_KEY)
|
| 33 |
-
entity = client.get_entity(id=entity_id)
|
| 34 |
-
conn = entity.initiate_connection(app_name=App.LINKEDIN, redirect_url=redirect_url)
|
| 35 |
-
if not conn.redirectUrl:
|
| 36 |
-
raise RuntimeError("Composio did not return a redirect URL")
|
| 37 |
-
return conn.redirectUrl
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def is_connected(entity_id: str) -> bool:
|
| 41 |
-
"""Return True if user has a LinkedIn connected account."""
|
| 42 |
-
try:
|
| 43 |
-
from composio import Composio, App
|
| 44 |
-
client = Composio(api_key=COMPOSIO_API_KEY)
|
| 45 |
-
entity = client.get_entity(id=entity_id)
|
| 46 |
-
entity.get_connection(app="linkedin")
|
| 47 |
-
return True
|
| 48 |
-
except Exception:
|
| 49 |
-
return False
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def import_profile(entity_id: str) -> dict:
|
| 53 |
-
"""
|
| 54 |
-
Fetch user's LinkedIn profile and return it in the same shape
|
| 55 |
-
as resume_matcher_ai.parse_resume_structured().
|
| 56 |
-
"""
|
| 57 |
-
ts = _toolset(entity_id)
|
| 58 |
-
result = ts.execute_action(
|
| 59 |
-
action="LINKEDIN_GET_MY_INFO",
|
| 60 |
-
params={},
|
| 61 |
-
entity_id=entity_id,
|
| 62 |
-
)
|
| 63 |
-
|
| 64 |
-
if not result.get("successfull") and not result.get("successful"):
|
| 65 |
-
raise RuntimeError(f"LinkedIn profile fetch failed: {result.get('error', result)}")
|
| 66 |
-
|
| 67 |
-
data = result.get("data", result)
|
| 68 |
-
return _map_profile(data)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def enrich_companies(jobs: List[dict], entity_id: str) -> List[dict]:
|
| 72 |
-
"""
|
| 73 |
-
Attach company_info to each job using LINKEDIN_GET_COMPANY_INFO.
|
| 74 |
-
Deduplicates lookups and caches results. Failures are silent β the
|
| 75 |
-
job is returned unchanged.
|
| 76 |
-
"""
|
| 77 |
-
if not COMPOSIO_API_KEY:
|
| 78 |
-
return jobs
|
| 79 |
-
|
| 80 |
-
try:
|
| 81 |
-
ts = _toolset(entity_id)
|
| 82 |
-
except Exception as e:
|
| 83 |
-
logger.warning("Composio toolset init failed: %s", e)
|
| 84 |
-
return jobs
|
| 85 |
-
|
| 86 |
-
unique_companies = {j.get("company", "") for j in jobs if j.get("company")}
|
| 87 |
-
|
| 88 |
-
for company_name in unique_companies:
|
| 89 |
-
if company_name in _company_cache:
|
| 90 |
-
continue
|
| 91 |
-
try:
|
| 92 |
-
result = ts.execute_action(
|
| 93 |
-
action="LINKEDIN_GET_COMPANY_INFO",
|
| 94 |
-
params={"company_name": company_name},
|
| 95 |
-
entity_id=entity_id,
|
| 96 |
-
)
|
| 97 |
-
ok = result.get("successfull") or result.get("successful")
|
| 98 |
-
if ok:
|
| 99 |
-
raw = result.get("data", {})
|
| 100 |
-
_company_cache[company_name] = _map_company(raw)
|
| 101 |
-
else:
|
| 102 |
-
_company_cache[company_name] = {}
|
| 103 |
-
except Exception as e:
|
| 104 |
-
logger.debug("Company enrichment failed for %s: %s", company_name, e)
|
| 105 |
-
_company_cache[company_name] = {}
|
| 106 |
-
|
| 107 |
-
for job in jobs:
|
| 108 |
-
name = job.get("company", "")
|
| 109 |
-
info = _company_cache.get(name)
|
| 110 |
-
if info:
|
| 111 |
-
job["company_info"] = info
|
| 112 |
-
|
| 113 |
-
return jobs
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
# ββ Mappers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
-
|
| 118 |
-
def _map_profile(data: dict) -> dict:
|
| 119 |
-
"""Convert LinkedIn profile response to resume_matcher_ai schema."""
|
| 120 |
-
first = _deep_localized(data.get("firstName", {}))
|
| 121 |
-
last = _deep_localized(data.get("lastName", {}))
|
| 122 |
-
name = f"{first} {last}".strip() or data.get("name", "")
|
| 123 |
-
|
| 124 |
-
headline = _deep_localized(data.get("headline", {})) or data.get("headline", "")
|
| 125 |
-
|
| 126 |
-
location_data = data.get("location", {})
|
| 127 |
-
location = (
|
| 128 |
-
location_data.get("name", "") if isinstance(location_data, dict)
|
| 129 |
-
else str(location_data)
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
email = ""
|
| 133 |
-
for elem in (data.get("elements") or []):
|
| 134 |
-
handle = elem.get("handle~", {})
|
| 135 |
-
if handle.get("emailAddress"):
|
| 136 |
-
email = handle["emailAddress"]
|
| 137 |
-
break
|
| 138 |
-
|
| 139 |
-
positions = data.get("positions", {}).get("values", []) or data.get("positions", [])
|
| 140 |
-
work_experience = []
|
| 141 |
-
for pos in positions:
|
| 142 |
-
company_obj = pos.get("company", {})
|
| 143 |
-
company_name = company_obj.get("name", "") if isinstance(company_obj, dict) else ""
|
| 144 |
-
start = pos.get("startDate", {})
|
| 145 |
-
end = pos.get("endDate", {})
|
| 146 |
-
start_str = f"{start.get('year', '')}" if start else ""
|
| 147 |
-
end_str = f"{end.get('year', '')}" if end else "Present"
|
| 148 |
-
work_experience.append({
|
| 149 |
-
"title": pos.get("title", ""),
|
| 150 |
-
"company": company_name,
|
| 151 |
-
"start_date": start_str,
|
| 152 |
-
"end_date": end_str,
|
| 153 |
-
"description": pos.get("summary", ""),
|
| 154 |
-
"achievements": [],
|
| 155 |
-
})
|
| 156 |
-
|
| 157 |
-
educations = data.get("educations", {}).get("values", []) or data.get("educations", [])
|
| 158 |
-
education = []
|
| 159 |
-
for edu in educations:
|
| 160 |
-
start = edu.get("startDate", {})
|
| 161 |
-
end = edu.get("endDate", {})
|
| 162 |
-
education.append({
|
| 163 |
-
"degree": edu.get("degree", ""),
|
| 164 |
-
"institution": edu.get("schoolName", ""),
|
| 165 |
-
"field": edu.get("fieldOfStudy", ""),
|
| 166 |
-
"graduation_year": str(end.get("year", "")) if end else "",
|
| 167 |
-
"gpa": None,
|
| 168 |
-
})
|
| 169 |
-
|
| 170 |
-
skills_data = data.get("skills", {}).get("values", []) or data.get("skills", [])
|
| 171 |
-
skills = []
|
| 172 |
-
for s in skills_data:
|
| 173 |
-
name_val = s.get("skill", {}).get("name", "") if isinstance(s.get("skill"), dict) else s.get("name", "")
|
| 174 |
-
if name_val:
|
| 175 |
-
skills.append(name_val)
|
| 176 |
-
|
| 177 |
-
return {
|
| 178 |
-
"personal_info": {
|
| 179 |
-
"name": name,
|
| 180 |
-
"email": email,
|
| 181 |
-
"phone": "",
|
| 182 |
-
"location": location,
|
| 183 |
-
"linkedin": data.get("publicProfileUrl", ""),
|
| 184 |
-
"website": "",
|
| 185 |
-
},
|
| 186 |
-
"summary": headline,
|
| 187 |
-
"skills": skills,
|
| 188 |
-
"technical_skills": {"languages": [], "frameworks": [], "tools": [], "databases": [], "cloud": []},
|
| 189 |
-
"work_experience": work_experience,
|
| 190 |
-
"education": education,
|
| 191 |
-
"certifications": [],
|
| 192 |
-
"projects": [],
|
| 193 |
-
"total_years_experience": None,
|
| 194 |
-
"seniority_level": "unknown",
|
| 195 |
-
"_source": "linkedin",
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
def _map_company(data: dict) -> dict:
|
| 200 |
-
"""Extract relevant company fields from LinkedIn company response."""
|
| 201 |
-
name_obj = data.get("name", {})
|
| 202 |
-
name = _deep_localized(name_obj) if isinstance(name_obj, dict) else str(name_obj)
|
| 203 |
-
|
| 204 |
-
industries = []
|
| 205 |
-
for ind in (data.get("industries", {}).get("values", []) or data.get("industries", [])):
|
| 206 |
-
if isinstance(ind, dict):
|
| 207 |
-
industries.append(ind.get("name", ""))
|
| 208 |
-
elif isinstance(ind, str):
|
| 209 |
-
industries.append(ind)
|
| 210 |
-
|
| 211 |
-
return {
|
| 212 |
-
"name": name,
|
| 213 |
-
"size": data.get("staffCount") or data.get("employeeCount"),
|
| 214 |
-
"industry": industries[0] if industries else "",
|
| 215 |
-
"followers": data.get("followersCount"),
|
| 216 |
-
"linkedin_url": data.get("companyPageUrl") or data.get("url", ""),
|
| 217 |
-
"description": _deep_localized(data.get("description", {})) or data.get("description", ""),
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def _deep_localized(obj) -> str:
|
| 222 |
-
"""Extract text from LinkedIn's localized string objects."""
|
| 223 |
-
if isinstance(obj, str):
|
| 224 |
-
return obj
|
| 225 |
-
if isinstance(obj, dict):
|
| 226 |
-
localized = obj.get("localized", {})
|
| 227 |
-
if localized:
|
| 228 |
-
return next(iter(localized.values()), "")
|
| 229 |
-
return obj.get("preferredLocale", {}).get("country", "") or ""
|
| 230 |
-
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/services/linkedin_nango.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LinkedIn integration via Nango proxy (replaces Composio)."""
|
| 2 |
+
import os
|
| 3 |
+
import logging
|
| 4 |
+
import httpx
|
| 5 |
+
from typing import Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
NANGO_SECRET_KEY = os.environ.get("NANGO_SECRET_KEY", "")
|
| 10 |
+
NANGO_API = "https://api.nango.dev"
|
| 11 |
+
PROVIDER = "linkedin"
|
| 12 |
+
|
| 13 |
+
_company_cache: Dict[str, dict] = {}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _auth_headers() -> dict:
|
| 17 |
+
return {"Authorization": f"Bearer {NANGO_SECRET_KEY}"}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _proxy_headers(connection_id: str) -> dict:
|
| 21 |
+
return {
|
| 22 |
+
"Authorization": f"Bearer {NANGO_SECRET_KEY}",
|
| 23 |
+
"Provider-Config-Key": PROVIDER,
|
| 24 |
+
"Connection-Id": connection_id,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_connect_url(user_id: str, redirect_url: str) -> str:
|
| 29 |
+
"""Create a Nango connect session and return the hosted connect_link."""
|
| 30 |
+
resp = httpx.post(
|
| 31 |
+
f"{NANGO_API}/connect/sessions",
|
| 32 |
+
headers={**_auth_headers(), "Content-Type": "application/json"},
|
| 33 |
+
json={
|
| 34 |
+
"end_user": {"id": user_id},
|
| 35 |
+
"allowed_integrations": [PROVIDER],
|
| 36 |
+
"callback_url": redirect_url,
|
| 37 |
+
},
|
| 38 |
+
timeout=10,
|
| 39 |
+
)
|
| 40 |
+
resp.raise_for_status()
|
| 41 |
+
return resp.json()["connect_link"]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _get_connection_id(user_id: str) -> Optional[str]:
|
| 45 |
+
"""Return the Nango connection_id for the user's LinkedIn connection."""
|
| 46 |
+
try:
|
| 47 |
+
resp = httpx.get(
|
| 48 |
+
f"{NANGO_API}/connections",
|
| 49 |
+
headers=_auth_headers(),
|
| 50 |
+
params={"tags[end_user_id]": user_id, "provider_config_key": PROVIDER},
|
| 51 |
+
timeout=10,
|
| 52 |
+
)
|
| 53 |
+
if resp.status_code != 200:
|
| 54 |
+
return None
|
| 55 |
+
connections = resp.json().get("connections", [])
|
| 56 |
+
return connections[0].get("connection_id") if connections else None
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.debug("Nango connection lookup failed: %s", e)
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def is_connected(user_id: str) -> bool:
|
| 63 |
+
return _get_connection_id(user_id) is not None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def import_profile(user_id: str) -> dict:
|
| 67 |
+
conn_id = _get_connection_id(user_id)
|
| 68 |
+
if not conn_id:
|
| 69 |
+
raise RuntimeError("LinkedIn not connected β please connect via /api/linkedin/auth-url")
|
| 70 |
+
|
| 71 |
+
headers = _proxy_headers(conn_id)
|
| 72 |
+
|
| 73 |
+
# OpenID Connect userinfo β name, email, picture
|
| 74 |
+
ui_resp = httpx.get(f"{NANGO_API}/proxy/v2/userinfo", headers=headers, timeout=15)
|
| 75 |
+
ui_resp.raise_for_status()
|
| 76 |
+
userinfo = ui_resp.json()
|
| 77 |
+
|
| 78 |
+
# Basic profile β headline, location, vanityName
|
| 79 |
+
me_resp = httpx.get(
|
| 80 |
+
f"{NANGO_API}/proxy/v2/me",
|
| 81 |
+
headers=headers,
|
| 82 |
+
params={"projection": "(id,headline,location,vanityName)"},
|
| 83 |
+
timeout=15,
|
| 84 |
+
)
|
| 85 |
+
me = me_resp.json() if me_resp.status_code == 200 else {}
|
| 86 |
+
|
| 87 |
+
return _map_profile(userinfo, me)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def enrich_companies(jobs: List[dict], user_id: str) -> List[dict]:
|
| 91 |
+
"""LinkedIn company enrichment requires partner API access β returns jobs unchanged."""
|
| 92 |
+
return jobs
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ββ Mappers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
|
| 97 |
+
def _localized(obj) -> str:
|
| 98 |
+
if isinstance(obj, str):
|
| 99 |
+
return obj
|
| 100 |
+
if isinstance(obj, dict):
|
| 101 |
+
loc = obj.get("localized", {})
|
| 102 |
+
if loc:
|
| 103 |
+
return next(iter(loc.values()), "")
|
| 104 |
+
return ""
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _map_profile(userinfo: dict, me: dict) -> dict:
|
| 108 |
+
name = userinfo.get("name", "")
|
| 109 |
+
if not name:
|
| 110 |
+
name = f"{userinfo.get('given_name', '')} {userinfo.get('family_name', '')}".strip()
|
| 111 |
+
|
| 112 |
+
headline = _localized(me.get("headline", ""))
|
| 113 |
+
|
| 114 |
+
location_obj = me.get("location", {})
|
| 115 |
+
location = ""
|
| 116 |
+
if isinstance(location_obj, dict):
|
| 117 |
+
location = location_obj.get("geographicArea", "") or location_obj.get("country", {}).get("code", "")
|
| 118 |
+
elif isinstance(location_obj, str):
|
| 119 |
+
location = location_obj
|
| 120 |
+
|
| 121 |
+
vanity = me.get("vanityName", "")
|
| 122 |
+
linkedin_url = f"https://www.linkedin.com/in/{vanity}" if vanity else ""
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
"personal_info": {
|
| 126 |
+
"name": name,
|
| 127 |
+
"email": userinfo.get("email", ""),
|
| 128 |
+
"phone": "",
|
| 129 |
+
"location": location,
|
| 130 |
+
"linkedin": linkedin_url,
|
| 131 |
+
"website": "",
|
| 132 |
+
},
|
| 133 |
+
"summary": headline,
|
| 134 |
+
"skills": [],
|
| 135 |
+
"technical_skills": {"languages": [], "frameworks": [], "tools": [], "databases": [], "cloud": []},
|
| 136 |
+
"work_experience": [],
|
| 137 |
+
"education": [],
|
| 138 |
+
"certifications": [],
|
| 139 |
+
"projects": [],
|
| 140 |
+
"total_years_experience": None,
|
| 141 |
+
"seniority_level": "unknown",
|
| 142 |
+
"_source": "linkedin_nango",
|
| 143 |
+
}
|